Author SHA1 Message Date
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00
jiantw83 127a59dafd docs(操作手冊): 新增心音操作手冊,涵蓋安裝、日常操作、API、冒煙測試與疑難排解 2026-08-18 11:15:29 +08:00
admin a45a5f1fc3 刪除 todo.md 2026-08-14 04:56:12 +00:00
Jeffery 5732925f64 docs(README): 補上 pnpm/turbo 開發流程、環境變數表與正式環境部署路徑說明 2026-08-14 12:44:59 +08:00
Jeffery 021284b852 test(冒煙測試): 更新 F/J 群組冒煙測試以涵蓋 llm/記憶快取新行為 2026-08-14 12:44:54 +08:00
Jeffery 30719a2960 feat(llm 對話): 新增 prompt builder 與 few-shot 範例組裝,強化 Claude 對話品質 2026-08-14 12:44:48 +08:00
Jeffery 6ce7b2e800 feat(記憶與情緒快取): 記憶與情緒狀態加入 Redis 快取層,連不到時自動退回原行為 2026-08-14 12:44:41 +08:00
Jeffery 080ac390d3 chore(monorepo 建置工具): 由 npm workspaces 遷移至 pnpm workspace + Turborepo 2026-08-14 12:44:36 +08:00
JefferyandClaude Sonnet 5 5da8b3a63e feat: 完成 Q 群組 — APP(React Native + Expo)
新增 apps/mobile:Expo Router 檔案系統路由(同 Next.js App Router 的
心智模型),底部四分頁(聊天/角色/日常/設定)皆為真實 API 串接(/chat、
/tachie、/characters、/schedule、/health、/push)。聊天分頁完整重用
H/O 群組已驗證過的對話+表情查詢節奏。立繪暫以依情緒 magnitude 調整
透明度的色塊佔位(PixiJS 是瀏覽器 DOM/Canvas API,未移植到 React
Native,需要 react-native-skia 等原生繪圖套件才能重現分層合成,超出
本群組範圍)。

新增 apps/api/src/push/:PushProvider 抽象(第三次套用 F-1/P-4 的
Provider 模式),MockPushProvider 供測試、ExpoPushProvider 是可直接
打真實 HTTP 請求的完整實作(Expo 推播匿名發送不需金鑰,不像 LLM/TTS
需要另外挑供應商待人工確認)。PushToken/PushNotificationLog 記錄裝置
註冊與每次推播嘗試。J 群組 TaskTriggerService 觸發時會嘗試推播給
使用者已註冊裝置,用觸發訊息本身當推播內容。

本環境沒有 Android/iOS 模擬器,改用 `expo start --web` + playwright-core
(同 O 群組手法)驅動無頭瀏覽器驗證四分頁切換、真實 API 串接、聊天
完整往返、推播按鈕在無裝置環境下的優雅降級——不是原生 APP 的完整驗證,
但驗證了畫面邏輯與 API 契約層。過程中發現並修正 CORS 設定只放行網頁版
origin、擋掉 Expo 網頁版請求的問題(永久修正:新增 localhost:8081 白名單,
不影響原生 APP,原生環境沒有 CORS 限制)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:19:20 +08:00
JefferyandClaude Sonnet 5 0e9401b891 feat: 完成 P 群組 — 語音子系統
新增 apps/api/src/voice/:Voice Sheet(基礎音色/預設語速/音域幅度/口頭
聲響庫/禁則,依性格原型推導預設值,1:1 掛在 Character,同 EmotionState
的關聯模式);情緒→韻律對照(六狀態語速/音高/音量/句尾走向),音高變化
依角色音域幅度縮放(三無角色近乎單音),設計原則同 O-4 表情外顯度縮放;
非語言發聲與節奏(依情緒插入聲響、重大情緒延長停頓、禁則過濾候選、
低親密度不打斷、talkative 特徵+親密度門檻才可插話);TTSProvider 抽象
完全比照 F-1 LLMProvider(MockTTSProvider 輸出韻律標記+佔位音檔,
RealTTSProvider 待人工確認供應商後再接,TTS_PROVIDER=mock|real 切換);
語音輸入副語言分析(EmotionService 新增 paralinguisticSignal,優先序
排在作息基線之前,讓「文字說沒事但聲音在抖」判為負向)。

修正一個真實 bug:非語言發聲的停頓時長原本直接拿目前主導情緒的數值
當強度訊號,但平靜狀態下 calm 預設就是 100,導致「什麼事都沒發生」被
誤判成「強度最強的重大情緒事件」而觸發不該有的長停頓——修正為 CALM
一律視為強度 0(平靜是情緒事件的缺席,不是訊號)。

