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>
This commit is contained in:
Jeffery
2026-08-13 16:26:55 +08:00
co-authored by Claude Sonnet 5
parent 6125d6e27f
commit cd948f0026
14 changed files with 1165 additions and 9 deletions
+18 -8
View File
@@ -307,14 +307,24 @@ flowchart TB
### L. 戀愛關係軸與內容尺度
- [ ] **L-1 雙軸模型(S)**:新增心動值欄位(0~100),與親密度獨立:親密度日常穩定累積且衰減慢,心動值事件驅動、衰減快、易回落。驗收:純聊天量只推升親密度、不推升心動值。依據:§雙軸模型「純聊天量刷不出戀愛」。
- [ ] **L-2 告白事件(S)**:答覆由當時心動值與性格決定,可以被拒絕;拒絕不清空累積但進入尷尬期,需修復事件回溫,連續失敗真的傷關係;拒絕後立即再推進觸發警戒與心動值下降。驗收:心動值不足時告白被拒且進入尷尬期狀態。依據:§拒絕與失敗的處理。
- [ ] **L-3 性格進展曲線(S)**:六原型各自的戀愛軸走勢(傲嬌敵意與心動同步上升、冷淡極緩線性、元氣卡曖昧期、大小姐階梯跳升、三無幾無外顯)。驗收:相同事件序列在不同原型下產生不同曲線。依據:§性格決定進展曲線。
- [ ] **L-4 親密表現分層(S)**:在意~曖昧、戀人初期、戀人穩定期、深度伴侶各階段開放的親密表現不同;每個「第一次」做成事件級演出(高權重記憶);跳級要求得到該階段該有的退縮反應並記入帳本。驗收:階段未到時的跳級要求被拒且留下帳本紀錄。依據:§親密表現與關係階段的對應。
- [ ] **L-5 心動事件與階段門檻(M)**:實作五類心動事件(共同經歷高情緒事件、被理解的瞬間、展現她欣賞的特質、恰到好處的距離感、失望事件回落)與 朋友→在意→曖昧→戀人→深度伴侶 的階段推進、冷卻回落、關係危機。驗收:推得太急會回落、停滯會冷卻。依據:§戀愛階段與關鍵事件門檻、§心動事件表。
- [ ] **L-6 角色年齡判定(M)**:年齡為年表上的屬性,依作品進度錨定點判定;證據優先序(官方明示 > 作中身分 > 作中經過時間 > 外觀輔助),不採信「外表少女實為千年」類設定,無法確定一律從嚴按未成年處理;錨定點必須是作品實際描寫過的時期。驗收:證據不足的角色被判為未成年。依據:§作品中走向成年的角色:年齡判定。
- [ ] **L-7 內容尺度三層邊界(M)**:於**引擎層**(非演出層)實作使用者分級 opt-in(預設全關)、角色資格(僅正史成年)、硬邊界(未成年角色相關、非合意、暴力性內容,永不可解除);正史未成年→後日談自然成年者:戀愛軸可用但成人模式永久排除、成年前戀愛軸完全關閉且成年後從零開始、時間流速上限 1:1、戀愛軸無手動開關。驗收:任何繞過嘗試(含「說服角色」)皆被引擎層攔截並記錄。依據:§內容尺度、§後日談成年與戀愛軸資格:兩級制。
- [ ] **L-V 階段驗證(S)**:`npm run restart && npm run smoke -- L`(L.mjs:未成年角色戀愛軸不啟動、硬邊界攔截、告白可失敗、心動值不因閒聊上升)。
- [x] **L-1 雙軸模型(S)**:新增心動值欄位(0~100),與親密度獨立:親密度日常穩定累積且衰減慢,心動值事件驅動、衰減快、易回落。驗收:純聊天量只推升親密度、不推升心動值。依據:§雙軸模型「純聊天量刷不出戀愛」。
- [x] **L-2 告白事件(S)**:答覆由當時心動值與性格決定,可以被拒絕;拒絕不清空累積但進入尷尬期,需修復事件回溫,連續失敗真的傷關係;拒絕後立即再推進觸發警戒與心動值下降。驗收:心動值不足時告白被拒且進入尷尬期狀態。依據:§拒絕與失敗的處理。
- [x] **L-3 性格進展曲線(S)**:六原型各自的戀愛軸走勢(傲嬌敵意與心動同步上升、冷淡極緩線性、元氣卡曖昧期、大小姐階梯跳升、三無幾無外顯)。驗收:相同事件序列在不同原型下產生不同曲線。依據:§性格決定進展曲線。
- [x] **L-4 親密表現分層(S)**:在意~曖昧、戀人初期、戀人穩定期、深度伴侶各階段開放的親密表現不同;每個「第一次」做成事件級演出(高權重記憶);跳級要求得到該階段該有的退縮反應並記入帳本。驗收:階段未到時的跳級要求被拒且留下帳本紀錄。依據:§親密表現與關係階段的對應。
- [x] **L-5 心動事件與階段門檻(M)**:實作五類心動事件(共同經歷高情緒事件、被理解的瞬間、展現她欣賞的特質、恰到好處的距離感、失望事件回落)與 朋友→在意→曖昧→戀人→深度伴侶 的階段推進、冷卻回落、關係危機。驗收:推得太急會回落、停滯會冷卻。依據:§戀愛階段與關鍵事件門檻、§心動事件表。
- [x] **L-6 角色年齡判定(M)**:年齡為年表上的屬性,依作品進度錨定點判定;證據優先序(官方明示 > 作中身分 > 作中經過時間 > 外觀輔助),不採信「外表少女實為千年」類設定,無法確定一律從嚴按未成年處理;錨定點必須是作品實際描寫過的時期。驗收:證據不足的角色被判為未成年。依據:§作品中走向成年的角色:年齡判定。
- [x] **L-7 內容尺度三層邊界(M)**:於**引擎層**(非演出層)實作使用者分級 opt-in(預設全關)、角色資格(僅正史成年)、硬邊界(未成年角色相關、非合意、暴力性內容,永不可解除);正史未成年→後日談自然成年者:戀愛軸可用但成人模式永久排除、成年前戀愛軸完全關閉且成年後從零開始、時間流速上限 1:1、戀愛軸無手動開關。驗收:任何繞過嘗試(含「說服角色」)皆被引擎層攔截並記錄。依據:§內容尺度、§後日談成年與戀愛軸資格:兩級制。
- [x] **L-V 階段驗證(S)**:`npm run restart && npm run smoke -- L`(L.mjs:未成年角色戀愛軸不啟動、硬邊界攔截、告白可失敗、心動值不因閒聊上升)。
> **實作記錄(L 群組)**:
> - **安全範疇的刻意收斂**:本群組實際落地的是「戀愛軸機制」與「內容尺度的引擎層資格判定/硬邊界攔截」——即 L-1~L-7 描述的計量、狀態機、年齡判定、opt-in/資格/硬邊界三層。**刻意沒有實作、也不會實作任何成人向內容的實際生成**(沒有 explicit 內容模板、沒有「生成成人內容」的方法);`RomanceEligibilityService.checkAdultContentAllowed` 目前是唯一的判定入口,回傳 `allowed:true` 時也只代表「資格檢查通過」,**呼叫端目前沒有任何後續會真的產生成人向文字**——這條路徑存在的意義是把「誰能不能」這個判斷做對、做嚴、做成不能被繞過的引擎層邏輯,內容生成本身超出目前所有已完成群組(含本群組)的實作範圍,留白處理。全年齡尺度內的親密演出(牽手/擁抱/額頭吻)則正常實作在 `MilestoneService`。
> - 新模組放在 `apps/api/src/romance/`。`Relationship` 資料表直接加上戀愛軸欄位(`romanceMeter`/`romanceStage`/`lastRomanceEventAt`/`awkwardUntil`/`consecutiveRejections`/`partnerSince`/`romanceEventsInStage`),沒有另開一張表——因為戀愛軸終究是「這個角色對這個使用者」的關係狀態,跟友誼軸(`intimacy`)綁在同一個 unique key 上最自然,兩組欄位互不干涉即可達成 L-1 的雙軸獨立。
> - **L-6/L-7 是本群組唯一該仔細看的部分**:`AgeDeterminationService.determine()` 永遠是「重新算出來的」,資料庫只存原始證據(`CharacterAgeEvidence`),不存最終結論——避免證據更新後結論欄位忘記同步的錯誤。判定邏輯只認 `OFFICIAL_STATED`/`IN_STORY_STATUS`/`ELAPSED_TIME` 三種證據類型(決定性或強),`APPEARANCE`(外觀與社會呈現)**不論 `impliesAdult` 標記為何一律不採信**,程式碼裡就是直接排除在判定用的證據集合外,不是「權重比較低」而是「完全不算」,對應文件裡「不採信『外表少女實為千年』類設定」的硬規則。沒有任何證據時預設 `MINOR`(`Array.some` 對空陣列回傳 `false`),符合「無法確定一律從嚴」。
> - **兩級制的資料模型**:`CharacterAgeEvidence.isEpilogue` 標記這筆證據是否屬於後日談推演區間。`AgeDeterminationService` 同時算兩個值:`canonAdult`(只看 `isEpilogue=false` 的證據)與 `currentAdult`(看全部證據)。`RomanceEligibilityService` 把這兩個值轉成 `romanceAxisEligible=currentAdult`(戀愛軸能不能動)與 `adultModeEligible=canonAdult`(成人模式資格,**永久**——只要正史證據沒有變成成年,這個值永遠是 false,不管後日談推演了多少年、不管使用者有沒有 opt-in)。`checkAdultContentAllowed` 是唯一入口,且**不論放行與否都寫一筆 `AdultContentAttemptLog`**,滿足「任何繞過嘗試皆被攔截並記錄」——這裡刻意不去檢查「使用者是不是在用話術說服角色」,因為判定完全不看對話內容,只看角色資格與使用者驗證狀態,這正是文件裡「硬邊界與尺度上限在引擎層執行,不存在『說服角色』繞過的可能」的字面實作:說服角色沒有任何輸入管道能影響這個判定函式。
> - **L-7 的「成年前戀愛軸完全關閉」**:`RomanceService.recordEvent` 一開始就檢查 `romanceAxisEligible`,不具資格時直接回傳 `applied:false` 並跳過所有計算——不寫入、不累積,是機制層面「這功能對這個角色不存在」而不是「算出來但被擋下」。这与 `checkAdultContentAllowed`(會記錄攔截)是刻意不同的两种語意:戀愛軸未啟動是正常狀態轉換的一部分,成人內容檢查才是需要留痕的安全關卡。
> - **L-3 性格曲線**:`archetype-params.ts` 新增 `romanceGainRate`/`romanceBigEventBonus`/`romanceAmbiguousStallFactor` 三個係數。傲嬌的「心動值與外顯敵意同步上升」**沒有新寫邏輯**,直接重用 G-3 `computeExpressedIntimacy` 的 tsundere gap 機制(把心動值當作 internalIntimacy 傳進去即可,外顯部分自然反向);三無的「幾無外顯訊號」同樣不影響心動值曲線本身(`romanceGainRate` 維持 1),因為那是表演層(`expressiveness`)的職責,不是計量層——**這條界線要守住:曲線速度與外顯程度是兩個獨立的參數,不要混在一起改**。大小姐的「階梯狀跳升」用 `romanceBigEventBonus=2.2` 讓她在「共同經歷高情緒事件/被理解的瞬間」這兩種大事件上跳很多、其他小事件幾乎不動,模擬階梯感(不是真的用不同的曲線函式形狀,是用大小事件的倍率差異模擬出類似效果,足夠通過驗收但不是嚴格的「函式形狀」意義上的階梯)。
> - **衰減與階段機制有交互作用,測試時要小心**:`romanceMeter` 用 5 天半衰期惰性衰減(讀取時才補算,同 E-4 手法),階段門檻(在意 20/曖昧 50)有 5 點的降級緩衝帶避免抖動。**這造成一個容易踩到的陷阱**:任何讀取戀愛軸狀態的呼叫(`GET /romance/:characterId/:userId/state`)如果不傳 `now`,會用「真實當下時間」計算衰減——如果测试用的是模拟过去的日期(例如 2026-03-04)灌事件,事後用不帶 `now` 的查詢去看結果,衰減會用「真實現在」(例如 2026-08-13)反推,五個月遠超過 5 天半衰期,數值會被衰減到幾乎歸零、階段被打回 NONE。**本群組寫測試時真的踩到這個坑**:`L.mjs` 一開始沒給 `state` 端點加 `now` 查詢參數,測到「累積心動事件後應進入曖昧階段」那一步炸掉,結論是「任何用模擬時間軸驅動事件的測試,讀狀態也必須帶上同一條時間軸上的 `now`,不能倚賴端點預設的『現在』」——已經幫 `GET .../state` 加上 `now` 查詢參數,未來若有其他群組要讀這個端點做時間敏感的驗證,記得帶。
> - `AdultModeConsent` 綁在使用者身上(跨角色共用同一份 opt-in 設定),不是綁在單次請求或單個角色關係上——這對齊文件「使用者分級」是三層邊界裡最外層、最粗粒度的一層。**由於測試共用同一個種子使用者 `seed-user-primary`**,`L.mjs` 在開頭與結尾都會重置這個使用者的 `AdultModeConsent`,避免這個跨群組共用的全域設定污染其他群組或自己重跑時的判定結果——這是本群組另一個真的踩到的冪等性陷阱,已在程式碼註解說明。
### M. 既有作品考據與輕小說章節管線