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
@@ -0,0 +1,81 @@
-- CreateTable
CREATE TABLE "character_age_evidence" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"evidenceType" TEXT NOT NULL,
"impliesAdult" BOOLEAN NOT NULL,
"isEpilogue" BOOLEAN NOT NULL DEFAULT false,
"description" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "character_age_evidence_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "romance_event_logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"delta" REAL NOT NULL DEFAULT 0,
"detail" TEXT,
"occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "romance_event_logs_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "romance_event_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "adult_mode_consents" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"verifiedAdult" BOOLEAN NOT NULL DEFAULT false,
"optedIn" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "adult_mode_consents_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "adult_content_attempt_logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"allowed" BOOLEAN NOT NULL,
"reason" TEXT NOT NULL,
"attemptedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "adult_content_attempt_logs_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "adult_content_attempt_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_relationships" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"intimacy" INTEGER NOT NULL DEFAULT 0,
"trust" INTEGER NOT NULL DEFAULT 0,
"firstMet" DATETIME,
"lastInteraction" DATETIME,
"interactionCount" INTEGER NOT NULL DEFAULT 0,
"stage" TEXT NOT NULL DEFAULT 'STRANGER',
"romanceMeter" INTEGER NOT NULL DEFAULT 0,
"romanceStage" TEXT NOT NULL DEFAULT 'NONE',
"lastRomanceEventAt" DATETIME,
"awkwardUntil" DATETIME,
"consecutiveRejections" INTEGER NOT NULL DEFAULT 0,
"partnerSince" DATETIME,
"romanceEventsInStage" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "relationships_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "relationships_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_relationships" ("characterId", "createdAt", "firstMet", "id", "interactionCount", "intimacy", "lastInteraction", "stage", "trust", "updatedAt", "userId") SELECT "characterId", "createdAt", "firstMet", "id", "interactionCount", "intimacy", "lastInteraction", "stage", "trust", "updatedAt", "userId" FROM "relationships";
DROP TABLE "relationships";
ALTER TABLE "new_relationships" RENAME TO "relationships";
CREATE UNIQUE INDEX "relationships_characterId_userId_key" ON "relationships"("characterId", "userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "adult_mode_consents_userId_key" ON "adult_mode_consents"("userId");
+105 -1
View File
@@ -44,6 +44,38 @@ enum RelationshipStage {
BONDED // 羈絆 80-100
}
// L-5 戀愛階段:與上面的 RelationshipStage(友誼軸分層)是完全獨立的兩套狀態機。
enum RomanceStage {
NONE // 戀愛軸未啟動(朋友)
INTERESTED // 在意
AMBIGUOUS // 曖昧
PARTNER // 戀人
DEEP_PARTNER // 深度伴侶
CRISIS // 關係危機(戀人階段的重大失望,需修復事件)
}
// L-6 角色年齡證據:證據強度依序為 官方明示(決定性)>作中身分/作中經過時間(強)>外觀與社會呈現(僅輔助,
// 不可單獨採信為成年)。isEpilogue 標記此證據屬於後日談推演區間、還是正史(已出版內容錨定點內)。
enum CharacterAgeEvidenceType {
OFFICIAL_STATED
IN_STORY_STATUS
ELAPSED_TIME
APPEARANCE
}
// L-5 心動事件類型與 L-2/L-4 的告白/里程碑事件,統一記在同一張表,供曲線與稀有度判斷共用。
enum RomanceEventType {
SHARED_HIGH_EMOTION_EXPERIENCE // 共同經歷高情緒事件(大幅上升)
UNDERSTOOD_MOMENT // 被理解的瞬間(大幅上升)
ADMIRED_TRAIT // 展現她欣賞的特質(中幅上升)
PERFECT_DISTANCE // 恰到好處的距離感(緩升)
DISAPPOINTMENT // 失望事件(回落)
CONFESSION_ACCEPTED
CONFESSION_REJECTED
MILESTONE_REACHED
MILESTONE_REJECTED
}
// I-1 週期表:平日/週末/假期各一份時段表
enum ScheduleDayType {
WEEKDAY // 平日
@@ -84,6 +116,9 @@ model User {
relationships Relationship[]
relatedMemories EpisodicMemory[]
tasks Task[]
adultModeConsent AdultModeConsent?
romanceEventLogs RomanceEventLog[]
adultContentAttempts AdultContentAttemptLog[]
@@map("users")
}
@@ -136,10 +171,70 @@ model Character {
proactiveCareLogs ProactiveCareLog[]
relationshipsToOthers CharacterRelationship[] @relation("RelationshipFrom")
relationshipsFromOthers CharacterRelationship[] @relation("RelationshipTo")
ageEvidence CharacterAgeEvidence[]
romanceEventLogs RomanceEventLog[]
adultContentAttempts AdultContentAttemptLog[]
@@map("characters")
}
// L-6 角色年齡判定的原始證據登錄;由 AgeDeterminationService 依證據優先序算出年齡狀態,不直接存最終結論
// ——結論永遠是「依目前登錄的證據重新算出來」,避免證據更新後忘記同步結論欄位。
model CharacterAgeEvidence {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
evidenceType CharacterAgeEvidenceType
impliesAdult Boolean
isEpilogue Boolean @default(false) // true=後日談推演區間的證據,非正史(已出版內容)
description String
createdAt DateTime @default(now())
@@map("character_age_evidence")
}
// L-5 心動事件與 L-2/L-4 的告白/里程碑,統一記錄,供戀愛軸曲線計算、稀有度節流、里程碑追蹤共用。
model RomanceEventLog {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
eventType RomanceEventType
delta Float @default(0)
detail String? // 例如里程碑類型(HAND_HOLDING/HUG/FOREHEAD_KISS…)
occurredAt DateTime @default(now())
@@map("romance_event_logs")
}
// L-7 使用者是否已完成成年驗證+明確 opt-in 開啟成人模式;預設全關,且僅影響「使用者分級」這一層邊界,
// 角色資格與硬邊界仍需獨立通過才會真的放行(見 RomanceEligibilityService)。
model AdultModeConsent {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
verifiedAdult Boolean @default(false)
optedIn Boolean @default(false)
updatedAt DateTime @updatedAt
@@map("adult_mode_consents")
}
// L-7 驗收:任何嘗試(無論是否放行)都留下記錄,證明繞過嘗試會被引擎層攔截並記錄,不會被悄悄放過或悄悄擋掉。
model AdultContentAttemptLog {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
allowed Boolean
reason String
attemptedAt DateTime @default(now())
@@map("adult_content_attempt_logs")
}
// K-2/K-4 角色間關係:群聊中「角色間關係上場」與發言權分配的「與發言者的關係」都吃這張表。
// 有向邊(characterId 對 otherCharacterId 的觀感),故同作品兩角色互相的觀感可以不對稱。
model CharacterRelationship {
@@ -283,13 +378,22 @@ model Relationship {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
intimacy Int @default(0) // 親密度 0~100
intimacy Int @default(0) // 親密度 0~100(友誼軸:穩定累積、衰減慢)
trust Int @default(0) // 信任度 0~100
firstMet DateTime?
lastInteraction DateTime?
interactionCount Int @default(0)
stage RelationshipStage @default(STRANGER)
// L-1 戀愛軸:與友誼軸(intimacy)獨立,事件驅動、衰減快、易回落。僅限成年角色資格生效後才會被寫入非零值。
romanceMeter Int @default(0) // 心動值 0~100
romanceStage RomanceStage @default(NONE)
lastRomanceEventAt DateTime?
awkwardUntil DateTime? // L-2 告白被拒後的尷尬期:互動變客氣,直到此時間點
consecutiveRejections Int @default(0) // L-2 連續告白失敗次數:累積到門檻會真的傷關係
partnerSince DateTime? // L-4 進入戀人階段的時間點,用於判斷戀人初期/穩定期
romanceEventsInStage Int @default(0) // L-4 進入目前階段後累積的正向心動事件數,同樣用於判斷穩定期
ledgerEntries SentimentLedgerEntry[]
createdAt DateTime @default(now())