刻意簡化:副語言特徵(語速/音量/顫抖/停頓)採結構化輸入,不做真實音訊
分析,比照 M/N 群組「先用結構化輸入代替真實訊號處理」的一貫取捨。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 18:19:51 +08:00
JefferyandClaude Sonnet 5 0f3d16059c feat: 完成 O 群組 — 立繪子系統
後端(apps/api/src/tachie/):立繪 manifest(身體姿勢/服裝/表情眉眼口/
效果層/微動態的資產目錄,服裝目錄有 M-1 登錄時優先採用);表情解析依
情緒引擎六狀態對照眉/眼/口/效果/姿勢,依 RelationshipStage 分層解鎖
(陌生~認識/朋友/摯友/羈絆,哭臉限羈絆才會出現),依角色外顯度縮放
差分幅度(magnitude = expressiveness × 情緒強度);emotionTag(實際
套用)與 rawEmotionTag(情緒引擎真實判定)分離,讓文字生成與表情解析
維持兩條獨立資料流,「文字與立繪故意不一致」是這個設計的自然結果,
不需要額外邏輯;視線方向依思考/迴避/警戒切換。

前端(apps/web/components/TachieStage.tsx):以 PixiJS 實作分層合成
與眨眼/呼吸微動態,預留 TachieRenderer 介面供未來 Live2D Cubism SDK
實作(未引入任何 Live2D 相依或授權,純架構佔位);接上 ChatView 取代
H 群組的佔位立繪,聊天回應後先查詢表情、延遲約 220ms 才顯示文字,
在非串流架構下盡力模擬「表情先變化、文字後出現」。

以 scratchpad 暫裝 playwright-core 實測瀏覽器行為(本環境無
chromium-cli),確認情緒變化即時反映在立繪、微動態持續運作。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:46:50 +08:00
JefferyandClaude Sonnet 5 eccdcba45c feat: 完成 N 群組 — 後日談模式
錨定點抵達已出版內容盡頭時切入後日談區間;時間流速(即時同步/緩速/凍結,
另加一個非文件既定的 FAST 選項供一般角色使用)對後日談推演成年角色強制
上限 1:1,重用 L 群組 RomanceEligibilityService 的 isEpilogueAdult 判斷。
性格漂移僅在正史已有 >=2 筆 PersonalityTraitSnapshot 建立趨勢時才位移,
無證據則鎖定 0(不憑空轉向),原型字串本身從不被觸碰。人生階段推演與
角色群同步成長重用 M 群組的 Scene/ProjectionService(推演事件其實就是
sourceType=EPILOGUE 的場景,在場者投影後自然敘述互相呼應);重大轉折
需要正史目標關鍵字對應才生成,並以 EpilogueMilestoneLog 節流頻率。
正史回收(移除被取代的推演場景、匯入官方新場景並強制投影、互動記憶
結構性保留、記憶修正事件敘事化消化)與分支保留(永久標記、擋下後續
回收)。

修正兩個真實 bug:reclaim 原本借用 importScene 的錨定閥門,導致回收
回來的正史內容反而不被投影(後日談的『現在』本來就走在錨定點之前);
分支保留擋下重新匯入時用裸 Error 導致 500,改為 ConflictException。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:18:53 +08:00
JefferyandClaude Sonnet 5 c66799ee7c feat: 完成 M 群組 — 既有作品考據與輕小說章節管線
共用場景資料庫(Scene/ScenePresence/SceneLine/SceneFact/SceneInteraction,
角色中立客觀記錄)與投影(ProjectionService 寫入 EpisodicMemory/
SemanticMemory/PersonalityTraitSnapshot/CharacterRelationshipEventLog,
逐一可追溯到來源場景)完全分層。歸屬信心分數(明示標記決定性、語言指紋
從已歸屬語料統計、場景名冊硬約束、對話輪替)與隔離區/指紋冷啟動(低信心
或樣本不足一律隔離,指紋精煉後可重新嘗試釋放)。多角色投影與修正(改一處
場景資料庫、reprojectScene 同步更新所有受影響角色,使用者互動記憶因為
從不帶 sourceSceneId 而結構性地不受影響,發現得晚的歸屬錯誤額外記一筆
CORRECTION 事件)。作品名冊建置門檻(達標自動建置,門檻檢查排在投影完成
之後才做,避免用到投影前的舊統計)。錨定點知識邊界(雙向:前進載入、
後退遺忘)與矛盾偵測(同優先序來源待人工確認,跨優先序可依小說原文>
官方設定集>動畫改編自動裁決)。插圖索引與衍生服裝目錄/姿勢語彙表。

