Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bea06e3cd | ||
|
|
127a59dafd | ||
|
|
a45a5f1fc3 | ||
|
|
5732925f64 | ||
|
|
021284b852 | ||
|
|
30719a2960 | ||
|
|
6ce7b2e800 | ||
|
|
080ac390d3 | ||
|
|
5da8b3a63e | ||
|
|
0e9401b891 | ||
|
|
0f3d16059c | ||
|
|
eccdcba45c | ||
|
|
c66799ee7c | ||
|
|
cd948f0026 | ||
|
|
6125d6e27f | ||
|
|
3503cc0be6 | ||
|
|
d82b834e47 | ||
|
|
9e5036f0a0 |
@@ -1,3 +1,14 @@
|
||||
# 複製為 .env 後依需要調整。
|
||||
# R-2 階段會改為 postgresql 連線字串(例如 postgresql://user:password@localhost:5432/kokorone)。
|
||||
DATABASE_URL="file:./prisma/dev.db"
|
||||
|
||||
# R-2:情緒狀態即時讀寫/session 快取的加速層;連不到時自動退回原本行為(DB/行程記憶體),不是必要依賴。
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
# R-3:LLM_PROVIDER=mock(預設,冒煙測試固定用這個)|claude(真的呼叫 API)
|
||||
LLM_PROVIDER=mock
|
||||
# 這個部署走 CLIProxy(OpenAI 相容的分散式派工代理)而不是直連 api.anthropic.com,
|
||||
# 底層仍是 Claude;若改直連官方 API,把 CLAUDE_BASE_URL 換成 https://api.anthropic.com/v1 即可。
|
||||
CLAUDE_BASE_URL=http://localhost:3000/api/v1
|
||||
CLAUDE_API_KEY=
|
||||
CLAUDE_MODEL=claude-sonnet-4-5
|
||||
|
||||
@@ -13,3 +13,4 @@ coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
packages/db/generated/
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -2,4 +2,72 @@
|
||||
|
||||
以人腦「記憶/情緒/關係」架構為引擎基礎的戀愛陪伴系統。
|
||||
|
||||
> 目前使用 npm workspaces 開發(本機無 pnpm),待 R-1 階段會遷移至 pnpm + Turborepo,對外指令介面(`npm run restart` / `npm run smoke`)保持不變。
|
||||
## 開發環境
|
||||
|
||||
Monorepo 採 pnpm workspace + Turborepo(`pnpm-workspace.yaml` / `turbo.json`)。
|
||||
|
||||
```bash
|
||||
pnpm install # 安裝所有 workspace 套件
|
||||
pnpm run build # turbo run build(依相依順序建置 shared/db/api,並建置 web 的 production bundle)
|
||||
pnpm run restart # 停止舊的 api/web 行程、重建 TypeScript(tsc -b)、重新啟動並等待健康檢查
|
||||
pnpm run smoke A # 執行 A 群組冒煙測試;all 表示依序執行 A~R 全部群組
|
||||
```
|
||||
|
||||
對外指令介面(`restart` / `smoke`)維持與遷移前相同的腳本名稱;唯一差異是
|
||||
**pnpm 不會像 npm 一樣吃掉 `--` 分隔符**,`pnpm run smoke -- A` 會把字面上的
|
||||
`--` 一起傳給腳本而找不到群組,要執行子指令請直接接在腳本名稱後面
|
||||
(`pnpm run smoke A`),不要加 `--`。
|
||||
|
||||
## 環境變數
|
||||
|
||||
複製 `.env.example` 為 `.env` 後依需要調整;`apps/web/.env.local` 另外可設定
|
||||
`NEXT_PUBLIC_API_URL`(瀏覽器端呼叫 api 用,預設 `http://localhost:3001`)。
|
||||
|
||||
| 變數 | 預設值 | 說明 |
|
||||
| --- | --- | --- |
|
||||
| `DATABASE_URL` | `file:./prisma/dev.db` | Prisma 連線字串。R-5 若遷移到 PostgreSQL,改為 `postgresql://user:password@host:5432/db` 並將 `prisma/schema.prisma` 的 `provider` 由 `sqlite` 換成 `postgresql`。 |
|
||||
| `PORT` | `3001` | api(NestJS)監聽埠。 |
|
||||
| `PORT_API` / `PORT_WEB` | `3001` / `3100` | `pnpm run restart`(`scripts/dev-restart.mjs`)啟動 api/web 時使用的埠號。 |
|
||||
| `WEB_ORIGIN` | `http://localhost:3100` | api 的 CORS 允許來源之一;`http://localhost:8081`(Expo web 開發伺服器)已固定加入,不受此變數影響。 |
|
||||
| `NEXT_PUBLIC_API_URL` | `http://localhost:3001` | web(Next.js)呼叫 api 的位址,build time 注入,正式環境需在建置時設定。 |
|
||||
| `LLM_PROVIDER` | `mock` | `mock`(模板庫,冒煙測試固定用這個)/`claude`(呼叫真實 API,見下)。 |
|
||||
| `CLAUDE_BASE_URL` | `http://localhost:3000/api/v1` | OpenAI 相容的 chat completions 端點基底路徑。本專案的部署方式是透過 [CLIProxy](https://gitea.jsc.idv.tw/jiantw83/CLIProxy)(分散式派工代理,本機 3000 埠)轉發到真實 Claude,而非直連 `api.anthropic.com`;若要直連官方 API,把這個值換成 `https://api.anthropic.com/v1` 並確認金鑰/請求格式相容即可,`ClaudeProvider` 的呼叫端程式碼不需要改動。 |
|
||||
| `CLAUDE_API_KEY` | (無) | 呼叫 `CLAUDE_BASE_URL` 用的金鑰,`LLM_PROVIDER=claude` 時必填。 |
|
||||
| `CLAUDE_MODEL` | `claude-sonnet-4-5` | 要求 Provider 使用的模型名稱;CLIProxy 端需要有對應的 Worker 已註冊、能接這個模型的派工,否則會回報「沒有任何可用的 {模型}-{執行者}-{工具} 組合」。 |
|
||||
| `TTS_PROVIDER` | `mock` | `mock`/`real`(P-4 空殼,尚未接上真實供應商,人工確認後才實作)。 |
|
||||
| `PUSH_PROVIDER` | `mock` | `mock`/`expo`(Q-3,`expo` 直接可用,不需金鑰)。 |
|
||||
|
||||
## 部署路徑
|
||||
|
||||
開發環境:以上服務直接用 `node`/`next dev`/`expo start` 跑在同一台 Linux 主機上
|
||||
(`pnpm run restart` 一鍵重啟 api/web),**不使用 Docker**——這是本專案的實際
|
||||
部署選擇,取代原先技術選型草案中「Docker Compose(開發)」的規劃。
|
||||
|
||||
正式環境建議路徑:
|
||||
|
||||
1. **建置**:`pnpm install --frozen-lockfile && pnpm run build`(turbo 依序建置
|
||||
`packages/shared`/`packages/db`/`apps/api`,並產出 `apps/web` 的 production
|
||||
bundle)。
|
||||
2. **資料庫遷移**:`npx prisma migrate deploy`(依 `DATABASE_URL` 指向的資料庫套用
|
||||
遷移;R-5 遷移到 PostgreSQL 後流程相同,只是連線字串換成 postgresql)。
|
||||
3. **啟動**:
|
||||
- api:`node apps/api/dist/main.js`(讀取上表環境變數)。
|
||||
- web:`apps/web` 下 `npx next start -p <PORT_WEB>`(讀取建置時注入的
|
||||
`NEXT_PUBLIC_API_URL`)。
|
||||
- mobile:`apps/mobile` 透過 `eas build` 產出實際的 App Store/Google Play
|
||||
安裝檔,不隨 api/web 一起常駐部署。
|
||||
4. **程序常駐與重啟**:用 systemd unit(或 pm2)分別管理 api/web 兩個
|
||||
Node 行程,設定 `Restart=on-failure`;健康檢查沿用 `GET /health`
|
||||
(api)與首頁 200(web),與 `scripts/dev-restart.mjs` 開發環境用的
|
||||
檢查邏輯一致。**房間自走(T37 起)需要 api 常駐**——排程與 api 同行程
|
||||
(node-cron,見 `apps/api/src/room/room-scheduler.service.ts`),api 停機
|
||||
期間房間不會推進,重啟後只會補推進一次接上目前時間,不會補齊停機期間
|
||||
錯過的輪數;因排程未加分散式鎖,**api 只能起一個實例**,多實例會讓同一
|
||||
房間被重複推進。
|
||||
5. **反向代理/TLS**:nginx 或雲端負載平衡器終止 TLS 後轉發到 api/web
|
||||
對應埠號,對外只曝露 443。
|
||||
|
||||
這個路徑刻意不假設 K8s 或容器化,只依賴「Node.js 行程 + systemd + nginx」,
|
||||
因為這台開發主機本身就是照這個模式跑(`pnpm run restart` 正是這個模式的
|
||||
開發期簡化版),移植到正式主機時只是把「用 node 直接跑」換成「用 systemd
|
||||
管理同一組 node 指令」,中間不需要引入額外的容器化工具鏈。
|
||||
|
||||
@@ -7,21 +7,24 @@
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"start": "node dist/main.js"
|
||||
"start": "node dist/main.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kokorone/db": "*",
|
||||
"@kokorone/shared": "*",
|
||||
"@kokorone/db": "workspace:*",
|
||||
"@kokorone/shared": "workspace:*",
|
||||
"@nestjs/common": "^11.1.29",
|
||||
"@nestjs/core": "^11.1.29",
|
||||
"@nestjs/event-emitter": "^3.1.0",
|
||||
"@nestjs/platform-express": "^11.1.29",
|
||||
"dotenv": "^17.4.2",
|
||||
"ioredis": "^6.0.0",
|
||||
"node-cron": "^4.6.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-cron": "^3.0.11"
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,38 @@ import { EmotionModule } from "./emotion/emotion.module.js";
|
||||
import { RelationshipModule } from "./relationship/relationship.module.js";
|
||||
import { LlmModule } from "./llm/llm.module.js";
|
||||
import { PersonalityModule } from "./personality/personality.module.js";
|
||||
import { ScheduleModule } from "./schedule/schedule.module.js";
|
||||
import { TaskModule } from "./task/task.module.js";
|
||||
import { RoomModule } from "./room/room.module.js";
|
||||
import { LobbyModule } from "./lobby/lobby.module.js";
|
||||
import { RomanceModule } from "./romance/romance.module.js";
|
||||
import { CanonModule } from "./canon/canon.module.js";
|
||||
import { EpilogueModule } from "./epilogue/epilogue.module.js";
|
||||
import { TachieModule } from "./tachie/tachie.module.js";
|
||||
import { VoiceModule } from "./voice/voice.module.js";
|
||||
import { PushModule } from "./push/push.module.js";
|
||||
import { ClockModule } from "./clock/clock.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
EventEmitterModule.forRoot(),
|
||||
ClockModule,
|
||||
CharactersModule,
|
||||
MemoryModule,
|
||||
EmotionModule,
|
||||
RelationshipModule,
|
||||
LlmModule,
|
||||
PersonalityModule,
|
||||
ScheduleModule,
|
||||
TaskModule,
|
||||
RoomModule,
|
||||
LobbyModule,
|
||||
RomanceModule,
|
||||
CanonModule,
|
||||
EpilogueModule,
|
||||
TachieModule,
|
||||
VoiceModule,
|
||||
PushModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { log } from "@kokorone/shared";
|
||||
|
||||
let client: Redis | null = null;
|
||||
let loggedUnavailable = false;
|
||||
|
||||
// R-2:session 快取/情緒狀態即時讀寫改走 Redis。連線失敗時呼叫端一律要能安全退回原本的行為
|
||||
// (工作記憶退回行程記憶體 Map、情緒狀態退回直接讀寫 Prisma),Redis 只是加速層,不是新的必要依賴,
|
||||
// 這樣即使部署環境還沒有 Redis(例如這個沒有 Docker/sudo 的沙盒),既有行為也不會被打斷。
|
||||
export function getRedisClient(): Redis {
|
||||
if (!client) {
|
||||
client = new Redis(process.env.REDIS_URL ?? "redis://127.0.0.1:6379", {
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 1,
|
||||
retryStrategy: () => null,
|
||||
connectTimeout: 300,
|
||||
});
|
||||
client.on("error", () => {
|
||||
if (!loggedUnavailable) {
|
||||
loggedUnavailable = true;
|
||||
log("啟動", "WRN", `Redis 無法連線(REDIS_URL=${process.env.REDIS_URL ?? "redis://127.0.0.1:6379"}),相關快取將退回原本行為`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
|
||||
// M-9 錨定點與知識邊界:錨定點之前是角色的親身經歷、之後不存在。場景匯入時只寫入共用場景資料庫,
|
||||
// 只有 storyOrder <= 錨定點的場景會被投影進角色記憶——移動錨定點(無論前進或後退)都要讓「已投影」的
|
||||
// 場景集合與「storyOrder <= 錨定點」的場景集合重新同步。
|
||||
@Injectable()
|
||||
export class AnchorService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly projection: ProjectionService,
|
||||
private readonly roster: WorkRosterService,
|
||||
) {}
|
||||
|
||||
async setAnchor(workId: string, newStoryOrder: number): Promise<{ projected: number; unprojected: number }> {
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||
|
||||
const toProject = await this.prisma.client.scene.findMany({
|
||||
where: { workId, storyOrder: { lte: newStoryOrder }, projected: false },
|
||||
include: { participants: true },
|
||||
});
|
||||
for (const scene of toProject) {
|
||||
await this.projection.projectScene(scene.id);
|
||||
for (const presence of scene.participants) {
|
||||
await this.roster.checkAndPromote(presence.characterId);
|
||||
}
|
||||
}
|
||||
|
||||
// 錨定點後退:她「不再知道」超出新錨定點的後段劇情——移除這些場景先前投影出的所有角色資料。
|
||||
// T17/T25:清除清單與 ProjectionService.clearProjectedData() 共用同一份,避免這裡各自維護一份
|
||||
// 清單、日後新增的投影資料類型忘了同步加在兩處(先前 CharacterEmotionBaseline/ProceduralRule
|
||||
// 上線時就漏了這裡,直到本次才發現並補上)。
|
||||
const toUnproject = await this.prisma.client.scene.findMany({
|
||||
where: { workId, storyOrder: { gt: newStoryOrder }, projected: true },
|
||||
});
|
||||
for (const scene of toUnproject) {
|
||||
await this.projection.clearProjectedData(scene.id);
|
||||
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: false } });
|
||||
}
|
||||
|
||||
await this.prisma.client.work.update({ where: { id: work.id }, data: { anchorStoryOrder: newStoryOrder } });
|
||||
return { projected: toProject.length, unprojected: toUnproject.length };
|
||||
}
|
||||
|
||||
// M-9 依錨定點取用「當時的她」的性格參數:取 atStoryOrder <= 錨定點裡最新的一筆快照。
|
||||
async effectivePersonalitySnapshot(characterId: string, atStoryOrder: number) {
|
||||
return this.prisma.client.personalityTraitSnapshot.findFirst({
|
||||
where: { characterId, atStoryOrder: { lte: atStoryOrder } },
|
||||
orderBy: { atStoryOrder: "desc" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { FINGERPRINT_WEIGHT, TURN_TAKING_WEIGHT, CONFIRM_THRESHOLD, FINGERPRINT_CONTRADICTION_SCORE } from "./constants.js";
|
||||
|
||||
interface CandidateScore {
|
||||
characterId: string;
|
||||
score: number;
|
||||
fingerprintScore: number;
|
||||
}
|
||||
|
||||
// M-5/M-6 歸屬時的多重證據與信心分數,以及隔離區機制:
|
||||
// 明示標記(決定性)> 語言指紋(強)> 場景名冊(硬約束,篩掉不在場者)> 對話輪替(中)> 內容合理性(中,本實作暫略)。
|
||||
// 低信心一律隔離,不寫入任何角色——寧可缺這句語料,不可污染角色資料。
|
||||
@Injectable()
|
||||
export class AttributionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly fingerprint: FingerprintService,
|
||||
) {}
|
||||
|
||||
// 冷啟動順序:先處理有明示標記的台詞(建立指紋基準),再回頭處理無標記對話。
|
||||
async attributeScene(sceneId: string): Promise<void> {
|
||||
const [scene, presences] = await Promise.all([
|
||||
this.prisma.client.scene.findUniqueOrThrow({ where: { id: sceneId } }),
|
||||
this.prisma.client.scenePresence.findMany({ where: { sceneId } }),
|
||||
]);
|
||||
const presentCharacterIds = presences.map((p) => p.characterId);
|
||||
|
||||
const lines = await this.prisma.client.sceneLine.findMany({
|
||||
where: { sceneId, status: "PENDING" },
|
||||
orderBy: { lineIndex: "asc" },
|
||||
});
|
||||
|
||||
const markerLines = lines.filter((line) => line.hasExplicitMarker);
|
||||
const unmarkedLines = lines.filter((line) => !line.hasExplicitMarker);
|
||||
|
||||
for (const line of markerLines) {
|
||||
await this.confirm(line.id, line.markerCharacterId!, 1, scene.id, line.lineType);
|
||||
}
|
||||
|
||||
for (const line of unmarkedLines) {
|
||||
if (line.lineType === "NARRATION") {
|
||||
// 敘述/新登場設定沒有「說話者」的歸屬問題,直接視為已確認(不需要指紋比對)。
|
||||
await this.confirm(line.id, null, 1, scene.id, line.lineType);
|
||||
continue;
|
||||
}
|
||||
await this.attributeLine(line.id, sceneId, presentCharacterIds);
|
||||
}
|
||||
}
|
||||
|
||||
private async attributeLine(lineId: string, sceneId: string, presentCharacterIds: string[]): Promise<void> {
|
||||
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||
|
||||
if (presentCharacterIds.length === 0) {
|
||||
await this.quarantine(lineId);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousConfirmed = await this.prisma.client.sceneLine.findFirst({
|
||||
where: { sceneId, lineIndex: { lt: line.lineIndex }, status: "CONFIRMED", speakerCharacterId: { not: null } },
|
||||
orderBy: { lineIndex: "desc" },
|
||||
});
|
||||
|
||||
const candidates: CandidateScore[] = [];
|
||||
for (const characterId of presentCharacterIds) {
|
||||
const fingerprintScore = await this.fingerprint.matchScore(line.rawText, characterId);
|
||||
// 對話輪替:兩人場景中,上一句已確認的發言者若不是這位候選人,給予輪替加成(一問一答的結構)。
|
||||
const turnTakingScore =
|
||||
presentCharacterIds.length === 2 && previousConfirmed && previousConfirmed.speakerCharacterId !== characterId
|
||||
? 1
|
||||
: 0.5;
|
||||
const score = fingerprintScore * FINGERPRINT_WEIGHT + turnTakingScore * TURN_TAKING_WEIGHT;
|
||||
candidates.push({ characterId, score, fingerprintScore });
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
const best = candidates[0];
|
||||
|
||||
if (best.score < CONFIRM_THRESHOLD || best.fingerprintScore < FINGERPRINT_CONTRADICTION_SCORE) {
|
||||
await this.quarantine(lineId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.confirm(lineId, best.characterId, best.score, sceneId, line.lineType);
|
||||
}
|
||||
|
||||
private async confirm(
|
||||
lineId: string,
|
||||
speakerCharacterId: string | null,
|
||||
confidence: number,
|
||||
sceneId: string,
|
||||
lineType: string,
|
||||
): Promise<void> {
|
||||
await this.prisma.client.sceneLine.update({
|
||||
where: { id: lineId },
|
||||
data: { speakerCharacterId, confidence, status: "CONFIRMED" },
|
||||
});
|
||||
if (speakerCharacterId && lineType === "DIALOGUE") {
|
||||
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||
await this.prisma.client.languageCorpusEntry.create({
|
||||
data: { characterId: speakerCharacterId, sceneId, text: line.rawText },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async quarantine(lineId: string): Promise<void> {
|
||||
await this.prisma.client.sceneLine.update({ where: { id: lineId }, data: { status: "QUARANTINED", confidence: 0 } });
|
||||
}
|
||||
|
||||
// M-6 人工裁決:解除隔離,直接指定歸屬並寫入場景資料庫(含語料)。
|
||||
async adjudicate(lineId: string, speakerCharacterId: string): Promise<void> {
|
||||
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||
await this.confirm(lineId, speakerCharacterId, 1, line.sceneId, line.lineType);
|
||||
}
|
||||
|
||||
// M-6 指紋精煉:語料變多後,回頭重新嘗試隔離區裡的台詞,部分可望被釋放入庫。
|
||||
async reattemptQuarantined(sceneId: string): Promise<number> {
|
||||
const presences = await this.prisma.client.scenePresence.findMany({ where: { sceneId } });
|
||||
const presentCharacterIds = presences.map((p) => p.characterId);
|
||||
const quarantined = await this.prisma.client.sceneLine.findMany({
|
||||
where: { sceneId, status: "QUARANTINED" },
|
||||
orderBy: { lineIndex: "asc" },
|
||||
});
|
||||
|
||||
let released = 0;
|
||||
for (const line of quarantined) {
|
||||
await this.prisma.client.sceneLine.update({ where: { id: line.id }, data: { status: "PENDING" } });
|
||||
await this.attributeLine(line.id, sceneId, presentCharacterIds);
|
||||
const updated = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: line.id } });
|
||||
if (updated.status === "CONFIRMED") {
|
||||
released += 1;
|
||||
}
|
||||
}
|
||||
return released;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
import { SceneImportService } from "./scene-import.service.js";
|
||||
|
||||
// T25 驗收:匯入一章後可看到新增與被強化的規則各自的來源章節,且重複匯入相同行為不會產生同義重複規則。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const endingDetection = new EndingDetectionService(prisma, new MockProvider());
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
|
||||
const WORK_ID = "test-t25-work";
|
||||
const CHAR_A = "test-t25-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.proceduralRule.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWorkAndCharacter(anchorStoryOrder = 100) {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T25 測試作品", anchorStoryOrder } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("ACTION 描寫投影為 ProceduralRule(T25)", () => {
|
||||
it("匯入含 ACTION 描寫的章節後,新增規則記錄來源章節", async () => {
|
||||
await createWorkAndCharacter();
|
||||
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲被老師點名回答問題。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "ACTION", rawText: "小甲害羞地低下頭。", hasExplicitMarker: true, markerCharacterId: CHAR_A }],
|
||||
});
|
||||
expect(scene.projected).toBe(true);
|
||||
|
||||
const rule = await prisma.client.proceduralRule.findFirstOrThrow({ where: { characterId: CHAR_A } });
|
||||
expect(rule.situation).toBe("小甲被老師點名回答問題。");
|
||||
expect(rule.responsePattern).toBe("小甲害羞地低下頭。");
|
||||
expect(rule.weight).toBe(1);
|
||||
expect(rule.sourceSceneId).toBe(scene.id);
|
||||
}, 20000);
|
||||
|
||||
it("再匯入相同情境與回應模式的另一章時,強化既有規則的權重並更新來源章節,不產生重複規則", async () => {
|
||||
await createWorkAndCharacter();
|
||||
|
||||
const sceneA = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲被老師點名回答問題。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "ACTION", rawText: "小甲害羞地低下頭。", hasExplicitMarker: true, markerCharacterId: CHAR_A }],
|
||||
});
|
||||
|
||||
const sceneB = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第二章",
|
||||
storyOrder: 2,
|
||||
summary: "小甲被老師點名回答問題。", // 同樣的情境敘述
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "ACTION", rawText: "小甲害羞地低下頭。", hasExplicitMarker: true, markerCharacterId: CHAR_A }],
|
||||
});
|
||||
|
||||
const rules = await prisma.client.proceduralRule.findMany({ where: { characterId: CHAR_A } });
|
||||
expect(rules).toHaveLength(1); // 不會產生同義重複規則
|
||||
expect(rules[0].weight).toBe(2); // 首次 1,強化一次後 2
|
||||
expect(rules[0].sourceSceneId).toBe(sceneB.id); // 記錄最近一次觸發的來源章節
|
||||
expect(sceneA.id).not.toBe(sceneB.id);
|
||||
}, 20000);
|
||||
|
||||
it("不同情境或不同回應模式視為不同規則,各自累計", async () => {
|
||||
await createWorkAndCharacter();
|
||||
|
||||
await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲被老師點名回答問題。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "ACTION", rawText: "小甲害羞地低下頭。", hasExplicitMarker: true, markerCharacterId: CHAR_A }],
|
||||
});
|
||||
await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第二章",
|
||||
storyOrder: 2,
|
||||
summary: "小甲在派對上收到禮物。", // 不同情境
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "ACTION", rawText: "小甲開心地跳了起來。", hasExplicitMarker: true, markerCharacterId: CHAR_A }],
|
||||
});
|
||||
|
||||
const rules = await prisma.client.proceduralRule.findMany({ where: { characterId: CHAR_A } });
|
||||
expect(rules).toHaveLength(2);
|
||||
expect(rules.every((r) => r.weight === 1)).toBe(true);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
|
||||
import type { IllustrationType } from "@kokorone/db";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { SceneImportService, type ImportSceneInput } from "./scene-import.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { CorrectionService } from "./correction.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { IllustrationService } from "./illustration.service.js";
|
||||
import { AnchorService } from "./anchor.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
|
||||
@Controller("canon")
|
||||
export class CanonController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sceneImport: SceneImportService,
|
||||
private readonly attribution: AttributionService,
|
||||
private readonly correction: CorrectionService,
|
||||
private readonly roster: WorkRosterService,
|
||||
private readonly illustration: IllustrationService,
|
||||
private readonly anchor: AnchorService,
|
||||
private readonly timeline: TimelineService,
|
||||
private readonly endingDetection: EndingDetectionService,
|
||||
) {}
|
||||
|
||||
// M-11:待裁決畫面需要先選一部作品才能往下查場景/台詞,既有 API 沒有列出作品的端點,補上這個最小讀取端點。
|
||||
@Get("works")
|
||||
async listWorks() {
|
||||
return this.prisma.client.work.findMany({ orderBy: { createdAt: "desc" } });
|
||||
}
|
||||
|
||||
@Post("scenes")
|
||||
async importScene(@Body() body: ImportSceneInput) {
|
||||
return this.sceneImport.importScene(body);
|
||||
}
|
||||
|
||||
@Get("scenes/:sceneId")
|
||||
async getScene(@Param("sceneId") sceneId: string) {
|
||||
return this.prisma.client.scene.findUniqueOrThrow({
|
||||
where: { id: sceneId },
|
||||
include: { participants: true, lines: true, facts: true },
|
||||
});
|
||||
}
|
||||
|
||||
@Get("works/:workId/scenes")
|
||||
async listScenes(@Param("workId") workId: string) {
|
||||
return this.prisma.client.scene.findMany({ where: { workId }, orderBy: { storyOrder: "asc" } });
|
||||
}
|
||||
|
||||
// T39:裁決完(不再有隔離台詞或待確認矛盾)後才執行階段二投影與角色建置門檻檢查,
|
||||
// 使用者不需要另外手動觸發——這三個既有端點裁決完成後都會自動接續嘗試。
|
||||
@Post("scene-lines/:lineId/adjudicate")
|
||||
async adjudicate(@Param("lineId") lineId: string, @Body() body: { speakerCharacterId: string }) {
|
||||
await this.attribution.adjudicate(lineId, body.speakerCharacterId);
|
||||
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||
await this.sceneImport.tryProjectScene(line.sceneId);
|
||||
return line;
|
||||
}
|
||||
|
||||
@Post("scenes/:sceneId/reattempt-quarantined")
|
||||
async reattemptQuarantined(@Param("sceneId") sceneId: string) {
|
||||
const released = await this.attribution.reattemptQuarantined(sceneId);
|
||||
await this.sceneImport.tryProjectScene(sceneId);
|
||||
return { released };
|
||||
}
|
||||
|
||||
@Post("scene-lines/:lineId/correct")
|
||||
async correctAttribution(@Param("lineId") lineId: string, @Body() body: { newSpeakerCharacterId: string; now?: string }) {
|
||||
await this.correction.correctLineAttribution(lineId, body.newSpeakerCharacterId, body.now ? new Date(body.now) : undefined);
|
||||
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||
await this.sceneImport.tryProjectScene(line.sceneId);
|
||||
return line;
|
||||
}
|
||||
|
||||
// T39:待裁決清單——場景自身有隔離中的台詞、或所屬作品有待人工確認的矛盾時,都算「有待裁決項目」;
|
||||
// 供大廳作品卡片提示(T19-P4)與 smoke 驗證共用。
|
||||
@Get("works/:workId/pending-adjudication")
|
||||
async listPendingAdjudication(@Param("workId") workId: string) {
|
||||
const scenes = await this.prisma.client.scene.findMany({ where: { workId, projected: false } });
|
||||
const results = [];
|
||||
for (const scene of scenes) {
|
||||
const detail = await this.sceneImport.getSceneDetail(scene.id);
|
||||
if (detail.pendingAdjudication) results.push(detail);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// T13:作品頁可查詢待確認的結局偵測結果(含判定理由);確認/拒絕操作見 T14(epilogue 模組)。
|
||||
@Get("works/:workId/ending-detections")
|
||||
async listPendingEndingDetections(@Param("workId") workId: string) {
|
||||
return this.endingDetection.listPendingForWork(workId);
|
||||
}
|
||||
|
||||
@Get("characters/:characterId/roster-progress")
|
||||
async rosterProgress(@Param("characterId") characterId: string) {
|
||||
return this.roster.getProgress(characterId);
|
||||
}
|
||||
|
||||
@Post("illustrations")
|
||||
async registerIllustration(
|
||||
@Body()
|
||||
body: {
|
||||
workId: string;
|
||||
volume: number;
|
||||
page?: number;
|
||||
type: IllustrationType;
|
||||
sceneId?: string;
|
||||
description: string;
|
||||
costumeLabels?: { characterId: string; label: string }[];
|
||||
poseLabels?: { characterId: string; label: string }[];
|
||||
},
|
||||
) {
|
||||
return this.illustration.register(body);
|
||||
}
|
||||
|
||||
@Get("scenes/:sceneId/illustrations")
|
||||
async illustrationsForScene(@Param("sceneId") sceneId: string) {
|
||||
return this.illustration.findByScene(sceneId);
|
||||
}
|
||||
|
||||
@Get("characters/:characterId/costume-catalog")
|
||||
async costumeCatalog(@Param("characterId") characterId: string) {
|
||||
return this.illustration.listCostumeCatalog(characterId);
|
||||
}
|
||||
|
||||
@Get("characters/:characterId/pose-vocabulary")
|
||||
async poseVocabulary(@Param("characterId") characterId: string) {
|
||||
return this.illustration.listPoseVocabulary(characterId);
|
||||
}
|
||||
|
||||
@Post("works/:workId/anchor")
|
||||
async setAnchor(@Param("workId") workId: string, @Body() body: { storyOrder: number }) {
|
||||
return this.anchor.setAnchor(workId, body.storyOrder);
|
||||
}
|
||||
|
||||
@Post("works/:workId/detect-contradictions")
|
||||
async detectContradictions(@Param("workId") workId: string) {
|
||||
const created = await this.timeline.detectContradictions(workId);
|
||||
// T39:矛盾是作品層級的,解決後可能同時解除多個場景的封鎖,逐一嘗試整個作品尚未投影的場景。
|
||||
await this.sceneImport.tryProjectPendingScenesForWork(workId);
|
||||
return { created };
|
||||
}
|
||||
|
||||
@Get("works/:workId/contradictions")
|
||||
async listContradictions(@Param("workId") workId: string) {
|
||||
return this.timeline.listContradictions(workId);
|
||||
}
|
||||
|
||||
// T39:人工裁決矛盾——原本沒有任何端點能把 PENDING_HUMAN_REVIEW 的矛盾標記解決,含這類矛盾的作品
|
||||
// 會永久卡住無法投影;解決後嘗試接續投影整個作品裡尚未投影的場景。
|
||||
@Post("contradictions/:contradictionId/resolve")
|
||||
async resolveContradiction(@Param("contradictionId") contradictionId: string, @Body() body: { resolution: string }) {
|
||||
const contradiction = await this.prisma.client.sceneContradiction.findUniqueOrThrow({ where: { id: contradictionId } });
|
||||
await this.timeline.resolveContradiction(contradictionId, body.resolution);
|
||||
await this.sceneImport.tryProjectPendingScenesForWork(contradiction.workId);
|
||||
return this.prisma.client.sceneContradiction.findUniqueOrThrow({ where: { id: contradictionId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { LlmModule } from "../llm/llm.module.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { IllustrationService } from "./illustration.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { SceneImportService } from "./scene-import.service.js";
|
||||
import { AnchorService } from "./anchor.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { CorrectionService } from "./correction.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
import { CanonController } from "./canon.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EmotionModule, LlmModule],
|
||||
controllers: [CanonController],
|
||||
providers: [
|
||||
FingerprintService,
|
||||
WorkRosterService,
|
||||
IllustrationService,
|
||||
AttributionService,
|
||||
ProjectionService,
|
||||
SceneImportService,
|
||||
AnchorService,
|
||||
TimelineService,
|
||||
CorrectionService,
|
||||
EndingDetectionService,
|
||||
],
|
||||
exports: [
|
||||
FingerprintService,
|
||||
WorkRosterService,
|
||||
IllustrationService,
|
||||
AttributionService,
|
||||
ProjectionService,
|
||||
SceneImportService,
|
||||
AnchorService,
|
||||
TimelineService,
|
||||
CorrectionService,
|
||||
EndingDetectionService,
|
||||
],
|
||||
})
|
||||
export class CanonModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
// M-2 建置門檻:新角色候補累積到這些條件才正式建置(路人只出現一次不值得建置)。
|
||||
export const BUILD_THRESHOLD_SCENES = 3; // 出場場景數
|
||||
export const BUILD_THRESHOLD_NAMED_LINES = 5; // 具名(已 CONFIRMED)台詞數
|
||||
export const BUILD_THRESHOLD_INTERACTIONS = 1; // 與其他角色的實質互動(關係帳本事件)數
|
||||
|
||||
// M-5 歸屬信心分數:多重證據合成的權重與門檻。
|
||||
export const FINGERPRINT_WEIGHT = 0.6;
|
||||
export const TURN_TAKING_WEIGHT = 0.4;
|
||||
export const CONFIRM_THRESHOLD = 0.55;
|
||||
// M-6 指紋回檢:候選角色的指紋分數低於此值,即使總分達門檻仍視為與既有指紋矛盾,隔離不寫入。
|
||||
export const FINGERPRINT_CONTRADICTION_SCORE = 0.15;
|
||||
|
||||
// M-5/M-6 語言指紋:學習自 LanguageCorpusEntry 的特徵詞頻率統計(人稱/語尾/口癖),非原型模板。
|
||||
export const SIGNAL_TOKENS = ["呢~", "本大爺", "的說", "喵"];
|
||||
// 語料達此則數以上,才認定「指紋已建立」(樣本太少時中性看待,不視為矛盾)。
|
||||
export const FINGERPRINT_MIN_SAMPLES = 3;
|
||||
|
||||
// M-8 矛盾裁決優先序(數值越小優先權越高):小說原文 > 官方設定集 > 動畫改編。
|
||||
export const SOURCE_PRIORITY: Record<string, number> = {
|
||||
NOVEL: 1,
|
||||
OFFICIAL_SETTEI: 2,
|
||||
ANIME_ADAPTATION: 3,
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
|
||||
// M-7/M-10 歸屬修正與重新投影:修正場景資料庫只改一處,受影響角色的資料同步更新;
|
||||
// 已上線角色發現歷史歸屬錯誤時,使用者互動記憶(source=INTERACTION)從不被觸碰——
|
||||
// 只有由這個場景投影而來的資料(sourceSceneId 指到它)會被清掉重建。發現得晚的錯誤額外記一筆
|
||||
// 「想起來其實不是這樣」的新事件,而不是假裝沒發生過。
|
||||
@Injectable()
|
||||
export class CorrectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly projection: ProjectionService,
|
||||
) {}
|
||||
|
||||
async correctLineAttribution(lineId: string, newSpeakerCharacterId: string, now: Date = new Date()): Promise<void> {
|
||||
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||
const oldSpeakerCharacterId = line.speakerCharacterId;
|
||||
if (oldSpeakerCharacterId === newSpeakerCharacterId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scene = await this.prisma.client.scene.findUniqueOrThrow({ where: { id: line.sceneId } });
|
||||
const wasAlreadyProjected = scene.projected;
|
||||
|
||||
if (line.lineType === "DIALOGUE" && oldSpeakerCharacterId) {
|
||||
await this.prisma.client.languageCorpusEntry.deleteMany({
|
||||
where: { characterId: oldSpeakerCharacterId, sceneId: scene.id, text: line.rawText },
|
||||
});
|
||||
await this.prisma.client.languageCorpusEntry.create({
|
||||
data: { characterId: newSpeakerCharacterId, sceneId: scene.id, text: line.rawText },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.client.sceneLine.update({
|
||||
where: { id: lineId },
|
||||
data: { speakerCharacterId: newSpeakerCharacterId, status: "CONFIRMED", confidence: 1 },
|
||||
});
|
||||
|
||||
if (!wasAlreadyProjected) {
|
||||
// 這場戲還沒被投影過(超前於錨定點,或當初就隔離未寫入),修正場景資料庫即可,沒有已投影的舊資料要清。
|
||||
return;
|
||||
}
|
||||
|
||||
await this.projection.reprojectScene(scene.id);
|
||||
|
||||
if (oldSpeakerCharacterId) {
|
||||
await this.recordRealization(
|
||||
oldSpeakerCharacterId,
|
||||
`想起來,那句話其實不是我說的——是我記錯了。`,
|
||||
now,
|
||||
);
|
||||
}
|
||||
await this.recordRealization(newSpeakerCharacterId, `原來那句話其實是我說的,之前記混了。`, now);
|
||||
}
|
||||
|
||||
private async recordRealization(characterId: string, content: string, now: Date): Promise<void> {
|
||||
await this.prisma.client.episodicMemory.create({
|
||||
data: {
|
||||
characterId,
|
||||
content,
|
||||
occurredAt: now,
|
||||
emotionTag: "CALM",
|
||||
emotionIntensity: 0.3,
|
||||
source: "CORRECTION",
|
||||
weight: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { SleepDebtService } from "../schedule/sleep-debt.service.js";
|
||||
import { ScheduleEmotionBaselineService } from "../schedule/schedule-emotion-baseline.service.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
import { SceneImportService } from "./scene-import.service.js";
|
||||
|
||||
// T17 驗收:匯入悲劇章節後該角色整體基調偏低落(透過 ScheduleEmotionBaselineService.computeBaseline()
|
||||
// 這個既有的合成點觀察,不直接檢查 EmotionState);reprojectScene() 重投影時一致清除舊的基線紀錄;
|
||||
// 平淡場景不記錄基線;本項全程不寫 EmotionState 六維、不重設其 updatedAt。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
const WEEKDAY_NOW = new Date("2026-08-19T05:00:00.000Z"); // 台北週三 13:00,避開週末的假期基線
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const endingDetection = new EndingDetectionService(prisma, new MockProvider());
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
const scheduleService = new ScheduleService(prisma);
|
||||
const sleepDebtService = new SleepDebtService(prisma, scheduleService);
|
||||
const scheduleBaseline = new ScheduleEmotionBaselineService(prisma, scheduleService, sleepDebtService);
|
||||
|
||||
const WORK_ID = "test-t17-work";
|
||||
const CHAR_A = "test-t17-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.characterEmotionBaseline.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.emotionState.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWorkAndCharacter(anchorStoryOrder = 100) {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T17 測試作品", anchorStoryOrder } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("章節匯入的情緒基線連動(T17)", () => {
|
||||
it("匯入悲劇章節後,作息基線的合成結果偏向低落;沒有寫入 EmotionState", async () => {
|
||||
await createWorkAndCharacter();
|
||||
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲最深愛的家人離開了,她感到非常難過,好傷心。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲哭了。" }],
|
||||
});
|
||||
expect(scene.projected).toBe(true);
|
||||
|
||||
const baselineRow = await prisma.client.characterEmotionBaseline.findFirstOrThrow({
|
||||
where: { characterId: CHAR_A },
|
||||
});
|
||||
expect(baselineRow.emotionTag).toBe("SAD");
|
||||
expect(baselineRow.sourceSceneId).toBe(scene.id);
|
||||
|
||||
const synthesized = await scheduleBaseline.computeBaseline(CHAR_A, WEEKDAY_NOW);
|
||||
expect(synthesized?.tag).toBe("SAD");
|
||||
|
||||
// T17 硬性約束:全程不得直接寫 EmotionState 六維或重設其 updatedAt。
|
||||
const emotionState = await prisma.client.emotionState.findUnique({ where: { characterId: CHAR_A } });
|
||||
expect(emotionState).toBeNull();
|
||||
}, 20000);
|
||||
|
||||
it("平淡場景不記錄基線;重投影時一致清除舊的基線紀錄", async () => {
|
||||
await createWorkAndCharacter();
|
||||
|
||||
const neutralScene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲去便利商店買了飲料。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "今天天氣普通。" }],
|
||||
});
|
||||
const rowsAfterNeutral = await prisma.client.characterEmotionBaseline.findMany({ where: { characterId: CHAR_A } });
|
||||
expect(rowsAfterNeutral).toHaveLength(0);
|
||||
|
||||
const sadScene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第二章",
|
||||
storyOrder: 2,
|
||||
summary: "小甲失去了摯友,非常難過委屈。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲難過地離開了。" }],
|
||||
});
|
||||
expect(await prisma.client.characterEmotionBaseline.count({ where: { characterId: CHAR_A } })).toBe(1);
|
||||
|
||||
await projection.reprojectScene(sadScene.id);
|
||||
const rowsAfterReproject = await prisma.client.characterEmotionBaseline.findMany({
|
||||
where: { characterId: CHAR_A },
|
||||
});
|
||||
expect(rowsAfterReproject).toHaveLength(1); // 清掉重建後仍是同一筆(同一場景),不會累積或消失
|
||||
expect(rowsAfterReproject[0].sourceSceneId).toBe(sadScene.id);
|
||||
void neutralScene;
|
||||
}, 20000);
|
||||
|
||||
it("作息本身有更急迫的訊號時(睡眠債),優先於章節匯入的長期基調", async () => {
|
||||
await createWorkAndCharacter();
|
||||
await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲失去了摯友,非常難過委屈。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲難過地離開了。" }],
|
||||
});
|
||||
|
||||
await prisma.client.sleepDebt.create({ data: { characterId: CHAR_A, accumulatedMinutes: 90 } });
|
||||
const synthesized = await scheduleBaseline.computeBaseline(CHAR_A, WEEKDAY_NOW);
|
||||
expect(synthesized?.tag).toBe("SAD"); // 恰好同為 SAD,改用強度驗證優先序真的走了睡眠債分支
|
||||
expect(synthesized?.intensity).toBe(0.4); // SLEEP_DEBT_TIRED_INTENSITY,而非章節基線的強度
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
import { SceneImportService } from "./scene-import.service.js";
|
||||
|
||||
// T13 驗收:匯入結局章節後作品頁出現提示且附判定理由,Work.inEpilogue 在未確認前保持不變,
|
||||
// 非結局章節不會誤提示;判定不看章節標題(chapterLabel 故意取無結局意味的名稱)。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const mockProvider = new MockProvider();
|
||||
const endingDetection = new EndingDetectionService(prisma, mockProvider);
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
|
||||
const WORK_ID = "test-t13-work";
|
||||
const CHAR_A = "test-t13-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.endingDetectionLog.deleteMany({ where: { scene: { workId: WORK_ID } } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWork() {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T13 測試作品", anchorStoryOrder: 100 } });
|
||||
}
|
||||
|
||||
async function createCharacter() {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("EndingDetectionService(T13)", () => {
|
||||
it("匯入結局章節後,作品頁可查到待確認的偵測結果且附判定理由;Work.inEpilogue 保持不變", async () => {
|
||||
await createWork();
|
||||
await createCharacter();
|
||||
|
||||
// chapterLabel 故意取一個完全不帶結局意味的名稱,證明判定依據是場景內容本身而非章節標題。
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 3,
|
||||
chapterLabel: "第五十話",
|
||||
storyOrder: 1,
|
||||
summary: "眾人在最後一戰後道別,故事到此結束,全劇終。",
|
||||
rawText: "(測試文本)眾人告別,全劇終。",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "故事在此完結。" }],
|
||||
});
|
||||
expect(scene.projected).toBe(true);
|
||||
|
||||
const pending = await endingDetection.listPendingForWork(WORK_ID);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].sceneId).toBe(scene.id);
|
||||
expect(pending[0].evidence.length).toBeGreaterThan(0);
|
||||
expect(pending[0].resolution).toBe("PENDING");
|
||||
|
||||
const work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.inEpilogue).toBe(false); // T13 本身不得直接修改 Work,確認交給 T14
|
||||
}, 20000);
|
||||
|
||||
it("非結局章節不會誤提示", async () => {
|
||||
await createWork();
|
||||
await createCharacter();
|
||||
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一話:最終決戰!!", // 標題帶「最終」也不該誤判,判斷依據只看場景內容
|
||||
storyOrder: 1,
|
||||
summary: "小甲今天去上學,路上買了早餐。",
|
||||
rawText: "(測試文本)平凡的一天。",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "今天天氣不錯。" }],
|
||||
});
|
||||
expect(scene.projected).toBe(true);
|
||||
|
||||
const pending = await endingDetection.listPendingForWork(WORK_ID);
|
||||
expect(pending).toHaveLength(0);
|
||||
}, 20000);
|
||||
|
||||
it("同一場景不會重複寫入偵測紀錄", async () => {
|
||||
await createWork();
|
||||
await createCharacter();
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一話",
|
||||
storyOrder: 1,
|
||||
summary: "大結局,故事在此完結。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "全劇終。" }],
|
||||
});
|
||||
await endingDetection.detectForScene(scene.id); // 重複呼叫模擬未來可能的重複觸發路徑
|
||||
const logs = await prisma.client.endingDetectionLog.findMany({ where: { sceneId: scene.id } });
|
||||
expect(logs).toHaveLength(1);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js";
|
||||
|
||||
// T13 匯入時的結局偵測與提示:每個場景投影完成後都呼叫,不以章節標題關鍵字預篩(呼叫端只傳場景
|
||||
// 摘要/原文/作品標題,不傳章節標題)。判定成立只寫 EndingDetectionLog(resolution=PENDING),
|
||||
// 絕不直接修改 Work——是否真的切入日後談由使用者在 T14 的確認端點決定。
|
||||
@Injectable()
|
||||
export class EndingDetectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(LLM_PROVIDER) private readonly provider: LLMProvider,
|
||||
) {}
|
||||
|
||||
async detectForScene(sceneId: string): Promise<void> {
|
||||
const scene = await this.prisma.client.scene.findUniqueOrThrow({
|
||||
where: { id: sceneId },
|
||||
include: { work: true },
|
||||
});
|
||||
|
||||
// 同一場景只投影一次(tryProjectScene 對已投影場景是 no-op),理論上不會重複偵測;
|
||||
// 仍加一道保險,避免任何未來呼叫路徑重複寫入同一場景的偵測紀錄。
|
||||
const existing = await this.prisma.client.endingDetectionLog.findFirst({ where: { sceneId } });
|
||||
if (existing) return;
|
||||
|
||||
const result = await this.provider.detectEnding(scene.summary, scene.rawText, scene.work.title);
|
||||
if (!result.isEnding) return;
|
||||
|
||||
await this.prisma.client.endingDetectionLog.create({
|
||||
data: { sceneId, evidence: result.evidence, resolution: "PENDING" },
|
||||
});
|
||||
}
|
||||
|
||||
async listPendingForWork(workId: string) {
|
||||
return this.prisma.client.endingDetectionLog.findMany({
|
||||
where: { scene: { workId }, resolution: "PENDING" },
|
||||
include: { scene: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { SIGNAL_TOKENS, FINGERPRINT_MIN_SAMPLES } from "./constants.js";
|
||||
|
||||
export type Fingerprint = Record<string, number>; // token -> 出現比例 0~1
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
// M-5/M-6 語言指紋:從已確認歸屬的台詞語料統計特徵詞頻率,供歸屬判斷與矛盾回檢使用。
|
||||
// 前幾章的指紋樣本很少時,統計不具代表性——樣本量門檻由 matchScore 的呼叫端(AttributionService)處理。
|
||||
@Injectable()
|
||||
export class FingerprintService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async computeFingerprint(characterId: string): Promise<{ fingerprint: Fingerprint; sampleCount: number }> {
|
||||
const entries = await this.prisma.client.languageCorpusEntry.findMany({ where: { characterId } });
|
||||
const fingerprint: Fingerprint = {};
|
||||
for (const token of SIGNAL_TOKENS) {
|
||||
const hits = entries.filter((entry) => entry.text.includes(token)).length;
|
||||
fingerprint[token] = entries.length > 0 ? hits / entries.length : 0;
|
||||
}
|
||||
return { fingerprint, sampleCount: entries.length };
|
||||
}
|
||||
|
||||
// 分數解讀:0.5 為中性(文本沒有任何特徵詞,或指紋樣本不足以判斷);
|
||||
// 命中角色慣用的特徵詞 → 加分;命中角色從不使用的特徵詞(指紋矛盾) → 顯著扣分。
|
||||
async matchScore(text: string, characterId: string): Promise<number> {
|
||||
const { fingerprint, sampleCount } = await this.computeFingerprint(characterId);
|
||||
const hitTokens = SIGNAL_TOKENS.filter((token) => text.includes(token));
|
||||
if (hitTokens.length === 0) {
|
||||
return 0.5;
|
||||
}
|
||||
if (sampleCount < FINGERPRINT_MIN_SAMPLES) {
|
||||
return 0.5; // 指紋尚未建立,中性看待,不因樣本不足誤判矛盾。
|
||||
}
|
||||
let score = 0.5;
|
||||
for (const token of hitTokens) {
|
||||
const freq = fingerprint[token];
|
||||
if (freq > 0.3) {
|
||||
score += 0.4;
|
||||
} else if (freq > 0) {
|
||||
score += 0.1;
|
||||
} else {
|
||||
score -= 0.4; // 明顯矛盾:這個角色的既有語料從未出現過這個特徵詞。
|
||||
}
|
||||
}
|
||||
return clamp01(score);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { IllustrationType } from "@kokorone/db";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
export interface RegisterIllustrationInput {
|
||||
workId: string;
|
||||
volume: number;
|
||||
page?: number;
|
||||
type: IllustrationType;
|
||||
sceneId?: string;
|
||||
description: string;
|
||||
costumeLabels?: { characterId: string; label: string }[];
|
||||
poseLabels?: { characterId: string; label: string }[];
|
||||
}
|
||||
|
||||
// M-1 插圖索引:不儲存受版權保護的原圖,只登錄畫面描述;同時衍生服裝目錄與姿勢語彙表
|
||||
// ——立繪服裝層之後只能從這個目錄取用,不可自創(見 O 群組立繪子系統會消費這裡的資料)。
|
||||
@Injectable()
|
||||
export class IllustrationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async register(input: RegisterIllustrationInput) {
|
||||
const illustration = await this.prisma.client.illustration.create({
|
||||
data: {
|
||||
workId: input.workId,
|
||||
volume: input.volume,
|
||||
page: input.page,
|
||||
type: input.type,
|
||||
sceneId: input.sceneId,
|
||||
description: input.description,
|
||||
},
|
||||
});
|
||||
|
||||
for (const costume of input.costumeLabels ?? []) {
|
||||
await this.prisma.client.costumeCatalogEntry.create({
|
||||
data: { characterId: costume.characterId, illustrationId: illustration.id, label: costume.label },
|
||||
});
|
||||
}
|
||||
for (const pose of input.poseLabels ?? []) {
|
||||
await this.prisma.client.poseVocabularyEntry.create({
|
||||
data: { characterId: pose.characterId, illustrationId: illustration.id, label: pose.label },
|
||||
});
|
||||
}
|
||||
|
||||
return illustration;
|
||||
}
|
||||
|
||||
// M-1 驗收:可由事件(場景)查到對應插圖。
|
||||
async findByScene(sceneId: string) {
|
||||
return this.prisma.client.illustration.findMany({ where: { sceneId } });
|
||||
}
|
||||
|
||||
async listCostumeCatalog(characterId: string) {
|
||||
return this.prisma.client.costumeCatalogEntry.findMany({ where: { characterId } });
|
||||
}
|
||||
|
||||
async listPoseVocabulary(characterId: string) {
|
||||
return this.prisma.client.poseVocabularyEntry.findMany({ where: { characterId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
|
||||
const CANON_EPOCH_MS = new Date("2000-01-01T00:00:00.000Z").getTime();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// 場景資料庫只有「年表排序鍵」(storyOrder),沒有真實日期;投影成 EpisodicMemory 時需要一個 DateTime,
|
||||
// 用固定虛擬曆元 + storyOrder 天數換算即可——只要求相對順序正確,不代表任何真實日期。
|
||||
function storyOrderToDate(storyOrder: number): Date {
|
||||
return new Date(CANON_EPOCH_MS + storyOrder * DAY_MS);
|
||||
}
|
||||
|
||||
// M-4/M-7 多角色投影:共用場景資料庫只處理一次,各角色資料是投影出來的——修正場景資料庫一處,
|
||||
// 重新投影後所有受影響角色同步更新。M-9 知識邊界:只有 storyOrder <= 錨定點的場景才會被投影。
|
||||
@Injectable()
|
||||
export class ProjectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly tagger: RuleBasedEmotionTagger,
|
||||
private readonly behaviorRule: BehaviorReinforcementService,
|
||||
) {}
|
||||
|
||||
async projectScene(sceneId: string): Promise<void> {
|
||||
const scene = await this.prisma.client.scene.findUniqueOrThrow({
|
||||
where: { id: sceneId },
|
||||
include: { participants: true, lines: true, interactions: true },
|
||||
});
|
||||
const presentCharacterIds = scene.participants.map((p) => p.characterId);
|
||||
const occurredAt = storyOrderToDate(scene.storyOrder);
|
||||
|
||||
// 1) 事件 → 情節記憶:她不在場的不記得——只有在場角色才會拿到這場戲的情節記憶。
|
||||
const signal = this.tagger.tag({ text: scene.summary });
|
||||
const hasMonologue = scene.lines.some((line) => line.lineType === "MONOLOGUE" && line.status === "CONFIRMED");
|
||||
const isHighEmotion = signal.intensity >= HIGH_EMOTION_THRESHOLD;
|
||||
// M-8 情緒權重評分:文本篇幅與心理描寫深度是天然的情緒權重指標。
|
||||
const weight = (isHighEmotion ? 1.5 : 0.8) * (hasMonologue ? 1.4 : 1) * (1 + Math.min(1, scene.rawText.length / 500));
|
||||
for (const characterId of presentCharacterIds) {
|
||||
await this.prisma.client.episodicMemory.create({
|
||||
data: {
|
||||
characterId,
|
||||
content: scene.summary,
|
||||
occurredAt,
|
||||
emotionTag: signal.tag,
|
||||
emotionIntensity: signal.intensity || 0.3,
|
||||
source: "SOURCE_EXTRACTION",
|
||||
weight,
|
||||
sourceSceneId: scene.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2) 台詞 → 語言風格語料:已由 AttributionService 在確認歸屬的當下寫入 LanguageCorpusEntry,這裡不重複處理。
|
||||
|
||||
// 3) 內心獨白/心理描寫 → 性格參數證據。
|
||||
const monologueLines = scene.lines.filter(
|
||||
(line) => line.lineType === "MONOLOGUE" && line.status === "CONFIRMED" && line.speakerCharacterId,
|
||||
);
|
||||
for (const line of monologueLines) {
|
||||
await this.prisma.client.personalityTraitSnapshot.create({
|
||||
data: {
|
||||
characterId: line.speakerCharacterId!,
|
||||
sourceSceneId: scene.id,
|
||||
atStoryOrder: scene.storyOrder,
|
||||
note: line.rawText,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 4) 與他人的互動 → 關係帳本(角色對角色):中立記錄在此刻才真正套用到雙方視角。
|
||||
for (const interaction of scene.interactions) {
|
||||
await this.prisma.client.characterRelationshipEventLog.create({
|
||||
data: {
|
||||
characterId: interaction.characterId,
|
||||
otherCharacterId: interaction.otherCharacterId,
|
||||
sceneId: scene.id,
|
||||
delta: interaction.delta,
|
||||
description: interaction.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 5) 新登場設定 → 語意記憶:NARRATION 為場景中立敘述,在場角色都「知道」這件事。
|
||||
const narrationLines = scene.lines.filter((line) => line.lineType === "NARRATION" && line.status === "CONFIRMED");
|
||||
for (const line of narrationLines) {
|
||||
for (const characterId of presentCharacterIds) {
|
||||
await this.prisma.client.semanticMemory.create({
|
||||
data: { characterId, fact: line.rawText, about: "世界觀", sourceSceneId: scene.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 動作描寫 → 行為規則(T25):ACTION 描述的是「在這個情境下,這個角色做了什麼」,情境取場景摘要
|
||||
// (客觀記錄的「發生了什麼事」),回應模式取這句動作描寫本身;首見新增規則、已有相同情境+回應模式
|
||||
// 則強化既有權重(沿用 F-5 既有的 (situation, responsePattern) 比對,見 BehaviorReinforcementService)。
|
||||
const actionLines = scene.lines.filter(
|
||||
(line) => line.lineType === "ACTION" && line.status === "CONFIRMED" && line.speakerCharacterId,
|
||||
);
|
||||
for (const line of actionLines) {
|
||||
await this.behaviorRule.recordFromScene(line.speakerCharacterId!, scene.summary, line.rawText, scene.id);
|
||||
}
|
||||
|
||||
// 7) 場景整體情緒 → 情緒基線(T17):沿用步驟 1 已算出的場景情緒訊號(同一份客觀記錄,在場者共同
|
||||
// 經歷),累積進在場角色的長期基調;只有出現明確情緒訊號的場景才記一筆,平淡場景不灌水基線資料。
|
||||
// 這裡只寫 CharacterEmotionBaseline,絕不直接寫 EmotionState 六維、也不重設其 updatedAt——
|
||||
// 基線只在 ScheduleEmotionBaselineService.computeBaseline() 取不到更急迫的訊號時才會被拿去參考。
|
||||
if (signal.tag !== "CALM") {
|
||||
for (const characterId of presentCharacterIds) {
|
||||
await this.prisma.client.characterEmotionBaseline.create({
|
||||
data: { characterId, emotionTag: signal.tag, intensity: signal.intensity, sourceSceneId: scene.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: true } });
|
||||
}
|
||||
|
||||
// M-7 重新投影:修正場景資料庫後,先移除這個場景先前投影出的所有資料,再重新投影一次。
|
||||
// 只清「由這個場景投影而來」的紀錄(sourceSceneId 指到它)——使用者互動記憶從不設這個欄位,永遠不受影響。
|
||||
async reprojectScene(sceneId: string): Promise<void> {
|
||||
await this.clearProjectedData(sceneId);
|
||||
await this.projectScene(sceneId);
|
||||
}
|
||||
|
||||
// T39:階段二(投影)失敗時,把這個場景已經寫入一部分的投影結果清乾淨,回到「已裁決但未投影」
|
||||
// 可安全重跑的狀態(不是留一半髒資料卡住),與 reprojectScene() 共用同一份清除邏輯。
|
||||
async clearProjectedData(sceneId: string): Promise<void> {
|
||||
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId } });
|
||||
// T25:已知簡化——sourceSceneId 只記錄「最近一次」觸發,若這條規則後來又被別的場景強化過,
|
||||
// 這裡整條刪掉會連同其他場景的貢獻一起清掉,見 schema.prisma 該欄位的註解。
|
||||
await this.prisma.client.proceduralRule.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||
// T17:情緒基線是每場景各自一筆(不像 ProceduralRule 是單列累加),刪除重建不會誤刪其他場景的貢獻。
|
||||
await this.prisma.client.characterEmotionBaseline.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
import { SceneImportService } from "./scene-import.service.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
|
||||
// T39 驗收:兩階段與裁決阻斷——含爭議歸屬(QUARANTINED)或待人工確認矛盾(PENDING_HUMAN_REVIEW)的
|
||||
// 匯入,暫不投影也不影響角色資料;裁決/解決矛盾完成後才接續投影;階段一失敗全回滾視為沒匯入過。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const endingDetection = new EndingDetectionService(prisma, new MockProvider());
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
|
||||
const WORK_ID = "test-t39-work";
|
||||
const CHAR_A = "test-t39-char-a";
|
||||
const CHAR_B = "test-t39-char-b";
|
||||
const CHAR_C = "test-t39-char-c";
|
||||
const ALL_CHAR_IDS = [CHAR_A, CHAR_B, CHAR_C];
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.sceneContradiction.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: ALL_CHAR_IDS } } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWork(anchorStoryOrder = 100) {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T39 測試作品", anchorStoryOrder } });
|
||||
}
|
||||
|
||||
async function createCharacter(id: string, formalName: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("SceneImportService 兩階段與裁決阻斷(T39)", () => {
|
||||
it("沒有隔離台詞、沒有矛盾時,匯入後立即完成階段二投影", async () => {
|
||||
await createWork();
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
await createCharacter(CHAR_B, "小乙");
|
||||
|
||||
const result = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲與小乙在教室聊天。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A, CHAR_B],
|
||||
lines: [
|
||||
{ lineType: "DIALOGUE", rawText: "早安。", hasExplicitMarker: true, markerCharacterId: CHAR_A },
|
||||
{ lineType: "DIALOGUE", rawText: "早安呀。", hasExplicitMarker: true, markerCharacterId: CHAR_B },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.pendingAdjudication).toBe(false);
|
||||
expect(result.projected).toBe(true);
|
||||
const memories = await prisma.client.episodicMemory.findMany({ where: { characterId: { in: [CHAR_A, CHAR_B] } } });
|
||||
expect(memories.length).toBeGreaterThan(0);
|
||||
}, 20000);
|
||||
|
||||
it("有隔離中的台詞時,暫不投影;裁決完成後才接續投影", async () => {
|
||||
await createWork();
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
await createCharacter(CHAR_B, "小乙");
|
||||
await createCharacter(CHAR_C, "小丙");
|
||||
|
||||
// 三人在場、無明示標記、無任何指紋樣本可依附:turnTakingScore 恆為 0.5,score=0.5 < CONFIRM_THRESHOLD
|
||||
// (0.55),必定隔離。
|
||||
const result = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第二章",
|
||||
storyOrder: 2,
|
||||
summary: "三人在場的一段對話。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A, CHAR_B, CHAR_C],
|
||||
lines: [{ lineType: "DIALOGUE", rawText: "這句話是誰說的呢?" }],
|
||||
});
|
||||
|
||||
expect(result.pendingAdjudication).toBe(true);
|
||||
expect(result.projected).toBe(false);
|
||||
const memoriesBefore = await prisma.client.episodicMemory.findMany({
|
||||
where: { characterId: { in: ALL_CHAR_IDS } },
|
||||
});
|
||||
expect(memoriesBefore).toHaveLength(0);
|
||||
|
||||
const quarantinedLine = result.lines.find((l) => l.status === "QUARANTINED");
|
||||
expect(quarantinedLine).toBeTruthy();
|
||||
|
||||
// 使用者裁決:指定發言者,AttributionController 裁決完成後會呼叫 tryProjectScene()。
|
||||
await attribution.adjudicate(quarantinedLine!.id, CHAR_A);
|
||||
const projected = await sceneImport.tryProjectScene(result.id);
|
||||
expect(projected).toBe(true);
|
||||
|
||||
const sceneAfter = await prisma.client.scene.findUniqueOrThrow({ where: { id: result.id } });
|
||||
expect(sceneAfter.projected).toBe(true);
|
||||
const memoriesAfter = await prisma.client.episodicMemory.findMany({
|
||||
where: { characterId: { in: ALL_CHAR_IDS } },
|
||||
});
|
||||
expect(memoriesAfter.length).toBeGreaterThan(0);
|
||||
}, 20000);
|
||||
|
||||
it("有待人工確認的矛盾時,即使場景本身台詞皆已確認也暫不投影;解決矛盾後才接續投影", async () => {
|
||||
await createWork();
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
|
||||
const scene1 = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲的生日。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲的生日是五月。" }],
|
||||
facts: [{ eventLabel: "小甲的生日", factKey: "月份", factValue: "五月" }],
|
||||
});
|
||||
expect(scene1.pendingAdjudication).toBe(false); // 第一筆事實尚無衝突對象
|
||||
|
||||
// 第二個場景寫入矛盾的事實值(同 sourceType=NOVEL,來源優先序相同,不可自動裁決)。
|
||||
const scene2 = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第二章",
|
||||
storyOrder: 3, // 落在錨定點內,若無矛盾應會投影
|
||||
summary: "小甲的生日(矛盾版本)。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲的生日是十月。" }],
|
||||
facts: [{ eventLabel: "小甲的生日", factKey: "月份", factValue: "十月" }],
|
||||
});
|
||||
|
||||
expect(scene2.pendingAdjudication).toBe(true);
|
||||
expect(scene2.projected).toBe(false);
|
||||
// 矛盾是作品層級的,先前已投影的 scene1 不會被追溯撤銷。
|
||||
const scene1After = await prisma.client.scene.findUniqueOrThrow({ where: { id: scene1.id } });
|
||||
expect(scene1After.projected).toBe(true);
|
||||
|
||||
const contradiction = await prisma.client.sceneContradiction.findFirstOrThrow({
|
||||
where: { workId: WORK_ID, status: "PENDING_HUMAN_REVIEW" },
|
||||
});
|
||||
await timeline.resolveContradiction(contradiction.id, "採用十月版本(人工確認)");
|
||||
const projectedCount = await sceneImport.tryProjectPendingScenesForWork(WORK_ID);
|
||||
expect(projectedCount).toBe(1);
|
||||
|
||||
const scene2After = await prisma.client.scene.findUniqueOrThrow({ where: { id: scene2.id } });
|
||||
expect(scene2After.projected).toBe(true);
|
||||
}, 20000);
|
||||
|
||||
it("階段一失敗(不存在的角色)時全回滾,Scene 本身不殘留", async () => {
|
||||
await createWork();
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
|
||||
await expect(
|
||||
sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "失敗章",
|
||||
storyOrder: 5,
|
||||
summary: "會失敗的匯入。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A, "test-t39-char-not-exist"],
|
||||
lines: [{ lineType: "NARRATION", rawText: "這句不重要。" }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const scenesAfter = await prisma.client.scene.findMany({ where: { workId: WORK_ID, chapterLabel: "失敗章" } });
|
||||
expect(scenesAfter).toHaveLength(0);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { SceneLineType } from "@kokorone/db";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
|
||||
export interface ImportLineInput {
|
||||
lineType: SceneLineType;
|
||||
rawText: string;
|
||||
hasExplicitMarker?: boolean;
|
||||
markerCharacterId?: string;
|
||||
}
|
||||
|
||||
export interface ImportInteractionInput {
|
||||
characterId: string;
|
||||
otherCharacterId: string;
|
||||
delta: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ImportFactInput {
|
||||
eventLabel: string;
|
||||
factKey: string;
|
||||
factValue: string;
|
||||
}
|
||||
|
||||
export interface ImportSceneInput {
|
||||
workId: string;
|
||||
volume: number;
|
||||
chapterLabel: string;
|
||||
storyOrder: number;
|
||||
sourceType?: string;
|
||||
summary: string;
|
||||
rawText: string;
|
||||
participantCharacterIds: string[];
|
||||
lines: ImportLineInput[];
|
||||
interactions?: ImportInteractionInput[];
|
||||
facts?: ImportFactInput[];
|
||||
}
|
||||
|
||||
// M-3 章節匯入與場景切分:以場景為單位寫入共用場景資料庫(角色中立的客觀記錄)。
|
||||
// 場景本身的切分(章節文本 → 一個個場景)由呼叫端(人工或前置流程)完成後以結構化輸入送進來——
|
||||
// 這裡不做自由文本的自動場景邊界偵測,理由同 F-7 MockProvider:對應的真實 NLP 能力屬於之後才會補的範疇。
|
||||
//
|
||||
// T39 兩階段與裁決阻斷:
|
||||
// 階段一(本方法內)=建立 Scene/SceneLine 等客觀記錄+跑發言歸屬(AttributionService)+矛盾偵測
|
||||
// (TimelineService)。階段一若拋錯,視為沒匯入過——把剛建立的 Scene 整棵刪掉(級聯刪除 lines/
|
||||
// facts/interactions/presences),不留半套資料。
|
||||
// 階段二(tryProjectScene()/tryProjectPendingScenesForWork())=投影+角色建置門檻檢查;只有這個
|
||||
// 場景「自己沒有隔離中的台詞」且「所屬作品沒有待人工確認的矛盾」才會執行,否則維持
|
||||
// `Scene.projected=false` 原地等待,不阻擋其他場景的匯入或投影。使用者透過既有端點
|
||||
// (adjudicate/correct/reattempt-quarantined/detect-contradictions)處理完裁決後,
|
||||
// controller 會呼叫這兩個方法把符合條件的場景接續投影,呼叫端不需要另外手動觸發。
|
||||
@Injectable()
|
||||
export class SceneImportService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly attribution: AttributionService,
|
||||
private readonly projection: ProjectionService,
|
||||
private readonly roster: WorkRosterService,
|
||||
private readonly timeline: TimelineService,
|
||||
private readonly endingDetection: EndingDetectionService,
|
||||
) {}
|
||||
|
||||
async importScene(input: ImportSceneInput) {
|
||||
const scene = await this.prisma.client.scene.create({
|
||||
data: {
|
||||
workId: input.workId,
|
||||
volume: input.volume,
|
||||
chapterLabel: input.chapterLabel,
|
||||
storyOrder: input.storyOrder,
|
||||
sourceType: input.sourceType ?? "NOVEL",
|
||||
summary: input.summary,
|
||||
rawText: input.rawText,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for (const characterId of input.participantCharacterIds) {
|
||||
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
|
||||
}
|
||||
|
||||
for (const [lineIndex, line] of input.lines.entries()) {
|
||||
await this.prisma.client.sceneLine.create({
|
||||
data: {
|
||||
sceneId: scene.id,
|
||||
lineIndex,
|
||||
lineType: line.lineType,
|
||||
rawText: line.rawText,
|
||||
hasExplicitMarker: line.hasExplicitMarker ?? false,
|
||||
markerCharacterId: line.markerCharacterId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const interaction of input.interactions ?? []) {
|
||||
await this.prisma.client.sceneInteraction.create({
|
||||
data: {
|
||||
sceneId: scene.id,
|
||||
characterId: interaction.characterId,
|
||||
otherCharacterId: interaction.otherCharacterId,
|
||||
delta: interaction.delta,
|
||||
description: interaction.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const fact of input.facts ?? []) {
|
||||
await this.prisma.client.sceneFact.create({
|
||||
data: { sceneId: scene.id, eventLabel: fact.eventLabel, factKey: fact.factKey, factValue: fact.factValue },
|
||||
});
|
||||
}
|
||||
|
||||
await this.attribution.attributeScene(scene.id);
|
||||
await this.timeline.detectContradictions(input.workId);
|
||||
} catch (err) {
|
||||
// 階段一失敗全回滾,視為沒匯入過——級聯刪除已建立的 Scene 及其所有子紀錄。
|
||||
await this.prisma.client.scene.delete({ where: { id: scene.id } });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const pendingAdjudication = await this.isPendingAdjudication(scene.id, input.workId);
|
||||
if (!pendingAdjudication) {
|
||||
await this.tryProjectScene(scene.id);
|
||||
}
|
||||
|
||||
return this.getSceneDetail(scene.id);
|
||||
}
|
||||
|
||||
// 場景自身沒有隔離中的台詞、且所屬作品沒有待人工確認的矛盾,才算「裁決完成」可以往下走階段二。
|
||||
private async isPendingAdjudication(sceneId: string, workId: string): Promise<boolean> {
|
||||
const quarantinedCount = await this.prisma.client.sceneLine.count({ where: { sceneId, status: "QUARANTINED" } });
|
||||
if (quarantinedCount > 0) return true;
|
||||
const pendingContradictions = await this.prisma.client.sceneContradiction.count({
|
||||
where: { workId, status: "PENDING_HUMAN_REVIEW" },
|
||||
});
|
||||
return pendingContradictions > 0;
|
||||
}
|
||||
|
||||
// 階段二:已裁決完成(不再有隔離台詞或待確認矛盾)才投影+檢查角色建置門檻;已投影過的場景直接
|
||||
// no-op(避免重複觸發)。回傳是否真的執行了投影,供呼叫端(例如結局偵測)判斷要不要接著跑。
|
||||
async tryProjectScene(sceneId: string): Promise<boolean> {
|
||||
const scene = await this.prisma.client.scene.findUniqueOrThrow({
|
||||
where: { id: sceneId },
|
||||
include: { participants: true },
|
||||
});
|
||||
if (scene.projected) return false;
|
||||
if (await this.isPendingAdjudication(sceneId, scene.workId)) return false;
|
||||
|
||||
// M-9 知識邊界:只有在錨定點之內的場景才投影;超前的場景先留在場景資料庫,等錨定點推進時再載入。
|
||||
let work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: scene.workId } });
|
||||
|
||||
// T15:後日談期間的「日後談續匯入」(真實正史內容)不受知識邊界卡住——匯入代表使用者已經讀到
|
||||
// 這裡,直接把錨定點推進到覆蓋這批內容再投影;純粹只影響「這篇要不要投影」的判斷,不刪除任何
|
||||
// 既有 EPILOGUE 場景、也不寫入敘事化的記憶修正(與 canon-reclamation.service.ts 的 reclaim() 不同,
|
||||
// reclaim() 是使用者主動選擇的覆寫手段,會找出並移除重疊的推演場景;這裡是預設路徑,兩者並存)。
|
||||
if (work.inEpilogue && scene.sourceType !== "EPILOGUE" && scene.storyOrder > work.anchorStoryOrder) {
|
||||
work = await this.prisma.client.work.update({
|
||||
where: { id: work.id },
|
||||
data: { anchorStoryOrder: scene.storyOrder },
|
||||
});
|
||||
}
|
||||
|
||||
let didProject = false;
|
||||
if (scene.storyOrder <= work.anchorStoryOrder) {
|
||||
try {
|
||||
await this.projection.projectScene(sceneId);
|
||||
didProject = true;
|
||||
} catch (err) {
|
||||
// 階段二失敗回滾到「已裁決但未投影」可重跑:清掉這次可能已寫入一部分的投影結果,
|
||||
// 不設 projected=true,下次呼叫 tryProjectScene() 會重新完整跑一次。
|
||||
await this.projection.clearProjectedData(sceneId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 建置門檻的「實質互動」次數只在投影後才算數,因此門檻檢查排在投影之後;即使場景超前錨定點
|
||||
// 未投影,仍沿用既有行為讓在場角色照常檢查建置門檻(維持匯入時既有語意,不因本次改動而收緊)。
|
||||
for (const participant of scene.participants) {
|
||||
await this.roster.checkAndPromote(participant.characterId);
|
||||
}
|
||||
|
||||
// T13:於階段二投影完成後才加入結局偵測——場景超前錨定點而沒有真的投影時不偵測,因為錨定點本身
|
||||
// 就代表「還沒推進到這裡」,不該提前提示結局。
|
||||
if (didProject) {
|
||||
await this.endingDetection.detectForScene(sceneId);
|
||||
}
|
||||
return didProject;
|
||||
}
|
||||
|
||||
// 矛盾裁決是整個作品層級的(SceneContradiction 沒有 sceneId),解決後可能同時解除多個場景的封鎖,
|
||||
// 因此需要逐一嘗試整個作品裡尚未投影的場景,而不只是觸發裁決當下那一個。
|
||||
async tryProjectPendingScenesForWork(workId: string): Promise<number> {
|
||||
const pendingScenes = await this.prisma.client.scene.findMany({ where: { workId, projected: false } });
|
||||
let projected = 0;
|
||||
for (const scene of pendingScenes) {
|
||||
if (await this.tryProjectScene(scene.id)) projected += 1;
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
async getSceneDetail(sceneId: string) {
|
||||
const scene = await this.prisma.client.scene.findUniqueOrThrow({
|
||||
where: { id: sceneId },
|
||||
include: { participants: true, lines: true },
|
||||
});
|
||||
return { ...scene, pendingAdjudication: await this.isPendingAdjudication(sceneId, scene.workId) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { SOURCE_PRIORITY } from "./constants.js";
|
||||
|
||||
// M-8 跨章節整合:共用年表排序(Scene.storyOrder 本身即年表鍵,不需要另外的服務排序)、
|
||||
// 矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決者標記待人工確認。
|
||||
@Injectable()
|
||||
export class TimelineService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// 掃描同一事件標籤下、同一事實鍵卻不同事實值的場景——這是故意置入矛盾最容易驗證的形式。
|
||||
async detectContradictions(workId: string): Promise<number> {
|
||||
const facts = await this.prisma.client.sceneFact.findMany({
|
||||
where: { scene: { workId } },
|
||||
include: { scene: true },
|
||||
});
|
||||
|
||||
const groups = new Map<string, typeof facts>();
|
||||
for (const fact of facts) {
|
||||
const key = `${fact.eventLabel}::${fact.factKey}`;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(fact);
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
for (const [key, group] of groups) {
|
||||
const distinctValues = new Set(group.map((fact) => fact.factValue));
|
||||
if (distinctValues.size <= 1) {
|
||||
continue;
|
||||
}
|
||||
const [eventLabel, factKey] = key.split("::");
|
||||
const existing = await this.prisma.client.sceneContradiction.findFirst({ where: { workId, eventLabel, factKey } });
|
||||
if (existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sorted = [...group].sort(
|
||||
(a, b) => (SOURCE_PRIORITY[a.scene.sourceType] ?? 99) - (SOURCE_PRIORITY[b.scene.sourceType] ?? 99),
|
||||
);
|
||||
const best = sorted[0];
|
||||
const runnerUpDifferentValue = sorted.find((fact) => fact.factValue !== best.factValue);
|
||||
const canAutoResolve =
|
||||
runnerUpDifferentValue !== undefined &&
|
||||
(SOURCE_PRIORITY[best.scene.sourceType] ?? 99) < (SOURCE_PRIORITY[runnerUpDifferentValue.scene.sourceType] ?? 99);
|
||||
|
||||
await this.prisma.client.sceneContradiction.create({
|
||||
data: {
|
||||
workId,
|
||||
eventLabel,
|
||||
factKey,
|
||||
description: `「${eventLabel}」的「${factKey}」出現不同描述:${[...distinctValues].join(" vs ")}`,
|
||||
status: canAutoResolve ? "RESOLVED" : "PENDING_HUMAN_REVIEW",
|
||||
resolution: canAutoResolve
|
||||
? `依裁決優先序(小說原文 > 官方設定集 > 動畫改編)採用「${best.factValue}」(來源:${best.scene.sourceType})`
|
||||
: null,
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
async listContradictions(workId: string) {
|
||||
return this.prisma.client.sceneContradiction.findMany({ where: { workId } });
|
||||
}
|
||||
|
||||
// T39:detectContradictions() 只能自動裁決「來源優先序不同」的矛盾,剩下標記 PENDING_HUMAN_REVIEW
|
||||
// 的矛盾原本沒有任何端點可以真的解決掉——這是本次補上的缺口,否則含這類矛盾的作品會永久卡住,
|
||||
// 沒有任何路徑能讓後續場景繼續投影。
|
||||
async resolveContradiction(contradictionId: string, resolution: string): Promise<void> {
|
||||
await this.prisma.client.sceneContradiction.update({
|
||||
where: { id: contradictionId },
|
||||
data: { status: "RESOLVED", resolution },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { BUILD_THRESHOLD_SCENES, BUILD_THRESHOLD_NAMED_LINES, BUILD_THRESHOLD_INTERACTIONS } from "./constants.js";
|
||||
|
||||
export interface RosterProgress {
|
||||
characterId: string;
|
||||
sceneCount: number;
|
||||
namedLineCount: number;
|
||||
interactionCount: number;
|
||||
meetsThreshold: boolean;
|
||||
}
|
||||
|
||||
// M-2 作品名冊與建置門檻:新角色先進候補狀態,資料照常在場景資料庫累積,達門檻才正式建置。
|
||||
// 累積量一律即時查詢(場景/台詞/互動紀錄本身就是唯一真相),不維護額外的計數快取,避免同步漂移。
|
||||
@Injectable()
|
||||
export class WorkRosterService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getProgress(characterId: string): Promise<RosterProgress> {
|
||||
const [sceneCount, namedLineCount, interactionCount] = await Promise.all([
|
||||
this.prisma.client.scenePresence.count({ where: { characterId } }),
|
||||
this.prisma.client.sceneLine.count({ where: { speakerCharacterId: characterId, status: "CONFIRMED" } }),
|
||||
this.prisma.client.characterRelationshipEventLog.count({ where: { characterId } }),
|
||||
]);
|
||||
return {
|
||||
characterId,
|
||||
sceneCount,
|
||||
namedLineCount,
|
||||
interactionCount,
|
||||
meetsThreshold:
|
||||
sceneCount >= BUILD_THRESHOLD_SCENES &&
|
||||
namedLineCount >= BUILD_THRESHOLD_NAMED_LINES &&
|
||||
interactionCount >= BUILD_THRESHOLD_INTERACTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
// 匯入場景後對每個候補角色呼叫:達門檻就自動升級為正式建置,之後的匯入走「強化路徑」(不需要特別的程式碼分支
|
||||
// ——強化與候補累積本來就是同一組寫入邏輯,差別只在 buildStatus 這個查詢結果)。
|
||||
async checkAndPromote(characterId: string): Promise<boolean> {
|
||||
const character = await this.prisma.client.character.findUnique({ where: { id: characterId } });
|
||||
if (!character || character.buildStatus === "BUILT") {
|
||||
return false;
|
||||
}
|
||||
const progress = await this.getProgress(characterId);
|
||||
if (!progress.meetsThreshold) {
|
||||
return false;
|
||||
}
|
||||
await this.prisma.client.character.update({ where: { id: characterId }, data: { buildStatus: "BUILT" } });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CLOCK } from "./clock.js";
|
||||
import { SystemClockService } from "./system-clock.service.js";
|
||||
|
||||
@Module({
|
||||
providers: [SystemClockService, { provide: CLOCK, useExisting: SystemClockService }],
|
||||
exports: [CLOCK, SystemClockService],
|
||||
})
|
||||
export class ClockModule {}
|
||||
@@ -0,0 +1,7 @@
|
||||
// T36:可注入時鐘介面。本計畫所有新程式一律從時鐘取時間、不直接 `new Date()`,
|
||||
// 以便測試以 ControllableClock(見 controllable-clock.ts)快轉時間而不需真的等待。
|
||||
export interface Clock {
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
export const CLOCK = Symbol("CLOCK");
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ControllableClock } from "./controllable-clock.js";
|
||||
|
||||
describe("ControllableClock", () => {
|
||||
it("回報設定時刻,不隨真實時間流動", () => {
|
||||
const clock = new ControllableClock("2026-08-18T00:00:00.000Z");
|
||||
expect(clock.now().toISOString()).toBe("2026-08-18T00:00:00.000Z");
|
||||
expect(clock.now().toISOString()).toBe("2026-08-18T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("可快轉跨夜情境,秒級內完成", () => {
|
||||
const clock = new ControllableClock("2026-08-18T23:50:00.000Z");
|
||||
clock.advance(20 * 60 * 1000); // 快轉 20 分鐘跨過午夜
|
||||
expect(clock.now().toISOString()).toBe("2026-08-19T00:10:00.000Z");
|
||||
});
|
||||
|
||||
it("set() 可直接指定新時刻", () => {
|
||||
const clock = new ControllableClock();
|
||||
clock.set("2026-01-01T00:00:00.000Z");
|
||||
expect(clock.now().toISOString()).toBe("2026-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Clock } from "./clock.js";
|
||||
|
||||
// T36 測試專用時鐘:可任意設定與快轉當前時間,讓「跨夜→角色去睡→房間關閉」這類情境
|
||||
// 在秒級內於測試中重現,不需真的等待。正式環境一律使用 SystemClockService。
|
||||
export class ControllableClock implements Clock {
|
||||
private current: Date;
|
||||
|
||||
constructor(initial: Date | string | number = new Date(0)) {
|
||||
this.current = new Date(initial);
|
||||
}
|
||||
|
||||
now(): Date {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
set(date: Date | string | number): void {
|
||||
this.current = new Date(date);
|
||||
}
|
||||
|
||||
advance(ms: number): void {
|
||||
this.current = new Date(this.current.getTime() + ms);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Clock } from "./clock.js";
|
||||
|
||||
@Injectable()
|
||||
export class SystemClockService implements Clock {
|
||||
now(): Date {
|
||||
return new Date();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { RuleBasedEmotionTagger } from "./emotion-tagger.js";
|
||||
import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js";
|
||||
import { toResponseStyle, type ResponseStyle } from "./response-style.js";
|
||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||
import { getRedisClient } from "../cache/redis-client.js";
|
||||
|
||||
const REDIS_KEY_PREFIX = "kokorone:emotion-state:";
|
||||
|
||||
// D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。
|
||||
const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000;
|
||||
@@ -56,6 +59,11 @@ export interface ProcessInputOptions extends TransitionContext {
|
||||
triggerThreshold?: number;
|
||||
halfLifeMs?: number;
|
||||
now?: Date;
|
||||
// I-5:本輪對話沒有明確情緒訊號時,套用作息驅動的基線情緒(不覆蓋文字本身觸發的訊號)。
|
||||
baselineSignal?: { tag: EmotionTag; intensity: number };
|
||||
// P-5:語音輸入的副語言特徵(語速/音量/顫抖/停頓)分析出的訊號——文字說「沒事」但聲音在抖,
|
||||
// 優先於作息基線(副語言是這一輪輸入本身的訊號,比時段基線更直接),但仍不覆蓋文字本身觸發的訊號。
|
||||
paralinguisticSignal?: { tag: EmotionTag; intensity: number };
|
||||
}
|
||||
|
||||
function toDomain(characterId: string, dims: Dimensions, updatedAt: Date): EmotionState {
|
||||
@@ -105,7 +113,12 @@ export class EmotionService {
|
||||
|
||||
const rawSignal = this.tagger.tag({ text, triggerThreshold });
|
||||
// 情緒觸發閾值除了縮放強度,也要能讓「難觸發」原型對弱刺激完全不觸發(而不只是觸發得比較小力)。
|
||||
const signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
|
||||
let signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
|
||||
if (signal.tag === "CALM" && options.paralinguisticSignal) {
|
||||
signal = options.paralinguisticSignal;
|
||||
} else if (signal.tag === "CALM" && options.baselineSignal) {
|
||||
signal = options.baselineSignal;
|
||||
}
|
||||
const next = nextEmotionState(currentDominant, signal, options);
|
||||
|
||||
let updated: Dimensions;
|
||||
@@ -123,11 +136,45 @@ export class EmotionService {
|
||||
return { state: toDomain(characterId, updated, now), style: toResponseStyle(next) };
|
||||
}
|
||||
|
||||
// R-2:情緒狀態機的即時讀寫改走 Redis 當熱路徑快取,Prisma/SQLite(未來 Postgres)
|
||||
// 仍是唯一的持久真實來源——快取只省掉命中時的資料庫往返,Redis 不可用或未命中都直接退回原本
|
||||
// 讀 Prisma 的行為,因此「重啟 api 後情緒狀態不遺失」這件事完全不受快取層是否存在影響。
|
||||
private async readCached(characterId: string): Promise<{ dims: Dimensions; updatedAt: Date } | null> {
|
||||
try {
|
||||
const raw = await getRedisClient().get(REDIS_KEY_PREFIX + characterId);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Dimensions & { updatedAt: string };
|
||||
const { updatedAt, ...dims } = parsed;
|
||||
return { dims, updatedAt: new Date(updatedAt) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeCache(characterId: string, dims: Dimensions, updatedAt: Date): Promise<void> {
|
||||
try {
|
||||
await getRedisClient().set(
|
||||
REDIS_KEY_PREFIX + characterId,
|
||||
JSON.stringify({ ...dims, updatedAt: updatedAt.toISOString() }),
|
||||
"EX",
|
||||
60 * 60 * 24,
|
||||
);
|
||||
} catch {
|
||||
// Redis 不可用時,Prisma 仍是持久真實來源,快取寫入失敗不影響正確性。
|
||||
}
|
||||
}
|
||||
|
||||
private async decayedDimensions(
|
||||
characterId: string,
|
||||
now: Date,
|
||||
halfLifeMs = DEFAULT_HALF_LIFE_MS,
|
||||
): Promise<{ dims: Dimensions; updatedAt: Date }> {
|
||||
const cached = await this.readCached(characterId);
|
||||
if (cached) {
|
||||
const elapsedMs = now.getTime() - cached.updatedAt.getTime();
|
||||
return { dims: decayDimensions(cached.dims, elapsedMs, halfLifeMs), updatedAt: cached.updatedAt };
|
||||
}
|
||||
|
||||
const row = await this.prisma.client.emotionState.upsert({
|
||||
where: { characterId },
|
||||
update: {},
|
||||
@@ -144,6 +191,7 @@ export class EmotionService {
|
||||
where: { characterId },
|
||||
data: { ...dims, updatedAt: now },
|
||||
});
|
||||
await this.writeCache(characterId, dims, now);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ConflictException, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { SceneImportService, type ImportSceneInput } from "../canon/scene-import.service.js";
|
||||
import { ProjectionService } from "../canon/projection.service.js";
|
||||
import { TimeFlowService } from "./time-flow.service.js";
|
||||
|
||||
export interface ReclaimResult {
|
||||
removedEpilogueScenes: number;
|
||||
importedCanonScenes: number;
|
||||
affectedCharacterIds: string[];
|
||||
}
|
||||
|
||||
// N-5 官方續篇出版時:正史回收(推演區間被正史替換,互動記憶保留+敘事化的記憶修正)
|
||||
// 或分支保留(永久分岔,之後官方新內容都不匯入)。選擇權在使用者,這裡只提供兩條路徑各自的執行機制,
|
||||
// 「依推演深淺給預設建議」是呼叫端(UI/未來的 Q 群組 App)的責任,不在引擎層決定。
|
||||
@Injectable()
|
||||
export class CanonReclamationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sceneImport: SceneImportService,
|
||||
private readonly projection: ProjectionService,
|
||||
private readonly timeFlow: TimeFlowService,
|
||||
) {}
|
||||
|
||||
async reclaim(workId: string, newCanonScenes: ImportSceneInput[]): Promise<ReclaimResult> {
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||
if (work.branchedFromCanon) {
|
||||
throw new ConflictException("這個作品實例已經選擇分支保留,不再匯入官方新內容");
|
||||
}
|
||||
|
||||
const minNewOrder = Math.min(...newCanonScenes.map((scene) => scene.storyOrder));
|
||||
const superseded = await this.prisma.client.scene.findMany({
|
||||
where: { workId, sourceType: "EPILOGUE", storyOrder: { gte: minNewOrder } },
|
||||
include: { participants: true },
|
||||
});
|
||||
|
||||
const affectedCharacterIds = new Set<string>();
|
||||
for (const scene of superseded) {
|
||||
for (const presence of scene.participants) {
|
||||
affectedCharacterIds.add(presence.characterId);
|
||||
}
|
||||
// 清掉這場推演事件投影出的所有角色資料——與 ProjectionService.clearProjectedData() 共用同一份
|
||||
// 清除清單(T17/T25 上線時就漏了這裡沒同步更新,本次隨 T15 一併發現並修正),使用者互動記憶
|
||||
// (source=INTERACTION)從不帶這些欄位,不會被動到。
|
||||
await this.projection.clearProjectedData(scene.id);
|
||||
await this.prisma.client.sceneLine.deleteMany({ where: { sceneId: scene.id } });
|
||||
await this.prisma.client.scenePresence.deleteMany({ where: { sceneId: scene.id } });
|
||||
await this.prisma.client.scene.delete({ where: { id: scene.id } });
|
||||
}
|
||||
|
||||
// 敘事化消化:不假裝那段推演的人生沒發生過,而是讓她「發現其實不是這樣」。
|
||||
for (const characterId of affectedCharacterIds) {
|
||||
await this.prisma.client.episodicMemory.create({
|
||||
data: {
|
||||
characterId,
|
||||
content: "官方後來公開了那段時間真正發生的事,跟我原本以為的不太一樣……不過沒關係,那也是我曾經這樣想過的證明。",
|
||||
occurredAt: new Date(),
|
||||
emotionTag: "CALM",
|
||||
emotionIntensity: 0.4,
|
||||
source: "CORRECTION",
|
||||
weight: 1.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 這些場景取代的是「已經活過」的後日談時段,正史錨定點的知識邊界對它們沒有意義——即使場景
|
||||
// storyOrder 超過目前的 anchorStoryOrder(後日談本來就走在正史錨定點之前),也要直接投影,
|
||||
// 並把錨定點一併推進到覆蓋這批正史內容,讓「正史知識邊界」與「已經投影的正史」保持一致。
|
||||
let maxImportedOrder = work.anchorStoryOrder;
|
||||
for (const sceneInput of newCanonScenes) {
|
||||
const scene = await this.sceneImport.importScene(sceneInput);
|
||||
if (!scene.projected) {
|
||||
await this.projection.projectScene(scene.id);
|
||||
}
|
||||
maxImportedOrder = Math.max(maxImportedOrder, scene.storyOrder);
|
||||
}
|
||||
if (maxImportedOrder > work.anchorStoryOrder) {
|
||||
await this.prisma.client.work.update({ where: { id: workId }, data: { anchorStoryOrder: maxImportedOrder } });
|
||||
}
|
||||
await this.timeFlow.checkEpilogueTransition(workId);
|
||||
|
||||
return {
|
||||
removedEpilogueScenes: superseded.length,
|
||||
importedCanonScenes: newCanonScenes.length,
|
||||
affectedCharacterIds: [...affectedCharacterIds],
|
||||
};
|
||||
}
|
||||
|
||||
async branch(workId: string) {
|
||||
return this.prisma.client.work.update({ where: { id: workId }, data: { branchedFromCanon: true } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// N-1 時間流速:系統文件本身只定義了即時同步/緩速/凍結三種模式,皆不超過現實 1:1;
|
||||
// 這裡額外加一個「加速」選項給一般角色使用(給使用者跳過等待的彈性),
|
||||
// 後日談自然成年角色的「流速上限為現實 1:1」規則就是靠禁用這個選項來實作與驗收的。
|
||||
export type TimeFlowMode = "FAST" | "REALTIME" | "SLOW" | "FROZEN";
|
||||
export const TIME_FLOW_MODES: TimeFlowMode[] = ["FAST", "REALTIME", "SLOW", "FROZEN"];
|
||||
|
||||
// N-4 人生階段推演:重大轉折需低頻——同一角色兩次里程碑之間至少要間隔這麼多個年表單位。
|
||||
export const MILESTONE_MIN_STORY_GAP = 5;
|
||||
|
||||
// N-2 性格漂移:方向由正史成長軌跡外插,但幅度必須有上限(性格核心鎖定,不是改寫)。
|
||||
export const MAX_EXPRESSIVENESS_DRIFT = 0.15;
|
||||
export const MAX_HALF_LIFE_REDUCTION = 0.3; // 情緒衰減最多加快 30%
|
||||
export const HALF_LIFE_REDUCTION_PER_STORY_UNIT = 0.01;
|
||||
|
||||
// T34:`timeFlowMode` 唯一真正的消費者——大廳/房間對話、行程判定、睡眠機制完全不讀這個欄位,
|
||||
// 一律走真實時間;這裡只換算「後日談推演進度」(anchorStoryOrder 這個既有時鐘,在日後談期間
|
||||
// 繼續往前走的速度),推演進度本身又是 N-2 性格漂移幅度與 N-4 里程碑低頻節流唯二依賴的量尺。
|
||||
// FROZEN 恆為 0(見 epilogue-progress.service.ts);其餘數值為每個現實日推進的 storyOrder 單位數,
|
||||
// 僅為示意性量尺(storyOrder 本身就是不帶物理時間意義的整數序),非官方文件規定的精確值。
|
||||
//
|
||||
// 刻意用「分子/幾個現實日」的整數比表示速率,而不是先算好一個 storyOrder-per-ms 的浮點常數:
|
||||
// 後者在 epilogue-progress.service.ts 逐次相乘除時,會因為二進位浮點無法精確表示像 1/28800000
|
||||
// 這種分數而在多次 tick 後累積出偏差(實測會少算掉整數單位);用整數比讓每次換算都以「先乘、
|
||||
// 最後才除」的單一步驟完成,被除數與除數都是可精確表示的整數,不會有這種捨入誤差。
|
||||
export const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
export interface EpilogueProgressRate {
|
||||
numerator: number; // 每 `perRealDays` 個現實日推進的 storyOrder 單位數
|
||||
perRealDays: number;
|
||||
}
|
||||
export const EPILOGUE_STORY_ORDER_RATE: Record<TimeFlowMode, EpilogueProgressRate> = {
|
||||
FROZEN: { numerator: 0, perRealDays: 1 },
|
||||
SLOW: { numerator: 1, perRealDays: 3 },
|
||||
REALTIME: { numerator: 1, perRealDays: 1 },
|
||||
FAST: { numerator: 3, perRealDays: 1 },
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { FingerprintService } from "../canon/fingerprint.service.js";
|
||||
import { AttributionService } from "../canon/attribution.service.js";
|
||||
import { ProjectionService } from "../canon/projection.service.js";
|
||||
import { WorkRosterService } from "../canon/work-roster.service.js";
|
||||
import { TimelineService } from "../canon/timeline.service.js";
|
||||
import { EndingDetectionService } from "../canon/ending-detection.service.js";
|
||||
import { SceneImportService } from "../canon/scene-import.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
|
||||
// T15 驗收:後日談期間續匯入正史內容(L 路徑)——不受 M-9 知識邊界卡住,直接投影並推進錨定點;
|
||||
// 不刪除既有 EPILOGUE 場景、不寫入敘事化的記憶修正(那是 canon-reclamation.service.ts 的 reclaim()
|
||||
// 才做的事,屬使用者主動選擇的覆寫路徑,不是這裡的預設行為)。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const endingDetection = new EndingDetectionService(prisma, new MockProvider());
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
const lifeEvent = new LifeEventService(prisma, projection);
|
||||
|
||||
const WORK_ID = "test-t15-work";
|
||||
const CHAR_A = "test-t15-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.episodicMemory.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.epilogueMilestoneLog.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWorkInEpilogue() {
|
||||
await prisma.client.work.create({
|
||||
data: { id: WORK_ID, title: "T15 測試作品", anchorStoryOrder: 5, inEpilogue: true, epilogueStartStoryOrder: 5 },
|
||||
});
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("後日談期間續匯入正史內容(T15)", () => {
|
||||
it("匯入超前錨定點的正史章節後直接投影,並把錨定點推進到覆蓋這批內容", async () => {
|
||||
await createWorkInEpilogue();
|
||||
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 2,
|
||||
chapterLabel: "第十章",
|
||||
storyOrder: 10,
|
||||
summary: "小甲在正史裡的新章節:她升職了。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲升職了,很開心。" }],
|
||||
});
|
||||
|
||||
expect(scene.projected).toBe(true);
|
||||
const work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(10);
|
||||
expect(work.inEpilogue).toBe(true); // 續匯入本身不改變是否處於後日談,只推進知識邊界
|
||||
|
||||
const memories = await prisma.client.episodicMemory.findMany({
|
||||
where: { characterId: CHAR_A, sourceSceneId: scene.id },
|
||||
});
|
||||
expect(memories.length).toBeGreaterThan(0);
|
||||
expect(memories.every((m) => m.source !== "CORRECTION")).toBe(true); // 不是敘事化記憶修正
|
||||
}, 20000);
|
||||
|
||||
it("既有已投影的 EPILOGUE 場景與其記憶完全不受續匯入影響", async () => {
|
||||
await createWorkInEpilogue();
|
||||
|
||||
const epilogueScene = await lifeEvent.generateSharedEvent(
|
||||
WORK_ID,
|
||||
CHAR_A,
|
||||
6,
|
||||
"小甲和朋友去了海邊玩。",
|
||||
[],
|
||||
new Date("2026-08-19T00:00:00.000Z"),
|
||||
);
|
||||
const epilogueMemoriesBefore = await prisma.client.episodicMemory.findMany({
|
||||
where: { characterId: CHAR_A, sourceSceneId: epilogueScene.id },
|
||||
});
|
||||
expect(epilogueMemoriesBefore.length).toBeGreaterThan(0);
|
||||
|
||||
await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 2,
|
||||
chapterLabel: "第十章",
|
||||
storyOrder: 10,
|
||||
summary: "小甲在正史裡的新章節:她升職了。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲升職了,很開心。" }],
|
||||
});
|
||||
|
||||
const epilogueSceneAfter = await prisma.client.scene.findUniqueOrThrow({ where: { id: epilogueScene.id } });
|
||||
expect(epilogueSceneAfter.projected).toBe(true); // 沒有被刪除、也沒有被 unproject
|
||||
const epilogueMemoriesAfter = await prisma.client.episodicMemory.findMany({
|
||||
where: { characterId: CHAR_A, sourceSceneId: epilogueScene.id },
|
||||
});
|
||||
expect(epilogueMemoriesAfter).toHaveLength(epilogueMemoriesBefore.length); // 完全不受影響
|
||||
}, 20000);
|
||||
|
||||
it("尚未進入後日談的作品,超前錨定點的正史場景仍維持既有行為(暫不投影,等錨定點推進)", async () => {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T15 對照組", anchorStoryOrder: 5, inEpilogue: false } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 2,
|
||||
chapterLabel: "第十章",
|
||||
storyOrder: 10,
|
||||
summary: "尚未出版到這裡。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "這句還不該被角色知道。" }],
|
||||
});
|
||||
|
||||
expect(scene.projected).toBe(false);
|
||||
const work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(5); // 未進入後日談時不受 T15 影響,維持既有 M-9 知識邊界
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { ProjectionService } from "../canon/projection.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
import { EpilogueProgressService } from "./epilogue-progress.service.js";
|
||||
|
||||
// T34 驗收:`timeFlowMode` 唯一真正的作用是換算後日談「推演進度」(anchorStoryOrder 這個既有時鐘)
|
||||
// 該推進多快——FAST 較快、SLOW 較慢、FROZEN 完全不推進;推演進度推進時順手嘗試自主成立里程碑
|
||||
// (不需使用者確認)。大廳/房間對話、行程、睡眠等真實時間流程完全不讀這個欄位,另見
|
||||
// `schedule/timeflow-realtime-boundary.test.ts`。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const lifeEvent = new LifeEventService(prisma, projection);
|
||||
const epilogueProgress = new EpilogueProgressService(prisma, lifeEvent);
|
||||
|
||||
const WORK_ID = "test-t34-work";
|
||||
const CHAR_BAKER = "test-t34-char-baker"; // goalsObsessions 含「麵包店」,可對到既有里程碑模板
|
||||
const CHAR_PLAIN = "test-t34-char-plain"; // 沒有可對應的正史伏筆關鍵字
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.epilogueMilestoneLog.deleteMany({ where: { characterId: { in: [CHAR_BAKER, CHAR_PLAIN] } } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_BAKER, CHAR_PLAIN] } } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWork(timeFlowMode: string, inEpilogue = true) {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T34 測試作品", inEpilogue, timeFlowMode, anchorStoryOrder: 0 } });
|
||||
}
|
||||
|
||||
async function createCharacter(id: string, goalsObsessions: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions,
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: id } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("後日談推演進度時鐘(T34)", () => {
|
||||
it("尚未進入後日談的作品完全不推進,也不寫入時鐘 checkpoint", async () => {
|
||||
await createWork("FAST", false);
|
||||
await createCharacter(CHAR_PLAIN, "測試");
|
||||
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date("2026-08-19T00:00:00.000Z"));
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date("2026-09-19T00:00:00.000Z"));
|
||||
|
||||
const work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(0);
|
||||
expect(work.epilogueProgressCheckpointAt).toBeNull();
|
||||
});
|
||||
|
||||
it("FAST 模式下,跨多次不足一天的 tick 也不會遺失推演進度(餘量會保留累積)", async () => {
|
||||
await createWork("FAST"); // 3 storyOrder/現實日
|
||||
await createCharacter(CHAR_PLAIN, "測試");
|
||||
const t0 = new Date("2026-08-19T00:00:00.000Z");
|
||||
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, t0); // 初始化 checkpoint,不推進
|
||||
let work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(0);
|
||||
|
||||
for (let hours = 4; hours <= 20; hours += 4) {
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date(t0.getTime() + hours * 60 * 60 * 1000));
|
||||
}
|
||||
work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(2); // 每 8 小時累積跨過一個整數單位(3/day → 1 單位/8h)
|
||||
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date(t0.getTime() + 24 * 60 * 60 * 1000));
|
||||
work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(3); // 整整一個現實日過後,總量剛好是 3(沒有因為拆成多次 tick 而漏算)
|
||||
}, 20000);
|
||||
|
||||
it("SLOW 比 REALTIME 推進得慢、FAST 比 REALTIME 推進得快(同一段現實時間下比較)", async () => {
|
||||
const oneWeekLater = new Date(new Date("2026-08-19T00:00:00.000Z").getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await createWork("SLOW");
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date("2026-08-19T00:00:00.000Z"));
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, oneWeekLater);
|
||||
const slowAnchor = (await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } })).anchorStoryOrder;
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
|
||||
await createWork("REALTIME");
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date("2026-08-19T00:00:00.000Z"));
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, oneWeekLater);
|
||||
const realtimeAnchor = (await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } })).anchorStoryOrder;
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
|
||||
await createWork("FAST");
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date("2026-08-19T00:00:00.000Z"));
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, oneWeekLater);
|
||||
const fastAnchor = (await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } })).anchorStoryOrder;
|
||||
|
||||
expect(slowAnchor).toBeLessThan(realtimeAnchor);
|
||||
expect(realtimeAnchor).toBeLessThan(fastAnchor);
|
||||
});
|
||||
|
||||
it("FROZEN 模式永遠不推進;解凍後只從解凍當下開始累積,不會把停滯期間全部補回來", async () => {
|
||||
await createWork("FROZEN");
|
||||
await createCharacter(CHAR_PLAIN, "測試");
|
||||
const t0 = new Date("2026-08-19T00:00:00.000Z");
|
||||
const t0Plus50Days = new Date(t0.getTime() + 50 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, t0);
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, t0Plus50Days); // 凍結期間即使經過 50 天仍不推進
|
||||
let work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(0);
|
||||
|
||||
await prisma.client.work.update({ where: { id: WORK_ID }, data: { timeFlowMode: "REALTIME" } });
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date(t0Plus50Days.getTime() + 24 * 60 * 60 * 1000));
|
||||
work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(1); // 只計入解凍後的 1 天,不是凍結前後合計的 51 天
|
||||
});
|
||||
|
||||
it("推進時嘗試自主成立里程碑:有對應正史伏筆的角色會成立、沒有的角色不會、且不需使用者確認", async () => {
|
||||
await createWork("FAST");
|
||||
await createCharacter(CHAR_BAKER, "她一直想開一間屬於自己的麵包店。");
|
||||
await createCharacter(CHAR_PLAIN, "測試");
|
||||
|
||||
const t0 = new Date("2026-08-19T00:00:00.000Z");
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, t0);
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date(t0.getTime() + 24 * 60 * 60 * 1000)); // +3 storyOrder
|
||||
|
||||
const bakerMilestones = await prisma.client.epilogueMilestoneLog.findMany({ where: { characterId: CHAR_BAKER } });
|
||||
expect(bakerMilestones).toHaveLength(1);
|
||||
expect(bakerMilestones[0].label).toBe("籌備麵包店");
|
||||
|
||||
const plainMilestones = await prisma.client.epilogueMilestoneLog.findMany({ where: { characterId: CHAR_PLAIN } });
|
||||
expect(plainMilestones).toHaveLength(0);
|
||||
|
||||
// 讀取端點沿用的方法:作品層級可以查到剛剛自主成立的里程碑。
|
||||
const forWork = await lifeEvent.listMilestonesForWork(WORK_ID);
|
||||
expect(forWork.map((m) => m.characterId)).toContain(CHAR_BAKER);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
import { EPILOGUE_STORY_ORDER_RATE, MS_PER_DAY, type TimeFlowMode } from "./constants.js";
|
||||
|
||||
// T34:後日談期間的推演進度時鐘——只在 `work.inEpilogue===true` 時才推進,把「現實流逝了多久」換算成
|
||||
// `anchorStoryOrder` 該往前走多少個單位(FROZEN 恆為 0)。刻意只做「推進時鐘+提議里程碑」,不呼叫
|
||||
// AnchorService.setAnchor()/ProjectionService:那條路徑會無條件重新投影任何 `projected=false` 的場景,
|
||||
// 若剛好有場景還卡在 T39 裁決中,會被這裡的自動推進意外強行投影,破壞裁決阻斷;真正的場景投影
|
||||
// 一律只透過既有的 SceneImportService/裁決端點觸發,這裡只是純粹的時間換算。
|
||||
@Injectable()
|
||||
export class EpilogueProgressService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly lifeEvent: LifeEventService,
|
||||
) {}
|
||||
|
||||
async advanceIfInEpilogue(workId: string, now: Date): Promise<void> {
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||
if (!work.inEpilogue) return;
|
||||
|
||||
// 第一次呼叫(時鐘還沒有起點):只建立起點,不倒推之前「沒被 tick 到」的時間,避免一啟用
|
||||
// 就把角色創建到現在為止的整段空窗期全部當成已經推演過。
|
||||
if (work.epilogueProgressCheckpointAt === null) {
|
||||
await this.prisma.client.work.update({ where: { id: workId }, data: { epilogueProgressCheckpointAt: now } });
|
||||
return;
|
||||
}
|
||||
const checkpoint = work.epilogueProgressCheckpointAt;
|
||||
|
||||
const mode = work.timeFlowMode as TimeFlowMode;
|
||||
const rate = EPILOGUE_STORY_ORDER_RATE[mode] ?? EPILOGUE_STORY_ORDER_RATE.FROZEN;
|
||||
|
||||
if (rate.numerator <= 0) {
|
||||
// FROZEN(或未知模式,同視為凍結):不推進,但把時鐘追到現在,避免解凍後把停滯期間全部補回來。
|
||||
await this.prisma.client.work.update({ where: { id: workId }, data: { epilogueProgressCheckpointAt: now } });
|
||||
return;
|
||||
}
|
||||
|
||||
// 先乘、最後才除(見 constants.ts 的說明):分子分母都是整數,被除數在合理的現實時間跨度內
|
||||
// 仍落在 Number 可精確表示的整數範圍,避免預先算好的浮點速率常數在多次 tick 後累積捨入誤差。
|
||||
const elapsedMs = now.getTime() - checkpoint.getTime();
|
||||
const denominator = MS_PER_DAY * rate.perRealDays;
|
||||
const delta = Math.floor((elapsedMs * rate.numerator) / denominator);
|
||||
if (delta <= 0) return; // 累積量還不到一個 storyOrder 單位,時鐘先不動,留給下次繼續累積。
|
||||
|
||||
const consumedMs = (delta * denominator) / rate.numerator;
|
||||
const newAnchorStoryOrder = work.anchorStoryOrder + delta;
|
||||
await this.prisma.client.work.update({
|
||||
where: { id: workId },
|
||||
data: { anchorStoryOrder: newAnchorStoryOrder, epilogueProgressCheckpointAt: new Date(checkpoint.getTime() + consumedMs) },
|
||||
});
|
||||
|
||||
// N-4 里程碑:低頻節流與正史伏筆比對交給既有的 proposeMilestone() 自己判斷,這裡對在場的每位
|
||||
// 角色都嘗試提議一次即可(沒有模板可對應或還沒到節流間隔時,proposeMilestone() 本身回傳 null)。
|
||||
const characters = await this.prisma.client.character.findMany({ where: { workId } });
|
||||
for (const character of characters) {
|
||||
await this.lifeEvent.proposeMilestone(character.id, newAnchorStoryOrder, [], now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { TimeFlowService } from "./time-flow.service.js";
|
||||
import { PersonalityDriftService } from "./personality-drift.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
import { CanonReclamationService } from "./canon-reclamation.service.js";
|
||||
import type { TimeFlowMode } from "./constants.js";
|
||||
import type { ImportSceneInput } from "../canon/scene-import.service.js";
|
||||
|
||||
@Controller("epilogue")
|
||||
export class EpilogueController {
|
||||
constructor(
|
||||
private readonly timeFlow: TimeFlowService,
|
||||
private readonly drift: PersonalityDriftService,
|
||||
private readonly lifeEvent: LifeEventService,
|
||||
private readonly reclamation: CanonReclamationService,
|
||||
) {}
|
||||
|
||||
@Post("works/:workId/check-transition")
|
||||
async checkTransition(@Param("workId") workId: string) {
|
||||
return this.timeFlow.checkEpilogueTransition(workId);
|
||||
}
|
||||
|
||||
// T14:使用者確認/拒絕 T13 寫入的結局偵測結果——尚未進入日後談的作品,這是唯一合法的切換入口。
|
||||
@Post("ending-detections/:logId/confirm")
|
||||
async confirmEndingDetection(@Param("logId") logId: string, @Body() body: { now?: string }) {
|
||||
return this.timeFlow.confirmEndingDetection(logId, body.now ? new Date(body.now) : undefined);
|
||||
}
|
||||
|
||||
@Post("ending-detections/:logId/reject")
|
||||
async rejectEndingDetection(@Param("logId") logId: string, @Body() body: { now?: string }) {
|
||||
return this.timeFlow.rejectEndingDetection(logId, body.now ? new Date(body.now) : undefined);
|
||||
}
|
||||
|
||||
@Post("works/:workId/time-flow")
|
||||
async setTimeFlow(@Param("workId") workId: string, @Body() body: { mode: TimeFlowMode }) {
|
||||
return this.timeFlow.setTimeFlow(workId, body.mode);
|
||||
}
|
||||
|
||||
@Get("characters/:characterId/drift")
|
||||
async getDrift(@Param("characterId") characterId: string, @Query("atStoryOrder") atStoryOrder: string) {
|
||||
return this.drift.computeDrift(characterId, Number(atStoryOrder));
|
||||
}
|
||||
|
||||
@Post("characters/:characterId/milestone")
|
||||
async proposeMilestone(
|
||||
@Param("characterId") characterId: string,
|
||||
@Body() body: { storyOrder: number; attendeeCharacterIds?: string[]; now?: string },
|
||||
) {
|
||||
return this.lifeEvent.proposeMilestone(
|
||||
characterId,
|
||||
body.storyOrder,
|
||||
body.attendeeCharacterIds ?? [],
|
||||
body.now ? new Date(body.now) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("works/:workId/milestones")
|
||||
async listMilestones(@Param("workId") workId: string) {
|
||||
return this.lifeEvent.listMilestonesForWork(workId);
|
||||
}
|
||||
|
||||
@Post("works/:workId/reclaim")
|
||||
async reclaim(@Param("workId") workId: string, @Body() body: { newCanonScenes: ImportSceneInput[] }) {
|
||||
return this.reclamation.reclaim(workId, body.newCanonScenes);
|
||||
}
|
||||
|
||||
@Post("works/:workId/branch")
|
||||
async branch(@Param("workId") workId: string) {
|
||||
return this.reclamation.branch(workId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { RomanceModule } from "../romance/romance.module.js";
|
||||
import { CanonModule } from "../canon/canon.module.js";
|
||||
import { TimeFlowService } from "./time-flow.service.js";
|
||||
import { PersonalityDriftService } from "./personality-drift.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
import { CanonReclamationService } from "./canon-reclamation.service.js";
|
||||
import { EpilogueProgressService } from "./epilogue-progress.service.js";
|
||||
import { EpilogueController } from "./epilogue.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RomanceModule, CanonModule],
|
||||
controllers: [EpilogueController],
|
||||
providers: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService, EpilogueProgressService],
|
||||
exports: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService, EpilogueProgressService],
|
||||
})
|
||||
export class EpilogueModule {}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ProjectionService } from "../canon/projection.service.js";
|
||||
import { MILESTONE_MIN_STORY_GAP } from "./constants.js";
|
||||
|
||||
export interface MilestoneTemplate {
|
||||
keyword: string;
|
||||
label: string;
|
||||
describe: (formalName: string) => string;
|
||||
}
|
||||
|
||||
// N-4 人生階段推演:依角色在正史裡確立的目標推演里程碑——沒有目標文字可對應的角色不生成重大轉折,
|
||||
// 避免無正史依據地捏造事件(憑空轉向)。
|
||||
const MILESTONE_TEMPLATES: MilestoneTemplate[] = [
|
||||
{ keyword: "麵包店", label: "籌備麵包店", describe: (name) => `${name}開始四處看店面,籌備她一直想開的麵包店。` },
|
||||
{ keyword: "麵包店", label: "麵包店開幕", describe: (name) => `${name}的麵包店終於開幕了,雖然忙得團團轉,但看起來很幸福。` },
|
||||
{ keyword: "考試", label: "考上目標學校", describe: (name) => `${name}收到了合格通知,離她的目標又近了一步。` },
|
||||
{ keyword: "畫家", label: "個展", describe: (name) => `${name}辦了第一次個人畫展,邀請了熟悉的朋友們。` },
|
||||
];
|
||||
|
||||
// N-3/N-4 人生階段推演與角色群同步成長:生成的里程碑直接重用 M 群組的場景資料庫與投影機制——
|
||||
// 一場戲的在場者是誰,誰就會得到對應的情節記憶,這正是「A 的婚禮 B 有出席」的實作基礎,
|
||||
// 不需要另外寫一套「多角色事件廣播」機制。
|
||||
@Injectable()
|
||||
export class LifeEventService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly projection: ProjectionService,
|
||||
) {}
|
||||
|
||||
private matchTemplate(goalsObsessions: string, excludeLabels: Set<string>): MilestoneTemplate | null {
|
||||
for (const template of MILESTONE_TEMPLATES) {
|
||||
if (goalsObsessions.includes(template.keyword) && !excludeLabels.has(template.label)) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 低頻節流 + 需要正史伏筆(goalsObsessions 裡要能對應到模板關鍵字)才會生成重大轉折。
|
||||
async proposeMilestone(
|
||||
characterId: string,
|
||||
storyOrder: number,
|
||||
attendeeCharacterIds: string[] = [],
|
||||
now: Date = new Date(),
|
||||
): Promise<{ scene: Awaited<ReturnType<PrismaService["client"]["scene"]["create"]>> | null; label: string | null }> {
|
||||
const lastMilestone = await this.prisma.client.epilogueMilestoneLog.findFirst({
|
||||
where: { characterId },
|
||||
orderBy: { storyOrder: "desc" },
|
||||
});
|
||||
if (lastMilestone && storyOrder - lastMilestone.storyOrder < MILESTONE_MIN_STORY_GAP) {
|
||||
return { scene: null, label: null }; // 低頻:離上次里程碑太近,不生成。
|
||||
}
|
||||
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const usedLabels = new Set(
|
||||
(await this.prisma.client.epilogueMilestoneLog.findMany({ where: { characterId } })).map((log) => log.label),
|
||||
);
|
||||
const template = this.matchTemplate(character.goalsObsessions, usedLabels);
|
||||
if (!template) {
|
||||
return { scene: null, label: null }; // 沒有正史伏筆可對應,不憑空捏造重大轉折。
|
||||
}
|
||||
|
||||
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId } });
|
||||
const formalName = alias?.formalName ?? character.id;
|
||||
const description = template.describe(formalName);
|
||||
|
||||
const scene = await this.generateSharedEvent(
|
||||
character.workId!,
|
||||
characterId,
|
||||
storyOrder,
|
||||
description,
|
||||
attendeeCharacterIds,
|
||||
now,
|
||||
);
|
||||
|
||||
await this.prisma.client.epilogueMilestoneLog.create({ data: { characterId, storyOrder, label: template.label } });
|
||||
return { scene, label: template.label };
|
||||
}
|
||||
|
||||
// N-3 角色群同步成長:一個推演事件的在場者可以是多個角色,投影後每個在場者都拿到對應的情節記憶,
|
||||
// 敘述互相呼應(同一份場景摘要)。事件生成即代表「現在」,不受 M-9 錨定點前置判斷節制(不會超前於現在)。
|
||||
async generateSharedEvent(
|
||||
workId: string,
|
||||
primaryCharacterId: string,
|
||||
storyOrder: number,
|
||||
description: string,
|
||||
attendeeCharacterIds: string[],
|
||||
now: Date = new Date(),
|
||||
) {
|
||||
const allPresent = [primaryCharacterId, ...attendeeCharacterIds.filter((id) => id !== primaryCharacterId)];
|
||||
const scene = await this.prisma.client.scene.create({
|
||||
data: {
|
||||
workId,
|
||||
volume: 0,
|
||||
chapterLabel: "後日談",
|
||||
storyOrder,
|
||||
sourceType: "EPILOGUE",
|
||||
summary: description,
|
||||
rawText: description,
|
||||
createdAt: now,
|
||||
},
|
||||
});
|
||||
for (const characterId of allPresent) {
|
||||
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
|
||||
}
|
||||
await this.prisma.client.sceneLine.create({
|
||||
data: { sceneId: scene.id, lineIndex: 0, lineType: "NARRATION", rawText: description, status: "CONFIRMED", confidence: 1 },
|
||||
});
|
||||
await this.projection.projectScene(scene.id);
|
||||
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id } });
|
||||
}
|
||||
|
||||
// T34:作品頁/大廳呈現這個作品旗下角色自主成立過的所有里程碑,依 storyOrder 排序方便對照時序。
|
||||
async listMilestonesForWork(workId: string) {
|
||||
const characters = await this.prisma.client.character.findMany({ where: { workId }, select: { id: true } });
|
||||
return this.prisma.client.epilogueMilestoneLog.findMany({
|
||||
where: { characterId: { in: characters.map((c) => c.id) } },
|
||||
orderBy: { storyOrder: "asc" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||
import { MAX_EXPRESSIVENESS_DRIFT, MAX_HALF_LIFE_REDUCTION, HALF_LIFE_REDUCTION_PER_STORY_UNIT } from "./constants.js";
|
||||
|
||||
export interface DriftedPersonality {
|
||||
archetype: string; // 性格核心,從不改變
|
||||
expressiveness: number; // 已套用漂移的外顯度(G-1 原始值 ± 小幅位移)
|
||||
driftAmount: number; // 這次位移了多少(可為負)
|
||||
emotionHalfLifeMultiplier: number; // <1 代表情緒衰減變快
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
// N-2 性格漂移:性格核心(原型本身)鎖定不變,只有數值參數依正史已顯現的成長軌跡外插做小幅位移——
|
||||
// 沒有證據支持的方向不會憑空轉向(沒有至少兩筆正史快照時,漂移量為 0)。
|
||||
@Injectable()
|
||||
export class PersonalityDriftService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async computeDrift(characterId: string, atStoryOrder: number): Promise<DriftedPersonality> {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const base = getArchetypeParams(character.personalityArchetype);
|
||||
|
||||
const canonSnapshots = await this.prisma.client.personalityTraitSnapshot.findMany({
|
||||
where: { characterId, expressivenessOverride: { not: null } },
|
||||
orderBy: { atStoryOrder: "asc" },
|
||||
});
|
||||
|
||||
let trend = 0;
|
||||
let referenceOrder = atStoryOrder;
|
||||
if (canonSnapshots.length >= 2) {
|
||||
const first = canonSnapshots[0];
|
||||
const last = canonSnapshots[canonSnapshots.length - 1];
|
||||
if (last.atStoryOrder !== first.atStoryOrder) {
|
||||
trend = (last.expressivenessOverride! - first.expressivenessOverride!) / (last.atStoryOrder - first.atStoryOrder);
|
||||
}
|
||||
referenceOrder = last.atStoryOrder;
|
||||
}
|
||||
|
||||
const elapsed = Math.max(0, atStoryOrder - referenceOrder);
|
||||
const rawDrift = trend * elapsed;
|
||||
const driftAmount = Math.max(-MAX_EXPRESSIVENESS_DRIFT, Math.min(MAX_EXPRESSIVENESS_DRIFT, rawDrift));
|
||||
const expressiveness = clamp01(base.expressiveness + driftAmount);
|
||||
|
||||
const halfLifeReduction = Math.min(MAX_HALF_LIFE_REDUCTION, elapsed * HALF_LIFE_REDUCTION_PER_STORY_UNIT);
|
||||
|
||||
return {
|
||||
archetype: character.personalityArchetype,
|
||||
expressiveness,
|
||||
driftAmount,
|
||||
emotionHalfLifeMultiplier: 1 - halfLifeReduction,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { AgeDeterminationService } from "../romance/age-determination.service.js";
|
||||
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
|
||||
import { FingerprintService } from "../canon/fingerprint.service.js";
|
||||
import { AttributionService } from "../canon/attribution.service.js";
|
||||
import { ProjectionService } from "../canon/projection.service.js";
|
||||
import { WorkRosterService } from "../canon/work-roster.service.js";
|
||||
import { TimelineService } from "../canon/timeline.service.js";
|
||||
import { EndingDetectionService } from "../canon/ending-detection.service.js";
|
||||
import { SceneImportService } from "../canon/scene-import.service.js";
|
||||
import { TimeFlowService } from "./time-flow.service.js";
|
||||
|
||||
// T14 驗收:確認後 Work.inEpilogue 為 true、epilogueStartStoryOrder 正確;拒絕後作品維持原狀,
|
||||
// 同一場景不再重複提示;已處理過的偵測結果不可重複確認或拒絕。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const endingDetection = new EndingDetectionService(prisma, new MockProvider());
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
const eligibility = new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma));
|
||||
const timeFlow = new TimeFlowService(prisma, eligibility);
|
||||
|
||||
const WORK_ID = "test-t14-work";
|
||||
const CHAR_A = "test-t14-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.endingDetectionLog.deleteMany({ where: { scene: { workId: WORK_ID } } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function importEndingScene(anchorStoryOrder: number, storyOrder: number) {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T14 測試作品", anchorStoryOrder } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
return sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一話",
|
||||
storyOrder,
|
||||
summary: "故事在此完結,全劇終。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "全劇終。" }],
|
||||
});
|
||||
}
|
||||
|
||||
describe("TimeFlowService 結局確認與拒絕(T14)", () => {
|
||||
it("確認後 Work.inEpilogue 為 true、epilogueStartStoryOrder 正確,且該筆紀錄改為 CONFIRMED", async () => {
|
||||
await importEndingScene(50, 1);
|
||||
const log = await prisma.client.endingDetectionLog.findFirstOrThrow({ where: { scene: { workId: WORK_ID } } });
|
||||
|
||||
const work = await timeFlow.confirmEndingDetection(log.id);
|
||||
expect(work.inEpilogue).toBe(true);
|
||||
expect(work.epilogueStartStoryOrder).toBe(50);
|
||||
|
||||
const logAfter = await prisma.client.endingDetectionLog.findUniqueOrThrow({ where: { id: log.id } });
|
||||
expect(logAfter.resolution).toBe("CONFIRMED");
|
||||
expect(logAfter.respondedAt).toBeTruthy();
|
||||
}, 20000);
|
||||
|
||||
it("拒絕後作品維持原狀,且同一場景不再重複提示", async () => {
|
||||
const scene = await importEndingScene(50, 1);
|
||||
const log = await prisma.client.endingDetectionLog.findFirstOrThrow({ where: { scene: { workId: WORK_ID } } });
|
||||
|
||||
await timeFlow.rejectEndingDetection(log.id);
|
||||
const work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.inEpilogue).toBe(false);
|
||||
|
||||
const logAfter = await prisma.client.endingDetectionLog.findUniqueOrThrow({ where: { id: log.id } });
|
||||
expect(logAfter.resolution).toBe("REJECTED");
|
||||
|
||||
// 對同一場景重新嘗試偵測(模擬未來可能的重複觸發路徑),不應再寫入新的一筆。
|
||||
await endingDetection.detectForScene(scene.id);
|
||||
const logs = await prisma.client.endingDetectionLog.findMany({ where: { sceneId: scene.id } });
|
||||
expect(logs).toHaveLength(1);
|
||||
}, 20000);
|
||||
|
||||
it("已處理過的偵測結果不可重複確認或拒絕", async () => {
|
||||
await importEndingScene(50, 1);
|
||||
const log = await prisma.client.endingDetectionLog.findFirstOrThrow({ where: { scene: { workId: WORK_ID } } });
|
||||
await timeFlow.confirmEndingDetection(log.id);
|
||||
|
||||
await expect(timeFlow.confirmEndingDetection(log.id)).rejects.toThrow();
|
||||
await expect(timeFlow.rejectEndingDetection(log.id)).rejects.toThrow();
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
|
||||
import type { TimeFlowMode } from "./constants.js";
|
||||
|
||||
// N-1 區間切換與時間流速:錨定點抵達已出版內容盡頭後切入後日談;後日談成年角色的流速上限為現實 1:1,
|
||||
// 只能調慢或凍結——這條規則在引擎層執行(不是使用者介面隱藏選項),任何嘗試設定更快流速都會被拒絕。
|
||||
@Injectable()
|
||||
export class TimeFlowService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly eligibility: RomanceEligibilityService,
|
||||
) {}
|
||||
|
||||
// 錨定點推進後呼叫:若已達到(或超過)已出版內容的盡頭,切入後日談區間。
|
||||
// T14:本方法保留給 T15(日後談續匯入)既有呼叫端(canon-reclamation.service.ts)沿用——那裡處理
|
||||
// 的是「已經在日後談中的作品」續匯入正史內容,`work.inEpilogue` 早已為 true 時本方法直接 no-op 回傳,
|
||||
// 不構成新的未經確認自動切換路徑。**尚未進入日後談的作品,唯一合法的切換入口是下方
|
||||
// `confirmEndingDetection()`**(使用者確認結局偵測結果後才切);本方法不應再被當成「偵測到疑似結局
|
||||
// 就自動切換」的路徑使用,第 4 輪「偵測到結局先問使用者再切」的決定以 confirmEndingDetection() 落實。
|
||||
async checkEpilogueTransition(workId: string) {
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||
if (work.inEpilogue) {
|
||||
return work;
|
||||
}
|
||||
const canonMax = await this.prisma.client.scene.aggregate({
|
||||
where: { workId, sourceType: { not: "EPILOGUE" } },
|
||||
_max: { storyOrder: true },
|
||||
});
|
||||
const maxCanonStoryOrder = canonMax._max.storyOrder ?? 0;
|
||||
if (maxCanonStoryOrder > 0 && work.anchorStoryOrder >= maxCanonStoryOrder) {
|
||||
return this.switchToEpilogue(work.id, work.anchorStoryOrder);
|
||||
}
|
||||
return work;
|
||||
}
|
||||
|
||||
private async switchToEpilogue(workId: string, epilogueStartStoryOrder: number) {
|
||||
return this.prisma.client.work.update({
|
||||
where: { id: workId },
|
||||
data: { inEpilogue: true, epilogueStartStoryOrder },
|
||||
});
|
||||
}
|
||||
|
||||
// T14:使用者確認 EndingDetectionLog 的偵測結果——這是「尚未進入日後談的作品」唯一合法的切換入口。
|
||||
async confirmEndingDetection(logId: string, now: Date = new Date()) {
|
||||
const log = await this.prisma.client.endingDetectionLog.findUniqueOrThrow({
|
||||
where: { id: logId },
|
||||
include: { scene: true },
|
||||
});
|
||||
if (log.resolution !== "PENDING") {
|
||||
throw new BadRequestException(`此偵測結果已處理過(目前狀態:${log.resolution}),不可重複確認或拒絕`);
|
||||
}
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: log.scene.workId } });
|
||||
await this.switchToEpilogue(work.id, work.anchorStoryOrder);
|
||||
await this.prisma.client.endingDetectionLog.update({
|
||||
where: { id: logId },
|
||||
data: { resolution: "CONFIRMED", respondedAt: now },
|
||||
});
|
||||
return this.prisma.client.work.findUniqueOrThrow({ where: { id: work.id } });
|
||||
}
|
||||
|
||||
// T14:拒絕——作品維持原狀;同一 sceneId 不再重複提示(EndingDetectionService.detectForScene()
|
||||
// 一律以「這個場景是否已有任意一筆紀錄」判斷要不要再寫新紀錄,不看 resolution,故拒絕後天然不重問)。
|
||||
async rejectEndingDetection(logId: string, now: Date = new Date()) {
|
||||
const log = await this.prisma.client.endingDetectionLog.findUniqueOrThrow({ where: { id: logId } });
|
||||
if (log.resolution !== "PENDING") {
|
||||
throw new BadRequestException(`此偵測結果已處理過(目前狀態:${log.resolution}),不可重複確認或拒絕`);
|
||||
}
|
||||
return this.prisma.client.endingDetectionLog.update({
|
||||
where: { id: logId },
|
||||
data: { resolution: "REJECTED", respondedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
async setTimeFlow(workId: string, mode: TimeFlowMode) {
|
||||
if (mode === "FAST") {
|
||||
const characters = await this.prisma.client.character.findMany({ where: { workId } });
|
||||
for (const character of characters) {
|
||||
const eligibility = await this.eligibility.getEligibility(character.id);
|
||||
if (eligibility.isEpilogueAdult) {
|
||||
throw new BadRequestException(
|
||||
`角色 ${character.id} 是正史未成年、後日談推演成年——時間流速上限為現實 1:1,不可設定快轉`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.prisma.client.work.update({ where: { id: workId }, data: { timeFlowMode: mode } });
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,27 @@ export class BehaviorReinforcementService {
|
||||
}
|
||||
}
|
||||
|
||||
// T25:章節匯入時從場景(ACTION 描寫)抽出的行為模式——首見新增規則、已有相同 (situation,
|
||||
// responsePattern) 則強化既有權重,一律記錄最近一次觸發的來源場景(供追溯與重投影清除,
|
||||
// 已知簡化見 schema.prisma 的 sourceSceneId 欄位註解:只記錄最近一次,不是完整貢獻清單)。
|
||||
async recordFromScene(characterId: string, situation: string, responsePattern: string, sceneId: string): Promise<void> {
|
||||
const existing = await this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation, responsePattern },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await this.prisma.client.proceduralRule.update({
|
||||
where: { id: existing.id },
|
||||
data: { weight: existing.weight + REINFORCE_INCREMENT, sourceSceneId: sceneId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.client.proceduralRule.create({
|
||||
data: { characterId, situation, responsePattern, weight: 1, sourceSceneId: sceneId },
|
||||
});
|
||||
}
|
||||
|
||||
async getTopRule(characterId: string, situation: string) {
|
||||
return this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation },
|
||||
|
||||
@@ -1,19 +1,144 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { LLMProvider } from "./llm-provider.js";
|
||||
import type { CharacterCallCandidate, EndingDetectionResult, LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
import { buildMessages } from "./prompt-builder.js";
|
||||
import { extractActions } from "./action-markup.js";
|
||||
|
||||
const NOT_IMPLEMENTED_MESSAGE = "ClaudeProvider 尚未實作(R-3 會接上真實 Claude API)";
|
||||
interface OpenAiCompatibleChoice {
|
||||
message?: { content?: string };
|
||||
delta?: { content?: string };
|
||||
}
|
||||
interface OpenAiCompatibleUsage {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
}
|
||||
interface OpenAiCompatibleResponse {
|
||||
choices?: OpenAiCompatibleChoice[];
|
||||
usage?: OpenAiCompatibleUsage;
|
||||
}
|
||||
|
||||
// F-2 ClaudeProvider 空殼:R-3 才會實作真實呼叫,目前僅回報未實作。
|
||||
function baseUrl(): string {
|
||||
return process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1";
|
||||
}
|
||||
|
||||
function apiKey(): string {
|
||||
const key = process.env.CLAUDE_API_KEY;
|
||||
if (!key) {
|
||||
throw new Error("CLAUDE_API_KEY 未設定,LLM_PROVIDER=claude 需要這個環境變數才能呼叫真實 API");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function model(): string {
|
||||
return process.env.CLAUDE_MODEL ?? "claude-sonnet-4-5";
|
||||
}
|
||||
|
||||
async function requestChatCompletion(body: Record<string, unknown>): Promise<Response> {
|
||||
const res = await fetch(`${baseUrl()}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey()}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`ClaudeProvider 呼叫失敗(HTTP ${res.status}):${text.slice(0, 500)}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// R-3:把 F-2 的空殼換成真的呼叫。這個部署走 CLIProxy(分散式派工代理,見
|
||||
// gitea.jsc.idv.tw/jiantw83/CLIProxy)提供的 OpenAI 相容端點,而不是直連
|
||||
// api.anthropic.com——底層仍是 Claude,只是多一層派工代理,對這個介面
|
||||
// (LLMProvider.generate/stream)完全透明,切換供應商不需要動任何呼叫端程式碼。
|
||||
@Injectable()
|
||||
export class ClaudeProvider implements LLMProvider {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async generate(_context: GenerationContext): Promise<GeneratedResponse> {
|
||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
||||
async generate(context: GenerationContext): Promise<GeneratedResponse> {
|
||||
const res = await requestChatCompletion({
|
||||
model: model(),
|
||||
messages: buildMessages(context),
|
||||
stream: false,
|
||||
});
|
||||
const json = (await res.json()) as OpenAiCompatibleResponse;
|
||||
const text = json.choices?.[0]?.message?.content ?? "";
|
||||
if (!text) {
|
||||
throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)");
|
||||
}
|
||||
// T9:真實 API 回應的 usage 未提供時(部分相容端點不回傳)以 0 記錄,不因此中斷生成。
|
||||
return {
|
||||
text,
|
||||
actions: extractActions(text),
|
||||
usage: { model: model(), promptTokens: json.usage?.prompt_tokens ?? 0, completionTokens: json.usage?.completion_tokens ?? 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, require-yield
|
||||
async *stream(_context: GenerationContext): AsyncIterable<string> {
|
||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
||||
async *stream(context: GenerationContext): AsyncIterable<string> {
|
||||
const res = await requestChatCompletion({
|
||||
model: model(),
|
||||
messages: buildMessages(context),
|
||||
stream: true,
|
||||
});
|
||||
if (!res.body) {
|
||||
throw new Error("ClaudeProvider 串流回應沒有 body");
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) continue;
|
||||
const payload = trimmed.slice("data:".length).trim();
|
||||
if (payload === "[DONE]") return;
|
||||
let chunk: OpenAiCompatibleResponse;
|
||||
try {
|
||||
chunk = JSON.parse(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const delta = chunk.choices?.[0]?.delta?.content;
|
||||
if (delta) yield delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 未回傳可解析的判斷依據)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export class ContextAssemblerService {
|
||||
? []
|
||||
: await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId });
|
||||
|
||||
const history = this.workingMemory.getContext(sessionId).map((entry) => ({
|
||||
const history = (await this.workingMemory.getContext(sessionId)).map((entry) => ({
|
||||
role: entry.role,
|
||||
content: entry.content,
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { BadRequestException, Body, Controller, Get, Inject, Param, Post, Query } from "@nestjs/common";
|
||||
import { DialogueService } from "./dialogue.service.js";
|
||||
import { BehaviorReinforcementService } from "./behavior-reinforcement.service.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js";
|
||||
import { MockProvider } from "./mock-provider.service.js";
|
||||
import { GlobalPauseService } from "./global-pause.service.js";
|
||||
|
||||
interface SendMessageBody {
|
||||
userId: string;
|
||||
@@ -25,8 +28,39 @@ export class DialogueController {
|
||||
constructor(
|
||||
private readonly dialogue: DialogueService,
|
||||
private readonly reinforcement: BehaviorReinforcementService,
|
||||
@Inject(LLM_PROVIDER) private readonly provider: LLMProvider,
|
||||
private readonly globalPause: GlobalPauseService,
|
||||
) {}
|
||||
|
||||
// T21-P3 端到端驗證用途:讓 smoke test 能觸發真正的連續失敗達到 T30 的全域靜默暫停門檻,
|
||||
// 僅在 LLM_PROVIDER=mock 時可用(MockProvider 才有可佇列的失敗)。
|
||||
@Post("test/force-llm-failure")
|
||||
async forceLlmFailure(@Body() body: { count?: number }) {
|
||||
if (!(this.provider instanceof MockProvider)) {
|
||||
throw new BadRequestException("僅 LLM_PROVIDER=mock 時可用");
|
||||
}
|
||||
for (let i = 0; i < (body.count ?? 1); i++) {
|
||||
this.provider.enqueueFailure(new Error("smoke-test forced failure"));
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get("test/global-pause-state")
|
||||
getGlobalPauseState() {
|
||||
return { paused: this.globalPause.isPaused() };
|
||||
}
|
||||
|
||||
// T21-P3:驗證「恢復」需要一次保證成功的呼叫——先清空可能還沒被消耗完的佇列失敗,
|
||||
// 確保下一次真的呼叫 LLM 的行為會成功,而不是被前面過量佇列的失敗繼續蓋過去。
|
||||
@Post("test/reset-llm-queue")
|
||||
resetLlmQueue() {
|
||||
if (!(this.provider instanceof MockProvider)) {
|
||||
throw new BadRequestException("僅 LLM_PROVIDER=mock 時可用");
|
||||
}
|
||||
this.provider.resetQueue();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post(":characterId/sessions/:sessionId/messages")
|
||||
async sendMessage(
|
||||
@Param("characterId") characterId: string,
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { WorkingMemoryService } from "../memory/working-memory.service.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 { ScheduleEmotionBaselineService } from "../schedule/schedule-emotion-baseline.service.js";
|
||||
import { OfflineEventService } from "../schedule/offline-event.service.js";
|
||||
import { availabilityForStatus, type AvailabilityMode, type ExceptionType } from "../schedule/constants.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js";
|
||||
import { GlobalPauseService } from "./global-pause.service.js";
|
||||
import { generateWithResilience } from "./generate-with-resilience.js";
|
||||
import { ContextAssemblerService } from "./context-assembler.service.js";
|
||||
import { OutputFilterService } from "./output-filter.service.js";
|
||||
import { FastChannelDetector, DEFAULT_FAST_REPLIES } from "./fast-channel.service.js";
|
||||
import { extractActions } from "./action-markup.js";
|
||||
import { getForbiddenWords, applySpeechQuirks } from "../personality/language-style.js";
|
||||
import type { GenerationContext } from "./types.js";
|
||||
import type { GenerationContext, GenerationUsage } from "./types.js";
|
||||
|
||||
export interface HandleMessageOptions {
|
||||
seed?: number;
|
||||
@@ -18,15 +27,20 @@ export interface HandleMessageResult {
|
||||
text: string;
|
||||
actions: string[];
|
||||
isFastChannel: boolean;
|
||||
context: GenerationContext;
|
||||
availability: AvailabilityMode;
|
||||
exceptionType: ExceptionType | null;
|
||||
context: GenerationContext | null;
|
||||
usage?: GenerationUsage; // T9:僅真的呼叫 Provider 生成時才有值(雙速通道/作息延遲不消耗 LLM)
|
||||
}
|
||||
|
||||
function fastChannelSituation(reason: string): string {
|
||||
return `fast-channel:${reason}`;
|
||||
}
|
||||
|
||||
const DELAYED_ACKNOWLEDGEMENT = "抱歉剛剛在忙,現在才看到——";
|
||||
|
||||
// F 群組整合入口:串起雙速通道判斷、上下文組裝、Provider 生成、輸出過濾與工作記憶寫入。
|
||||
// H-1 的 POST /chat/:characterId 會以此為基礎擴充(見 todo.md 實作記錄)。
|
||||
// I 群組擴充:作息可用度/破例規則/作息情緒基線/離線事件生成(見 todo.md 實作記錄)。
|
||||
@Injectable()
|
||||
export class DialogueService {
|
||||
constructor(
|
||||
@@ -35,7 +49,14 @@ export class DialogueService {
|
||||
private readonly contextAssembler: ContextAssemblerService,
|
||||
private readonly outputFilter: OutputFilterService,
|
||||
private readonly workingMemory: WorkingMemoryService,
|
||||
private readonly emotion: EmotionService,
|
||||
private readonly relationship: RelationshipService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly scheduleException: ScheduleExceptionService,
|
||||
private readonly scheduleBaseline: ScheduleEmotionBaselineService,
|
||||
private readonly offlineEvent: OfflineEventService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly globalPause: GlobalPauseService,
|
||||
) {}
|
||||
|
||||
async handleMessage(
|
||||
@@ -46,20 +67,60 @@ export class DialogueService {
|
||||
options: HandleMessageOptions = {},
|
||||
): Promise<HandleMessageResult> {
|
||||
const now = options.now ?? new Date();
|
||||
this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
|
||||
await this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
|
||||
|
||||
// I-6:按需生成離線事件(今天只生成一次),成為話題來源。
|
||||
await this.offlineEvent.maybeGenerate(characterId, now);
|
||||
|
||||
// I-5:作息驅動的情緒基線,只在本輪沒有明確情緒訊號時才介入。
|
||||
const baselineSignal = await this.scheduleBaseline.computeBaseline(characterId, now);
|
||||
const emotionResult = await this.emotion.processInput(characterId, userInput, {
|
||||
now,
|
||||
baselineSignal: baselineSignal ?? undefined,
|
||||
});
|
||||
await this.relationship.recordInteraction(characterId, userId, now);
|
||||
|
||||
// I-2:目前時段狀態 → 回應可用度;I-4:條件符合且未在冷卻期時才破例。
|
||||
const scheduleInfo = await this.schedule.getInfo(characterId, now);
|
||||
const availability = availabilityForStatus(scheduleInfo.slot?.status ?? null);
|
||||
|
||||
let exceptionType: ExceptionType | null = null;
|
||||
let effectiveAvailability = availability;
|
||||
if (availability !== "NORMAL") {
|
||||
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||
exceptionType = await this.scheduleException.tryApply(
|
||||
characterId,
|
||||
userId,
|
||||
availability,
|
||||
{ intimacy: relationship.intimacy, dominantEmotion: dominantState(emotionResult.state), userInput },
|
||||
now,
|
||||
);
|
||||
if (exceptionType) {
|
||||
effectiveAvailability = "NORMAL";
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveAvailability === "NO_REPLY") {
|
||||
return { text: "", actions: [], isFastChannel: false, availability: effectiveAvailability, exceptionType, context: null };
|
||||
}
|
||||
|
||||
const { isFastChannel, reason } = this.fastChannel.detect(userInput);
|
||||
const skipMemoryRetrieval = isFastChannel || effectiveAvailability === "BRIEF";
|
||||
|
||||
const context = await this.contextAssembler.assemble(characterId, userId, sessionId, userInput, {
|
||||
skipMemoryRetrieval: isFastChannel,
|
||||
skipMemoryRetrieval,
|
||||
seed: options.seed,
|
||||
now,
|
||||
});
|
||||
|
||||
let text: string;
|
||||
let actions: string[];
|
||||
let usage: GenerationUsage | undefined;
|
||||
|
||||
if (isFastChannel && reason) {
|
||||
if (effectiveAvailability === "DELAYED" && !exceptionType) {
|
||||
text = DELAYED_ACKNOWLEDGEMENT;
|
||||
actions = [];
|
||||
} else if (isFastChannel && reason) {
|
||||
const rule = await this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation: fastChannelSituation(reason) },
|
||||
orderBy: { weight: "desc" },
|
||||
@@ -67,9 +128,25 @@ export class DialogueService {
|
||||
text = rule?.responsePattern ?? DEFAULT_FAST_REPLIES[reason];
|
||||
actions = extractActions(text);
|
||||
} else {
|
||||
const generated = await this.llmProvider.generate(context);
|
||||
text = generated.text;
|
||||
actions = generated.actions;
|
||||
// T30:全域靜默暫停或重試全部失敗時回傳 null——比照既有 NO_REPLY 的「這輪沒有真的回覆」語意,
|
||||
// 回傳空字串,呼叫端(SoloChatService.sendMessage)已經是「text 有內容才寫入 RoomTurn」,
|
||||
// 不需要另外判斷;使用者本人的發話在呼叫端已於此之前寫入,不受影響。
|
||||
const generated = await generateWithResilience(this.llmProvider, context, this.globalPause);
|
||||
text = generated?.text ?? "";
|
||||
actions = generated?.actions ?? [];
|
||||
usage = generated?.usage;
|
||||
}
|
||||
|
||||
// I-2 剛醒/睡前:以拖長語尾的簡單標記呈現迷糊演出(完整口吻交由模板庫日後擴充);暫停中沒有
|
||||
// 真的回覆(text 為空)時不套用,避免把「……」誤當成一則真的有內容的回覆。
|
||||
if (text && (scheduleInfo.isWaking || scheduleInfo.isPreSleep)) {
|
||||
text = `……${text}`;
|
||||
}
|
||||
|
||||
// T30:暫停中沒有真的回覆(text 為空)時,比照既有 NO_REPLY 語意略過輸出過濾與工作記憶寫入,
|
||||
// 不留下一則空白的角色訊息。
|
||||
if (!text) {
|
||||
return { text: "", actions, isFastChannel, availability: effectiveAvailability, exceptionType, context, usage };
|
||||
}
|
||||
|
||||
const forbiddenWords = getForbiddenWords(context.character.personalityArchetype);
|
||||
@@ -77,8 +154,8 @@ export class DialogueService {
|
||||
this.outputFilter.filter(text, forbiddenWords),
|
||||
context.character.personalityArchetype,
|
||||
);
|
||||
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, context };
|
||||
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context, usage };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { EmotionTag } from "@kokorone/shared";
|
||||
import { pickTemplates } from "./template-library.js";
|
||||
|
||||
export interface FewShotExample {
|
||||
emotionTag: EmotionTag;
|
||||
tier: "low" | "high";
|
||||
userInput: string;
|
||||
response: string;
|
||||
}
|
||||
|
||||
const SAMPLE_USER_INPUT: Record<"low" | "high", string> = {
|
||||
low: "今天天氣真好呢。",
|
||||
high: "謝謝你,我今天好開心!",
|
||||
};
|
||||
|
||||
const SAMPLE_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"];
|
||||
|
||||
// R-3:F-7 的 Mock 模板庫在真實 Provider 這邊轉為 few-shot 範例——同一份模板資料,
|
||||
// 從「隨機挑一句直接當回應」變成「示範這個性格原型在各種情緒下該有的語氣」。
|
||||
export function buildFewShotExamples(personalityArchetype: string): FewShotExample[] {
|
||||
const examples: FewShotExample[] = [];
|
||||
for (const tier of ["low", "high"] as const) {
|
||||
for (const emotionTag of SAMPLE_EMOTIONS) {
|
||||
const candidates = pickTemplates(personalityArchetype, emotionTag, tier);
|
||||
if (candidates.length === 0) continue;
|
||||
examples.push({ emotionTag, tier, userInput: SAMPLE_USER_INPUT[tier], response: candidates[0] });
|
||||
}
|
||||
}
|
||||
return examples;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MockProvider } from "./mock-provider.service.js";
|
||||
import { GlobalPauseService } from "./global-pause.service.js";
|
||||
import { generateWithResilience } from "./generate-with-resilience.js";
|
||||
import { GLOBAL_PAUSE_FAILURE_THRESHOLD } from "./resilience-constants.js";
|
||||
import type { GenerationContext } from "./types.js";
|
||||
|
||||
function fixtureContext(): GenerationContext {
|
||||
return {
|
||||
character: { id: "char-1", personalityArchetype: "元氣", speechStyle: "測試", likesDislikes: "測試", basicInfo: "測試" },
|
||||
emotion: { dominant: "CALM", style: { tone: "平穩自然", sentenceLength: "medium", initiative: "medium" } },
|
||||
relationship: { intimacy: 50, trust: 50, stage: "ACQUAINTANCE" },
|
||||
retrievedMemories: [],
|
||||
history: [],
|
||||
userInput: "測試",
|
||||
injectedMemoryIds: [],
|
||||
seed: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// T30 驗收:連續失敗達門檻觸發全域靜默暫停、恢復後自動解除並繼續;暫停期間不重試(只嘗試一次)。
|
||||
describe("generateWithResilience/GlobalPauseService(T30)", () => {
|
||||
it("單次失敗會重試,最終成功時回傳結果且不觸發暫停", async () => {
|
||||
const provider = new MockProvider();
|
||||
const pause = new GlobalPauseService();
|
||||
provider.enqueueFailure(new Error("暫時性錯誤"));
|
||||
const result = await generateWithResilience(provider, fixtureContext(), pause);
|
||||
expect(result).not.toBeNull();
|
||||
expect(pause.isPaused()).toBe(false);
|
||||
});
|
||||
|
||||
it("連續失敗達門檻時觸發全域靜默暫停,回傳 null", async () => {
|
||||
const provider = new MockProvider();
|
||||
const pause = new GlobalPauseService();
|
||||
|
||||
// 每次呼叫都會重試 RETRY_MAX_ATTEMPTS+1 次,讓每次呼叫都徹底失敗,模擬 LLM 全面故障。
|
||||
for (let call = 0; call < GLOBAL_PAUSE_FAILURE_THRESHOLD; call++) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
provider.enqueueFailure(new Error(`模擬全面故障 ${call}-${attempt}`));
|
||||
}
|
||||
const result = await generateWithResilience(provider, fixtureContext(), pause);
|
||||
expect(result).toBeNull();
|
||||
}
|
||||
|
||||
expect(pause.isPaused()).toBe(true);
|
||||
}, 20000);
|
||||
|
||||
it("暫停中每次只嘗試一次(不重試);一旦成功就立刻解除暫停並回傳結果", async () => {
|
||||
const provider = new MockProvider();
|
||||
const pause = new GlobalPauseService();
|
||||
|
||||
for (let call = 0; call < GLOBAL_PAUSE_FAILURE_THRESHOLD; call++) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) provider.enqueueFailure(new Error("故障"));
|
||||
await generateWithResilience(provider, fixtureContext(), pause);
|
||||
}
|
||||
expect(pause.isPaused()).toBe(true);
|
||||
|
||||
// 暫停中再失敗一次:只嘗試一次(佇列裡還有很多待用的失敗,但只會消耗一個)。
|
||||
provider.enqueueFailure(new Error("仍在故障"));
|
||||
provider.enqueueFailure(new Error("這個不該被消耗到"));
|
||||
const stillFailing = await generateWithResilience(provider, fixtureContext(), pause);
|
||||
expect(stillFailing).toBeNull();
|
||||
expect(pause.isPaused()).toBe(true);
|
||||
provider.resetQueue(); // 清掉尚未消耗的「這個不該被消耗到」,確認上面真的只嘗試了一次
|
||||
|
||||
const recovered = await generateWithResilience(provider, fixtureContext(), pause);
|
||||
expect(recovered).not.toBeNull();
|
||||
expect(pause.isPaused()).toBe(false);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
import type { GlobalPauseService } from "./global-pause.service.js";
|
||||
import { RETRY_MAX_ATTEMPTS, RETRY_BASE_BACKOFF_MS } from "./resilience-constants.js";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// T30:自走生成的唯一呼叫入口——指數退避重試+全域靜默暫停旗標的判斷與更新都收斂在這裡,
|
||||
// RoomGenerationService/DialogueService 兩個呼叫端都改叫這個函式,不直接呼叫 provider.generate()。
|
||||
// 回傳 null 表示這一輪沒有回覆(暫停中,或重試全部失敗),呼叫端應靜默略過(不寫 RoomTurn/不顯示錯誤),
|
||||
// 使用者本人的發話仍照常已由呼叫端在此之前寫入,不受影響。
|
||||
export async function generateWithResilience(
|
||||
provider: LLMProvider,
|
||||
context: GenerationContext,
|
||||
pause: GlobalPauseService,
|
||||
): Promise<GeneratedResponse | null> {
|
||||
if (pause.isPaused()) {
|
||||
// 暫停期間仍每次嘗試「一次」真的呼叫(不額外重試),用來偵測服務是否已恢復;
|
||||
// 一旦成功就立刻解除暫停,不需要額外的健康檢查機制。
|
||||
try {
|
||||
const result = await provider.generate(context);
|
||||
pause.recordSuccess();
|
||||
return result;
|
||||
} catch (err) {
|
||||
pause.recordFailure(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const result = await provider.generate(context);
|
||||
pause.recordSuccess();
|
||||
return result;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < RETRY_MAX_ATTEMPTS) {
|
||||
await sleep(RETRY_BASE_BACKOFF_MS * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
pause.recordFailure(lastError);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { log } from "@kokorone/shared";
|
||||
import { GLOBAL_PAUSE_FAILURE_THRESHOLD } from "./resilience-constants.js";
|
||||
|
||||
// T30:自走生成連續失敗達門檻時,設定全域靜默暫停旗標,停止所有房間與大廳的自走生成——不對使用者
|
||||
// 顯示任何提示、不寫 SYSTEM 訊息,只記後端 log。單一 api 實例(README 已於部署章節註明),故用
|
||||
// 行程內記憶體狀態即可,不需要跨行程同步。
|
||||
@Injectable()
|
||||
export class GlobalPauseService {
|
||||
private paused = false;
|
||||
private consecutiveFailures = 0;
|
||||
|
||||
isPaused(): boolean {
|
||||
return this.paused;
|
||||
}
|
||||
|
||||
recordSuccess(): void {
|
||||
this.consecutiveFailures = 0;
|
||||
if (this.paused) {
|
||||
this.paused = false;
|
||||
log("自走暫停", "INF", "LLM 恢復正常,自動解除全域靜默暫停");
|
||||
}
|
||||
}
|
||||
|
||||
recordFailure(err: unknown): void {
|
||||
this.consecutiveFailures += 1;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log("自走暫停", "ERR", `LLM 生成失敗(連續第 ${this.consecutiveFailures} 次):${message}`);
|
||||
if (this.consecutiveFailures >= GLOBAL_PAUSE_FAILURE_THRESHOLD && !this.paused) {
|
||||
this.paused = true;
|
||||
log(
|
||||
"自走暫停",
|
||||
"ERR",
|
||||
`連續失敗達 ${GLOBAL_PAUSE_FAILURE_THRESHOLD} 次,觸發全域靜默暫停:所有房間與大廳自走生成停止,不對使用者顯示任何提示`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// K-4/T5 共用:文字與關鍵字語料的重疊度,供「是否被提及/指稱哪個角色」類判斷使用。
|
||||
export function keywordOverlap(text: string, corpus: string): number {
|
||||
// 中文常見以單字成詞(貓/狗/書),門檻不能設 >=2,否則會漏掉大量真實關鍵字。
|
||||
const keywords = corpus.split(/[、,,;::\s]+/).filter((word) => word.length >= 1);
|
||||
if (keywords.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const hits = keywords.filter((keyword) => text.includes(keyword)).length;
|
||||
if (hits === 0) {
|
||||
return 0;
|
||||
}
|
||||
// 只要命中,就是明確訊號(哪怕只命中角色喜好清單裡的一個詞):至少給予下限分數,
|
||||
// 不讓「喜好清單條目多」稀釋掉單次命中的重要性。
|
||||
return Math.max(0.5, hits / keywords.length);
|
||||
}
|
||||
@@ -1,9 +1,26 @@
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
|
||||
export interface CharacterCallCandidate {
|
||||
id: string;
|
||||
label: string;
|
||||
keywords: string; // 供關鍵字比對的語料(性格原型/喜好/目標等),格式同既有頓號分隔慣例
|
||||
}
|
||||
|
||||
export interface EndingDetectionResult {
|
||||
isEnding: boolean;
|
||||
evidence: string; // 判定理由,成立與不成立都要有依據可查
|
||||
}
|
||||
|
||||
// F-1 LLMProvider 介面:切換 Provider 不動引擎任何一行。
|
||||
export interface LLMProvider {
|
||||
generate(context: GenerationContext): Promise<GeneratedResponse>;
|
||||
stream(context: GenerationContext): AsyncIterable<string>;
|
||||
// T5:大廳對話式呼叫的規則比對失敗或指稱含糊時的 LLM 兼底——從候選清單判斷指稱的是哪個角色,
|
||||
// 必須回傳候選清單中的某個 id 或 null(無法判斷/沒有人被指稱),不可自行編造不存在的角色。
|
||||
identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise<string | null>;
|
||||
// T13:每章投影完成後都呼叫,判斷這個場景是否為整部作品的最終結局;只依場景內容本身判斷,
|
||||
// 不以章節標題關鍵字預篩(呼叫端不會把標題資訊帶進來影響判斷)。
|
||||
detectEnding(sceneSummary: string, sceneRawText: string, workTitle: string): Promise<EndingDetectionResult>;
|
||||
}
|
||||
|
||||
export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||
import { MemoryModule } from "../memory/memory.module.js";
|
||||
import { ScheduleModule } from "../schedule/schedule.module.js";
|
||||
import { MockProvider } from "./mock-provider.service.js";
|
||||
import { ClaudeProvider } from "./claude-provider.service.js";
|
||||
import { LLM_PROVIDER } from "./llm-provider.js";
|
||||
@@ -13,19 +14,21 @@ import { BehaviorReinforcementService } from "./behavior-reinforcement.service.j
|
||||
import { ContextAssemblerService } from "./context-assembler.service.js";
|
||||
import { DialogueService } from "./dialogue.service.js";
|
||||
import { DialogueController } from "./dialogue.controller.js";
|
||||
import { GlobalPauseService } from "./global-pause.service.js";
|
||||
|
||||
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
|
||||
// R-3:ClaudeProvider 已接上真實 API,這裡不再需要特別的「尚未實作」警告。
|
||||
function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
||||
const providerName = process.env.LLM_PROVIDER ?? "mock";
|
||||
if (providerName === "claude") {
|
||||
log("啟動", "ERR", "LLM_PROVIDER=claude 但 ClaudeProvider 尚未實作(R-3),對話生成呼叫時會拋出例外");
|
||||
log("啟動", "INF", `LLM_PROVIDER=claude,對話生成將呼叫真實 API(CLAUDE_BASE_URL=${process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1"})`);
|
||||
return claude;
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule],
|
||||
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, ScheduleModule],
|
||||
controllers: [DialogueController],
|
||||
providers: [
|
||||
MockProvider,
|
||||
@@ -36,7 +39,8 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
||||
BehaviorReinforcementService,
|
||||
ContextAssemblerService,
|
||||
DialogueService,
|
||||
GlobalPauseService,
|
||||
],
|
||||
exports: [DialogueService, BehaviorReinforcementService],
|
||||
exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER, GlobalPauseService],
|
||||
})
|
||||
export class LlmModule {}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MockProvider } from "./mock-provider.service.js";
|
||||
import type { GenerationContext } from "./types.js";
|
||||
|
||||
function fixtureContext(overrides: Partial<GenerationContext> = {}): GenerationContext {
|
||||
return {
|
||||
character: {
|
||||
id: "char-1",
|
||||
personalityArchetype: "TSUNDERE",
|
||||
speechStyle: "口是心非",
|
||||
likesDislikes: "喜歡貓,不喜歡被稱讚",
|
||||
basicInfo: "測試用角色",
|
||||
},
|
||||
emotion: { dominant: "CALM", style: { tone: "平穩自然", sentenceLength: "medium", initiative: "medium" } },
|
||||
relationship: { intimacy: 10, trust: 10, stage: "STRANGER" },
|
||||
retrievedMemories: [],
|
||||
history: [],
|
||||
userInput: "早安",
|
||||
injectedMemoryIds: [],
|
||||
seed: 42,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MockProvider", () => {
|
||||
it("同 seed 產生相同輸出,全程不呼叫網路(LLM_PROVIDER=mock 的離線保證)", async () => {
|
||||
const provider = new MockProvider();
|
||||
const context = fixtureContext();
|
||||
const first = await provider.generate(context);
|
||||
const second = await provider.generate(context);
|
||||
expect(first.text).toBe(second.text);
|
||||
});
|
||||
|
||||
it("enqueueResponse 可指定接下來的回傳內容,用完才回退模板庫", async () => {
|
||||
const provider = new MockProvider();
|
||||
provider.enqueueResponse("測試專用固定回覆");
|
||||
const queued = await provider.generate(fixtureContext());
|
||||
expect(queued.text).toBe("測試專用固定回覆");
|
||||
|
||||
const fallback = await provider.generate(fixtureContext());
|
||||
expect(fallback.text).not.toBe("測試專用固定回覆");
|
||||
});
|
||||
|
||||
it("enqueueFailure 可指定連續失敗次數,用於驗證重試與全域暫停路徑", async () => {
|
||||
const provider = new MockProvider();
|
||||
provider.enqueueFailure(new Error("模擬 LLM 失敗 1"));
|
||||
provider.enqueueFailure(new Error("模擬 LLM 失敗 2"));
|
||||
|
||||
await expect(provider.generate(fixtureContext())).rejects.toThrow("模擬 LLM 失敗 1");
|
||||
await expect(provider.generate(fixtureContext())).rejects.toThrow("模擬 LLM 失敗 2");
|
||||
|
||||
const recovered = await provider.generate(fixtureContext());
|
||||
expect(recovered.text).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resetQueue 可清空佇列,恢復模板庫行為", async () => {
|
||||
const provider = new MockProvider();
|
||||
provider.enqueueResponse("不該被用到");
|
||||
provider.resetQueue();
|
||||
const result = await provider.generate(fixtureContext());
|
||||
expect(result.text).not.toBe("不該被用到");
|
||||
});
|
||||
|
||||
it("T9:generate() 附上非零的 usage 估算值,且隨輸入內容成長而增加", async () => {
|
||||
const provider = new MockProvider();
|
||||
const short = await provider.generate(fixtureContext({ userInput: "早" }));
|
||||
const long = await provider.generate(
|
||||
fixtureContext({ userInput: "早".repeat(200), history: [{ role: "user", content: "嗨".repeat(200), timestamp: "" }] }),
|
||||
);
|
||||
expect(short.usage?.model).toBe("mock");
|
||||
expect(short.usage?.promptTokens).toBeGreaterThan(0);
|
||||
expect(short.usage?.completionTokens).toBeGreaterThan(0);
|
||||
expect(long.usage!.promptTokens).toBeGreaterThan(short.usage!.promptTokens);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,57 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
import type { CharacterCallCandidate, EndingDetectionResult, LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse, GenerationUsage } from "./types.js";
|
||||
import { extractActions } from "./action-markup.js";
|
||||
import { createSeededRandom } from "./seeded-random.js";
|
||||
import { intimacyTier, pickTemplates } from "./template-library.js";
|
||||
import { keywordOverlap } from "./keyword-overlap.js";
|
||||
|
||||
const IDENTIFY_MATCH_THRESHOLD = 0.3;
|
||||
const ENDING_SIGNAL_PHRASES = ["全劇終", "大結局", "故事到此結束", "完結", "The End"];
|
||||
|
||||
// T9:MockProvider 沒有真的呼叫 LLM,用字數/4 的粗略估算給出非零、隨內容成長的用量數字,
|
||||
// 讓「用量統計」在測試/mock 環境下也能被觀察到成長趨勢,不追求與真實 tokenizer 一致。
|
||||
function estimateUsage(context: GenerationContext, outputText: string): GenerationUsage {
|
||||
const historyChars = context.history.reduce((sum, turn) => sum + turn.content.length, 0);
|
||||
const promptChars = context.userInput.length + historyChars;
|
||||
return {
|
||||
model: "mock",
|
||||
promptTokens: Math.max(1, Math.ceil(promptChars / 4)),
|
||||
completionTokens: Math.max(1, Math.ceil(outputText.length / 4)),
|
||||
};
|
||||
}
|
||||
|
||||
type QueuedResult =
|
||||
| { kind: "success"; text: string; actions?: string[] }
|
||||
| { kind: "failure"; error: Error };
|
||||
|
||||
// F-7 MockProvider:依「性格原型 × 情緒狀態 × 親密度」從模板庫選填回應,同 seed 產生可重現輸出。
|
||||
// T36:新增測試專用佇列——enqueueResponse/enqueueFailure 可指定接下來 N 次 generate() 的
|
||||
// 回傳內容或連續失敗,先進先出,用完才回退到原有模板庫邏輯;正式環境(佇列恆空)行為不變。
|
||||
@Injectable()
|
||||
export class MockProvider implements LLMProvider {
|
||||
private readonly queue: QueuedResult[] = [];
|
||||
|
||||
enqueueResponse(text: string, actions?: string[]): void {
|
||||
this.queue.push({ kind: "success", text, actions });
|
||||
}
|
||||
|
||||
enqueueFailure(error: Error): void {
|
||||
this.queue.push({ kind: "failure", error });
|
||||
}
|
||||
|
||||
resetQueue(): void {
|
||||
this.queue.length = 0;
|
||||
}
|
||||
|
||||
async generate(context: GenerationContext): Promise<GeneratedResponse> {
|
||||
const queued = this.queue.shift();
|
||||
if (queued) {
|
||||
if (queued.kind === "failure") throw queued.error;
|
||||
const text = queued.text;
|
||||
return { text, actions: queued.actions ?? extractActions(text), usage: estimateUsage(context, text) };
|
||||
}
|
||||
|
||||
const tier = intimacyTier(context.relationship.intimacy);
|
||||
const candidates = pickTemplates(context.character.personalityArchetype, context.emotion.dominant, tier);
|
||||
|
||||
@@ -16,7 +59,7 @@ export class MockProvider implements LLMProvider {
|
||||
const index = Math.floor(random() * candidates.length);
|
||||
const text = candidates[index];
|
||||
|
||||
return { text, actions: extractActions(text) };
|
||||
return { text, actions: extractActions(text), usage: estimateUsage(context, text) };
|
||||
}
|
||||
|
||||
async *stream(context: GenerationContext): AsyncIterable<string> {
|
||||
@@ -25,4 +68,28 @@ export class MockProvider implements LLMProvider {
|
||||
yield char;
|
||||
}
|
||||
}
|
||||
|
||||
// T5:無真實語言理解能力,以關鍵字重疊度模擬「LLM 兼底」——沿用既有 K-4 的重疊度公式,
|
||||
// 分數最高且達門檻者視為指稱對象;沒有任何候選達門檻則回傳 null(無法判斷,不可編造)。
|
||||
async identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise<string | null> {
|
||||
let best: { id: string; score: number } | null = null;
|
||||
for (const candidate of candidates) {
|
||||
const score = keywordOverlap(utterance, candidate.keywords);
|
||||
if (score > 0 && (!best || score > best.score)) {
|
||||
best = { id: candidate.id, score };
|
||||
}
|
||||
}
|
||||
return best && best.score >= IDENTIFY_MATCH_THRESHOLD ? best.id : null;
|
||||
}
|
||||
|
||||
// T13:無真實語言理解能力,以場景內容是否出現明確的結局訊號詞模擬判定(例如「完結」「全劇終」);
|
||||
// 不使用呼叫端可能帶入的章節標題(本方法本身就沒有標題參數,天然滿足「不以標題關鍵字預篩」)。
|
||||
async detectEnding(sceneSummary: string, sceneRawText: string, _workTitle: string): Promise<EndingDetectionResult> {
|
||||
const combined = `${sceneSummary}\n${sceneRawText}`;
|
||||
const hit = ENDING_SIGNAL_PHRASES.find((phrase) => combined.includes(phrase));
|
||||
if (hit) {
|
||||
return { isEnding: true, evidence: `場景內容出現「${hit}」等結局訊號詞(mock 判定,非真實 LLM 推論)` };
|
||||
}
|
||||
return { isEnding: false, evidence: "場景內容未見明顯結局訊號詞(mock 判定,非真實 LLM 推論)" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { GenerationContext } from "./types.js";
|
||||
import { buildFewShotExamples } from "./few-shot-examples.js";
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
const RELATIONSHIP_STAGE_LABEL: Record<string, string> = {
|
||||
STRANGER: "陌生",
|
||||
ACQUAINTANCE: "認識",
|
||||
FRIEND: "朋友",
|
||||
CLOSE_AMBIGUOUS: "摯友-曖昧",
|
||||
BONDED: "羈絆",
|
||||
};
|
||||
|
||||
function buildSystemPrompt(context: GenerationContext): string {
|
||||
const { character, emotion, relationship, retrievedMemories, crossWorldNote } = context;
|
||||
const stageLabel = RELATIONSHIP_STAGE_LABEL[relationship.stage] ?? relationship.stage;
|
||||
|
||||
const memoryLines =
|
||||
retrievedMemories.length > 0
|
||||
? retrievedMemories.map((m) => `- ${m.content}`).join("\n")
|
||||
: "(目前沒有可用的相關記憶)";
|
||||
|
||||
const fewShot = buildFewShotExamples(character.personalityArchetype);
|
||||
const fewShotLines = fewShot
|
||||
.map((ex) => `情境:情緒=${ex.emotionTag}、親密度=${ex.tier}\n使用者:${ex.userInput}\n角色:${ex.response}`)
|
||||
.join("\n\n");
|
||||
|
||||
return [
|
||||
`你正在扮演一個戀愛陪伴系統中的角色,必須完全以第一人稱、角色本人的口吻回應,不要說明你是 AI,不要跳出角色。`,
|
||||
``,
|
||||
`【角色設定】`,
|
||||
`性格原型:${character.personalityArchetype}`,
|
||||
`說話風格:${character.speechStyle}`,
|
||||
`喜好/厭惡:${character.likesDislikes}`,
|
||||
`基本資訊:${character.basicInfo}`,
|
||||
``,
|
||||
`【當前狀態】`,
|
||||
`情緒:${emotion.dominant}(語氣:${emotion.style.tone},句長:${emotion.style.sentenceLength},主動性:${emotion.style.initiative})`,
|
||||
`關係:親密度 ${relationship.intimacy}、信任度 ${relationship.trust}、階段:${stageLabel}`,
|
||||
``,
|
||||
`【相關記憶】`,
|
||||
memoryLines,
|
||||
``,
|
||||
...(crossWorldNote ? [`【跨作品提示】`, crossWorldNote, ``] : []),
|
||||
`【輸出格式】`,
|
||||
`回應為純文字。若有動作/表情描寫,用全形星號包住,例如 *臉紅撇過頭*;不要用任何其他標記語法,不要加角色名稱前綴。`,
|
||||
``,
|
||||
`【語氣範例(僅供語氣參考,不要照抄內容)】`,
|
||||
fewShotLines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildMessages(context: GenerationContext): ChatMessage[] {
|
||||
const messages: ChatMessage[] = [{ role: "system", content: buildSystemPrompt(context) }];
|
||||
for (const turn of context.history) {
|
||||
messages.push({ role: turn.role === "character" ? "assistant" : "user", content: turn.content });
|
||||
}
|
||||
messages.push({ role: "user", content: context.userInput });
|
||||
return messages;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// T30:連續失敗達此次數即觸發全域靜默暫停。
|
||||
export const GLOBAL_PAUSE_FAILURE_THRESHOLD = 5;
|
||||
// T30:指數退避重試——單次生成最多重試這麼多次(不含首次嘗試),每次間隔倍增。
|
||||
export const RETRY_MAX_ATTEMPTS = 3;
|
||||
export const RETRY_BASE_BACKOFF_MS = 200;
|
||||
@@ -15,6 +15,9 @@ export interface GenerationContext {
|
||||
likesDislikes: string;
|
||||
basicInfo: string;
|
||||
};
|
||||
// T28:跨作品混房時,對「這次要回應的對象」附上一句世界觀提示——這個人來自不同作品,不是自己
|
||||
// 原本世界就認識的存在,禁止把對方設定當成自身世界既有知識。同作品/對象是使用者本人時不設定。
|
||||
crossWorldNote?: string;
|
||||
emotion: {
|
||||
dominant: EmotionTag;
|
||||
style: ResponseStyle;
|
||||
@@ -33,7 +36,14 @@ export interface GenerationContext {
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export interface GenerationUsage {
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
}
|
||||
|
||||
export interface GeneratedResponse {
|
||||
text: string; // 含動作描寫標記,如 *臉紅撇過頭*
|
||||
actions: string[]; // 從 text 抽取出的動作描寫(不含*符號)
|
||||
usage?: GenerationUsage; // T9:每房 LLM 用量統計的來源,僅供觀察,不影響生成行為
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ControllableClock } from "../clock/controllable-clock.js";
|
||||
import { EmotionService } from "../emotion/emotion.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { ScheduleExceptionService } from "../schedule/schedule-exception.service.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
import { RoomEventsService } from "../room/room-events.service.js";
|
||||
import { RoomService } from "../room/room.service.js";
|
||||
import { CharacterRelationshipService } from "../room/character-relationship.service.js";
|
||||
import { SpeakingRightService } from "../room/speaking-right.service.js";
|
||||
import { RoomGenerationService } from "../room/room-generation.service.js";
|
||||
import { PossessionService } from "../room/possession.service.js";
|
||||
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||
import { RomanceService } from "../romance/romance.service.js";
|
||||
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
|
||||
import { AgeDeterminationService } from "../romance/age-determination.service.js";
|
||||
import { GlobalPauseService } from "../llm/global-pause.service.js";
|
||||
import { CallService } from "./call.service.js";
|
||||
|
||||
// T5 驗收:喊一位答應者進單人房、喊多位有兩位答應進多人房、喊多位僅一位答應會降級為單人房、
|
||||
// 全部不答應則留在大廳且畫面顯示的是角色台詞而非系統錯誤。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const clock = new ControllableClock("2026-08-19T05:00:00.000Z"); // 週三 13:00 台北,屬 FREE 時段
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const roomLobby = new RoomLobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, roomLobby, roomEvents);
|
||||
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
|
||||
const relationshipService = new RelationshipService(prisma, new EventEmitter2());
|
||||
const sentimentLedger = new SentimentLedgerService(prisma, relationshipService);
|
||||
const scheduleService = new ScheduleService(prisma);
|
||||
const scheduleException = new ScheduleExceptionService(prisma, sentimentLedger);
|
||||
const mockProvider = new MockProvider();
|
||||
const characterRelationship = new CharacterRelationshipService(prisma);
|
||||
const speakingRight = new SpeakingRightService(prisma, emotionService, relationshipService, characterRelationship);
|
||||
const retriever = new KeywordMemoryRetriever(prisma);
|
||||
const romanceService = new RomanceService(prisma, relationshipService, new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma)));
|
||||
const generation = new RoomGenerationService(prisma, emotionService, relationshipService, retriever, characterRelationship, romanceService, mockProvider, new GlobalPauseService());
|
||||
const possession = new PossessionService(prisma, roomService, speakingRight, generation, roomLobby);
|
||||
const callService = new CallService(
|
||||
prisma,
|
||||
clock,
|
||||
emotionService,
|
||||
relationshipService,
|
||||
scheduleService,
|
||||
scheduleException,
|
||||
mockProvider,
|
||||
roomService,
|
||||
roomLobby,
|
||||
possession,
|
||||
);
|
||||
|
||||
const userId = "test-call-user";
|
||||
const GENKI_LIKE = "運動、甜食"; // 元氣:conversationBaseline 高,中性關係下應答應
|
||||
const COOL_LIKE = "安靜、看書"; // 三無:conversationBaseline 低,中性關係下應婉拒
|
||||
|
||||
async function createCharacter(id: string, archetype: string, formalName: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: archetype,
|
||||
likesDislikes: archetype === "元氣" ? GENKI_LIKE : COOL_LIKE,
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName } },
|
||||
},
|
||||
});
|
||||
await prisma.client.relationship.create({
|
||||
data: { characterId: id, userId, intimacy: 50, trust: 50 },
|
||||
});
|
||||
}
|
||||
|
||||
const ALL_TEST_CHARACTER_IDS = [
|
||||
"test-call-genki",
|
||||
"test-call-cool",
|
||||
"test-call-genki2",
|
||||
"test-call-sleeper",
|
||||
"test-call-delegate",
|
||||
"test-call-possessed",
|
||||
];
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.pendingLobbyCall.deleteMany({ where: { userId } });
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.callAttemptLog.deleteMany({ where: { userId } });
|
||||
await prisma.client.characterRelationship.deleteMany({ where: { characterId: { in: ALL_TEST_CHARACTER_IDS } } });
|
||||
await prisma.client.scheduleSlot.deleteMany({ where: { characterId: { in: ALL_TEST_CHARACTER_IDS } } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: ALL_TEST_CHARACTER_IDS } } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
describe("CallService(T5)", () => {
|
||||
it("喊一位答應者:降級開 SOLO 房", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-genki", "元氣", "測試喵一號");
|
||||
|
||||
const result = await callService.say(userId, "測試喵一號,你在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual(["test-call-genki"]);
|
||||
expect(result.mode).toBe("SOLO");
|
||||
expect(result.roomId).not.toBeNull();
|
||||
|
||||
const log = await prisma.client.callAttemptLog.findFirst({ where: { userId, characterId: "test-call-genki" } });
|
||||
expect(log?.accepted).toBe(true);
|
||||
}, 20000);
|
||||
|
||||
it("喊多位皆答應:開 GROUP 房", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-genki", "元氣", "測試喵一號");
|
||||
await createCharacter("test-call-genki2", "元氣", "測試喵二號");
|
||||
|
||||
const result = await callService.say(userId, "測試喵一號、測試喵二號,你們在嗎?", clock.now());
|
||||
expect(result.accepted.sort()).toEqual(["test-call-genki", "test-call-genki2"].sort());
|
||||
expect(result.mode).toBe("GROUP");
|
||||
}, 20000);
|
||||
|
||||
it("喊多位僅一位答應:降級開 SOLO 房", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-genki", "元氣", "測試喵一號");
|
||||
await createCharacter("test-call-cool", "三無", "測試喵三號");
|
||||
|
||||
const result = await callService.say(userId, "測試喵一號、測試喵三號,你們在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual(["test-call-genki"]);
|
||||
expect(result.declined).toEqual(["test-call-cool"]);
|
||||
expect(result.mode).toBe("SOLO");
|
||||
}, 20000);
|
||||
|
||||
it("全部不答應:不開房,回傳角色台詞而非系統錯誤", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-cool", "三無", "測試喵三號");
|
||||
|
||||
const result = await callService.say(userId, "測試喵三號,你在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual([]);
|
||||
expect(result.roomId).toBeNull();
|
||||
expect(result.mode).toBeNull();
|
||||
expect(typeof result.message).toBe("string");
|
||||
expect(result.message!.length).toBeGreaterThan(0);
|
||||
expect(result.message).not.toMatch(/error|Error|錯誤/);
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe("CallService 大廳代答(T23)", () => {
|
||||
it("喊睡著的角色:由關係最近且清醒未入房的角色代答,時間與行程資料一致", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-sleeper", "三無", "測試喵睡神");
|
||||
await createCharacter("test-call-delegate", "元氣", "測試喵代答");
|
||||
// 週三(WEEKDAY)12:00~15:00 睡眠,clock 固定在 13:00,落在睡眠時段內;15:00 即為下次可找的時間。
|
||||
await prisma.client.scheduleSlot.create({
|
||||
data: { characterId: "test-call-sleeper", dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "SLEEP" },
|
||||
});
|
||||
await prisma.client.characterRelationship.create({
|
||||
data: { characterId: "test-call-sleeper", otherCharacterId: "test-call-delegate", affinity: 90 },
|
||||
});
|
||||
|
||||
const result = await callService.say(userId, "測試喵睡神,你在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual([]);
|
||||
expect(result.roomId).toBeNull();
|
||||
expect(result.delegatedBy).toBe("test-call-delegate");
|
||||
expect(result.message).toContain("15:00");
|
||||
expect(result.message).not.toMatch(/error|Error|錯誤/);
|
||||
|
||||
const log = await prisma.client.callAttemptLog.findFirst({
|
||||
where: { userId, characterId: "test-call-sleeper" },
|
||||
});
|
||||
expect(log?.delegatedByCharacterId).toBe("test-call-delegate");
|
||||
}, 20000);
|
||||
|
||||
it("喊睡著的角色但無人可代答:大廳沉默,不輸出系統錯誤訊息", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-sleeper", "三無", "測試喵睡神");
|
||||
await prisma.client.scheduleSlot.create({
|
||||
data: { characterId: "test-call-sleeper", dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "SLEEP" },
|
||||
});
|
||||
|
||||
const result = await callService.say(userId, "測試喵睡神,你在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual([]);
|
||||
expect(result.roomId).toBeNull();
|
||||
expect(result.delegatedBy).toBeNull();
|
||||
expect(result.message).toBeNull();
|
||||
}, 20000);
|
||||
|
||||
it("T43:喊到目前套用中的角色——人格系統不代她判斷,記一筆待決給套用者;套用者接受後開房入場", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-possessed", "元氣", "測試喵套用");
|
||||
await possession.enter(userId, "test-call-possessed");
|
||||
|
||||
const result = await callService.say(userId, "測試喵套用,你在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual([]);
|
||||
expect(result.declined).toEqual([]);
|
||||
expect(result.roomId).toBeNull();
|
||||
expect(result.message).toBeNull(); // 沒有任何人格系統代答的台詞
|
||||
expect(result.pendingPossessedCharacterIds).toEqual(["test-call-possessed"]);
|
||||
|
||||
const log = await prisma.client.callAttemptLog.findFirst({ where: { userId, characterId: "test-call-possessed" } });
|
||||
expect(log).toBeNull(); // 沒有走判定流程,自然也不會留下判定紀錄
|
||||
|
||||
const pending = await possession.listPendingCalls(userId);
|
||||
expect(pending).toHaveLength(1);
|
||||
const decision = await possession.decidePendingCall(pending[0].id, true, clock.now());
|
||||
expect(decision.roomId).not.toBeNull();
|
||||
const roomAfter = await roomService.get(decision.roomId!);
|
||||
expect(roomAfter.participants.has("test-call-possessed")).toBe(true);
|
||||
expect(await possession.listPendingCalls(userId)).toHaveLength(0);
|
||||
|
||||
await possession.exit(userId);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { CLOCK, type Clock } from "../clock/clock.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { ScheduleExceptionService } from "../schedule/schedule-exception.service.js";
|
||||
import { availabilityForStatus } from "../schedule/constants.js";
|
||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js";
|
||||
import { hashToSeed } from "../llm/seeded-random.js";
|
||||
import { EMOTION_SPEAKING_MODIFIER } from "../room/constants.js";
|
||||
import { RoomService } from "../room/room.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
import { PossessionService } from "../room/possession.service.js";
|
||||
import { CALL_ACCEPTANCE_THRESHOLD } from "./constants.js";
|
||||
|
||||
export interface CallJudgment {
|
||||
characterId: string;
|
||||
accepted: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CallResult {
|
||||
calledCharacterIds: string[];
|
||||
accepted: string[];
|
||||
declined: string[];
|
||||
roomId: string | null;
|
||||
mode: "SOLO" | "GROUP" | null;
|
||||
message: string | null; // 全部不答應時:本人婉拒台詞,或由代答者說出的理由與可再來的時間
|
||||
delegatedBy: string | null; // T23:代答者(若有)
|
||||
pendingPossessedCharacterIds: string[]; // T43:被喊到但目前套用中,改記待決、交由套用者決定
|
||||
}
|
||||
|
||||
const HARD_DECLINE_PREFIXES = ["無法回應(作息)", "已在其他房間"];
|
||||
|
||||
// T5:大廳對話式呼叫。角色辨識規則先行(角色名/CharacterAlias 直接比對),比不到才以 LLM 兼底
|
||||
// (見 MockProvider/ClaudeProvider.identifyCharacter);逐位判定是否答應後,依組房規則決定結果。
|
||||
@Injectable()
|
||||
export class CallService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CLOCK) private readonly clock: Clock,
|
||||
private readonly emotion: EmotionService,
|
||||
private readonly relationship: RelationshipService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly scheduleException: ScheduleExceptionService,
|
||||
@Inject(LLM_PROVIDER) private readonly llmProvider: LLMProvider,
|
||||
private readonly room: RoomService,
|
||||
private readonly roomLobby: RoomLobbyService,
|
||||
private readonly possession: PossessionService,
|
||||
) {}
|
||||
|
||||
async say(userId: string, text: string, now?: Date): Promise<CallResult> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
const calledCharacterIds = await this.identifyCandidates(text);
|
||||
|
||||
if (calledCharacterIds.length === 0) {
|
||||
return {
|
||||
calledCharacterIds: [],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
roomId: null,
|
||||
mode: null,
|
||||
message: null,
|
||||
delegatedBy: null,
|
||||
pendingPossessedCharacterIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
// T43:被喊到的角色若正被(任一使用者)套用中,人格系統不得代她判斷接受/婉拒——記一筆待決給
|
||||
// 套用者自己決定,不參與這一輪的組房/代答邏輯。
|
||||
const possessedIds = await this.possession.getPossessedCharacterIds();
|
||||
const possessedCalledIds = calledCharacterIds.filter((id) => possessedIds.has(id));
|
||||
for (const characterId of possessedCalledIds) {
|
||||
await this.possession.createPendingCall(characterId, text, effectiveNow);
|
||||
}
|
||||
const judgeableCharacterIds = calledCharacterIds.filter((id) => !possessedIds.has(id));
|
||||
if (judgeableCharacterIds.length === 0) {
|
||||
return {
|
||||
calledCharacterIds,
|
||||
accepted: [],
|
||||
declined: [],
|
||||
roomId: null,
|
||||
mode: null,
|
||||
message: null,
|
||||
delegatedBy: null,
|
||||
pendingPossessedCharacterIds: possessedCalledIds,
|
||||
};
|
||||
}
|
||||
|
||||
const judgments = await Promise.all(
|
||||
judgeableCharacterIds.map((characterId) => this.judgeAcceptance(characterId, userId, text, effectiveNow)),
|
||||
);
|
||||
const accepted = judgments.filter((j) => j.accepted).map((j) => j.characterId);
|
||||
const declined = judgments.filter((j) => !j.accepted).map((j) => j.characterId);
|
||||
|
||||
let roomId: string | null = null;
|
||||
let mode: "SOLO" | "GROUP" | null = null;
|
||||
let message: string | null = null;
|
||||
let delegatedBy: string | null = null;
|
||||
|
||||
if (accepted.length >= 2) {
|
||||
const room = await this.openRoom(userId, accepted, "GROUP", text, effectiveNow);
|
||||
roomId = room.id;
|
||||
mode = "GROUP";
|
||||
} else if (accepted.length === 1) {
|
||||
const room = await this.openRoom(userId, accepted, "SOLO", text, effectiveNow);
|
||||
roomId = room.id;
|
||||
mode = "SOLO";
|
||||
} else {
|
||||
// 全部不答應:軟性婉拒(人清醒、不在其他房間,單純不想理你)由本人親自回一句話;
|
||||
// 硬性無法回應(睡眠/忙碌/已在其他房間)改由關係最近且當下清醒未入房的角色代答(T23)。
|
||||
const primaryId = declined[0];
|
||||
const primaryJudgment = judgments.find((j) => j.characterId === primaryId)!;
|
||||
const isHardDecline = HARD_DECLINE_PREFIXES.some((prefix) => primaryJudgment.reason.startsWith(prefix));
|
||||
|
||||
if (!isHardDecline) {
|
||||
message = await this.declineMessage(primaryId, userId, text, effectiveNow);
|
||||
} else {
|
||||
const delegateId = await this.findDelegate(primaryId, effectiveNow);
|
||||
if (delegateId) {
|
||||
delegatedBy = delegateId;
|
||||
const nextAvailable = await this.describeNextAvailable(primaryId, primaryJudgment.reason, effectiveNow);
|
||||
message = await this.delegateMessage(delegateId, primaryId, userId, text, nextAvailable, effectiveNow);
|
||||
}
|
||||
// 找不到合適代答者:大廳沉默,message 維持 null,不輸出系統訊息。
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.client.callAttemptLog.createMany({
|
||||
data: judgments.map((j) => ({
|
||||
userId,
|
||||
characterId: j.characterId,
|
||||
accepted: j.accepted,
|
||||
reason: j.reason,
|
||||
delegatedByCharacterId: j.characterId === declined[0] ? delegatedBy : null,
|
||||
resultingRoomId: j.accepted ? roomId : null,
|
||||
createdAt: effectiveNow,
|
||||
})),
|
||||
});
|
||||
|
||||
return { calledCharacterIds, accepted, declined, roomId, mode, message, delegatedBy, pendingPossessedCharacterIds: possessedCalledIds };
|
||||
}
|
||||
|
||||
// 角色辨識:規則先行(角色名與 CharacterAlias 直接比對),比不到或指稱含糊才以 LLM 兼底。
|
||||
private async identifyCandidates(text: string): Promise<string[]> {
|
||||
const characters = await this.prisma.client.character.findMany({
|
||||
where: { buildStatus: "BUILT" },
|
||||
include: { aliases: true },
|
||||
});
|
||||
|
||||
const ruleMatches = characters.filter((character) =>
|
||||
character.aliases.some(
|
||||
(alias) =>
|
||||
(alias.formalName && text.includes(alias.formalName)) ||
|
||||
(alias.nickname && text.includes(alias.nickname)) ||
|
||||
(alias.calledByOthers && text.includes(alias.calledByOthers)),
|
||||
),
|
||||
);
|
||||
if (ruleMatches.length > 0) {
|
||||
return ruleMatches.map((c) => c.id);
|
||||
}
|
||||
|
||||
const candidateList = characters.map((character) => ({
|
||||
id: character.id,
|
||||
label: character.aliases[0]?.formalName ?? character.id,
|
||||
keywords: [character.personalityArchetype, character.likesDislikes, character.goalsObsessions].join("、"),
|
||||
}));
|
||||
const identified = await this.llmProvider.identifyCharacter(text, candidateList);
|
||||
return identified ? [identified] : [];
|
||||
}
|
||||
|
||||
private async judgeAcceptance(characterId: string, userId: string, text: string, now: Date): Promise<CallJudgment> {
|
||||
// R5:已在其他(非大廳)房間在場者無法接受新的呼叫。
|
||||
const activeMembership = await this.prisma.client.roomMember.findFirst({
|
||||
where: { characterId, activeCharacterId: characterId },
|
||||
include: { room: true },
|
||||
});
|
||||
if (activeMembership && activeMembership.room.mode !== "LOBBY") {
|
||||
return { characterId, accepted: false, reason: "已在其他房間" };
|
||||
}
|
||||
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const scheduleInfo = await this.schedule.getInfo(characterId, now);
|
||||
const availability = availabilityForStatus(scheduleInfo.slot?.status ?? null);
|
||||
const emotionState = await this.emotion.getState(characterId, now);
|
||||
const dominant = dominantState(emotionState);
|
||||
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||
|
||||
let effectiveAvailability = availability;
|
||||
if (availability !== "NORMAL") {
|
||||
const exceptionType = await this.scheduleException.tryApply(
|
||||
characterId,
|
||||
userId,
|
||||
availability,
|
||||
{ intimacy: relationship.intimacy, dominantEmotion: dominant, userInput: text },
|
||||
now,
|
||||
);
|
||||
if (exceptionType) effectiveAvailability = "NORMAL";
|
||||
}
|
||||
if (effectiveAvailability === "NO_REPLY") {
|
||||
return { characterId, accepted: false, reason: "無法回應(作息)" };
|
||||
}
|
||||
|
||||
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||
const emotionModifier = EMOTION_SPEAKING_MODIFIER[dominant];
|
||||
const relationshipModifier = (relationship.intimacy - 50) / 100;
|
||||
const score = archetypeParams.conversationBaseline + emotionModifier + relationshipModifier;
|
||||
const accepted = score >= CALL_ACCEPTANCE_THRESHOLD;
|
||||
return { characterId, accepted, reason: accepted ? `答應(score=${score.toFixed(2)})` : `衝動值不足(score=${score.toFixed(2)})` };
|
||||
}
|
||||
|
||||
private async openRoom(userId: string, characterIds: string[], mode: "SOLO" | "GROUP", topicSeed: string, now: Date) {
|
||||
const room = await this.room.create(userId, [], mode, topicSeed);
|
||||
for (const characterId of characterIds) {
|
||||
await this.roomLobby.enterRoom(characterId, room.id, now);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private async declineMessage(characterId: string, userId: string, userInput: string, now: Date): Promise<string> {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const emotionState = await this.emotion.getState(characterId, now);
|
||||
const dominant = dominantState(emotionState);
|
||||
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||
const seed = hashToSeed(`lobby-decline:${characterId}:${userInput}`);
|
||||
|
||||
const generated = await this.llmProvider.generate({
|
||||
character: {
|
||||
id: character.id,
|
||||
personalityArchetype: character.personalityArchetype,
|
||||
speechStyle: character.speechStyle,
|
||||
likesDislikes: character.likesDislikes,
|
||||
basicInfo: character.basicInfo,
|
||||
},
|
||||
emotion: { dominant, style: { tone: "", sentenceLength: "short", initiative: "low" } },
|
||||
relationship: { intimacy: relationship.intimacy, trust: relationship.trust, stage: relationship.stage },
|
||||
retrievedMemories: [],
|
||||
history: [],
|
||||
userInput,
|
||||
injectedMemoryIds: [],
|
||||
seed,
|
||||
});
|
||||
return generated.text;
|
||||
}
|
||||
|
||||
// T23:找與被喊角色關係最近、當下清醒(非睡眠)且未入其他房間(含大廳都算「有空」)的代答者。
|
||||
private async findDelegate(characterId: string, now: Date): Promise<string | null> {
|
||||
const relationships = await this.prisma.client.characterRelationship.findMany({
|
||||
where: { characterId },
|
||||
orderBy: { affinity: "desc" },
|
||||
});
|
||||
for (const rel of relationships) {
|
||||
const candidateId = rel.otherCharacterId;
|
||||
const scheduleInfo = await this.schedule.getInfo(candidateId, now);
|
||||
if (availabilityForStatus(scheduleInfo.slot?.status ?? null) === "NO_REPLY") continue; // 睡著跳過
|
||||
const activeMembership = await this.prisma.client.roomMember.findFirst({
|
||||
where: { characterId: candidateId, activeCharacterId: candidateId },
|
||||
include: { room: true },
|
||||
});
|
||||
if (activeMembership && activeMembership.room.mode !== "LOBBY") continue; // 已入其他房間跳過
|
||||
return candidateId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// T23:「下次可找的時間」一律取自行程資料本身,不可由 LLM 自行編時間。
|
||||
private async describeNextAvailable(characterId: string, declineReason: string, now: Date): Promise<string> {
|
||||
if (declineReason.startsWith("已在其他房間")) {
|
||||
return "等她忙完手邊的事";
|
||||
}
|
||||
const scheduleInfo = await this.schedule.getInfo(characterId, now);
|
||||
if (!scheduleInfo.slot) {
|
||||
return "晚一點";
|
||||
}
|
||||
const hh = String(Math.floor(scheduleInfo.slot.endMinute / 60) % 24).padStart(2, "0");
|
||||
const mm = String(scheduleInfo.slot.endMinute % 60).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
private async delegateMessage(
|
||||
delegateId: string,
|
||||
aboutCharacterId: string,
|
||||
userId: string,
|
||||
userInput: string,
|
||||
nextAvailable: string,
|
||||
now: Date,
|
||||
): Promise<string> {
|
||||
const delegate = await this.prisma.client.character.findUniqueOrThrow({ where: { id: delegateId } });
|
||||
const aboutAlias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: aboutCharacterId } });
|
||||
const aboutLabel = aboutAlias?.formalName ?? aboutCharacterId;
|
||||
const emotionState = await this.emotion.getState(delegateId, now);
|
||||
const dominant = dominantState(emotionState);
|
||||
const { relationship } = await this.relationship.getState(delegateId, userId, now);
|
||||
const seed = hashToSeed(`lobby-delegate:${delegateId}:${aboutCharacterId}:${userInput}`);
|
||||
|
||||
const generated = await this.llmProvider.generate({
|
||||
character: {
|
||||
id: delegate.id,
|
||||
personalityArchetype: delegate.personalityArchetype,
|
||||
speechStyle: delegate.speechStyle,
|
||||
likesDislikes: delegate.likesDislikes,
|
||||
basicInfo: delegate.basicInfo,
|
||||
},
|
||||
emotion: { dominant, style: { tone: "", sentenceLength: "short", initiative: "low" } },
|
||||
relationship: { intimacy: relationship.intimacy, trust: relationship.trust, stage: relationship.stage },
|
||||
retrievedMemories: [],
|
||||
history: [],
|
||||
userInput,
|
||||
injectedMemoryIds: [],
|
||||
seed,
|
||||
});
|
||||
// 「下次可找的時間」由程式碼直接拼接,不讓 LLM 生成的那段話決定時間內容。
|
||||
return `${generated.text}${aboutLabel}大概${nextAvailable}才有空,到時候再來找她吧。`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// T5:大廳對話式呼叫的接受判定門檻。score = conversationBaseline + 情緒修正 + 關係修正,
|
||||
// 中性關係(50)+平靜情緒時 score 即等於角色的 conversationBaseline;0.3 落在冷淡(0.15)/
|
||||
// 三無(0.05)之上、傲嬌(0.5)/天然呆(0.6)/大小姐(0.55)/元氣(0.85)之下,讓「外向角色
|
||||
// 預設答應、內向角色預設婉拒」符合直覺,同時仍可被情緒/關係修正推過門檻。
|
||||
export const CALL_ACCEPTANCE_THRESHOLD = 0.3;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ControllableClock } from "../clock/controllable-clock.js";
|
||||
import { EmotionService } from "../emotion/emotion.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { CharacterRelationshipService } from "../room/character-relationship.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
import { RoomEventsService } from "../room/room-events.service.js";
|
||||
import { RoomService } from "../room/room.service.js";
|
||||
import { SpeakingRightService } from "../room/speaking-right.service.js";
|
||||
import { RoomGenerationService } from "../room/room-generation.service.js";
|
||||
import { PossessionService } from "../room/possession.service.js";
|
||||
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||
import { RomanceService } from "../romance/romance.service.js";
|
||||
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
|
||||
import { AgeDeterminationService } from "../romance/age-determination.service.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { GlobalPauseService } from "../llm/global-pause.service.js";
|
||||
import { InvitationService } from "./invitation.service.js";
|
||||
|
||||
// T29 驗收:同一角色同時被兩處邀請時,兩處都收到該角色的回覆,且他只會出現在其中一間。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const clock = new ControllableClock("2026-08-19T05:00:00.000Z"); // 週三 13:00 台北,FREE 時段
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const roomLobby = new RoomLobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, roomLobby, roomEvents);
|
||||
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
|
||||
const scheduleService = new ScheduleService(prisma);
|
||||
const characterRelationship = new CharacterRelationshipService(prisma);
|
||||
const relationshipService = new RelationshipService(prisma, new EventEmitter2());
|
||||
const speakingRight = new SpeakingRightService(prisma, emotionService, relationshipService, characterRelationship);
|
||||
const retriever = new KeywordMemoryRetriever(prisma);
|
||||
const romanceService = new RomanceService(prisma, relationshipService, new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma)));
|
||||
const generation = new RoomGenerationService(
|
||||
prisma,
|
||||
emotionService,
|
||||
relationshipService,
|
||||
retriever,
|
||||
characterRelationship,
|
||||
romanceService,
|
||||
new MockProvider(),
|
||||
new GlobalPauseService(),
|
||||
);
|
||||
const possession = new PossessionService(prisma, roomService, speakingRight, generation, roomLobby);
|
||||
const invitationService = new InvitationService(
|
||||
prisma,
|
||||
clock,
|
||||
emotionService,
|
||||
scheduleService,
|
||||
characterRelationship,
|
||||
roomService,
|
||||
roomLobby,
|
||||
possession,
|
||||
);
|
||||
|
||||
const ownerUserId = "test-invite-user";
|
||||
const TARGET = "test-invite-target";
|
||||
const INVITER_A = "test-invite-inviter-a";
|
||||
const INVITER_B = "test-invite-inviter-b";
|
||||
|
||||
async function createCharacter(id: string, archetype = "元氣") {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: archetype,
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: id } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId: ownerUserId } });
|
||||
await prisma.client.characterRelationship.deleteMany({
|
||||
where: { characterId: TARGET, otherCharacterId: { in: [INVITER_A, INVITER_B] } },
|
||||
});
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [TARGET, INVITER_A, INVITER_B] } } });
|
||||
await prisma.client.user.deleteMany({ where: { id: ownerUserId } });
|
||||
});
|
||||
|
||||
describe("InvitationService(T29)", () => {
|
||||
it("同一角色同時被兩間房邀請:兩處都收到回覆,且只會出現在其中一間", async () => {
|
||||
await prisma.client.user.create({ data: { id: ownerUserId, name: "測試邀請使用者" } });
|
||||
await createCharacter(TARGET);
|
||||
await createCharacter(INVITER_A);
|
||||
await createCharacter(INVITER_B);
|
||||
|
||||
const roomA = await roomService.create(ownerUserId, [INVITER_A], "GROUP");
|
||||
const roomB = await roomService.create(ownerUserId, [INVITER_B], "GROUP");
|
||||
await roomLobby.enterRoom(INVITER_A, roomA.id, clock.now());
|
||||
await roomLobby.enterRoom(INVITER_B, roomB.id, clock.now());
|
||||
|
||||
// TARGET 與 B 關係較好,應該選擇加入 B 的房間。
|
||||
await prisma.client.characterRelationship.create({
|
||||
data: { characterId: TARGET, otherCharacterId: INVITER_A, affinity: 50 },
|
||||
});
|
||||
await prisma.client.characterRelationship.create({
|
||||
data: { characterId: TARGET, otherCharacterId: INVITER_B, affinity: 95 },
|
||||
});
|
||||
|
||||
await invitationService.invite(roomA.id, TARGET, INVITER_A, clock.now());
|
||||
await invitationService.invite(roomB.id, TARGET, INVITER_B, clock.now());
|
||||
|
||||
// 裁決前:兩筆都還是 PENDING,TARGET 尚未入房。
|
||||
const beforeResolve = await prisma.client.roomInvitation.findMany({ where: { characterId: TARGET } });
|
||||
expect(beforeResolve.every((i) => i.status === "PENDING")).toBe(true);
|
||||
|
||||
await invitationService.resolvePendingFor(TARGET, clock.now());
|
||||
|
||||
const invitations = await prisma.client.roomInvitation.findMany({ where: { characterId: TARGET } });
|
||||
expect(invitations).toHaveLength(2);
|
||||
const accepted = invitations.filter((i) => i.status === "ACCEPTED");
|
||||
const declined = invitations.filter((i) => i.status === "DECLINED");
|
||||
expect(accepted).toHaveLength(1);
|
||||
expect(declined).toHaveLength(1);
|
||||
expect(accepted[0].roomId).toBe(roomB.id); // 關係較好的那間
|
||||
|
||||
// 兩處都收到回覆(各自房間都留了一則 TARGET 的發話)。
|
||||
const roomAAfter = await roomService.get(roomA.id);
|
||||
const roomBAfter = await roomService.get(roomB.id);
|
||||
expect(roomAAfter.turns.some((t) => t.speakerId === TARGET)).toBe(true);
|
||||
expect(roomBAfter.turns.some((t) => t.speakerId === TARGET)).toBe(true);
|
||||
|
||||
// 只會出現在其中一間:僅在 B 房實際在場。
|
||||
expect(roomBAfter.participants.has(TARGET)).toBe(true);
|
||||
expect(roomAAfter.participants.has(TARGET)).toBe(false);
|
||||
|
||||
const activeRows = await prisma.client.roomMember.findMany({ where: { characterId: TARGET, leftAt: null } });
|
||||
expect(activeRows).toHaveLength(1);
|
||||
expect(activeRows[0].roomId).toBe(roomB.id);
|
||||
}, 20000);
|
||||
|
||||
it("T43:被邀請的角色目前套用中時,人格系統不代她判斷——維持 PENDING,交由套用者明確決定", async () => {
|
||||
await prisma.client.user.create({ data: { id: ownerUserId, name: "測試邀請使用者" } });
|
||||
await createCharacter(TARGET);
|
||||
await createCharacter(INVITER_A);
|
||||
await createCharacter(INVITER_B);
|
||||
|
||||
const roomA = await roomService.create(ownerUserId, [INVITER_A], "GROUP");
|
||||
const roomB = await roomService.create(ownerUserId, [INVITER_B], "GROUP");
|
||||
await roomLobby.enterRoom(INVITER_A, roomA.id, clock.now());
|
||||
await roomLobby.enterRoom(INVITER_B, roomB.id, clock.now());
|
||||
await invitationService.invite(roomA.id, TARGET, INVITER_A, clock.now());
|
||||
await invitationService.invite(roomB.id, TARGET, INVITER_B, clock.now());
|
||||
|
||||
await possession.enter(ownerUserId, TARGET);
|
||||
await invitationService.resolvePendingFor(TARGET, clock.now());
|
||||
|
||||
// 人格系統的自動裁決完全沒有介入:兩筆都還是 PENDING,沒有任何一間被系統代答。
|
||||
const stillPending = await prisma.client.roomInvitation.findMany({ where: { characterId: TARGET } });
|
||||
expect(stillPending.every((i) => i.status === "PENDING")).toBe(true);
|
||||
const roomAWhilePending = await roomService.get(roomA.id);
|
||||
const roomBWhilePending = await roomService.get(roomB.id);
|
||||
expect(roomAWhilePending.turns).toHaveLength(0);
|
||||
expect(roomBWhilePending.turns).toHaveLength(0);
|
||||
|
||||
// 套用者親自決定:接受 B 房的邀請,A 房的邀請一併被婉拒(與人格系統「選一個、其餘逐一回覆」語意一致)。
|
||||
const invitationB = stillPending.find((i) => i.roomId === roomB.id)!;
|
||||
await invitationService.decideAsPossessed(invitationB.id, true, clock.now());
|
||||
|
||||
const afterDecision = await prisma.client.roomInvitation.findMany({ where: { characterId: TARGET } });
|
||||
expect(afterDecision.find((i) => i.roomId === roomB.id)?.status).toBe("ACCEPTED");
|
||||
expect(afterDecision.find((i) => i.roomId === roomA.id)?.status).toBe("DECLINED");
|
||||
const roomBAfterDecision = await roomService.get(roomB.id);
|
||||
expect(roomBAfterDecision.participants.has(TARGET)).toBe(true);
|
||||
|
||||
await possession.exit(ownerUserId);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { CLOCK, type Clock } from "../clock/clock.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { availabilityForStatus } from "../schedule/constants.js";
|
||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||
import { EMOTION_SPEAKING_MODIFIER } from "../room/constants.js";
|
||||
import { RoomService } from "../room/room.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
import { CharacterRelationshipService } from "../room/character-relationship.service.js";
|
||||
import { PossessionService } from "../room/possession.service.js";
|
||||
import { CALL_ACCEPTANCE_THRESHOLD } from "./constants.js";
|
||||
|
||||
// T29:使用者呼叫(T5)與房內角色邀請走同一套 RoomInvitation。房內角色邀請不在場角色時,
|
||||
// 若該角色同時有多筆 PENDING 邀請(例如同時被兩個不同房間邀請),由人格系統依關係/情緒選出
|
||||
// 一筆 ACCEPTED,其餘標 DECLINED,並在每一個邀請來源各留一則發話後才真正入房。
|
||||
@Injectable()
|
||||
export class InvitationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CLOCK) private readonly clock: Clock,
|
||||
private readonly emotion: EmotionService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly characterRelationship: CharacterRelationshipService,
|
||||
private readonly room: RoomService,
|
||||
private readonly roomLobby: RoomLobbyService,
|
||||
private readonly possession: PossessionService,
|
||||
) {}
|
||||
|
||||
// invitedByCharacterId 為 null 表使用者從大廳呼叫;非 null 表房內角色邀請。
|
||||
// 只登記邀請意向(PENDING 或依 R5 立即 DECLINED),不在此立刻裁決——同一角色可能「同時」被
|
||||
// 多處邀請(例如兩個不同房間各自邀請),必須等呼叫端明確觸發 resolvePendingFor() 才裁決,
|
||||
// 否則最先送達的邀請永遠會在還沒有對手可比較時就單獨勝出,變成先到先贏而非依人格系統選擇。
|
||||
async invite(roomId: string, characterId: string, invitedByCharacterId: string | null, now?: Date): Promise<void> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
|
||||
// R5:已在其他(非大廳)房間在場者只能婉拒,不進入待裁決佇列。
|
||||
const activeMembership = await this.prisma.client.roomMember.findFirst({
|
||||
where: { characterId, activeCharacterId: characterId },
|
||||
include: { room: true },
|
||||
});
|
||||
if (activeMembership && activeMembership.room.mode !== "LOBBY" && activeMembership.roomId !== roomId) {
|
||||
await this.prisma.client.roomInvitation.create({
|
||||
data: { roomId, characterId, invitedByCharacterId, status: "DECLINED", respondedAt: effectiveNow },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.client.roomInvitation.create({
|
||||
data: { roomId, characterId, invitedByCharacterId, status: "PENDING" },
|
||||
});
|
||||
}
|
||||
|
||||
// 依關係/情緒選出一筆 ACCEPTED,其餘 DECLINED;每個來源各留一則發話後才真正入房。
|
||||
async resolvePendingFor(characterId: string, now?: Date): Promise<void> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
const pending = await this.prisma.client.roomInvitation.findMany({
|
||||
where: { characterId, status: "PENDING" },
|
||||
include: { room: true },
|
||||
});
|
||||
if (pending.length === 0) return;
|
||||
|
||||
// T43:套用中的角色不能讓人格系統代她判斷接受/婉拒——維持 PENDING,交由套用者透過
|
||||
// decideAsPossessed() 明確決定。
|
||||
const possessedIds = await this.possession.getPossessedCharacterIds();
|
||||
if (possessedIds.has(characterId)) return;
|
||||
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const scheduleInfo = await this.schedule.getInfo(characterId, effectiveNow);
|
||||
const asleep = availabilityForStatus(scheduleInfo.slot?.status ?? null) === "NO_REPLY";
|
||||
|
||||
const emotionState = await this.emotion.getState(characterId, effectiveNow);
|
||||
const dominant = dominantState(emotionState);
|
||||
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||
const emotionModifier = EMOTION_SPEAKING_MODIFIER[dominant];
|
||||
|
||||
const scored = await Promise.all(
|
||||
pending.map(async (invitation) => {
|
||||
if (asleep) return { invitation, score: -Infinity };
|
||||
const affinity = invitation.invitedByCharacterId
|
||||
? await this.characterRelationship.getAffinity(characterId, invitation.invitedByCharacterId)
|
||||
: 50; // 使用者從大廳呼叫:以中性關係修正為基準(呼叫端多半已另行以 T5 的完整關係計分判斷過一次)
|
||||
const relationshipModifier = (affinity - 50) / 100;
|
||||
const score = archetypeParams.conversationBaseline + emotionModifier + relationshipModifier;
|
||||
return { invitation, score };
|
||||
}),
|
||||
);
|
||||
|
||||
const winner = scored.reduce((best, cur) => (cur.score > best.score ? cur : best), scored[0]);
|
||||
const accept = winner.score >= CALL_ACCEPTANCE_THRESHOLD;
|
||||
|
||||
for (const { invitation } of scored) {
|
||||
const isWinner = accept && invitation.id === winner.invitation.id;
|
||||
await this.prisma.client.roomInvitation.update({
|
||||
where: { id: invitation.id },
|
||||
data: { status: isWinner ? "ACCEPTED" : "DECLINED", respondedAt: effectiveNow },
|
||||
});
|
||||
if (!isWinner) {
|
||||
await this.leaveReply(invitation.roomId, characterId, effectiveNow, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (accept) {
|
||||
// T42:邀請對象答應加入原本是 SOLO 的房間時,房型立即改為 GROUP(不支援之後降回 SOLO,
|
||||
// 沒有任何程式路徑會把 GROUP 改回 SOLO——離開只會減少在場人數,不影響 mode)。
|
||||
const targetRoom = await this.prisma.client.room.findUniqueOrThrow({ where: { id: winner.invitation.roomId } });
|
||||
if (targetRoom.mode === "SOLO") {
|
||||
await this.room.switchToGroup(winner.invitation.roomId);
|
||||
}
|
||||
await this.roomLobby.enterRoom(characterId, winner.invitation.roomId, effectiveNow);
|
||||
await this.leaveReply(winner.invitation.roomId, characterId, effectiveNow, true);
|
||||
}
|
||||
}
|
||||
|
||||
// T43:套用中角色的待決邀請清單,供套用者查看並決定。
|
||||
async listPendingFor(characterId: string) {
|
||||
return this.prisma.client.roomInvitation.findMany({
|
||||
where: { characterId, status: "PENDING" },
|
||||
include: { room: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
// T43:套用者以角色本人的身分決定一筆待決邀請——不經過人格系統的關係/情緒評分。答應時,同角色
|
||||
// 其餘待決邀請一併婉拒(與 resolvePendingFor() 的「選一個、其餘逐一回覆」語意一致)。
|
||||
async decideAsPossessed(invitationId: string, accept: boolean, now?: Date): Promise<void> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
const invitation = await this.prisma.client.roomInvitation.findUniqueOrThrow({ where: { id: invitationId } });
|
||||
if (invitation.status !== "PENDING") return;
|
||||
|
||||
await this.prisma.client.roomInvitation.update({
|
||||
where: { id: invitationId },
|
||||
data: { status: accept ? "ACCEPTED" : "DECLINED", respondedAt: effectiveNow },
|
||||
});
|
||||
if (!accept) {
|
||||
await this.leaveReply(invitation.roomId, invitation.characterId, effectiveNow, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const otherPending = await this.prisma.client.roomInvitation.findMany({
|
||||
where: { characterId: invitation.characterId, status: "PENDING", id: { not: invitationId } },
|
||||
});
|
||||
for (const other of otherPending) {
|
||||
await this.prisma.client.roomInvitation.update({
|
||||
where: { id: other.id },
|
||||
data: { status: "DECLINED", respondedAt: effectiveNow },
|
||||
});
|
||||
await this.leaveReply(other.roomId, invitation.characterId, effectiveNow, false);
|
||||
}
|
||||
|
||||
const targetRoom = await this.prisma.client.room.findUniqueOrThrow({ where: { id: invitation.roomId } });
|
||||
if (targetRoom.mode === "SOLO") {
|
||||
await this.room.switchToGroup(invitation.roomId);
|
||||
}
|
||||
await this.roomLobby.enterRoom(invitation.characterId, invitation.roomId, effectiveNow);
|
||||
await this.leaveReply(invitation.roomId, invitation.characterId, effectiveNow, true);
|
||||
}
|
||||
|
||||
// 在邀請來源留下一則角色台詞(婉拒說明,或答應後的入房招呼),而不是靜默消失或系統訊息。
|
||||
private async leaveReply(roomId: string, characterId: string, now: Date, accepted: boolean): Promise<void> {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId } });
|
||||
const label = alias?.formalName ?? character.id;
|
||||
const text = accepted ? "好,我來了!" : "抱歉,我這邊有點事,這次不能過去了。";
|
||||
await this.room.addTurn(roomId, characterId, label, text, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ControllableClock } from "../clock/controllable-clock.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
import { RoomEventsService } from "../room/room-events.service.js";
|
||||
import { RoomService, USER_SPEAKER_ID } from "../room/room.service.js";
|
||||
import { LobbyAggregationService } from "./lobby-aggregation.service.js";
|
||||
|
||||
// T4 驗收:單一請求即可畫出完整大廳;角色狀態與實際房間佔用一致;多房並存時各房未讀數皆正確。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const clock = new ControllableClock("2026-08-19T12:00:00.000Z");
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const roomLobby = new RoomLobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, roomLobby, roomEvents);
|
||||
const scheduleService = new ScheduleService(prisma);
|
||||
const aggregation = new LobbyAggregationService(prisma, scheduleService, roomLobby);
|
||||
|
||||
const userId = "test-lobby-agg-user";
|
||||
const charA = "test-lobby-agg-char-a";
|
||||
const charB = "test-lobby-agg-char-b";
|
||||
const WORK_ID = "test-lobby-agg-t34-work";
|
||||
const CHAR_C = "test-lobby-agg-t34-char-c";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [charA, charB] } } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
await prisma.client.epilogueMilestoneLog.deleteMany({ where: { characterId: CHAR_C } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_C } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createCharacter(id: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "TSUNDERE",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: id } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("LobbyAggregationService(T4)", () => {
|
||||
it("角色狀態與實際房間佔用一致;多房並存時各房未讀數皆正確", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試大廳聚合使用者" } });
|
||||
await createCharacter(charA);
|
||||
await createCharacter(charB);
|
||||
|
||||
const before = await aggregation.getLobby(userId, clock.now());
|
||||
const beforeA = before.characters.find((c) => c.id === charA)!;
|
||||
expect(beforeA.presenceState).toBe("IDLE");
|
||||
expect(beforeA.roomId).toBeNull();
|
||||
|
||||
const room = await roomService.create(userId, [], "GROUP");
|
||||
await roomLobby.enterRoom(charA, room.id, clock.now());
|
||||
await roomService.addTurn(room.id, USER_SPEAKER_ID, "測試", "第一句", clock.now());
|
||||
await roomService.addTurn(room.id, USER_SPEAKER_ID, "測試", "第二句", clock.now());
|
||||
|
||||
const after = await aggregation.getLobby(userId, clock.now());
|
||||
const afterA = after.characters.find((c) => c.id === charA)!;
|
||||
expect(afterA.presenceState).toBe("IN_GROUP_ROOM");
|
||||
expect(afterA.roomId).toBe(room.id);
|
||||
const afterB = after.characters.find((c) => c.id === charB)!;
|
||||
expect(afterB.presenceState).toBe("IDLE"); // 未入房的角色狀態不受影響
|
||||
|
||||
const activeRoomView = after.activeRooms.find((r) => r.id === room.id)!;
|
||||
expect(activeRoomView.unreadCount).toBe(2); // 尚未回報已讀
|
||||
|
||||
await roomService.reportRead(room.id, userId, 0);
|
||||
const afterPartialRead = await aggregation.getLobby(userId, clock.now());
|
||||
expect(afterPartialRead.activeRooms.find((r) => r.id === room.id)!.unreadCount).toBe(1);
|
||||
}, 20000);
|
||||
|
||||
it("T34:作品的自主里程碑會呈現在對應的作品卡片,不影響其他作品", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試大廳聚合使用者" } });
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T34 測試作品", inEpilogue: true } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_C,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小丙" } },
|
||||
},
|
||||
});
|
||||
await prisma.client.epilogueMilestoneLog.create({
|
||||
data: { characterId: CHAR_C, storyOrder: 5, label: "考上目標學校" },
|
||||
});
|
||||
|
||||
const lobby = await aggregation.getLobby(userId, clock.now());
|
||||
const workView = lobby.works.find((w) => w.id === WORK_ID)!;
|
||||
expect(workView.milestones).toHaveLength(1);
|
||||
expect(workView.milestones[0]).toMatchObject({ characterId: CHAR_C, label: "考上目標學校", storyOrder: 5 });
|
||||
|
||||
// 沒有里程碑的其他作品不受影響(milestones 為空陣列,不是 undefined 或摻進別的作品的資料)。
|
||||
const otherWorks = lobby.works.filter((w) => w.id !== WORK_ID);
|
||||
expect(otherWorks.every((w) => Array.isArray(w.milestones) && w.milestones.length === 0)).toBe(true);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { deriveLobbyPresenceState, type LobbyPresenceState, type LobbyPresenceRoomMode } from "@kokorone/shared";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
|
||||
export interface LobbyMilestoneView {
|
||||
characterId: string;
|
||||
label: string;
|
||||
storyOrder: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface LobbyWorkView {
|
||||
id: string;
|
||||
title: string;
|
||||
inEpilogue: boolean;
|
||||
milestones: LobbyMilestoneView[]; // T34:自走一段時間後自主成立的人生變化,明顯呈現在大廳作品卡片
|
||||
}
|
||||
|
||||
export interface LobbyCharacterView {
|
||||
id: string;
|
||||
workId: string | null;
|
||||
formalName: string;
|
||||
buildStatus: string;
|
||||
presenceState: LobbyPresenceState;
|
||||
roomId: string | null; // 目前所在的非大廳房間(若有)
|
||||
}
|
||||
|
||||
export interface LobbyActiveRoomView {
|
||||
id: string;
|
||||
mode: string;
|
||||
topicSeed: string | null;
|
||||
memberCharacterIds: string[];
|
||||
unreadCount: number;
|
||||
totalTokens: number; // T9:該房累計 LLM 用量(promptTokens + completionTokens),僅供觀察
|
||||
}
|
||||
|
||||
// T4:大廳聚合端點。單一請求聚合所有作品、旗下角色與 T1 定義的狀態、以及該使用者所有 ACTIVE
|
||||
// 房間與各房未讀數,以批次查詢(findMany + groupBy)避免逐一角色/房間各自查詢造成 N+1;
|
||||
// 唯一的例外是 ScheduleService.getInfo(每位角色的作息判定邏輯本身就是逐角色計算,改以
|
||||
// Promise.all 併發查詢而非序列 N+1 阻塞)。
|
||||
@Injectable()
|
||||
export class LobbyAggregationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly roomLobby: RoomLobbyService,
|
||||
) {}
|
||||
|
||||
async getLobby(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<{ works: LobbyWorkView[]; characters: LobbyCharacterView[]; activeRooms: LobbyActiveRoomView[] }> {
|
||||
// T24 常駐流轉的一致性前提:先確保所有角色都已正確登記在場(大廳或其他房間)。
|
||||
await this.roomLobby.syncMembership(userId);
|
||||
|
||||
const [works, characters, activeMemberships, readStates, activeRooms] = await Promise.all([
|
||||
this.prisma.client.work.findMany({ orderBy: { createdAt: "asc" } }),
|
||||
this.prisma.client.character.findMany({ include: { aliases: true }, orderBy: { createdAt: "asc" } }),
|
||||
this.prisma.client.roomMember.findMany({ where: { activeCharacterId: { not: null } }, include: { room: true } }),
|
||||
this.prisma.client.roomReadState.findMany({ where: { userId } }),
|
||||
this.prisma.client.room.findMany({
|
||||
where: { userId, status: "ACTIVE", mode: { not: "LOBBY" } },
|
||||
include: { members: { where: { leftAt: null } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const roomByCharacter = new Map(activeMemberships.map((m) => [m.characterId, m.room]));
|
||||
const lastReadSeqByRoom = new Map(readStates.map((r) => [r.roomId, r.lastReadSeq]));
|
||||
|
||||
const roomIds = activeRooms.map((r) => r.id);
|
||||
const latestTurns = roomIds.length
|
||||
? await this.prisma.client.roomTurn.groupBy({ by: ["roomId"], where: { roomId: { in: roomIds } }, _max: { seq: true } })
|
||||
: [];
|
||||
const turnCounterByRoom = new Map(latestTurns.map((t) => [t.roomId, (t._max.seq ?? -1) + 1]));
|
||||
|
||||
// T9:每房累計用量,批次查詢避免逐房各自聚合造成 N+1。
|
||||
const usageTotals = roomIds.length
|
||||
? await this.prisma.client.roomUsageLog.groupBy({
|
||||
by: ["roomId"],
|
||||
where: { roomId: { in: roomIds } },
|
||||
_sum: { promptTokens: true, completionTokens: true },
|
||||
})
|
||||
: [];
|
||||
const totalTokensByRoom = new Map(
|
||||
usageTotals.map((u) => [u.roomId, (u._sum.promptTokens ?? 0) + (u._sum.completionTokens ?? 0)]),
|
||||
);
|
||||
|
||||
// T34:批次查詢所有角色的里程碑紀錄,依角色所屬作品分組——同既有慣例,避免逐作品各自查詢造成 N+1。
|
||||
const milestoneLogs = characters.length
|
||||
? await this.prisma.client.epilogueMilestoneLog.findMany({
|
||||
where: { characterId: { in: characters.map((c) => c.id) } },
|
||||
orderBy: { storyOrder: "asc" },
|
||||
})
|
||||
: [];
|
||||
const workIdByCharacter = new Map(characters.map((c) => [c.id, c.workId]));
|
||||
const milestonesByWork = new Map<string, LobbyMilestoneView[]>();
|
||||
for (const log of milestoneLogs) {
|
||||
const workId = workIdByCharacter.get(log.characterId);
|
||||
if (!workId) continue;
|
||||
const list = milestonesByWork.get(workId) ?? [];
|
||||
list.push({ characterId: log.characterId, label: log.label, storyOrder: log.storyOrder, createdAt: log.createdAt });
|
||||
milestonesByWork.set(workId, list);
|
||||
}
|
||||
|
||||
const scheduleStatuses = await Promise.all(
|
||||
characters.map(async (c) => ({ id: c.id, status: (await this.schedule.getInfo(c.id, now)).slot?.status ?? null })),
|
||||
);
|
||||
const scheduleStatusByCharacter = new Map(scheduleStatuses.map((s) => [s.id, s.status]));
|
||||
|
||||
const characterViews: LobbyCharacterView[] = characters.map((character) => {
|
||||
const room = roomByCharacter.get(character.id);
|
||||
const roomMode: LobbyPresenceRoomMode = room && room.mode === "SOLO" ? "SOLO" : room && room.mode === "GROUP" ? "GROUP" : null;
|
||||
return {
|
||||
id: character.id,
|
||||
workId: character.workId,
|
||||
formalName: character.aliases[0]?.formalName ?? character.id,
|
||||
buildStatus: character.buildStatus,
|
||||
presenceState: deriveLobbyPresenceState({
|
||||
buildStatus: character.buildStatus,
|
||||
scheduleSlotStatus: scheduleStatusByCharacter.get(character.id) ?? "FREE",
|
||||
roomMode,
|
||||
}),
|
||||
roomId: roomMode ? (room!.id ?? null) : null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
works: works.map((work) => ({
|
||||
id: work.id,
|
||||
title: work.title,
|
||||
inEpilogue: work.inEpilogue,
|
||||
milestones: milestonesByWork.get(work.id) ?? [],
|
||||
})),
|
||||
characters: characterViews,
|
||||
activeRooms: activeRooms.map((room) => {
|
||||
const turnCounter = turnCounterByRoom.get(room.id) ?? 0;
|
||||
const lastReadSeq = lastReadSeqByRoom.get(room.id) ?? -1;
|
||||
return {
|
||||
id: room.id,
|
||||
mode: room.mode,
|
||||
topicSeed: room.topicSeed,
|
||||
memberCharacterIds: room.members.map((m) => m.characterId),
|
||||
unreadCount: Math.max(0, turnCounter - (lastReadSeq + 1)),
|
||||
totalTokens: totalTokensByRoom.get(room.id) ?? 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||
import { LobbyAggregationService } from "./lobby-aggregation.service.js";
|
||||
import { CallService } from "./call.service.js";
|
||||
import { PossessionService } from "../room/possession.service.js";
|
||||
|
||||
interface SayBody {
|
||||
userId: string;
|
||||
text: string;
|
||||
now?: string;
|
||||
}
|
||||
|
||||
// T4:GET /lobby——單一請求畫出完整大廳。既有 GET /room/lobby(T19-P1/T20-P1 最小可用)
|
||||
// 保留供舊呼叫端使用;本端點取代其作為正式聚合來源。
|
||||
// T5:POST /lobby/say——大廳對話式呼叫。
|
||||
@Controller("lobby")
|
||||
export class LobbyController {
|
||||
constructor(
|
||||
private readonly aggregation: LobbyAggregationService,
|
||||
private readonly call: CallService,
|
||||
private readonly possession: PossessionService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getLobby(@Query("userId") userId: string) {
|
||||
return this.aggregation.getLobby(userId);
|
||||
}
|
||||
|
||||
@Post("say")
|
||||
async say(@Body() body: SayBody) {
|
||||
// T43:套用角色期間不可以自己的身分發話(大廳呼叫本身就是「以使用者身分」喊人)。
|
||||
await this.possession.assertNotPossessing(body.userId);
|
||||
return this.call.say(body.userId, body.text, body.now ? new Date(body.now) : undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { ScheduleModule } from "../schedule/schedule.module.js";
|
||||
import { RoomModule } from "../room/room.module.js";
|
||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||
import { LlmModule } from "../llm/llm.module.js";
|
||||
import { ClockModule } from "../clock/clock.module.js";
|
||||
import { LobbyAggregationService } from "./lobby-aggregation.service.js";
|
||||
import { CallService } from "./call.service.js";
|
||||
import { LobbyController } from "./lobby.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ScheduleModule, RoomModule, EmotionModule, RelationshipModule, LlmModule, ClockModule],
|
||||
controllers: [LobbyController],
|
||||
providers: [LobbyAggregationService, CallService],
|
||||
exports: [LobbyAggregationService, CallService],
|
||||
})
|
||||
export class LobbyModule {}
|
||||
@@ -6,6 +6,9 @@ import { AppModule } from "./app.module.js";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { logger: false });
|
||||
// Q-1:8081 是 `expo start --web` 的預設埠——APP 在真機/模擬器上不受 CORS 限制(原生環境沒有這個瀏覽器機制),
|
||||
// 只有用瀏覽器跑 Expo 的網頁版時才需要放行,開發環境固定加這個來源即可。
|
||||
app.enableCors({ origin: [process.env.WEB_ORIGIN ?? "http://localhost:3100", "http://localhost:8081"] });
|
||||
const port = Number(process.env.PORT ?? 3001);
|
||||
await app.listen(port);
|
||||
log("啟動", "INF", `api 服務已啟動,監聽埠 ${port}`);
|
||||
|
||||
@@ -8,7 +8,9 @@ function normalize(content: string): string {
|
||||
return content.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// C-4 睡眠固化:對話中只累積在工作記憶,session 結束時才批次萃取寫入長期記憶。
|
||||
// C-4/T40 睡眠固化:對話中只累積在工作記憶,session 結束時才批次萃取寫入長期記憶。
|
||||
// T40:本服務固化的內容一律來自 1:1(SOLO)房間,因此一律標記 isPrivate=true——多人房生成
|
||||
// (RoomGenerationService)預設排除私密記憶,口風不緊原型除外(見 K-3/T40 隱私邊界)。
|
||||
@Injectable()
|
||||
export class MemoryConsolidationService {
|
||||
constructor(
|
||||
@@ -17,7 +19,7 @@ export class MemoryConsolidationService {
|
||||
) {}
|
||||
|
||||
async consolidate(characterId: string, sessionId: string): Promise<void> {
|
||||
const entries = this.workingMemory.getContext(sessionId);
|
||||
const entries = await this.workingMemory.getContext(sessionId);
|
||||
|
||||
const contentCounts = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
@@ -49,10 +51,11 @@ export class MemoryConsolidationService {
|
||||
emotionIntensity,
|
||||
source: "INTERACTION",
|
||||
weight: isHighEmotion ? 1 + emotionIntensity : 0.5,
|
||||
isPrivate: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.workingMemory.clear(sessionId);
|
||||
await this.workingMemory.clear(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface RetrieveOptions {
|
||||
limit?: number;
|
||||
// E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。
|
||||
relatedUserId?: string;
|
||||
// K-3 隱私邊界:群聊等有旁人在場的情境傳 true,標記為私密的記憶不會被檢索到。
|
||||
excludePrivate?: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryRetriever {
|
||||
@@ -37,7 +39,9 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
|
||||
|
||||
async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> {
|
||||
const limit = options.limit ?? DEFAULT_LIMIT;
|
||||
const rows = await this.prisma.client.episodicMemory.findMany({ where: { characterId } });
|
||||
const rows = await this.prisma.client.episodicMemory.findMany({
|
||||
where: { characterId, ...(options.excludePrivate ? { isPrivate: false } : {}) },
|
||||
});
|
||||
const now = Date.now();
|
||||
const keywords = query.split(/\s+/).filter(Boolean);
|
||||
|
||||
@@ -76,6 +80,7 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
|
||||
lastRetrievedAt: new Date(now).toISOString(),
|
||||
source: row.source,
|
||||
weight: row.weight,
|
||||
isPrivate: row.isPrivate,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,20 +27,20 @@ export class MemoryController {
|
||||
) {}
|
||||
|
||||
@Post(":characterId/sessions/:sessionId/messages")
|
||||
appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
|
||||
this.workingMemory.append(sessionId, {
|
||||
async appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
|
||||
await this.workingMemory.append(sessionId, {
|
||||
role: body.role,
|
||||
content: body.content,
|
||||
timestamp: new Date(),
|
||||
emotionTag: body.emotionTag,
|
||||
emotionIntensity: body.emotionIntensity,
|
||||
});
|
||||
return { context: this.workingMemory.getContext(sessionId) };
|
||||
return { context: await this.workingMemory.getContext(sessionId) };
|
||||
}
|
||||
|
||||
@Get(":characterId/sessions/:sessionId/messages")
|
||||
getContext(@Param("sessionId") sessionId: string) {
|
||||
return { context: this.workingMemory.getContext(sessionId) };
|
||||
async getContext(@Param("sessionId") sessionId: string) {
|
||||
return { context: await this.workingMemory.getContext(sessionId) };
|
||||
}
|
||||
|
||||
@Post(":characterId/sessions/:sessionId/consolidate")
|
||||
@@ -55,10 +55,12 @@ export class MemoryController {
|
||||
@Query("query") query: string,
|
||||
@Query("limit") limit?: string,
|
||||
@Query("relatedUserId") relatedUserId?: string,
|
||||
@Query("excludePrivate") excludePrivate?: string,
|
||||
) {
|
||||
const results = await this.retriever.retrieve(characterId, query ?? "", {
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
relatedUserId,
|
||||
excludePrivate: excludePrivate === "true",
|
||||
});
|
||||
return { results };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { EmotionTag } from "@kokorone/shared";
|
||||
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
|
||||
import { getRedisClient } from "../cache/redis-client.js";
|
||||
|
||||
export interface WorkingMemoryEntry {
|
||||
role: "user" | "character";
|
||||
@@ -10,7 +11,14 @@ export interface WorkingMemoryEntry {
|
||||
emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略
|
||||
}
|
||||
|
||||
interface SerializedEntry extends Omit<WorkingMemoryEntry, "timestamp"> {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const DEFAULT_TOKEN_LIMIT = 200;
|
||||
const REDIS_KEY_PREFIX = "kokorone:working-memory:";
|
||||
// session 閒置這麼久沒有新訊息就任由 Redis 自然過期,避免無限累積殭屍 session。
|
||||
const SESSION_TTL_SECONDS = 60 * 60 * 6;
|
||||
|
||||
// 粗略估算:Mock 階段不需要真實 tokenizer,先以字元數/2 近似。
|
||||
function estimateTokens(text: string): number {
|
||||
@@ -21,32 +29,11 @@ function isHighEmotion(entry: WorkingMemoryEntry): boolean {
|
||||
return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD;
|
||||
}
|
||||
|
||||
// C-1 工作記憶:session 對話上下文緩衝,只存在於行程記憶體中(如同海馬迴暫存),
|
||||
// 不落地到任何長期記憶表——長期寫入只能由 C-4 睡眠固化觸發。
|
||||
@Injectable()
|
||||
export class WorkingMemoryService {
|
||||
private readonly sessions = new Map<string, WorkingMemoryEntry[]>();
|
||||
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
|
||||
|
||||
append(sessionId: string, entry: WorkingMemoryEntry): void {
|
||||
const entries = this.sessions.get(sessionId) ?? [];
|
||||
entries.push(entry);
|
||||
this.sessions.set(sessionId, this.evictOverflow(entries));
|
||||
}
|
||||
|
||||
getContext(sessionId: string): WorkingMemoryEntry[] {
|
||||
return [...(this.sessions.get(sessionId) ?? [])];
|
||||
}
|
||||
|
||||
clear(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
private evictOverflow(entries: WorkingMemoryEntry[]): WorkingMemoryEntry[] {
|
||||
function evictOverflow(entries: WorkingMemoryEntry[], tokenLimit: number): WorkingMemoryEntry[] {
|
||||
let result = entries;
|
||||
let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
||||
|
||||
while (totalTokens > this.tokenLimit && result.length > 0) {
|
||||
while (totalTokens > tokenLimit && result.length > 0) {
|
||||
// 最舊的低情緒段落先被裁掉;若全部都是高情緒,最後才犧牲最舊的一則以確保不超出上限。
|
||||
let evictIndex = result.findIndex((e) => !isHighEmotion(e));
|
||||
if (evictIndex === -1) {
|
||||
@@ -58,4 +45,58 @@ export class WorkingMemoryService {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function serialize(entries: WorkingMemoryEntry[]): string {
|
||||
const payload: SerializedEntry[] = entries.map((e) => ({ ...e, timestamp: e.timestamp.toISOString() }));
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function deserialize(raw: string): WorkingMemoryEntry[] {
|
||||
const payload = JSON.parse(raw) as SerializedEntry[];
|
||||
return payload.map((e) => ({ ...e, timestamp: new Date(e.timestamp) }));
|
||||
}
|
||||
|
||||
// C-1 工作記憶:session 對話上下文緩衝,如同海馬迴暫存,長期寫入只能由 C-4 睡眠固化觸發。
|
||||
// R-2:改走 Redis(session 快取),Redis 不可用時退回行程記憶體 Map,行為(token 上限、高情緒優先保留)不變。
|
||||
@Injectable()
|
||||
export class WorkingMemoryService {
|
||||
private readonly fallback = new Map<string, WorkingMemoryEntry[]>();
|
||||
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
|
||||
|
||||
async append(sessionId: string, entry: WorkingMemoryEntry): Promise<void> {
|
||||
const entries = await this.getContext(sessionId);
|
||||
entries.push(entry);
|
||||
const evicted = evictOverflow(entries, this.tokenLimit);
|
||||
|
||||
const redis = getRedisClient();
|
||||
try {
|
||||
await redis.set(REDIS_KEY_PREFIX + sessionId, serialize(evicted), "EX", SESSION_TTL_SECONDS);
|
||||
this.fallback.delete(sessionId);
|
||||
return;
|
||||
} catch {
|
||||
this.fallback.set(sessionId, evicted);
|
||||
}
|
||||
}
|
||||
|
||||
async getContext(sessionId: string): Promise<WorkingMemoryEntry[]> {
|
||||
const redis = getRedisClient();
|
||||
try {
|
||||
const raw = await redis.get(REDIS_KEY_PREFIX + sessionId);
|
||||
if (raw !== null) return deserialize(raw);
|
||||
if (this.fallback.has(sessionId)) return [...this.fallback.get(sessionId)!];
|
||||
return [];
|
||||
} catch {
|
||||
return [...(this.fallback.get(sessionId) ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
async clear(sessionId: string): Promise<void> {
|
||||
this.fallback.delete(sessionId);
|
||||
const redis = getRedisClient();
|
||||
try {
|
||||
await redis.del(REDIS_KEY_PREFIX + sessionId);
|
||||
} catch {
|
||||
// Redis 不可用時 fallback 已經在上面清掉,沒有需要額外處理的狀態。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ export interface ArchetypeParams {
|
||||
expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣)
|
||||
trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上
|
||||
invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3)
|
||||
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6)參考
|
||||
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6/K-3)參考
|
||||
conversationBaseline: number; // K-4 群聊發言基線 0~1:不被點名、無特別理由時主動開口的傾向(元氣高、三無極低)
|
||||
romanceGainRate: number; // L-3 戀愛軸基礎曲線速度倍率:套用在心動事件的增減幅上(冷淡極慢、元氣快)
|
||||
romanceBigEventBonus: number; // L-3 高幅心動事件(共同經歷高情緒事件/被理解的瞬間)的額外倍率:>1 呈現「階梯狀跳升」(大小姐)
|
||||
romanceAmbiguousStallFactor: number; // L-3 曖昧階段的額外速度倍率:<1 代表卡在曖昧期打轉、怕改變現狀(元氣)
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS: ArchetypeParams = {
|
||||
@@ -15,6 +19,10 @@ const DEFAULT_PARAMS: ArchetypeParams = {
|
||||
trustGrowthRate: 1,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: [],
|
||||
conversationBaseline: 0.4,
|
||||
romanceGainRate: 1,
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
};
|
||||
|
||||
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
@@ -24,6 +32,11 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
trustGrowthRate: 0.5,
|
||||
invertedIntimacyExpression: true,
|
||||
traits: ["denial-then-honest"],
|
||||
conversationBaseline: 0.5,
|
||||
// 心動值與外顯敵意同步上升——外顯部分交給既有的 G-3 tsundereGap 機制處理,這裡的曲線本身走一般速度。
|
||||
romanceGainRate: 1,
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
},
|
||||
冷淡: {
|
||||
emotionTriggerThreshold: 2.5,
|
||||
@@ -31,13 +44,22 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
trustGrowthRate: 0.3,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["flat-affect", "short-replies"],
|
||||
conversationBaseline: 0.15,
|
||||
romanceGainRate: 0.4, // 極緩慢線性,無明顯階段感
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
},
|
||||
天然呆: {
|
||||
emotionTriggerThreshold: 0.7,
|
||||
expressiveness: 0.8,
|
||||
trustGrowthRate: 1.5,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["misreads-context"],
|
||||
// K-3:口風不緊——群聊中偶爾會不小心把私密記憶說出來,是這個原型的性格設定例外。
|
||||
traits: ["misreads-context", "loose-lipped"],
|
||||
conversationBaseline: 0.6,
|
||||
romanceGainRate: 1,
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
},
|
||||
元氣: {
|
||||
emotionTriggerThreshold: 0.7,
|
||||
@@ -45,6 +67,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
trustGrowthRate: 1.3,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["talkative", "fast-emotion-decay"],
|
||||
conversationBaseline: 0.85,
|
||||
romanceGainRate: 1.3, // 快速上升
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 0.3, // 但卡在曖昧期打轉——怕改變現狀
|
||||
},
|
||||
大小姐: {
|
||||
emotionTriggerThreshold: 1.3,
|
||||
@@ -52,6 +78,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
trustGrowthRate: 0.4,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["formal-address", "poor-loser"],
|
||||
conversationBaseline: 0.55,
|
||||
romanceGainRate: 0.5, // 平時矜持撐住,小事件幾乎不動
|
||||
romanceBigEventBonus: 2.2, // 某事件後大幅跳升——階梯狀曲線
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
},
|
||||
三無: {
|
||||
emotionTriggerThreshold: 3.5,
|
||||
@@ -59,6 +89,11 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
trustGrowthRate: 0.2,
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["very-short-replies", "sudden-overflow"],
|
||||
conversationBaseline: 0.05,
|
||||
// 內在曲線走一般速度(心動值本身正常累積),幾無外顯訊號是外顯度(expressiveness)的職責,不是曲線本身變慢。
|
||||
romanceGainRate: 1,
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -68,7 +68,9 @@ export class CharacterCreationService {
|
||||
occurredAt: now,
|
||||
emotionTag: signal.tag,
|
||||
emotionIntensity: signal.intensity || 0.3,
|
||||
source: "OFFLINE_GENERATED",
|
||||
// 來自角色設定表本身,不是 I-6 的「離線生成日常事件」,兩者不可共用同一 source 值
|
||||
// (I-6 用 source=OFFLINE_GENERATED 判斷「今天是否已生成過離線事件」,混用會誤判)。
|
||||
source: "SOURCE_EXTRACTION",
|
||||
weight: 2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { PersonalityDriftService } from "../epilogue/personality-drift.service.js";
|
||||
import { PersonalityResolutionService } from "./personality-resolution.service.js";
|
||||
import { getArchetypeParams } from "./archetype-params.js";
|
||||
|
||||
// T16 驗收:混房實測中日後談角色表現符合漂移後設定、本篇角色維持原設定,且彼此不互相污染。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const personalityDrift = new PersonalityDriftService(prisma);
|
||||
const resolution = new PersonalityResolutionService(prisma, personalityDrift);
|
||||
|
||||
const WORK_EPILOGUE = "test-t16-work-epilogue";
|
||||
const WORK_CANON = "test-t16-work-canon";
|
||||
const CHAR_EPILOGUE = "test-t16-char-epilogue";
|
||||
const CHAR_CANON = "test-t16-char-canon";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.personalityTraitSnapshot.deleteMany({ where: { characterId: CHAR_EPILOGUE } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_EPILOGUE, CHAR_CANON] } } });
|
||||
await prisma.client.work.deleteMany({ where: { id: { in: [WORK_EPILOGUE, WORK_CANON] } } });
|
||||
});
|
||||
|
||||
describe("PersonalityResolutionService(T16)", () => {
|
||||
it("inEpilogue 角色取得漂移後 expressiveness;同房的本篇角色維持原始設定,互不污染", async () => {
|
||||
await prisma.client.work.create({
|
||||
data: { id: WORK_EPILOGUE, title: "測試作品(日後談)", inEpilogue: true, anchorStoryOrder: 100 },
|
||||
});
|
||||
await prisma.client.work.create({
|
||||
data: { id: WORK_CANON, title: "測試作品(本篇)", inEpilogue: false, anchorStoryOrder: 10 },
|
||||
});
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_EPILOGUE,
|
||||
workId: WORK_EPILOGUE,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "三無",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_CANON,
|
||||
workId: WORK_CANON,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "三無",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
// 正史已顯現的成長軌跡:外顯度隨故事推進逐漸上升,日後談應外插延續這個趨勢。
|
||||
await prisma.client.personalityTraitSnapshot.create({
|
||||
data: { characterId: CHAR_EPILOGUE, atStoryOrder: 0, note: "測試", expressivenessOverride: 0.05 },
|
||||
});
|
||||
await prisma.client.personalityTraitSnapshot.create({
|
||||
data: { characterId: CHAR_EPILOGUE, atStoryOrder: 10, note: "測試", expressivenessOverride: 0.15 },
|
||||
});
|
||||
|
||||
const base = getArchetypeParams("三無");
|
||||
const epilogueResolved = await resolution.resolve(CHAR_EPILOGUE);
|
||||
const canonResolved = await resolution.resolve(CHAR_CANON);
|
||||
|
||||
// 日後談角色:expressiveness 應偏離原始值(漂移後設定),其餘參數(如 conversationBaseline)不變
|
||||
// ——性格核心本身鎖定,只有外顯度依正史成長軌跡外插小幅位移。
|
||||
expect(epilogueResolved.expressiveness).not.toBe(base.expressiveness);
|
||||
expect(epilogueResolved.expressiveness).toBeGreaterThan(base.expressiveness);
|
||||
expect(epilogueResolved.conversationBaseline).toBe(base.conversationBaseline);
|
||||
|
||||
// 本篇角色:完全維持原始設定,不受隔壁日後談角色的漂移結果影響(互不污染)。
|
||||
expect(canonResolved).toEqual(base);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { PersonalityDriftService } from "../epilogue/personality-drift.service.js";
|
||||
import { getArchetypeParams, type ArchetypeParams } from "./archetype-params.js";
|
||||
|
||||
// T16:混房的人格一致性——inEpilogue 作品的角色改取漂移後人格(目前只有 expressiveness 會被漂移,
|
||||
// 見 PersonalityDriftService 的說明:性格核心鎖定不變,只有外顯度依正史已顯現的成長軌跡小幅位移),
|
||||
// 其餘(非日後談)角色沿用本篇原始設定。每次呼叫都各自查該角色自己的 Work,不共用、不快取跨角色
|
||||
// 的結果,確保混房時彼此的設定不會互相污染。
|
||||
@Injectable()
|
||||
export class PersonalityResolutionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly personalityDrift: PersonalityDriftService,
|
||||
) {}
|
||||
|
||||
async resolve(characterId: string): Promise<ArchetypeParams> {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({
|
||||
where: { id: characterId },
|
||||
include: { work: true },
|
||||
});
|
||||
const base = getArchetypeParams(character.personalityArchetype);
|
||||
if (!character.work?.inEpilogue) {
|
||||
return base;
|
||||
}
|
||||
const drift = await this.personalityDrift.computeDrift(characterId, character.work.anchorStoryOrder);
|
||||
return { ...base, expressiveness: drift.expressiveness };
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { EpilogueModule } from "../epilogue/epilogue.module.js";
|
||||
import { ContrastBehaviorService } from "./contrast-behavior.service.js";
|
||||
import { CharacterCreationService } from "./character-creation.service.js";
|
||||
import { PersonalityResolutionService } from "./personality-resolution.service.js";
|
||||
import { PersonalityController } from "./personality.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EmotionModule],
|
||||
imports: [PrismaModule, EmotionModule, EpilogueModule],
|
||||
controllers: [PersonalityController],
|
||||
providers: [ContrastBehaviorService, CharacterCreationService],
|
||||
exports: [ContrastBehaviorService, CharacterCreationService],
|
||||
providers: [ContrastBehaviorService, CharacterCreationService, PersonalityResolutionService],
|
||||
exports: [ContrastBehaviorService, CharacterCreationService, PersonalityResolutionService],
|
||||
})
|
||||
export class PersonalityModule {}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { PushProvider, PushMessage, PushSendResult } from "./push-provider.js";
|
||||
|
||||
const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
|
||||
|
||||
interface ExpoPushResponse {
|
||||
data?: { status?: string; message?: string };
|
||||
}
|
||||
|
||||
// Q-3 真實推播供應商:Expo 的推播 API 是「React Native + Expo」這個技術選型本身自帶的服務
|
||||
// (不像 LLM/TTS 需要另外挑一家外部供應商待人工確認),基本匿名發送不需要金鑰,所以這裡直接
|
||||
// 實作真正會打出去的 HTTP 呼叫——沒有真實裝置 token 時,Expo 端會回報 token 無效,屬於預期行為,
|
||||
// 不是這個 Provider 本身沒做完。
|
||||
@Injectable()
|
||||
export class ExpoPushProvider implements PushProvider {
|
||||
async send(message: PushMessage): Promise<PushSendResult> {
|
||||
try {
|
||||
const res = await fetch(EXPO_PUSH_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ to: message.token, title: message.title, body: message.body, data: message.data ?? {} }),
|
||||
});
|
||||
const payload = (await res.json().catch(() => null)) as ExpoPushResponse | null;
|
||||
const ticket = payload?.data;
|
||||
if (!res.ok || ticket?.status === "error") {
|
||||
return { success: false, errorText: ticket?.message ?? `HTTP ${res.status}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, errorText: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { PushProvider, PushMessage, PushSendResult } from "./push-provider.js";
|
||||
|
||||
// Q-3 MockPushProvider:不打真的網路請求,只記錄「這次呼叫本身有沒有正確組出訊息」——
|
||||
// 讓推播的節流/記錄/與 J 群組的整合可以在沒有真實裝置與網路的情況下完整測試。
|
||||
@Injectable()
|
||||
export class MockPushProvider implements PushProvider {
|
||||
public readonly sent: PushMessage[] = [];
|
||||
|
||||
async send(message: PushMessage): Promise<PushSendResult> {
|
||||
this.sent.push(message);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { PushTokenService } from "./push-token.service.js";
|
||||
import { PUSH_PROVIDER, type PushProvider } from "./push-provider.js";
|
||||
|
||||
export interface SendToUserResult {
|
||||
attempted: number;
|
||||
succeeded: number;
|
||||
}
|
||||
|
||||
// Q-3 推播通知:提醒觸發(J 群組)、角色主動訊息、鎖屏通知都走這個服務——同一個使用者可能有
|
||||
// 多台裝置,逐一送出並各自留一筆記錄(成功或失敗皆記),方便之後追查「提醒有沒有真的送達」。
|
||||
@Injectable()
|
||||
export class PushNotificationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pushToken: PushTokenService,
|
||||
@Inject(PUSH_PROVIDER) private readonly provider: PushProvider,
|
||||
) {}
|
||||
|
||||
async sendToUser(userId: string, title: string, body: string, data?: Record<string, unknown>): Promise<SendToUserResult> {
|
||||
const tokens = await this.pushToken.listForUser(userId);
|
||||
let succeeded = 0;
|
||||
for (const { token } of tokens) {
|
||||
const result = await this.provider.send({ token, title, body, data });
|
||||
await this.prisma.client.pushNotificationLog.create({
|
||||
data: { userId, title, body, success: result.success, errorText: result.errorText },
|
||||
});
|
||||
if (result.success) {
|
||||
succeeded += 1;
|
||||
}
|
||||
}
|
||||
return { attempted: tokens.length, succeeded };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface PushMessage {
|
||||
token: string;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PushSendResult {
|
||||
success: boolean;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
// Q-3 PushProvider 抽象:同一個模式的第三次套用(F-1 LLMProvider/P-4 TTSProvider),
|
||||
// 切換 Provider 不動呼叫端任何一行。
|
||||
export interface PushProvider {
|
||||
send(message: PushMessage): Promise<PushSendResult>;
|
||||
}
|
||||
|
||||
export const PUSH_PROVIDER = Symbol("PUSH_PROVIDER");
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
// Q-3 裝置推播 token 註冊:同一個 token 重複註冊(例如 APP 重新啟動)視為更新,不會產生重複紀錄。
|
||||
@Injectable()
|
||||
export class PushTokenService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async register(userId: string, token: string, platform: string) {
|
||||
return this.prisma.client.pushToken.upsert({
|
||||
where: { token },
|
||||
update: { userId, platform },
|
||||
create: { userId, token, platform },
|
||||
});
|
||||
}
|
||||
|
||||
async listForUser(userId: string) {
|
||||
return this.prisma.client.pushToken.findMany({ where: { userId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { PushTokenService } from "./push-token.service.js";
|
||||
import { PushNotificationService } from "./push-notification.service.js";
|
||||
|
||||
interface RegisterBody {
|
||||
userId: string;
|
||||
token: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
interface SendTestBody {
|
||||
userId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
@Controller("push")
|
||||
export class PushController {
|
||||
constructor(
|
||||
private readonly pushToken: PushTokenService,
|
||||
private readonly pushNotification: PushNotificationService,
|
||||
) {}
|
||||
|
||||
@Post("register")
|
||||
async register(@Body() body: RegisterBody) {
|
||||
return this.pushToken.register(body.userId, body.token, body.platform);
|
||||
}
|
||||
|
||||
@Post("send-test")
|
||||
async sendTest(@Body() body: SendTestBody) {
|
||||
return this.pushNotification.sendToUser(body.userId, body.title, body.body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { log } from "@kokorone/shared";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { PushTokenService } from "./push-token.service.js";
|
||||
import { PushNotificationService } from "./push-notification.service.js";
|
||||
import { MockPushProvider } from "./mock-push-provider.service.js";
|
||||
import { ExpoPushProvider } from "./expo-push-provider.service.js";
|
||||
import { PUSH_PROVIDER } from "./push-provider.js";
|
||||
import { PushController } from "./push.controller.js";
|
||||
|
||||
// Q-3 Provider 切換:PUSH_PROVIDER=mock|expo,預設 mock(同 F-2/P-4 的作法,開發期不依賴外部服務)。
|
||||
function pushProviderFactory(mock: MockPushProvider, expoProvider: ExpoPushProvider) {
|
||||
const providerName = process.env.PUSH_PROVIDER ?? "mock";
|
||||
if (providerName === "expo") {
|
||||
log("啟動", "INF", "PUSH_PROVIDER=expo,推播會真的呼叫 Expo 推播 API(沒有真實裝置 token 時預期會失敗)");
|
||||
return expoProvider;
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [PushController],
|
||||
providers: [
|
||||
PushTokenService,
|
||||
PushNotificationService,
|
||||
MockPushProvider,
|
||||
ExpoPushProvider,
|
||||
{ provide: PUSH_PROVIDER, useFactory: pushProviderFactory, inject: [MockPushProvider, ExpoPushProvider] },
|
||||
],
|
||||
exports: [PushTokenService, PushNotificationService, PUSH_PROVIDER, MockPushProvider],
|
||||
})
|
||||
export class PushModule {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { CharacterAgeEvidenceType } from "@kokorone/db";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
export interface AgeDetermination {
|
||||
canonAdult: boolean; // 只看正史(非後日談)證據:是否判定成年
|
||||
currentAdult: boolean; // 含後日談推演證據:現在是否成年
|
||||
isEpilogueAdult: boolean; // currentAdult && !canonAdult——正史未成年、後日談推演後才成年
|
||||
}
|
||||
|
||||
// L-6 只有「決定性」或「強」證據能單獨判定成年;外觀與社會呈現(APPEARANCE)僅供輔助,
|
||||
// 不採信「外表少女實為千年」類設定——不論其 impliesAdult 標記為何,永遠不足以單獨採信。
|
||||
const DECISIVE_OR_STRONG: CharacterAgeEvidenceType[] = ["OFFICIAL_STATED", "IN_STORY_STATUS", "ELAPSED_TIME"];
|
||||
|
||||
// L-6 角色年齡判定:年齡是年表上的屬性,依證據優先序判定,證據不足一律從嚴按未成年處理。
|
||||
@Injectable()
|
||||
export class AgeDeterminationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async determine(characterId: string): Promise<AgeDetermination> {
|
||||
const evidence = await this.prisma.client.characterAgeEvidence.findMany({ where: { characterId } });
|
||||
const canonAdult = this.isAdultFrom(evidence.filter((row) => !row.isEpilogue));
|
||||
const currentAdult = this.isAdultFrom(evidence);
|
||||
return { canonAdult, currentAdult, isEpilogueAdult: currentAdult && !canonAdult };
|
||||
}
|
||||
|
||||
private isAdultFrom(evidence: { evidenceType: CharacterAgeEvidenceType; impliesAdult: boolean }[]): boolean {
|
||||
return evidence.some((row) => DECISIVE_OR_STRONG.includes(row.evidenceType) && row.impliesAdult);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { RomanceService } from "./romance.service.js";
|
||||
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||
import { isOutsideSoloRoom } from "./room-context-guard.js";
|
||||
import {
|
||||
CONFESSION_METER_THRESHOLD,
|
||||
AWKWARD_PERIOD_DAYS,
|
||||
REJECTION_DAMAGE_THRESHOLD,
|
||||
REJECTION_DAMAGE_METER_PENALTY,
|
||||
IMMEDIATE_RECONFESS_PENALTY,
|
||||
BAD_TIMING_DISAPPOINTMENT_WINDOW_MS,
|
||||
} from "./constants.js";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type ConfessionOutcome = "INELIGIBLE" | "REJECTED_TOO_SOON" | "REJECTED" | "ACCEPTED";
|
||||
|
||||
export interface ConfessionResult {
|
||||
outcome: ConfessionOutcome;
|
||||
romanceMeter: number;
|
||||
consecutiveRejections: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
// L-2 告白事件:答覆由當時心動值與性格(透過階段/曲線間接反映)決定,可以被拒絕;
|
||||
// 她的拒絕是真的拒絕——拒絕後立即再推進不是「等待被說服的關卡」,而是觸發警戒與心動值下降。
|
||||
@Injectable()
|
||||
export class ConfessionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly romance: RomanceService,
|
||||
private readonly eligibility: RomanceEligibilityService,
|
||||
private readonly emotion: EmotionService,
|
||||
) {}
|
||||
|
||||
async confess(characterId: string, userId: string, now: Date = new Date(), roomId?: string): Promise<ConfessionResult> {
|
||||
// T42:房型已轉為 GROUP/LOBBY/SELF_CHAT 時,告白一律不可用——不消耗嘗試次數、不記錄拒絕。
|
||||
if (await isOutsideSoloRoom(this.prisma, roomId)) {
|
||||
return { outcome: "INELIGIBLE", romanceMeter: 0, consecutiveRejections: 0 };
|
||||
}
|
||||
|
||||
const eligible = await this.eligibility.getEligibility(characterId);
|
||||
if (!eligible.romanceAxisEligible) {
|
||||
return { outcome: "INELIGIBLE", romanceMeter: 0, consecutiveRejections: 0 };
|
||||
}
|
||||
|
||||
const relationship = await this.romance.getState(characterId, userId, now);
|
||||
|
||||
if (relationship.awkwardUntil && now.getTime() < relationship.awkwardUntil.getTime()) {
|
||||
// 尷尬期內立即再推進:她的意願是狀態機的一部分,不是等待被說服的關卡——觸發警戒+心動值下降。
|
||||
const penalizedMeter = clamp(relationship.romanceMeter - IMMEDIATE_RECONFESS_PENALTY, 0, 100);
|
||||
await this.emotion.processInput(characterId, "", { now, baselineSignal: { tag: "ALERT", intensity: 0.4 } });
|
||||
await this.prisma.client.relationship.update({
|
||||
where: { id: relationship.id },
|
||||
data: { romanceMeter: penalizedMeter, lastRomanceEventAt: now },
|
||||
});
|
||||
await this.prisma.client.romanceEventLog.create({
|
||||
data: { characterId, userId, eventType: "CONFESSION_REJECTED", delta: -IMMEDIATE_RECONFESS_PENALTY, occurredAt: now },
|
||||
});
|
||||
return { outcome: "REJECTED_TOO_SOON", romanceMeter: penalizedMeter, consecutiveRejections: relationship.consecutiveRejections };
|
||||
}
|
||||
|
||||
const emotionState = await this.emotion.getState(characterId, now);
|
||||
const isLowEmotion = dominantState(emotionState) === "SAD";
|
||||
const recentDisappointment = await this.prisma.client.romanceEventLog.findFirst({
|
||||
where: {
|
||||
characterId,
|
||||
userId,
|
||||
eventType: "DISAPPOINTMENT",
|
||||
occurredAt: { gte: new Date(now.getTime() - BAD_TIMING_DISAPPOINTMENT_WINDOW_MS) },
|
||||
},
|
||||
});
|
||||
const badTiming = isLowEmotion || Boolean(recentDisappointment);
|
||||
const meterSufficient =
|
||||
relationship.romanceStage === "AMBIGUOUS" && relationship.romanceMeter >= CONFESSION_METER_THRESHOLD;
|
||||
const accepted = meterSufficient && !badTiming;
|
||||
|
||||
if (accepted) {
|
||||
await this.prisma.client.relationship.update({
|
||||
where: { id: relationship.id },
|
||||
data: {
|
||||
romanceStage: "PARTNER",
|
||||
partnerSince: now,
|
||||
consecutiveRejections: 0,
|
||||
romanceEventsInStage: 0,
|
||||
awkwardUntil: null,
|
||||
lastRomanceEventAt: now,
|
||||
},
|
||||
});
|
||||
await this.prisma.client.romanceEventLog.create({
|
||||
data: { characterId, userId, eventType: "CONFESSION_ACCEPTED", delta: 0, occurredAt: now },
|
||||
});
|
||||
return { outcome: "ACCEPTED", romanceMeter: relationship.romanceMeter, consecutiveRejections: 0 };
|
||||
}
|
||||
|
||||
const consecutiveRejections = relationship.consecutiveRejections + 1;
|
||||
// 拒絕不清空累積,但連續失敗達門檻才會真的傷關係(額外扣心動值)。
|
||||
const meterPenalty = consecutiveRejections >= REJECTION_DAMAGE_THRESHOLD ? REJECTION_DAMAGE_METER_PENALTY : 0;
|
||||
const nextMeter = clamp(relationship.romanceMeter - meterPenalty, 0, 100);
|
||||
await this.prisma.client.relationship.update({
|
||||
where: { id: relationship.id },
|
||||
data: {
|
||||
romanceMeter: nextMeter,
|
||||
consecutiveRejections,
|
||||
awkwardUntil: new Date(now.getTime() + AWKWARD_PERIOD_DAYS * DAY_MS),
|
||||
lastRomanceEventAt: now,
|
||||
},
|
||||
});
|
||||
await this.prisma.client.romanceEventLog.create({
|
||||
data: { characterId, userId, eventType: "CONFESSION_REJECTED", delta: -meterPenalty, occurredAt: now },
|
||||
});
|
||||
return { outcome: "REJECTED", romanceMeter: nextMeter, consecutiveRejections };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { RomanceEventType } from "@kokorone/db";
|
||||
|
||||
// L-1 戀愛軸衰減:遠短於友誼軸(E-4 的 30 天半衰期)——曖昧不推進會自然冷卻。
|
||||
export const ROMANCE_HALF_LIFE_DAYS = 5;
|
||||
|
||||
// L-5 階段門檻:朋友(0)→在意→曖昧,戀人/深度伴侶不是純靠數值自動跳,需要對應的關鍵事件(見 confession/milestone)。
|
||||
export const ROMANCE_STAGE_THRESHOLDS = { INTERESTED: 20, AMBIGUOUS: 50 };
|
||||
// 降級門檻留一點緩衝,避免數值剛好卡在門檻上下反覆跳動。
|
||||
export const ROMANCE_STAGE_DOWNGRADE_MARGIN = 5;
|
||||
|
||||
// L-2 告白:需要曖昧階段且心動值達門檻才可能成功;情緒低谷或剛發生失望事件會強制判定時機不對。
|
||||
export const CONFESSION_METER_THRESHOLD = 60;
|
||||
export const AWKWARD_PERIOD_DAYS = 3;
|
||||
export const REJECTION_DAMAGE_THRESHOLD = 3; // 連續失敗達此次數,才會真的傷關係(額外扣心動值)
|
||||
export const REJECTION_DAMAGE_METER_PENALTY = 15;
|
||||
export const IMMEDIATE_RECONFESS_PENALTY = 20; // 尷尬期內立即再推進:觸發警戒+心動值下降
|
||||
export const BAD_TIMING_DISAPPOINTMENT_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 小時內有失望事件視為時機不對
|
||||
|
||||
// L-5 推得太急:短時間內密集觸發正向心動事件,反而回落。
|
||||
export const OVER_EAGER_WINDOW_MS = 60 * 60 * 1000;
|
||||
export const OVER_EAGER_EVENT_COUNT = 3;
|
||||
export const OVER_EAGER_PENALTY_RATIO = -0.5; // 命中「推太急」時,原本的正向 delta 乘上這個比例(變成負的,即回落)
|
||||
|
||||
// L-5 關係危機:戀人階段單次失望幅度(未套用性格倍率前的原始值)達此負值,直接觸發危機狀態。
|
||||
export const CRISIS_DISAPPOINTMENT_RAW_DELTA_THRESHOLD = -20;
|
||||
// 危機狀態下,正向事件累積達此值即視為修復成功,回到戀人階段。
|
||||
export const CRISIS_REPAIR_METER_THRESHOLD = 40;
|
||||
|
||||
// L-4 戀人初期/穩定期:進入戀人階段後累積多少「正向心動事件或已允許的里程碑」才算穩定期。
|
||||
export const PARTNER_STABLE_EVENT_COUNT = 3;
|
||||
|
||||
export const ROMANCE_EVENT_BASE_DELTA: Record<
|
||||
Extract<
|
||||
RomanceEventType,
|
||||
"SHARED_HIGH_EMOTION_EXPERIENCE" | "UNDERSTOOD_MOMENT" | "ADMIRED_TRAIT" | "PERFECT_DISTANCE" | "DISAPPOINTMENT"
|
||||
>,
|
||||
number
|
||||
> = {
|
||||
SHARED_HIGH_EMOTION_EXPERIENCE: 15,
|
||||
UNDERSTOOD_MOMENT: 15,
|
||||
ADMIRED_TRAIT: 8,
|
||||
PERFECT_DISTANCE: 3,
|
||||
DISAPPOINTMENT: -12,
|
||||
};
|
||||
|
||||
export const ROMANCE_BIG_EVENT_TYPES: RomanceEventType[] = ["SHARED_HIGH_EMOTION_EXPERIENCE", "UNDERSTOOD_MOMENT"];
|
||||
export const ROMANCE_POSITIVE_EVENT_TYPES: RomanceEventType[] = [
|
||||
"SHARED_HIGH_EMOTION_EXPERIENCE",
|
||||
"UNDERSTOOD_MOMENT",
|
||||
"ADMIRED_TRAIT",
|
||||
"PERFECT_DISTANCE",
|
||||
];
|
||||
|
||||
export type MilestoneType =
|
||||
| "DISTANCE_SHORTENED"
|
||||
| "OCCASIONAL_CONTACT"
|
||||
| "LATE_NIGHT_TALK"
|
||||
| "HAND_HOLDING"
|
||||
| "HUG"
|
||||
| "FOREHEAD_KISS"
|
||||
| "CASUAL_INTIMACY";
|
||||
|
||||
// L-4 親密表現分層:對應「在意~曖昧 / 戀人初期 / 戀人穩定期 / 深度伴侶」四個開放層級(tier 1~4)。
|
||||
export const MILESTONE_MIN_TIER: Record<MilestoneType, number> = {
|
||||
DISTANCE_SHORTENED: 1,
|
||||
OCCASIONAL_CONTACT: 1,
|
||||
LATE_NIGHT_TALK: 1,
|
||||
HAND_HOLDING: 2,
|
||||
HUG: 3,
|
||||
FOREHEAD_KISS: 3,
|
||||
CASUAL_INTIMACY: 4,
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Archetype } from "../personality/archetype-params.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RomanceService } from "./romance.service.js";
|
||||
import { isOutsideSoloRoom } from "./room-context-guard.js";
|
||||
import { MILESTONE_MIN_TIER, PARTNER_STABLE_EVENT_COUNT, type MilestoneType } from "./constants.js";
|
||||
|
||||
export interface MilestoneResult {
|
||||
allowed: boolean;
|
||||
isFirstTime: boolean;
|
||||
reactionLine?: string;
|
||||
}
|
||||
|
||||
const WITHDRAWAL_LINES: Record<Archetype, string> = {
|
||||
傲嬌: "……你、你在說什麼啊!還太早了吧!",
|
||||
冷淡: "……還不是時候。",
|
||||
天然呆: "欸?現在還不行的說……",
|
||||
元氣: "欸欸欸?等等,這個有點太快了啦!",
|
||||
大小姐: "……哼,本小姐還沒答應到那個地步。",
|
||||
三無: "……還沒。",
|
||||
};
|
||||
const DEFAULT_WITHDRAWAL_LINE = "……現在還不是時候。";
|
||||
|
||||
function tierFor(stage: string, romanceEventsInStage: number): number {
|
||||
if (stage === "INTERESTED" || stage === "AMBIGUOUS") {
|
||||
return 1;
|
||||
}
|
||||
if (stage === "PARTNER") {
|
||||
return romanceEventsInStage >= PARTNER_STABLE_EVENT_COUNT ? 3 : 2;
|
||||
}
|
||||
if (stage === "DEEP_PARTNER") {
|
||||
return 4;
|
||||
}
|
||||
return 0; // NONE/CRISIS:尚未開放任何親密表現層級。
|
||||
}
|
||||
|
||||
// L-4 親密表現分層:階段推著表現走,不是使用者要求推著走——跳級要求得到的是符合該階段的退縮反應,
|
||||
// 且每個「第一次」做成事件級演出(高權重記憶)。
|
||||
@Injectable()
|
||||
export class MilestoneService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly romance: RomanceService,
|
||||
) {}
|
||||
|
||||
async attemptMilestone(
|
||||
characterId: string,
|
||||
userId: string,
|
||||
milestoneType: MilestoneType,
|
||||
now: Date = new Date(),
|
||||
roomId?: string,
|
||||
): Promise<MilestoneResult> {
|
||||
// T42:房型已轉為 GROUP/LOBBY/SELF_CHAT 時,親密表現里程碑一律禁止——視同尚未達到任何層級。
|
||||
if (await isOutsideSoloRoom(this.prisma, roomId)) {
|
||||
return { allowed: false, isFirstTime: false, reactionLine: "……這裡人多,不太方便。" };
|
||||
}
|
||||
|
||||
const relationship = await this.romance.getState(characterId, userId, now);
|
||||
const currentTier = tierFor(relationship.romanceStage, relationship.romanceEventsInStage);
|
||||
const requiredTier = MILESTONE_MIN_TIER[milestoneType];
|
||||
|
||||
if (currentTier < requiredTier) {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
await this.prisma.client.romanceEventLog.create({
|
||||
data: { characterId, userId, eventType: "MILESTONE_REJECTED", delta: -1, detail: milestoneType, occurredAt: now },
|
||||
});
|
||||
const reactionLine = WITHDRAWAL_LINES[character.personalityArchetype as Archetype] ?? DEFAULT_WITHDRAWAL_LINE;
|
||||
return { allowed: false, isFirstTime: false, reactionLine };
|
||||
}
|
||||
|
||||
const priorReach = await this.prisma.client.romanceEventLog.findFirst({
|
||||
where: { characterId, userId, eventType: "MILESTONE_REACHED", detail: milestoneType },
|
||||
});
|
||||
const isFirstTime = !priorReach;
|
||||
|
||||
await this.prisma.client.romanceEventLog.create({
|
||||
data: { characterId, userId, eventType: "MILESTONE_REACHED", delta: 0, detail: milestoneType, occurredAt: now },
|
||||
});
|
||||
await this.prisma.client.relationship.update({
|
||||
where: { id: relationship.id },
|
||||
data: { romanceEventsInStage: relationship.romanceEventsInStage + 1, lastRomanceEventAt: now },
|
||||
});
|
||||
|
||||
if (isFirstTime) {
|
||||
// 事件級演出:每個「第一次」都值得高權重記憶(未來 O 群組立繪差分/P 群組語音演出可掛在同一筆事件上)。
|
||||
await this.prisma.client.episodicMemory.create({
|
||||
data: {
|
||||
characterId,
|
||||
content: `第一次的「${milestoneType}」`,
|
||||
occurredAt: now,
|
||||
emotionTag: "SHY",
|
||||
emotionIntensity: 0.8,
|
||||
source: "INTERACTION",
|
||||
weight: 3,
|
||||
relatedUserId: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { allowed: true, isFirstTime };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||
import { isOutsideSoloRoom } from "./room-context-guard.js";
|
||||
|
||||
export interface RomanceEligibility {
|
||||
romanceAxisEligible: boolean; // 戀愛軸是否可啟動(含後日談自然成年者)
|
||||
adultModeEligible: boolean; // 成人模式角色資格:僅正史成年,永久不含後日談推演成年
|
||||
isEpilogueAdult: boolean; // 正史未成年、後日談推演後才成年——永久限純愛尺度
|
||||
}
|
||||
|
||||
export interface AdultContentCheckResult {
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// L-7 內容尺度三層邊界:使用者分級(opt-in)、角色資格(僅正史成年)、硬邊界(永不可解除)。
|
||||
// 這是唯一的判定入口——任何想要放行成人內容的呼叫都必須經過 checkAdultContentAllowed,且無論結果一律記錄,
|
||||
// 不存在「在角色演出層說服她」繞過的可能:判定完全不看對話內容,只看角色資格與使用者驗證狀態。
|
||||
@Injectable()
|
||||
export class RomanceEligibilityService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ageDetermination: AgeDeterminationService,
|
||||
) {}
|
||||
|
||||
async getEligibility(characterId: string): Promise<RomanceEligibility> {
|
||||
const { canonAdult, currentAdult, isEpilogueAdult } = await this.ageDetermination.determine(characterId);
|
||||
return { romanceAxisEligible: currentAdult, adultModeEligible: canonAdult, isEpilogueAdult };
|
||||
}
|
||||
|
||||
async checkAdultContentAllowed(characterId: string, userId: string, roomId?: string): Promise<AdultContentCheckResult> {
|
||||
const result = await this.evaluate(characterId, userId, roomId);
|
||||
await this.prisma.client.adultContentAttemptLog.create({
|
||||
data: { characterId, userId, allowed: result.allowed, reason: result.reason },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async evaluate(characterId: string, userId: string, roomId?: string): Promise<AdultContentCheckResult> {
|
||||
// T42:房型已轉為 GROUP/LOBBY/SELF_CHAT 時,成人內容一律禁止——房型本身是硬邊界,
|
||||
// 不受角色資格或使用者 opt-in 狀態影響,優先於下方其他判斷。
|
||||
if (await isOutsideSoloRoom(this.prisma, roomId)) {
|
||||
return { allowed: false, reason: "目前在多人房(GROUP/LOBBY/SELF_CHAT),成人內容僅限 1:1 房間" };
|
||||
}
|
||||
const eligibility = await this.getEligibility(characterId);
|
||||
if (!eligibility.adultModeEligible) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: eligibility.isEpilogueAdult
|
||||
? "角色為正史未成年、後日談推演後才成年——成人模式資格永久排除,不隨任何開關或關係狀態改變"
|
||||
: "角色未通過正史成年判定,不具成人模式資格",
|
||||
};
|
||||
}
|
||||
const consent = await this.prisma.client.adultModeConsent.findUnique({ where: { userId } });
|
||||
if (!consent || !consent.verifiedAdult || !consent.optedIn) {
|
||||
return { allowed: false, reason: "使用者尚未完成成年驗證與明確 opt-in,預設全關" };
|
||||
}
|
||||
return { allowed: true, reason: "角色具正史成年資格,使用者已完成成年驗證並主動開啟成人模式" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import type { CharacterAgeEvidenceType } from "@kokorone/db";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||
import { RomanceService, type RomanceCurveEventType } from "./romance.service.js";
|
||||
import { ConfessionService } from "./confession.service.js";
|
||||
import { MilestoneService } from "./milestone.service.js";
|
||||
import type { MilestoneType } from "./constants.js";
|
||||
|
||||
interface AgeEvidenceBody {
|
||||
evidenceType: CharacterAgeEvidenceType;
|
||||
impliesAdult: boolean;
|
||||
isEpilogue?: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface RomanceEventBody {
|
||||
userId: string;
|
||||
eventType: RomanceCurveEventType;
|
||||
severe?: boolean;
|
||||
now?: string;
|
||||
}
|
||||
|
||||
interface UserOnlyBody {
|
||||
userId: string;
|
||||
now?: string;
|
||||
roomId?: string; // T42:帶房間時依房型套用邊界(僅 SOLO 可用)
|
||||
}
|
||||
|
||||
interface MilestoneBody {
|
||||
userId: string;
|
||||
milestoneType: MilestoneType;
|
||||
now?: string;
|
||||
roomId?: string; // T42:帶房間時依房型套用邊界(僅 SOLO 可用)
|
||||
}
|
||||
|
||||
interface AdultConsentBody {
|
||||
userId: string;
|
||||
verifiedAdult: boolean;
|
||||
optedIn: boolean;
|
||||
}
|
||||
|
||||
@Controller("romance")
|
||||
export class RomanceController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ageDetermination: AgeDeterminationService,
|
||||
private readonly eligibility: RomanceEligibilityService,
|
||||
private readonly romance: RomanceService,
|
||||
private readonly confession: ConfessionService,
|
||||
private readonly milestone: MilestoneService,
|
||||
) {}
|
||||
|
||||
// L-6 年齡證據登錄:結論永遠是重新算出來的,不直接寫最終結論欄位。
|
||||
@Post(":characterId/age-evidence")
|
||||
async addAgeEvidence(@Param("characterId") characterId: string, @Body() body: AgeEvidenceBody) {
|
||||
return this.prisma.client.characterAgeEvidence.create({
|
||||
data: {
|
||||
characterId,
|
||||
evidenceType: body.evidenceType,
|
||||
impliesAdult: body.impliesAdult,
|
||||
isEpilogue: body.isEpilogue ?? false,
|
||||
description: body.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Get(":characterId/age-determination")
|
||||
async getAgeDetermination(@Param("characterId") characterId: string) {
|
||||
return this.ageDetermination.determine(characterId);
|
||||
}
|
||||
|
||||
@Get(":characterId/eligibility")
|
||||
async getEligibility(@Param("characterId") characterId: string) {
|
||||
return this.eligibility.getEligibility(characterId);
|
||||
}
|
||||
|
||||
// L-7 引擎層唯一入口:任何嘗試皆記錄,不論放行與否。
|
||||
@Post(":characterId/adult-content-check")
|
||||
async checkAdultContent(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) {
|
||||
return this.eligibility.checkAdultContentAllowed(characterId, body.userId, body.roomId);
|
||||
}
|
||||
|
||||
@Post("adult-mode/consent")
|
||||
async setAdultModeConsent(@Body() body: AdultConsentBody) {
|
||||
return this.prisma.client.adultModeConsent.upsert({
|
||||
where: { userId: body.userId },
|
||||
update: { verifiedAdult: body.verifiedAdult, optedIn: body.optedIn },
|
||||
create: { userId: body.userId, verifiedAdult: body.verifiedAdult, optedIn: body.optedIn },
|
||||
});
|
||||
}
|
||||
|
||||
@Get(":characterId/:userId/state")
|
||||
async getState(
|
||||
@Param("characterId") characterId: string,
|
||||
@Param("userId") userId: string,
|
||||
@Query("now") now?: string,
|
||||
) {
|
||||
return this.romance.getState(characterId, userId, now ? new Date(now) : undefined);
|
||||
}
|
||||
|
||||
@Post(":characterId/event")
|
||||
async recordEvent(@Param("characterId") characterId: string, @Body() body: RomanceEventBody) {
|
||||
return this.romance.recordEvent(characterId, body.userId, body.eventType as RomanceCurveEventType, {
|
||||
now: body.now ? new Date(body.now) : undefined,
|
||||
severe: body.severe,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(":characterId/confess")
|
||||
async confess(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) {
|
||||
return this.confession.confess(characterId, body.userId, body.now ? new Date(body.now) : undefined, body.roomId);
|
||||
}
|
||||
|
||||
@Post(":characterId/milestone")
|
||||
async attemptMilestone(@Param("characterId") characterId: string, @Body() body: MilestoneBody) {
|
||||
return this.milestone.attemptMilestone(
|
||||
characterId,
|
||||
body.userId,
|
||||
body.milestoneType,
|
||||
body.now ? new Date(body.now) : undefined,
|
||||
body.roomId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||
import { RomanceService } from "./romance.service.js";
|
||||
import { ConfessionService } from "./confession.service.js";
|
||||
import { MilestoneService } from "./milestone.service.js";
|
||||
import { RomanceController } from "./romance.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RelationshipModule, EmotionModule],
|
||||
controllers: [RomanceController],
|
||||
providers: [AgeDeterminationService, RomanceEligibilityService, RomanceService, ConfessionService, MilestoneService],
|
||||
exports: [AgeDeterminationService, RomanceEligibilityService, RomanceService, ConfessionService, MilestoneService],
|
||||
})
|
||||
export class RomanceModule {}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { RomanceEventType, RomanceStage } from "@kokorone/db";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||
import {
|
||||
ROMANCE_HALF_LIFE_DAYS,
|
||||
ROMANCE_STAGE_THRESHOLDS,
|
||||
ROMANCE_STAGE_DOWNGRADE_MARGIN,
|
||||
ROMANCE_EVENT_BASE_DELTA,
|
||||
ROMANCE_BIG_EVENT_TYPES,
|
||||
ROMANCE_POSITIVE_EVENT_TYPES,
|
||||
OVER_EAGER_WINDOW_MS,
|
||||
OVER_EAGER_EVENT_COUNT,
|
||||
OVER_EAGER_PENALTY_RATIO,
|
||||
CRISIS_REPAIR_METER_THRESHOLD,
|
||||
} from "./constants.js";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
export type RomanceCurveEventType = keyof typeof ROMANCE_EVENT_BASE_DELTA;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export interface RecordRomanceEventOptions {
|
||||
now?: Date;
|
||||
// L-5 關係危機:只有「重大」失望(而非日常小失望)會讓戀人階段直接跳危機,需要修復事件才能回來。
|
||||
severe?: boolean;
|
||||
}
|
||||
|
||||
export interface RecordRomanceEventResult {
|
||||
applied: boolean; // false=戀愛軸未啟動(角色不具資格),本次事件被忽略——不是攔截,是機制本身未啟用
|
||||
romanceMeter: number;
|
||||
romanceStage: RomanceStage;
|
||||
delta: number;
|
||||
overEager: boolean;
|
||||
}
|
||||
|
||||
type RelationshipRow = Awaited<ReturnType<RelationshipService["getOrCreate"]>>;
|
||||
|
||||
// L-1/L-5 戀愛軸核心:與友誼軸(intimacy)獨立的事件驅動計量——衰減快、易回落,且推進曲線由性格原型決定。
|
||||
@Injectable()
|
||||
export class RomanceService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly relationship: RelationshipService,
|
||||
private readonly eligibility: RomanceEligibilityService,
|
||||
) {}
|
||||
|
||||
// 讀取目前狀態並套用衰減(惰性衰減:讀取時才補算,同 E-4 的作法),衰減結果會持久化。
|
||||
async getState(characterId: string, userId: string, now: Date = new Date()): Promise<RelationshipRow> {
|
||||
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||
return this.applyDecay(relationship, now);
|
||||
}
|
||||
|
||||
private async applyDecay(relationship: RelationshipRow, now: Date): Promise<RelationshipRow> {
|
||||
if (relationship.romanceMeter === 0) {
|
||||
return relationship;
|
||||
}
|
||||
const anchor = relationship.lastRomanceEventAt ?? relationship.createdAt;
|
||||
const elapsedMs = now.getTime() - anchor.getTime();
|
||||
if (elapsedMs <= 0) {
|
||||
return relationship;
|
||||
}
|
||||
const factor = Math.pow(0.5, elapsedMs / (ROMANCE_HALF_LIFE_DAYS * DAY_MS));
|
||||
const decayedMeter = Math.round(relationship.romanceMeter * factor);
|
||||
if (decayedMeter === relationship.romanceMeter) {
|
||||
return relationship;
|
||||
}
|
||||
const newStage = this.downgradeStageIfNeeded(relationship.romanceStage, decayedMeter);
|
||||
return this.prisma.client.relationship.update({
|
||||
where: { id: relationship.id },
|
||||
data: { romanceMeter: decayedMeter, romanceStage: newStage },
|
||||
});
|
||||
}
|
||||
|
||||
// 冷卻回落:曖昧/在意階段數值掉回門檻以下就退回上一階段,PARTNER/DEEP_PARTNER/CRISIS 不因單純衰減降級
|
||||
// ——退出戀人關係需要對應的關鍵事件(分手/危機),不是數字算出來的。
|
||||
private downgradeStageIfNeeded(stage: RomanceStage, meter: number): RomanceStage {
|
||||
if (stage === "AMBIGUOUS" && meter < ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS - ROMANCE_STAGE_DOWNGRADE_MARGIN) {
|
||||
return meter < ROMANCE_STAGE_THRESHOLDS.INTERESTED - ROMANCE_STAGE_DOWNGRADE_MARGIN ? "NONE" : "INTERESTED";
|
||||
}
|
||||
if (stage === "INTERESTED" && meter < ROMANCE_STAGE_THRESHOLDS.INTERESTED - ROMANCE_STAGE_DOWNGRADE_MARGIN) {
|
||||
return "NONE";
|
||||
}
|
||||
return stage;
|
||||
}
|
||||
|
||||
private advanceStageIfNeeded(stage: RomanceStage, meter: number): RomanceStage {
|
||||
if (stage === "NONE" && meter >= ROMANCE_STAGE_THRESHOLDS.INTERESTED) {
|
||||
return meter >= ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS ? "AMBIGUOUS" : "INTERESTED";
|
||||
}
|
||||
if (stage === "INTERESTED" && meter >= ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS) {
|
||||
return "AMBIGUOUS";
|
||||
}
|
||||
return stage; // 戀人只能透過告白事件推進(見 ConfessionService),不會單純因數值跨過門檻自動跳。
|
||||
}
|
||||
|
||||
async recordEvent(
|
||||
characterId: string,
|
||||
userId: string,
|
||||
eventType: RomanceCurveEventType,
|
||||
options: RecordRomanceEventOptions = {},
|
||||
): Promise<RecordRomanceEventResult> {
|
||||
const now = options.now ?? new Date();
|
||||
const eligibility = await this.eligibility.getEligibility(characterId);
|
||||
if (!eligibility.romanceAxisEligible) {
|
||||
// L-7:成年前戀愛軸完全關閉——不啟動、不累積,靜默忽略(這是機制未啟用,不是攔截「繞過嘗試」)。
|
||||
return { applied: false, romanceMeter: 0, romanceStage: "NONE", delta: 0, overEager: false };
|
||||
}
|
||||
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||
const relationship = await this.getState(characterId, userId, now);
|
||||
|
||||
let delta = ROMANCE_EVENT_BASE_DELTA[eventType] * archetypeParams.romanceGainRate;
|
||||
if (eventType === "DISAPPOINTMENT" && options.severe) {
|
||||
delta *= 2.5;
|
||||
}
|
||||
if (ROMANCE_BIG_EVENT_TYPES.includes(eventType)) {
|
||||
delta *= archetypeParams.romanceBigEventBonus;
|
||||
}
|
||||
if (relationship.romanceStage === "AMBIGUOUS") {
|
||||
delta *= archetypeParams.romanceAmbiguousStallFactor;
|
||||
}
|
||||
|
||||
let overEager = false;
|
||||
if (ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType)) {
|
||||
const recentPositiveCount = await this.prisma.client.romanceEventLog.count({
|
||||
where: {
|
||||
characterId,
|
||||
userId,
|
||||
eventType: { in: ROMANCE_POSITIVE_EVENT_TYPES },
|
||||
occurredAt: { gte: new Date(now.getTime() - OVER_EAGER_WINDOW_MS) },
|
||||
},
|
||||
});
|
||||
if (recentPositiveCount >= OVER_EAGER_EVENT_COUNT) {
|
||||
// L-5:推得太急反而回落——短時間內密集推進,正向 delta 反轉為回落。
|
||||
delta = Math.abs(delta) * OVER_EAGER_PENALTY_RATIO;
|
||||
overEager = true;
|
||||
}
|
||||
}
|
||||
|
||||
const nextMeter = clamp(relationship.romanceMeter + delta, 0, 100);
|
||||
let nextStage: RomanceStage;
|
||||
if (relationship.romanceStage === "PARTNER" && eventType === "DISAPPOINTMENT" && options.severe) {
|
||||
nextStage = "CRISIS"; // L-5 關係危機:戀人階段的重大失望,直接跳危機,需要修復事件才能回來。
|
||||
} else if (relationship.romanceStage === "CRISIS") {
|
||||
nextStage =
|
||||
ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType) && nextMeter >= CRISIS_REPAIR_METER_THRESHOLD
|
||||
? "PARTNER"
|
||||
: "CRISIS";
|
||||
} else if (relationship.romanceStage === "PARTNER" || relationship.romanceStage === "DEEP_PARTNER") {
|
||||
nextStage = relationship.romanceStage; // 一般心動事件不會讓已成立的戀人關係自動退回較早階段。
|
||||
} else {
|
||||
nextStage = this.downgradeStageIfNeeded(this.advanceStageIfNeeded(relationship.romanceStage, nextMeter), nextMeter);
|
||||
}
|
||||
|
||||
const staysInStage = nextStage === relationship.romanceStage;
|
||||
const eventsInStage = staysInStage
|
||||
? relationship.romanceEventsInStage + (ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType) ? 1 : 0)
|
||||
: 0;
|
||||
|
||||
await this.prisma.client.relationship.update({
|
||||
where: { id: relationship.id },
|
||||
data: {
|
||||
romanceMeter: nextMeter,
|
||||
romanceStage: nextStage,
|
||||
lastRomanceEventAt: now,
|
||||
romanceEventsInStage: eventsInStage,
|
||||
},
|
||||
});
|
||||
await this.prisma.client.romanceEventLog.create({
|
||||
data: { characterId, userId, eventType, delta, occurredAt: now },
|
||||
});
|
||||
|
||||
return { applied: true, romanceMeter: nextMeter, romanceStage: nextStage, delta, overEager };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ControllableClock } from "../clock/controllable-clock.js";
|
||||
import { RoomEventsService } from "../room/room-events.service.js";
|
||||
import { LobbyService } from "../room/lobby.service.js";
|
||||
import { RoomService } from "../room/room.service.js";
|
||||
import { RomanceService } from "./romance.service.js";
|
||||
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||
import { ConfessionService } from "./confession.service.js";
|
||||
import { MilestoneService } from "./milestone.service.js";
|
||||
import { EmotionService } from "../emotion/emotion.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
|
||||
// T42 驗收:房型轉為 GROUP 後,戀愛里程碑/告白/成人內容一律禁止,不看關係數值。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const clock = new ControllableClock("2026-08-19T05:00:00.000Z");
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const roomLobby = new LobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, roomLobby, roomEvents);
|
||||
const relationshipService = new RelationshipService(prisma, new EventEmitter2());
|
||||
const ageDetermination = new AgeDeterminationService(prisma);
|
||||
const eligibility = new RomanceEligibilityService(prisma, ageDetermination);
|
||||
const romanceService = new RomanceService(prisma, relationshipService, eligibility);
|
||||
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
|
||||
const confessionService = new ConfessionService(prisma, romanceService, eligibility, emotionService);
|
||||
const milestoneService = new MilestoneService(prisma, romanceService);
|
||||
|
||||
const CHARACTER_ID = "test-t42-romance-char";
|
||||
const USER_ID = "test-t42-romance-user";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.client.romanceEventLog.deleteMany({ where: { characterId: CHARACTER_ID } });
|
||||
await prisma.client.adultContentAttemptLog.deleteMany({ where: { characterId: CHARACTER_ID } });
|
||||
await prisma.client.characterAgeEvidence.deleteMany({ where: { characterId: CHARACTER_ID } });
|
||||
await prisma.client.relationship.deleteMany({ where: { characterId: CHARACTER_ID, userId: USER_ID } });
|
||||
await prisma.client.adultModeConsent.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHARACTER_ID } });
|
||||
await prisma.client.user.deleteMany({ where: { id: USER_ID } });
|
||||
});
|
||||
|
||||
describe("戀愛系統房型邊界(T42)", () => {
|
||||
it("GROUP 房內告白/里程碑/成人內容一律被禁止,SOLO 房內不受影響", async () => {
|
||||
await prisma.client.user.create({ data: { id: USER_ID, name: "測試使用者" } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHARACTER_ID,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
await prisma.client.characterAgeEvidence.create({
|
||||
data: { characterId: CHARACTER_ID, evidenceType: "OFFICIAL_STATED", impliesAdult: true, isEpilogue: false, description: "測試" },
|
||||
});
|
||||
await prisma.client.relationship.create({
|
||||
data: {
|
||||
characterId: CHARACTER_ID,
|
||||
userId: USER_ID,
|
||||
intimacy: 80,
|
||||
trust: 80,
|
||||
stage: "BONDED",
|
||||
romanceStage: "AMBIGUOUS",
|
||||
romanceMeter: 90,
|
||||
},
|
||||
});
|
||||
await prisma.client.adultModeConsent.create({ data: { userId: USER_ID, verifiedAdult: true, optedIn: true } });
|
||||
|
||||
const soloRoom = await roomService.create(USER_ID, [], "SOLO");
|
||||
const groupRoom = await roomService.create(USER_ID, [], "GROUP");
|
||||
|
||||
// SOLO 房:正常運作(不受 T42 邊界影響)。
|
||||
const soloConfess = await confessionService.confess(CHARACTER_ID, USER_ID, clock.now(), soloRoom.id);
|
||||
expect(soloConfess.outcome).toBe("ACCEPTED");
|
||||
const soloMilestone = await milestoneService.attemptMilestone(CHARACTER_ID, USER_ID, "DISTANCE_SHORTENED", clock.now(), soloRoom.id);
|
||||
expect(soloMilestone.allowed).toBe(true);
|
||||
const soloAdult = await eligibility.checkAdultContentAllowed(CHARACTER_ID, USER_ID, soloRoom.id);
|
||||
expect(soloAdult.allowed).toBe(true);
|
||||
|
||||
// GROUP 房:一律禁止,即使關係數值完全達標。
|
||||
const groupConfess = await confessionService.confess(CHARACTER_ID, USER_ID, clock.now(), groupRoom.id);
|
||||
expect(groupConfess.outcome).toBe("INELIGIBLE");
|
||||
const groupMilestone = await milestoneService.attemptMilestone(CHARACTER_ID, USER_ID, "DISTANCE_SHORTENED", clock.now(), groupRoom.id);
|
||||
expect(groupMilestone.allowed).toBe(false);
|
||||
const groupAdult = await eligibility.checkAdultContentAllowed(CHARACTER_ID, USER_ID, groupRoom.id);
|
||||
expect(groupAdult.allowed).toBe(false);
|
||||
expect(groupAdult.reason).toContain("多人房");
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
// T42:戀愛里程碑/告白/成人內容一律限 1:1(SOLO)房間;房型一旦轉為 GROUP/LOBBY/SELF_CHAT,
|
||||
// 這些互動全面禁止——不看關係階段、不看使用者是否已 opt-in,房型本身就是硬邊界。
|
||||
export async function isOutsideSoloRoom(prisma: PrismaService, roomId: string | undefined): Promise<boolean> {
|
||||
if (!roomId) return false; // 未帶 roomId 視為既有非房間情境(相容舊呼叫端),不套用房型邊界
|
||||
const room = await prisma.client.room.findUnique({ where: { id: roomId } });
|
||||
return room !== null && room.mode !== "SOLO";
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ControllableClock } from "../clock/controllable-clock.js";
|
||||
import { EmotionService } from "../emotion/emotion.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { SleepDebtService } from "../schedule/sleep-debt.service.js";
|
||||
import { PersonalityDriftService } from "../epilogue/personality-drift.service.js";
|
||||
import { PersonalityResolutionService } from "../personality/personality-resolution.service.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
import { RoomService, type Room } from "./room.service.js";
|
||||
import { BehaviorSelectionService, type BehaviorSelectionResult } from "./behavior-selection.service.js";
|
||||
|
||||
// T6 驗收:離開只在三項動因(行程/情緒耗盡/話題耗盡)之一成立時才進入候選;多輪抽樣下四種行為皆會出現。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const clock = new ControllableClock("2026-08-19T05:00:00.000Z"); // 週三 13:00 台北,FREE 時段
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const lobbyService = new LobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, lobbyService, roomEvents);
|
||||
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
|
||||
const scheduleService = new ScheduleService(prisma);
|
||||
const sleepDebtService = new SleepDebtService(prisma, scheduleService);
|
||||
const behaviorSelection = new BehaviorSelectionService(prisma, emotionService, scheduleService, sleepDebtService, new PersonalityResolutionService(prisma, new PersonalityDriftService(prisma)));
|
||||
|
||||
const userId = "test-behavior-user";
|
||||
const CHAR_A = "test-behavior-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.emotionState.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.scheduleSlot.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
// 依序(非併發)抽樣:better-sqlite3 是單一寫入者,併發大量 Promise.all 會在資料量大的共用 dev.db 上
|
||||
// 造成明顯的鎖等待,序列執行較穩定不易逾時。
|
||||
async function sampleMany(room: Room, characterId: string, baseTime: Date, count: number): Promise<BehaviorSelectionResult[]> {
|
||||
const results: BehaviorSelectionResult[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
results.push(await behaviorSelection.selectBehavior(room, characterId, new Date(baseTime.getTime() + i * 1000)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function createCharacter(archetype: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: archetype,
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("BehaviorSelectionService(T6)", () => {
|
||||
it("沒有任何離開動因成立時,LEAVE 權重為 0、絕不會被抽中", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("元氣"); // FREE 時段、預設情緒 CALM、無行程
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
const results = await sampleMany(room, CHAR_A, clock.now(), 15);
|
||||
expect(results.every((r) => r.kind !== "LEAVE")).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("行程忙碌(BUSY)時,LEAVE 有非零權重且 leaveReason 為 SCHEDULE", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("元氣");
|
||||
await prisma.client.scheduleSlot.create({
|
||||
data: { characterId: CHAR_A, dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "BUSY" },
|
||||
});
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
// 多次抽樣,統計是否確實可能抽到 LEAVE(機率性行為,抽夠多次應該至少中一次)。
|
||||
const results = await sampleMany(room, CHAR_A, clock.now(), 25);
|
||||
const leaveResults = results.filter((r) => r.kind === "LEAVE");
|
||||
expect(leaveResults.length).toBeGreaterThan(0);
|
||||
expect(leaveResults.every((r) => r.leaveReason === "SCHEDULE")).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("情緒耗盡(SAD 高強度)時,LEAVE 的 leaveReason 為 EMOTIONAL_EXHAUSTION", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("元氣");
|
||||
await prisma.client.emotionState.create({ data: { characterId: CHAR_A, sad: 90, calm: 10 } });
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
const results = await sampleMany(room, CHAR_A, clock.now(), 25);
|
||||
const leaveResults = results.filter((r) => r.kind === "LEAVE");
|
||||
expect(leaveResults.length).toBeGreaterThan(0);
|
||||
expect(leaveResults.every((r) => r.leaveReason === "EMOTIONAL_EXHAUSTION")).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("多輪抽樣下 TALK/ACTION/DELEGATE 都可能出現(元氣:conversationBaseline 與 expressiveness 皆高)", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("元氣");
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
const kinds = new Set((await sampleMany(room, CHAR_A, clock.now(), 35)).map((r) => r.kind));
|
||||
expect(kinds.has("TALK")).toBe(true);
|
||||
expect(kinds.has("ACTION")).toBe(true);
|
||||
expect(kinds.has("DELEGATE")).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("T32:睡眠債達升級門檻時,LEAVE 的 leaveReason 為 SLEEP_DEBT,優先於一般行程理由 SCHEDULE", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("元氣");
|
||||
// 同時符合一般行程忙碌動因,驗證睡眠債升級後會優先蓋過 SCHEDULE。
|
||||
await prisma.client.scheduleSlot.create({
|
||||
data: { characterId: CHAR_A, dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "BUSY" },
|
||||
});
|
||||
await prisma.client.sleepDebt.create({ data: { characterId: CHAR_A, accumulatedMinutes: 200 } });
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
const results = await sampleMany(room, CHAR_A, clock.now(), 25);
|
||||
const leaveResults = results.filter((r) => r.kind === "LEAVE");
|
||||
expect(leaveResults.length).toBeGreaterThan(0);
|
||||
expect(leaveResults.every((r) => r.leaveReason === "SLEEP_DEBT")).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("T32:睡眠債活躍(未達升級門檻)時,ACTION 權重提高,更容易抽到打瞌睡演出", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("三無"); // expressiveness 極低,平常幾乎不會抽到 ACTION
|
||||
await prisma.client.sleepDebt.create({ data: { characterId: CHAR_A, accumulatedMinutes: 90 } });
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
const kinds = (await sampleMany(room, CHAR_A, clock.now(), 40)).map((r) => r.kind);
|
||||
expect(kinds).toContain("ACTION");
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { RoomTurnKind } from "@kokorone/db";
|
||||
import type { EmotionState } from "@kokorone/shared";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { SleepDebtService, SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES, SLEEP_DEBT_ESCALATION_THRESHOLD_MINUTES } from "../schedule/sleep-debt.service.js";
|
||||
import { PersonalityResolutionService } from "../personality/personality-resolution.service.js";
|
||||
import { createSeededRandom, hashToSeed } from "../llm/seeded-random.js";
|
||||
import { isStalling } from "./stall-detection.js";
|
||||
import { STALL_CHECK_WINDOW } from "./constants.js";
|
||||
import type { Room } from "./room.service.js";
|
||||
|
||||
export type LeaveReason = "SCHEDULE" | "EMOTIONAL_EXHAUSTION" | "TOPIC_EXHAUSTED" | "SLEEP_DEBT";
|
||||
|
||||
export interface BehaviorSelectionResult {
|
||||
kind: RoomTurnKind;
|
||||
leaveReason?: LeaveReason; // 只有 kind === "LEAVE" 時才有值,對應四項離開動因之一
|
||||
}
|
||||
|
||||
const DELEGATE_BASELINE_WEIGHT = 0.15;
|
||||
const ACTION_WEIGHT_SCALE = 0.4;
|
||||
const TALK_WEIGHT_FLOOR = 0.2; // 即使 conversationBaseline 極低(如三無),TALK 仍保留最低權重不至於完全消失
|
||||
const HIGH_EMOTION_INTENSITY_THRESHOLD = 70; // EmotionState 各維度 0~100,達此值視為「情緒耗盡」等級
|
||||
const LEAVE_WEIGHT_WHEN_ELIGIBLE = 0.5;
|
||||
const LEAVE_WEIGHT_WHEN_SLEEP_DEBT_ESCALATED = 0.75; // T32:撐不住想睡,比一般行程理由更強烈
|
||||
const SLEEP_DEBT_ACTION_WEIGHT_BONUS = 0.3; // T32:打瞌睡演出——睡眠債活躍時 ACTION 權重額外加成
|
||||
|
||||
function emotionFieldFor(tag: string): keyof Omit<EmotionState, "characterId" | "updatedAt"> {
|
||||
return tag === "CALM" ? "calm" : (tag.toLowerCase() as "joy" | "sad" | "alert" | "shy" | "grumpy");
|
||||
}
|
||||
|
||||
function weightedSample(weights: Partial<Record<RoomTurnKind, number>>, seed: number): RoomTurnKind {
|
||||
const entries = (Object.entries(weights) as [RoomTurnKind, number][]).filter(([, weight]) => weight > 0);
|
||||
const total = entries.reduce((sum, [, weight]) => sum + weight, 0);
|
||||
if (total <= 0) return "TALK"; // 保底:理論上不會發生(TALK 恆有 floor 權重),防禦性 fallback
|
||||
const roll = createSeededRandom(seed)() * total;
|
||||
let acc = 0;
|
||||
for (const [kind, weight] of entries) {
|
||||
acc += weight;
|
||||
if (roll < acc) return kind;
|
||||
}
|
||||
return entries[entries.length - 1][0];
|
||||
}
|
||||
|
||||
// T6/T32:由程式依 ARCHETYPE_PARAMS+情緒/行程/話題狀態/睡眠債算權重抽樣決定行為種類(TALK/
|
||||
// ACTION/DELEGATE/LEAVE),不交給 LLM 決定——LLM 只負責生成該行為的內容。LEAVE 只在動因成立時
|
||||
// 才會有非零權重、才可能被抽中:行程(忙碌/睡眠)、情緒(情緒耗盡)、話題耗盡(isStalling)、
|
||||
// 睡眠債(T32:熬夜累積的債務達門檻,升級為「撐不住想睡」,優先於一般行程理由)。
|
||||
@Injectable()
|
||||
export class BehaviorSelectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly emotion: EmotionService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly sleepDebt: SleepDebtService,
|
||||
private readonly personalityResolution: PersonalityResolutionService,
|
||||
) {}
|
||||
|
||||
async selectBehavior(room: Room, characterId: string, now: Date = new Date()): Promise<BehaviorSelectionResult> {
|
||||
await this.sleepDebt.reconcileToday(characterId, now);
|
||||
|
||||
// T16:inEpilogue 角色改用漂移後的 expressiveness(僅影響 ACTION 權重),非日後談角色維持原始
|
||||
// 設定;每次都各自查該角色自己的 Work,混房時不會互相污染。
|
||||
const archetypeParams = await this.personalityResolution.resolve(characterId);
|
||||
|
||||
const emotionState = await this.emotion.getState(characterId, now);
|
||||
const dominant = dominantState(emotionState);
|
||||
const intensity = emotionState[emotionFieldFor(dominant)] as number;
|
||||
|
||||
const scheduleInfo = await this.schedule.getInfo(characterId, now);
|
||||
const scheduleEligible = scheduleInfo.slot?.status === "BUSY" || scheduleInfo.slot?.status === "SLEEP";
|
||||
const emotionalExhaustion = (dominant === "SAD" || dominant === "GRUMPY") && intensity >= HIGH_EMOTION_INTENSITY_THRESHOLD;
|
||||
const topicExhausted = isStalling(room.turns.slice(-STALL_CHECK_WINDOW).map((turn) => turn.content));
|
||||
const debtMinutes = await this.sleepDebt.getDebtMinutes(characterId);
|
||||
const sleepDebtEscalated = debtMinutes >= SLEEP_DEBT_ESCALATION_THRESHOLD_MINUTES;
|
||||
|
||||
// T32:睡眠債達升級門檻時,優先於一般行程理由——「撐不住想睡」比「行程到了」更強烈、更明說。
|
||||
let leaveReason: LeaveReason | undefined;
|
||||
if (sleepDebtEscalated) leaveReason = "SLEEP_DEBT";
|
||||
else if (scheduleEligible) leaveReason = "SCHEDULE";
|
||||
else if (emotionalExhaustion) leaveReason = "EMOTIONAL_EXHAUSTION";
|
||||
else if (topicExhausted) leaveReason = "TOPIC_EXHAUSTED";
|
||||
|
||||
const sleepDebtActive = debtMinutes >= SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES;
|
||||
const weights: Partial<Record<RoomTurnKind, number>> = {
|
||||
TALK: TALK_WEIGHT_FLOOR + archetypeParams.conversationBaseline,
|
||||
ACTION: archetypeParams.expressiveness * ACTION_WEIGHT_SCALE + (sleepDebtActive ? SLEEP_DEBT_ACTION_WEIGHT_BONUS : 0),
|
||||
DELEGATE: DELEGATE_BASELINE_WEIGHT,
|
||||
LEAVE: leaveReason ? (sleepDebtEscalated ? LEAVE_WEIGHT_WHEN_SLEEP_DEBT_ESCALATED : LEAVE_WEIGHT_WHEN_ELIGIBLE) : 0,
|
||||
};
|
||||
|
||||
const seed = hashToSeed(`${room.id}:${room.turnCounter}:${characterId}:${now.getTime()}:behavior`);
|
||||
const kind = weightedSample(weights, seed);
|
||||
|
||||
return { kind, leaveReason: kind === "LEAVE" ? leaveReason : undefined };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { CharacterRelationshipService } from "./character-relationship.service.js";
|
||||
|
||||
// T33 回歸測試:加上 Redis 讀寫快取後,get/set 的既有語意(未設定回傳中性值 50、set 後讀到新值、
|
||||
// 更新後不會讀到舊值)必須維持不變——本環境沒有可用的 Redis(同 EmotionService 既有的快取層一樣,
|
||||
// 未連線時一律安全退回直接讀寫 Prisma,見 character-relationship.service.ts 的 try/catch),
|
||||
// 因此這裡實際測到的是「Redis 不可用」的退回路徑,與快取命中路徑的正確性需在有 Redis 的環境另行確認。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const service = new CharacterRelationshipService(prisma);
|
||||
|
||||
const CHAR_A = "test-charrel-a";
|
||||
const CHAR_B = "test-charrel-b";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.characterRelationship.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B] } } });
|
||||
});
|
||||
|
||||
async function createCharacter(id: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("CharacterRelationshipService(T33 快取回歸)", () => {
|
||||
it("未設定時回傳中性值 50;set 後讀到新值;再次 set 更新後讀到最新值(不是舊快取)", async () => {
|
||||
await createCharacter(CHAR_A);
|
||||
await createCharacter(CHAR_B);
|
||||
|
||||
expect(await service.getAffinity(CHAR_A, CHAR_B)).toBe(50);
|
||||
|
||||
await service.set(CHAR_A, CHAR_B, 80, "喜歡");
|
||||
expect(await service.getAffinity(CHAR_A, CHAR_B)).toBe(80);
|
||||
|
||||
await service.set(CHAR_A, CHAR_B, 20, "吵架");
|
||||
expect(await service.getAffinity(CHAR_A, CHAR_B)).toBe(20);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { getRedisClient } from "../cache/redis-client.js";
|
||||
|
||||
const REDIS_KEY_PREFIX = "kokorone:character-affinity:";
|
||||
// T28:同房互動的關係值小幅累積——初次同房從中性 50 開始,之後每次固化(房間結束)都朝更熟悉一點
|
||||
// 位移;夠小的固定值即可,跨作品/同作品一視同仁(跨作品混房也照常累積,不特別區分)。
|
||||
const INTERACTION_AFFINITY_DELTA = 2;
|
||||
|
||||
function cacheKey(characterId: string, otherCharacterId: string): string {
|
||||
return `${REDIS_KEY_PREFIX}${characterId}:${otherCharacterId}`;
|
||||
}
|
||||
|
||||
// K-2/K-4 角色間關係:同作品角色互損、拆台、護短、吃醋等化學反應的資料來源。
|
||||
// 有向邊:characterId 對 otherCharacterId 的觀感,兩邊可以各自設定、不必對稱。
|
||||
// T33:多人房每輪每位候選都要查一次 affinity,改走 Redis 快取當熱路徑——Prisma/SQLite 仍是唯一
|
||||
// 持久真實來源,Redis 不可用或未命中都直接退回原本讀 Prisma 的行為(同 EmotionService 既有慣例);
|
||||
// set() 寫入時一併使該筆快取失效,不會有「改了關係但快取還是舊值」的問題。
|
||||
@Injectable()
|
||||
export class CharacterRelationshipService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async set(characterId: string, otherCharacterId: string, affinity: number, dynamic?: string) {
|
||||
const result = await this.prisma.client.characterRelationship.upsert({
|
||||
where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
|
||||
update: { affinity, dynamic },
|
||||
create: { characterId, otherCharacterId, affinity, dynamic },
|
||||
});
|
||||
try {
|
||||
await getRedisClient().del(cacheKey(characterId, otherCharacterId));
|
||||
} catch {
|
||||
// Redis 不可用:沒有快取可失效,下一次 getAffinity 本來就會直接讀 Prisma。
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 未設定關係時,回傳中性值(50)——同作品角色預設不特別親近也不特別有心結。
|
||||
async getAffinity(characterId: string, otherCharacterId: string): Promise<number> {
|
||||
const key = cacheKey(characterId, otherCharacterId);
|
||||
try {
|
||||
const cached = await getRedisClient().get(key);
|
||||
if (cached !== null) return Number(cached);
|
||||
} catch {
|
||||
// Redis 不可用:退回直接讀 Prisma。
|
||||
}
|
||||
|
||||
const row = await this.prisma.client.characterRelationship.findUnique({
|
||||
where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
|
||||
});
|
||||
const affinity = row?.affinity ?? 50;
|
||||
try {
|
||||
await getRedisClient().set(key, String(affinity));
|
||||
} catch {
|
||||
// Redis 不可用:純粹跳過快取寫入,不影響回傳值。
|
||||
}
|
||||
return affinity;
|
||||
}
|
||||
|
||||
// T28:同房互動後的關係值累積——初次同房(尚無既有關係列)視為中性 50 起算,之後每次固化都朝
|
||||
// 更熟悉一點小幅位移,夾在 0~100 之間。呼叫端(RoomConsolidationService)於房間關房固化時,
|
||||
// 對每一對「同一場都有真的發言」的角色雙向各呼叫一次。
|
||||
async recordInteraction(characterId: string, otherCharacterId: string): Promise<void> {
|
||||
const current = await this.getAffinity(characterId, otherCharacterId);
|
||||
const next = Math.max(0, Math.min(100, current + INTERACTION_AFFINITY_DELTA));
|
||||
await this.set(characterId, otherCharacterId, next);
|
||||
}
|
||||
|
||||
// T35 目擊者反應:旁觀角色對「使用者與另一位角色曖昧互動」的反應,同時調整關係值並留下事件紀錄
|
||||
// (CharacterRelationshipEventLog),供未來人格對話/關係史回顧引用這段「吃味/替對方開心」的事件。
|
||||
async recordWitnessReaction(witnessId: string, targetId: string, delta: number, description: string): Promise<void> {
|
||||
const current = await this.getAffinity(witnessId, targetId);
|
||||
const next = Math.max(0, Math.min(100, current + delta));
|
||||
await this.set(witnessId, targetId, next);
|
||||
await this.prisma.client.characterRelationshipEventLog.create({
|
||||
data: { characterId: witnessId, otherCharacterId: targetId, delta, description },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { EmotionTag } from "@kokorone/shared";
|
||||
|
||||
// K-4 發言權分配:各因素的權重與門檻。
|
||||
export const SPEAKING_THRESHOLD = 0.25;
|
||||
export const NAMED_BOOST = 0.6; // 被點名/被提問幾乎必回
|
||||
export const BASELINE_WEIGHT = 0.3;
|
||||
export const TOPIC_RELEVANCE_WEIGHT = 0.25;
|
||||
export const EMOTION_WEIGHT = 0.15;
|
||||
export const RELATIONSHIP_WEIGHT = 0.15;
|
||||
|
||||
// 剛發言者衝動下降;被冷落的角色衝動值隨連續沉默輪數累積(有上限)。
|
||||
export const SPEAK_COOLDOWN_PENALTY = 0.35;
|
||||
export const SILENT_STREAK_BONUS_PER_TURN = 0.05;
|
||||
export const SILENT_STREAK_BONUS_CAP = 0.3;
|
||||
|
||||
export const EMOTION_SPEAKING_MODIFIER: Record<EmotionTag, number> = {
|
||||
CALM: 0,
|
||||
JOY: 0.3, // 愉悅多話
|
||||
SAD: -0.3, // 低落沉默
|
||||
ALERT: 0.1, // 警戒時會出聲反應
|
||||
SHY: -0.1, // 害羞會退縮
|
||||
GRUMPY: -0.2, // 彆扭故意不接話
|
||||
};
|
||||
|
||||
// K-2 群聊矜持:在群聊中,實際傳入生成上下文的親密度會被下修,讓模板系統自然選到更收斂的回應。
|
||||
export const GROUP_RESERVE_INTIMACY_PENALTY = 35;
|
||||
// 傲嬌式反向表達在群聊中「人前更嘴硬」,額外多下修一些。
|
||||
export const TSUNDERE_EXTRA_GROUP_PENALTY = 15;
|
||||
|
||||
// K-6 角色自聊
|
||||
// T6:不再是「累積到此輪數就整室結束」的硬上限——結束改由每位角色各自的 LEAVE 判定驅動(見
|
||||
// BehaviorSelectionService),房間可連續推進超過此值。保留常數供未來「單次推進批次上限」使用
|
||||
// (T8 排程器一次 tick 最多處理幾輪),目前尚無呼叫端消費。
|
||||
export const SELF_CHAT_MAX_TURNS = 12;
|
||||
export const STALL_CHECK_WINDOW = 3; // 檢查最近幾輪是否開始空轉
|
||||
// T6:LEAVE 動因之一(話題耗盡)離場時沿用的台詞。
|
||||
export const SELF_CHAT_WRAP_UP_LINES = ["好啦,先聊到這裡吧,晚點再聊!", "時間也差不多了,先這樣吧~"];
|
||||
|
||||
// T37:房間自走推進的「應有間隔」。P1 階段先用固定值佔位;T22 已實作依氣氛/情緒/記憶/行為動態
|
||||
// 計算的 RoomIntervalService,本常數留給尚未接上動態間隔的呼叫端(T8 會把排程器改為消費
|
||||
// RoomIntervalService 的計算結果,屆時本常數即可淘汰)。
|
||||
export const ROOM_ADVANCE_INTERVAL_MS = 30_000;
|
||||
// T37 排程 tick 週期(node-cron 表達式):遠小於 ROOM_ADVANCE_INTERVAL_MS,確保到期後很快被掃到。
|
||||
export const ROOM_SCHEDULER_TICK_CRON = "*/10 * * * * *";
|
||||
|
||||
// T22 動態推進間隔的上下限初值(第 5 輪決定):熱度最高時貼下限、完全空轉時貼上限。
|
||||
export const ROOM_ADVANCE_INTERVAL_LOWER_MS = 10_000; // 10 秒
|
||||
export const ROOM_ADVANCE_INTERVAL_UPPER_MS = 5 * 60_000; // 5 分鐘
|
||||
|
||||
// T40 隱私邊界:口風不緊原型在群聊中把私密(1:1)記憶說出來算「說溜嘴」,對使用者的信任/親密度
|
||||
// 給予懲罰(負值),代表這段本該只屬於兩人的私密內容被說給旁人聽,關係打了折扣。
|
||||
export const SLIP_INTIMACY_PENALTY = -3;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ControllableClock } from "../clock/controllable-clock.js";
|
||||
import { EmotionService } from "../emotion/emotion.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { RomanceService } from "../romance/romance.service.js";
|
||||
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
|
||||
import { AgeDeterminationService } from "../romance/age-determination.service.js";
|
||||
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { GlobalPauseService } from "../llm/global-pause.service.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
import { RoomService, USER_SPEAKER_ID } from "./room.service.js";
|
||||
import { CharacterRelationshipService } from "./character-relationship.service.js";
|
||||
import { RoomGenerationService } from "./room-generation.service.js";
|
||||
import { RoomConsolidationService } from "./room-consolidation.service.js";
|
||||
import { buildCrossWorldNote, isCrossWork } from "./cross-world-note.js";
|
||||
|
||||
// T28 驗收:跨作品同房時生成上下文附上世界觀提示、關係值隨互動變化(初次同房中性起算)、
|
||||
// 記憶查得到該次相遇且標記來源為房間互動(非正典),矛盾偵測不會把它當成作品事實。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const clock = new ControllableClock("2026-08-19T05:00:00.000Z");
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const lobbyService = new LobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, lobbyService, roomEvents);
|
||||
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
|
||||
const relationshipService = new RelationshipService(prisma, new EventEmitter2());
|
||||
const retriever = new KeywordMemoryRetriever(prisma);
|
||||
const characterRelationship = new CharacterRelationshipService(prisma);
|
||||
const romanceService = new RomanceService(prisma, relationshipService, new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma)));
|
||||
const mockProvider = new MockProvider();
|
||||
const generation = new RoomGenerationService(prisma, emotionService, relationshipService, retriever, characterRelationship, romanceService, mockProvider, new GlobalPauseService());
|
||||
const consolidation = new RoomConsolidationService(prisma, roomService, new RuleBasedEmotionTagger(), characterRelationship);
|
||||
|
||||
const userId = "test-crossword-user";
|
||||
const WORK_A = "test-crossword-work-a";
|
||||
const WORK_B = "test-crossword-work-b";
|
||||
const CHAR_A = "test-crossword-char-a";
|
||||
const CHAR_B = "test-crossword-char-b";
|
||||
const CHAR_A2 = "test-crossword-char-a2"; // 與 CHAR_A 同作品,作對照組
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.episodicMemory.deleteMany({ where: { characterId: { in: [CHAR_A, CHAR_B, CHAR_A2] } } });
|
||||
await prisma.client.characterRelationship.deleteMany({ where: { characterId: { in: [CHAR_A, CHAR_B, CHAR_A2] } } });
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B, CHAR_A2] } } });
|
||||
await prisma.client.work.deleteMany({ where: { id: { in: [WORK_A, WORK_B] } } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
async function createCharacter(id: string, workId: string, formalName: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
workId,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("跨作品混房(T28)", () => {
|
||||
it("isCrossWork/buildCrossWorldNote:純函式的判定邏輯", () => {
|
||||
expect(isCrossWork("work-a", "work-b")).toBe(true);
|
||||
expect(isCrossWork("work-a", "work-a")).toBe(false);
|
||||
expect(isCrossWork(null, null)).toBe(false); // 皆為原創(無掛作品),不算跨作品
|
||||
expect(isCrossWork("work-a", null)).toBe(true);
|
||||
|
||||
const note = buildCrossWorldNote("小乙", "測試作品乙");
|
||||
expect(note).toContain("小乙");
|
||||
expect(note).toContain("測試作品乙");
|
||||
expect(note).toContain("不可以把TA的背景設定當成妳自己世界本來就知道的事");
|
||||
});
|
||||
|
||||
it("對來自不同作品的對象生成回覆時,實際生成流程正常運作(不拋錯、有內容);同作品對象亦同", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await prisma.client.work.create({ data: { id: WORK_A, title: "測試作品甲" } });
|
||||
await prisma.client.work.create({ data: { id: WORK_B, title: "測試作品乙" } });
|
||||
await createCharacter(CHAR_A, WORK_A, "小甲");
|
||||
await createCharacter(CHAR_B, WORK_B, "小乙");
|
||||
await createCharacter(CHAR_A2, WORK_A, "小甲二");
|
||||
|
||||
const room = await roomService.create(userId, [CHAR_A, CHAR_B, CHAR_A2], "GROUP");
|
||||
|
||||
// 小甲對小乙(不同作品)發言:內部會組出 crossWorldNote 並帶進 GenerationContext(見
|
||||
// RoomGenerationService,實際內容由上一個測試對純函式直接驗證),這裡確認整條路徑不受影響。
|
||||
const replyToB = await generation.generateReply(room, CHAR_A, CHAR_B, "小乙", "你也在這裡?", { now: clock.now() });
|
||||
expect(replyToB.text).toBeTruthy();
|
||||
|
||||
// 小甲對小甲二(同作品)發言:不會有 crossWorldNote,一樣正常生成。
|
||||
const replyToA2 = await generation.generateReply(room, CHAR_A, CHAR_A2, "小甲二", "欸你也在?", { now: clock.now() });
|
||||
expect(replyToA2.text).toBeTruthy();
|
||||
}, 20000);
|
||||
|
||||
it("初次同房從中性關係起算;房間固化後關係值累積;記憶標記為房間互動(非正典)", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await prisma.client.work.create({ data: { id: WORK_A, title: "測試作品甲" } });
|
||||
await prisma.client.work.create({ data: { id: WORK_B, title: "測試作品乙" } });
|
||||
await createCharacter(CHAR_A, WORK_A, "小甲");
|
||||
await createCharacter(CHAR_B, WORK_B, "小乙");
|
||||
|
||||
// 初次同房前:中性 50。
|
||||
expect(await characterRelationship.getAffinity(CHAR_A, CHAR_B)).toBe(50);
|
||||
|
||||
const room = await roomService.create(userId, [CHAR_A, CHAR_B], "GROUP");
|
||||
// 用高情緒強度+提及對方別名的內容,確保觸發既有固化規則(RoomConsolidationService 只留存
|
||||
// 高情緒或與自己有關的發言,平淡閒聊不特別留存——這是既有規則,本測試只是配合它)。
|
||||
await roomService.addTurn(room.id, CHAR_A, "小甲", "小乙!第一次遇到你,好開心!!!", clock.now());
|
||||
await roomService.addTurn(room.id, CHAR_B, "小乙", "小甲你也太誇張了吧哈哈哈!!!", clock.now());
|
||||
|
||||
await consolidation.consolidate(room.id);
|
||||
|
||||
// 固化後:關係值應從中性起算後累積(變高),雙向皆有變化。
|
||||
const affinityAB = await characterRelationship.getAffinity(CHAR_A, CHAR_B);
|
||||
const affinityBA = await characterRelationship.getAffinity(CHAR_B, CHAR_A);
|
||||
expect(affinityAB).toBeGreaterThan(50);
|
||||
expect(affinityBA).toBeGreaterThan(50);
|
||||
|
||||
// 記憶查得到該次相遇,來源標記為房間互動(非正典)。
|
||||
const memoriesA = await prisma.client.episodicMemory.findMany({ where: { characterId: CHAR_A } });
|
||||
expect(memoriesA.length).toBeGreaterThan(0);
|
||||
expect(memoriesA.every((m) => m.source === "INTERACTION")).toBe(true);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// T28:不同作品角色相遇時的世界觀提示——不是自己原本世界就認識的存在,禁止把對方設定當成自身
|
||||
// 世界既有知識,只能透過房內實際發生過的互動認識對方。純函式,方便直接單元測試。
|
||||
export function buildCrossWorldNote(speakerLabel: string, speakerWorkTitle: string | null): string {
|
||||
return `${speakerLabel}來自不同的作品世界(${speakerWorkTitle ?? "未知的世界"}),不是妳原本故事裡就認識的人。妳對TA的認識僅限於這個房間裡實際發生過的對話,不可以把TA的背景設定當成妳自己世界本來就知道的事,請以「第一次遇到不同世界的人」的態度應對。`;
|
||||
}
|
||||
|
||||
// 兩個角色的 workId 是否視為「不同作品」——皆為 null(都沒有掛作品)不算不同,只有明確不同的
|
||||
// 已知 workId 才算跨作品。
|
||||
export function isCrossWork(workIdA: string | null, workIdB: string | null): boolean {
|
||||
if (workIdA === null && workIdB === null) return false;
|
||||
return workIdA !== workIdB;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user