刻意簡化:章節→場景切分與互動正負權重皆採結構化輸入(不做自動 NLP
場景邊界偵測與情感分析),比照 F-7 MockProvider 的做法——機制先做對,
之後有真實 NLP/LLM 能力再替換輸入來源。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:58:04 +08:00
JefferyandClaude Sonnet 5 cd948f0026 feat: 完成 L 群組 — 戀愛關係軸與內容尺度
戀愛軸雙軸模型(Relationship 新增心動值/戀愛階段欄位,與親密度獨立、事件
驅動、衰減快)、告白事件狀態機(可拒絕、尷尬期、連續失敗傷關係、立即再
推進觸發警戒)、六原型戀愛曲線參數化(重用 G-3 tsundere gap 處理傲嬌)、
親密表現分層與里程碑(跳級拒絕+退縮反應+帳本、第一次做高權重記憶)、
心動事件與階段門檻(含推太急回落、關係危機與修復)。

角色年齡判定(apps/api/src/romance/age-determination.service.ts):僅
決定性/強證據可判定成年,外觀證據不可單獨採信,證據不足從嚴按未成年
處理,且不存最終結論、每次重新算。內容尺度三層邊界(引擎層):使用者
opt-in、角色資格(僅正史成年)、硬邊界(後日談推演成年者永久排除成人
模式);任何嘗試不論放行與否都記錄,判定完全不看對話內容,沒有「說服
角色」繞過的管道。

刻意收斂範圍:僅實作資格判定與硬邊界攔截機制,不實作任何成人向內容的
實際生成。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:26:55 +08:00
JefferyandClaude Sonnet 5 6125d6e27f feat: 完成 K 群組 — 對話模式:群聊與角色自聊
群聊/自聊子系統(apps/api/src/room/):在場者名冊與共用場景記錄(純記憶體,
同 C-1 工作記憶的設計)、發言權分配(點名/話題相關度/性格基線/情緒/角色間
關係/發言冷卻與冷落累積算衝動值)、群聊矜持(重用 G-3 親密度分層模板、
下修有效親密度而非另建模板庫)、隱私邊界(EpisodicMemory.isPrivate +
KeywordMemoryRetriever.excludePrivate,天然呆為口風不緊例外)、群聊記憶
投影(各角色依自身視角固化,被虧的那位權重較高)、角色自聊(沿用發言權
機制、空轉偵測注入轉折或收尾、輪數硬上限、使用者插話切換群聊)。
新增 CharacterRelationship 表供角色間關係使用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:15:00 +08:00
JefferyandClaude Sonnet 5 3503cc0be6 feat: 完成 J 群組 — 委託子系統
Task 資料表與自然語句解析(單次提醒)、依原型的接案/觸發/逾期追擊演出、
委託與關係帳本/情緒的回饋迴路(含默契旗標)、主動關照(待辦偵測+作息交叉比對,
稀有度節流不重複台詞)、排程器可靠性(服務重啟後重新掃描 PENDING 任務掛回
JobQueue,逾期立即補觸發)。J.mjs 以 spawnSync 實際重啟服務驗證 J-7。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:35:16 +08:00
Jeffery d82b834e47 feat: 完成 I 群組 — 生活作息與離線生活
- ScheduleService:I-1 平日/週末/假期時段表(可為角色寫入高中生預設作息)、
  I-3 時間感知(Asia/Taipei 當地時間,不受伺服器時區影響)、剛醒/睡前偵測
- constants.ts:I-2 時段狀態 → 回應可用度對照(FREE/SEMI_BUSY/BUSY/SLEEP →
  NORMAL/BRIEF/DELAYED/NO_REPLY),沒有作息表的角色一律視為 NORMAL(相容既有角色)
- ScheduleExceptionService:I-4 三種破例(深夜還陪你聊/上課偷回訊息/為你調整行程),
  各自獨立冷卻期,觸發時記入關係帳本
- ScheduleEmotionBaselineService:I-5 作息驅動情緒基線(社團剛結束的疲憊、假日偏高心情),
  只在本輪沒有明確情緒訊號時介入,串接進 EmotionService.processInput 的新增選項
- OfflineEventService:I-6 離線事件生成,依「今天已發生的固定行程」挑選對應模板,
  一天只生成一次,寫入低權重情節記憶
- DialogueService 整合以上機制:離線事件生成 → 情緒基線標記 → 累積親密度 →
  作息可用度判定 → 不符合則嘗試破例 → 依可用度決定回覆內容
- 修正真實 bug:G-5 角色建立流程誤用 source=OFFLINE_GENERATED 標記背景故事,
  與 I-6「今天是否已生成離線事件」的判斷共用同一標記,已改為 SOURCE_EXTRACTION
- ChatView.tsx 補上 NO_REPLY/context 為 null 的防呆(作息可用度整合後的必要調整)
- scripts/smoke/I.mjs:以獨立測試角色(避免污染種子角色的排程狀態)驗證全部六項

npm run restart && npm run smoke -- I 皆通過(I-V),A~H 群組冒煙測試無回歸。
2026-08-13 14:08:24 +08:00
Jeffery 9e5036f0a0 feat: 完成 H 群組 — 網頁對話介面(第一個可實際使用的垂直切片)
- POST /chat/:characterId:正式對外對話端點,DialogueService 補上情緒標記與親密度
  累積(每輪對話走完整管線:標記情緒→檢索記憶→組裝→生成→過濾)
- POST /session/:sessionId/end:觸發睡眠固化
- NestJS 啟用 CORS(WEB_ORIGIN,預設 http://localhost:3100),本清單第一次有瀏覽器直接呼叫 api
- apps/web/app/chat:對話頁面(ChatView 用戶端元件),電腦雙欄/行動單欄同一份 RWD 程式,
  立繪半身像置頂佔 40% 為行動版背景層
- 情緒晶片+親密度數字顯示,跟著每輪回應即時更新
- 動作描寫標記(*…*)以暮空紫小字獨立呈現,與台詞區分
- HeartbeatWave 元件:載入時由左至右畫出、親密度決定振幅、通知紅點心跳脈動,
  皆尊重 prefers-reduced-motion;首頁與對話頁共用同一元件
- 色票換成 wiki 主視覺提案頁的完整 token 組(含官方深色模式數值)
- scripts/smoke/H.mjs:端到端驗證對話 API 狀態變化、session 結束固化、頁面結構標記;
  另以 Playwright + headless Chromium 實際開瀏覽器驗證桌面/行動版面、送出訊息、
  動作標記渲染,過程無 console 錯誤

npm run restart && npm run smoke -- H 皆通過(H-V),A~G 群組冒煙測試無回歸。
2026-08-13 12:12:49 +08:00
289 changed files with 38709 additions and 10720 deletions
+11
View File
@@ -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
+1
View File
@@ -13,3 +13,4 @@ coverage/
*.tsbuildinfo
packages/db/generated/
.claude/scheduled_tasks.lock
+69 -1
View File
@@ -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 -4
View File
@@ -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"
}
}
+22
View File
@@ -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],
})
+26
View File
@@ -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;
}
+54
View File
@@ -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" },
});
}
}
+137
View File
@@ -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);
});
+159
View File
@@ -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 } });
}
}
+45
View File
@@ -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 {}
+23
View File
@@ -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,
};
+70
View File
@@ -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 },
});
}
}
+51
View File
@@ -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 } });
}
}
+140
View File
@@ -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);
});
+212
View File
@@ -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) };
}
}
+77
View File
@@ -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 },
});
}
}
+51
View File
@@ -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;
}
}
+9
View File
@@ -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 {}
+7
View File
@@ -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");
});
});
+23
View File
@@ -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();
}
}
+49 -1
View File
@@ -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 } });
}
}
+35
View File
@@ -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);
}
}
+18
View File
@@ -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 {}
+122
View File
@@ -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 },
+134 -9
View File
@@ -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(),
+35 -1
View File
@@ -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,
+88 -11
View File
@@ -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 };
}
}
+30
View File
@@ -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;
}
+38
View File
@@ -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} 次,觸發全域靜默暫停:所有房間與大廳自走生成停止,不對使用者顯示任何提示`,
);
}
}
}
+15
View File
@@ -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);
}
+17
View File
@@ -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");
+7 -3
View File
@@ -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);
});
});
+70 -3
View File
@@ -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 推論)" };
}
}
+63
View File
@@ -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;
}
+5
View File
@@ -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;
+10
View File
@@ -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 用量統計的來源,僅供觀察,不影響生成行為
}
+221
View File
@@ -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);
});
+317
View File
@@ -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}才有空,到時候再來找她吧。`;
}
}
+5
View File
@@ -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);
});
+168
View File
@@ -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,
};
}),
};
}
}
+34
View File
@@ -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);
}
}
+19
View File
@@ -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 {}
+3
View File
@@ -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,
}));
}
}
+7 -5
View File
@@ -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 };
}
+64 -23
View File
@@ -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 已經在上面清掉,沒有需要額外處理的狀態。
}
}
}
+37 -2
View File
@@ -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 };
}
}
+19
View File
@@ -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");
+20
View File
@@ -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 } });
}
}
+33
View File
@@ -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);
}
}
+33
View File
@@ -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);
}
}
+119
View File
@@ -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 };
}
}
+72
View File
@@ -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,
};
+102
View File
@@ -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: "角色具正史成年資格,使用者已完成成年驗證並主動開啟成人模式" };
}
}
+126
View File
@@ -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,
);
}
}
+18
View File
@@ -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 {}
+181
View File
@@ -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 };
}
}
+103
View File
@@ -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 },
});
}
}
+52
View File
@@ -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;
+140
View File
@@ -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);
});
+12
View File
@@ -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