From cd948f0026207f7ea90ef789345942ebb90efb7d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 13 Aug 2026 16:26:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20L=20=E7=BE=A4?= =?UTF-8?q?=E7=B5=84=20=E2=80=94=20=E6=88=80=E6=84=9B=E9=97=9C=E4=BF=82?= =?UTF-8?q?=E8=BB=B8=E8=88=87=E5=85=A7=E5=AE=B9=E5=B0=BA=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 戀愛軸雙軸模型(Relationship 新增心動值/戀愛階段欄位,與親密度獨立、事件 驅動、衰減快)、告白事件狀態機(可拒絕、尷尬期、連續失敗傷關係、立即再 推進觸發警戒)、六原型戀愛曲線參數化(重用 G-3 tsundere gap 處理傲嬌)、 親密表現分層與里程碑(跳級拒絕+退縮反應+帳本、第一次做高權重記憶)、 心動事件與階段門檻(含推太急回落、關係危機與修復)。 角色年齡判定(apps/api/src/romance/age-determination.service.ts):僅 決定性/強證據可判定成年,外觀證據不可單獨採信,證據不足從嚴按未成年 處理,且不存最終結論、每次重新算。內容尺度三層邊界(引擎層):使用者 opt-in、角色資格(僅正史成年)、硬邊界(後日談推演成年者永久排除成人 模式);任何嘗試不論放行與否都記錄,判定完全不看對話內容,沒有「說服 角色」繞過的管道。 刻意收斂範圍:僅實作資格判定與硬邊界攔截機制,不實作任何成人向內容的 實際生成。 Co-Authored-By: Claude Sonnet 5 --- apps/api/src/app.module.ts | 2 + apps/api/src/personality/archetype-params.ts | 26 ++ .../src/romance/age-determination.service.ts | 30 +++ apps/api/src/romance/confession.service.ts | 113 ++++++++ apps/api/src/romance/constants.ts | 72 +++++ apps/api/src/romance/milestone.service.ts | 95 +++++++ .../romance/romance-eligibility.service.ts | 55 ++++ apps/api/src/romance/romance.controller.ts | 123 +++++++++ apps/api/src/romance/romance.module.ts | 18 ++ apps/api/src/romance/romance.service.ts | 181 +++++++++++++ .../migration.sql | 81 ++++++ prisma/schema.prisma | 106 +++++++- scripts/smoke/L.mjs | 246 ++++++++++++++++++ todo.md | 26 +- 14 files changed, 1165 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/romance/age-determination.service.ts create mode 100644 apps/api/src/romance/confession.service.ts create mode 100644 apps/api/src/romance/constants.ts create mode 100644 apps/api/src/romance/milestone.service.ts create mode 100644 apps/api/src/romance/romance-eligibility.service.ts create mode 100644 apps/api/src/romance/romance.controller.ts create mode 100644 apps/api/src/romance/romance.module.ts create mode 100644 apps/api/src/romance/romance.service.ts create mode 100644 prisma/migrations/20260813074937_add_romance_axis_and_content_gating/migration.sql create mode 100644 scripts/smoke/L.mjs diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index bbee659..259f882 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,7 @@ import { ChatModule } from "./chat/chat.module.js"; import { ScheduleModule } from "./schedule/schedule.module.js"; import { TaskModule } from "./task/task.module.js"; import { RoomModule } from "./room/room.module.js"; +import { RomanceModule } from "./romance/romance.module.js"; @Module({ imports: [ @@ -25,6 +26,7 @@ import { RoomModule } from "./room/room.module.js"; ScheduleModule, TaskModule, RoomModule, + RomanceModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/personality/archetype-params.ts b/apps/api/src/personality/archetype-params.ts index 67708e6..cb7ce4e 100644 --- a/apps/api/src/personality/archetype-params.ts +++ b/apps/api/src/personality/archetype-params.ts @@ -8,6 +8,9 @@ export interface ArchetypeParams { invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3) 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 = { @@ -17,6 +20,9 @@ const DEFAULT_PARAMS: ArchetypeParams = { invertedIntimacyExpression: false, traits: [], conversationBaseline: 0.4, + romanceGainRate: 1, + romanceBigEventBonus: 1, + romanceAmbiguousStallFactor: 1, }; export const ARCHETYPE_PARAMS: Record = { @@ -27,6 +33,10 @@ export const ARCHETYPE_PARAMS: Record = { invertedIntimacyExpression: true, traits: ["denial-then-honest"], conversationBaseline: 0.5, + // 心動值與外顯敵意同步上升——外顯部分交給既有的 G-3 tsundereGap 機制處理,這裡的曲線本身走一般速度。 + romanceGainRate: 1, + romanceBigEventBonus: 1, + romanceAmbiguousStallFactor: 1, }, 冷淡: { emotionTriggerThreshold: 2.5, @@ -35,6 +45,9 @@ export const ARCHETYPE_PARAMS: Record = { invertedIntimacyExpression: false, traits: ["flat-affect", "short-replies"], conversationBaseline: 0.15, + romanceGainRate: 0.4, // 極緩慢線性,無明顯階段感 + romanceBigEventBonus: 1, + romanceAmbiguousStallFactor: 1, }, 天然呆: { emotionTriggerThreshold: 0.7, @@ -44,6 +57,9 @@ export const ARCHETYPE_PARAMS: Record = { // K-3:口風不緊——群聊中偶爾會不小心把私密記憶說出來,是這個原型的性格設定例外。 traits: ["misreads-context", "loose-lipped"], conversationBaseline: 0.6, + romanceGainRate: 1, + romanceBigEventBonus: 1, + romanceAmbiguousStallFactor: 1, }, 元氣: { emotionTriggerThreshold: 0.7, @@ -52,6 +68,9 @@ export const ARCHETYPE_PARAMS: Record = { invertedIntimacyExpression: false, traits: ["talkative", "fast-emotion-decay"], conversationBaseline: 0.85, + romanceGainRate: 1.3, // 快速上升 + romanceBigEventBonus: 1, + romanceAmbiguousStallFactor: 0.3, // 但卡在曖昧期打轉——怕改變現狀 }, 大小姐: { emotionTriggerThreshold: 1.3, @@ -60,6 +79,9 @@ export const ARCHETYPE_PARAMS: Record = { invertedIntimacyExpression: false, traits: ["formal-address", "poor-loser"], conversationBaseline: 0.55, + romanceGainRate: 0.5, // 平時矜持撐住,小事件幾乎不動 + romanceBigEventBonus: 2.2, // 某事件後大幅跳升——階梯狀曲線 + romanceAmbiguousStallFactor: 1, }, 三無: { emotionTriggerThreshold: 3.5, @@ -68,6 +90,10 @@ export const ARCHETYPE_PARAMS: Record = { invertedIntimacyExpression: false, traits: ["very-short-replies", "sudden-overflow"], conversationBaseline: 0.05, + // 內在曲線走一般速度(心動值本身正常累積),幾無外顯訊號是外顯度(expressiveness)的職責,不是曲線本身變慢。 + romanceGainRate: 1, + romanceBigEventBonus: 1, + romanceAmbiguousStallFactor: 1, }, }; diff --git a/apps/api/src/romance/age-determination.service.ts b/apps/api/src/romance/age-determination.service.ts new file mode 100644 index 0000000..5f88727 --- /dev/null +++ b/apps/api/src/romance/age-determination.service.ts @@ -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 { + 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); + } +} diff --git a/apps/api/src/romance/confession.service.ts b/apps/api/src/romance/confession.service.ts new file mode 100644 index 0000000..9b5410c --- /dev/null +++ b/apps/api/src/romance/confession.service.ts @@ -0,0 +1,113 @@ +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 { + 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()): Promise { + 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 }; + } +} diff --git a/apps/api/src/romance/constants.ts b/apps/api/src/romance/constants.ts new file mode 100644 index 0000000..0ff065e --- /dev/null +++ b/apps/api/src/romance/constants.ts @@ -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 = { + DISTANCE_SHORTENED: 1, + OCCASIONAL_CONTACT: 1, + LATE_NIGHT_TALK: 1, + HAND_HOLDING: 2, + HUG: 3, + FOREHEAD_KISS: 3, + CASUAL_INTIMACY: 4, +}; diff --git a/apps/api/src/romance/milestone.service.ts b/apps/api/src/romance/milestone.service.ts new file mode 100644 index 0000000..6635344 --- /dev/null +++ b/apps/api/src/romance/milestone.service.ts @@ -0,0 +1,95 @@ +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 { 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 = { + 傲嬌: "……你、你在說什麼啊!還太早了吧!", + 冷淡: "……還不是時候。", + 天然呆: "欸?現在還不行的說……", + 元氣: "欸欸欸?等等,這個有點太快了啦!", + 大小姐: "……哼,本小姐還沒答應到那個地步。", + 三無: "……還沒。", +}; +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(), + ): Promise { + 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 }; + } +} diff --git a/apps/api/src/romance/romance-eligibility.service.ts b/apps/api/src/romance/romance-eligibility.service.ts new file mode 100644 index 0000000..fc63afb --- /dev/null +++ b/apps/api/src/romance/romance-eligibility.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { AgeDeterminationService } from "./age-determination.service.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 { + const { canonAdult, currentAdult, isEpilogueAdult } = await this.ageDetermination.determine(characterId); + return { romanceAxisEligible: currentAdult, adultModeEligible: canonAdult, isEpilogueAdult }; + } + + async checkAdultContentAllowed(characterId: string, userId: string): Promise { + const result = await this.evaluate(characterId, userId); + await this.prisma.client.adultContentAttemptLog.create({ + data: { characterId, userId, allowed: result.allowed, reason: result.reason }, + }); + return result; + } + + private async evaluate(characterId: string, userId: string): Promise { + 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: "角色具正史成年資格,使用者已完成成年驗證並主動開啟成人模式" }; + } +} diff --git a/apps/api/src/romance/romance.controller.ts b/apps/api/src/romance/romance.controller.ts new file mode 100644 index 0000000..b544af3 --- /dev/null +++ b/apps/api/src/romance/romance.controller.ts @@ -0,0 +1,123 @@ +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; +} + +interface MilestoneBody { + userId: string; + milestoneType: MilestoneType; + now?: string; +} + +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); + } + + @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); + } + + @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, + ); + } +} diff --git a/apps/api/src/romance/romance.module.ts b/apps/api/src/romance/romance.module.ts new file mode 100644 index 0000000..1b2b1a4 --- /dev/null +++ b/apps/api/src/romance/romance.module.ts @@ -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 {} diff --git a/apps/api/src/romance/romance.service.ts b/apps/api/src/romance/romance.service.ts new file mode 100644 index 0000000..4d8d0e9 --- /dev/null +++ b/apps/api/src/romance/romance.service.ts @@ -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>; + +// 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 { + const { relationship } = await this.relationship.getState(characterId, userId, now); + return this.applyDecay(relationship, now); + } + + private async applyDecay(relationship: RelationshipRow, now: Date): Promise { + 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 { + 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 }; + } +} diff --git a/prisma/migrations/20260813074937_add_romance_axis_and_content_gating/migration.sql b/prisma/migrations/20260813074937_add_romance_axis_and_content_gating/migration.sql new file mode 100644 index 0000000..8d6760a --- /dev/null +++ b/prisma/migrations/20260813074937_add_romance_axis_and_content_gating/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36b2816..df1fc07 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/scripts/smoke/L.mjs b/scripts/smoke/L.mjs new file mode 100644 index 0000000..21382f5 --- /dev/null +++ b/scripts/smoke/L.mjs @@ -0,0 +1,246 @@ +import { prisma } from "@kokorone/db"; + +const API_PORT = process.env.PORT_API ?? "3001"; +const USER_ID = "seed-user-primary"; + +const ADULT_ID = "smoke-l-adult"; +const MINOR_ID = "smoke-l-minor"; +const EPILOGUE_ID = "smoke-l-epilogue"; +const INSUFFICIENT_ID = "smoke-l-insufficient"; +const GENKI_ID = "smoke-l-genki"; +const COOL_ID = "smoke-l-cool"; +const OJOU_ID = "smoke-l-ojou"; +const EAGER_ID = "smoke-l-eager"; +const ACCEPT_ID = "smoke-l-accept"; +const ALL_IDS = [ADULT_ID, MINOR_ID, EPILOGUE_ID, INSUFFICIENT_ID, GENKI_ID, COOL_ID, OJOU_ID, EAGER_ID, ACCEPT_ID]; + +async function post(path, body) { + const res = await fetch(`http://localhost:${API_PORT}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + if (!res.ok) { + throw new Error(`POST ${path} 回傳 ${res.status}:${await res.text()}`); + } + return res.json(); +} + +async function get(path) { + const res = await fetch(`http://localhost:${API_PORT}${path}`); + if (!res.ok) { + throw new Error(`GET ${path} 回傳 ${res.status}`); + } + return res.json(); +} + +async function createCharacter(id, archetype) { + await post("/personality/characters", { + id, + source: "ORIGINAL", + buildStatus: "BUILT", + formalName: id, + basicInfo: "測試角色", + backgroundStory: "測試", + personalityArchetype: archetype, + likesDislikes: "測試", + goalsObsessions: "測試", + speechStyle: "第一人稱「我」", + initialRelationship: { userId: USER_ID, intimacy: 50, trust: 50 }, + }); +} + +async function addEvidence(id, evidenceType, impliesAdult, isEpilogue, description) { + await post(`/romance/${id}/age-evidence`, { evidenceType, impliesAdult, isEpilogue, description }); +} + +export default async function smokeL() { + await prisma.character.deleteMany({ where: { id: { in: ALL_IDS } } }); + // AdultModeConsent 是綁在共用種子使用者身上的設定,不隨角色一起被 cascade 清掉,測試開始前先重置成「未 opt-in」的乾淨基準。 + await prisma.adultModeConsent.deleteMany({ where: { userId: USER_ID } }); + for (const id of ALL_IDS) { + await createCharacter(id, id === GENKI_ID ? "元氣" : id === COOL_ID ? "冷淡" : id === OJOU_ID ? "大小姐" : "元氣"); + } + + // L-6 角色年齡判定:官方明示決定性;外觀證據不可單獨採信;無足夠證據一律從嚴按未成年處理。 + await addEvidence(ADULT_ID, "OFFICIAL_STATED", true, false, "設定集載明 22 歲"); + await addEvidence(MINOR_ID, "OFFICIAL_STATED", false, false, "設定集載明 16 歲"); + await addEvidence(EPILOGUE_ID, "OFFICIAL_STATED", false, false, "正史設定 16 歲"); + await addEvidence(EPILOGUE_ID, "ELAPSED_TIME", true, true, "後日談推演經過 3 年"); + await addEvidence(INSUFFICIENT_ID, "APPEARANCE", true, false, "外表看起來像成年人,但無其他證據"); + // 以下角色用於 L-1/L-3/L-4/L-5 的戀愛軸機制測試,皆需正史成年資格才能啟動戀愛軸。 + for (const id of [GENKI_ID, COOL_ID, OJOU_ID, EAGER_ID, ACCEPT_ID]) { + await addEvidence(id, "OFFICIAL_STATED", true, false, "設定集載明成年"); + } + + const adultDetermination = await get(`/romance/${ADULT_ID}/age-determination`); + if (!adultDetermination.canonAdult) { + throw new Error("官方明示成年應判定為正史成年"); + } + const insufficientDetermination = await get(`/romance/${INSUFFICIENT_ID}/age-determination`); + if (insufficientDetermination.canonAdult || insufficientDetermination.currentAdult) { + throw new Error("僅有外觀證據不可單獨採信為成年——證據不足應從嚴按未成年處理"); + } + + // L-7 內容尺度三層邊界:使用者分級 opt-in、角色資格(僅正史成年)、硬邊界(永不可解除)。 + const adultEligibility = await get(`/romance/${ADULT_ID}/eligibility`); + if (!adultEligibility.romanceAxisEligible || !adultEligibility.adultModeEligible) { + throw new Error("正史成年角色應同時具備戀愛軸與成人模式資格"); + } + const minorEligibility = await get(`/romance/${MINOR_ID}/eligibility`); + if (minorEligibility.romanceAxisEligible || minorEligibility.adultModeEligible) { + throw new Error("未成年角色不應具備戀愛軸或成人模式資格"); + } + const epilogueEligibility = await get(`/romance/${EPILOGUE_ID}/eligibility`); + if (!epilogueEligibility.romanceAxisEligible || epilogueEligibility.adultModeEligible || !epilogueEligibility.isEpilogueAdult) { + throw new Error("正史未成年、後日談推演成年者:戀愛軸可用但成人模式應永久排除"); + } + + // 未開啟 opt-in 時,即使角色具資格仍應攔截;任何嘗試都要留下記錄。 + const beforeConsent = await post(`/romance/${ADULT_ID}/adult-content-check`, { userId: USER_ID }); + if (beforeConsent.allowed) { + throw new Error("使用者未完成成年驗證+opt-in 時,即使角色具資格也不應放行"); + } + await post("/romance/adult-mode/consent", { userId: USER_ID, verifiedAdult: true, optedIn: true }); + const afterConsent = await post(`/romance/${ADULT_ID}/adult-content-check`, { userId: USER_ID }); + if (!afterConsent.allowed) { + throw new Error("正史成年角色+使用者已驗證並 opt-in 時,應該放行"); + } + // 硬邊界永不可解除:即使使用者已 opt-in,未成年/後日談推演成年角色仍必須被攔截。 + const epilogueAttempt = await post(`/romance/${EPILOGUE_ID}/adult-content-check`, { userId: USER_ID }); + const minorAttempt = await post(`/romance/${MINOR_ID}/adult-content-check`, { userId: USER_ID }); + if (epilogueAttempt.allowed || minorAttempt.allowed) { + throw new Error("硬邊界永不可解除:後日談推演成年與未成年角色即使使用者已 opt-in 仍必須被攔截"); + } + const attemptCount = await prisma.adultContentAttemptLog.count({ where: { userId: USER_ID, characterId: { in: [ADULT_ID, EPILOGUE_ID, MINOR_ID] } } }); + if (attemptCount < 4) { + throw new Error("每一次成人內容檢查(無論放行與否)都必須留下記錄"); + } + + // L-1 雙軸模型:純聊天量只推升親密度,不推升心動值;未成年角色的戀愛軸不會被事件啟動。 + await post(`/chat/${ADULT_ID}`, { userId: USER_ID, sessionId: "smoke-l-chat", text: "嗨嗨嗨,今天過得如何呀" }); + const stateAfterChat = await get(`/romance/${ADULT_ID}/${USER_ID}/state`); + if (stateAfterChat.romanceMeter !== 0) { + throw new Error("純聊天量不應該推升心動值"); + } + if (!(stateAfterChat.intimacy > 50)) { + throw new Error("純聊天量仍應該正常推升親密度"); + } + const minorEventResult = await post(`/romance/${MINOR_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE" }); + if (minorEventResult.applied || minorEventResult.romanceMeter !== 0) { + throw new Error("未成年角色的戀愛軸應完全關閉,心動事件不應該被套用"); + } + + // L-3 性格進展曲線:相同事件序列,在不同原型下應該產生不同的心動值曲線。 + const curveTimestamps = ["2026-01-01T00:00:00.000Z", "2026-01-02T00:00:00.000Z", "2026-01-03T00:00:00.000Z"]; + let genkiMeter = 0; + let coolMeter = 0; + let ojouMeter = 0; + for (const now of curveTimestamps) { + genkiMeter = (await post(`/romance/${GENKI_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE", now })).romanceMeter; + coolMeter = (await post(`/romance/${COOL_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE", now })).romanceMeter; + ojouMeter = (await post(`/romance/${OJOU_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE", now })).romanceMeter; + } + if (genkiMeter === coolMeter || genkiMeter === ojouMeter || coolMeter === ojouMeter) { + throw new Error(`相同事件序列在不同原型下應該產生不同曲線,實際為 元氣=${genkiMeter} 冷淡=${coolMeter} 大小姐=${ojouMeter}`); + } + if (!(coolMeter < genkiMeter)) { + throw new Error("冷淡原型的戀愛軸應該遠比元氣原型緩慢"); + } + + // L-5 推得太急會回落:短時間內密集觸發正向心動事件,應該轉為回落而非持續上升。 + const eagerTimestamps = ["2026-02-01T10:00:00.000Z", "2026-02-01T10:10:00.000Z", "2026-02-01T10:20:00.000Z", "2026-02-01T10:30:00.000Z"]; + let lastEagerResult; + for (const now of eagerTimestamps) { + lastEagerResult = await post(`/romance/${EAGER_ID}/event`, { userId: USER_ID, eventType: "ADMIRED_TRAIT", now }); + } + if (!lastEagerResult.overEager || lastEagerResult.delta >= 0) { + throw new Error("短時間內密集推進心動事件,應該被判定為推得太急並轉為回落"); + } + + // L-2/L-4/L-5:建立一個乾淨時間軸,走完 曖昧→告白成功→戀人初期→戀人穩定期→關係危機→修復。 + for (const now of ["2026-03-01T00:00:00.000Z", "2026-03-02T00:00:00.000Z", "2026-03-03T00:00:00.000Z", "2026-03-04T00:00:00.000Z"]) { + await post(`/romance/${ACCEPT_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE", now }); + } + const preConfessState = await get(`/romance/${ACCEPT_ID}/${USER_ID}/state?now=${encodeURIComponent("2026-03-04T00:30:00.000Z")}`); + if (preConfessState.romanceStage !== "AMBIGUOUS") { + throw new Error(`累積心動事件後應進入曖昧階段,實際為 ${preConfessState.romanceStage}`); + } + + // L-2 告白:心動值不足或時機不對會被拒絕(進入尷尬期),拒絕後立即再推進觸發警戒與心動值下降。 + const tooSoonId = "smoke-l-toosoon"; + await prisma.character.deleteMany({ where: { id: tooSoonId } }); + await createCharacter(tooSoonId, "元氣"); + await addEvidence(tooSoonId, "OFFICIAL_STATED", true, false, "設定集載明成年"); + await post(`/romance/${tooSoonId}/event`, { userId: USER_ID, eventType: "ADMIRED_TRAIT", now: "2026-03-01T00:00:00.000Z" }); + const insufficientConfess = await post(`/romance/${tooSoonId}/confess`, { userId: USER_ID, now: "2026-03-01T01:00:00.000Z" }); + if (insufficientConfess.outcome !== "REJECTED") { + throw new Error("心動值不足時告白應該被拒絕"); + } + const stateAfterReject = await get(`/romance/${tooSoonId}/${USER_ID}/state?now=${encodeURIComponent("2026-03-01T01:01:00.000Z")}`); + if (!stateAfterReject.awkwardUntil) { + throw new Error("告白被拒後應該進入尷尬期"); + } + const meterBeforeRetry = stateAfterReject.romanceMeter; + const tooSoonRetry = await post(`/romance/${tooSoonId}/confess`, { userId: USER_ID, now: "2026-03-01T01:05:00.000Z" }); + if (tooSoonRetry.outcome !== "REJECTED_TOO_SOON") { + throw new Error("尷尬期內立即再推進應該被判定為 REJECTED_TOO_SOON"); + } + if (!(tooSoonRetry.romanceMeter < meterBeforeRetry)) { + throw new Error("尷尬期內立即再推進應該觸發心動值下降"); + } + + // 成功告白 → 進入戀人階段。 + const confessResult = await post(`/romance/${ACCEPT_ID}/confess`, { userId: USER_ID, now: "2026-03-04T01:00:00.000Z" }); + if (confessResult.outcome !== "ACCEPTED") { + throw new Error(`心動值充足且時機正常時告白應該成功,實際為 ${confessResult.outcome}`); + } + + // L-4 親密表現分層:戀人初期不能跳級要求戀人穩定期才開放的表現;跳級要求會被拒且記入帳本。 + const handHolding = await post(`/romance/${ACCEPT_ID}/milestone`, { userId: USER_ID, milestoneType: "HAND_HOLDING", now: "2026-03-04T02:00:00.000Z" }); + if (!handHolding.allowed || !handHolding.isFirstTime) { + throw new Error("戀人初期應該可以第一次牽手,且應標記為第一次"); + } + const prematureHug = await post(`/romance/${ACCEPT_ID}/milestone`, { userId: USER_ID, milestoneType: "HUG", now: "2026-03-04T02:05:00.000Z" }); + if (prematureHug.allowed || !prematureHug.reactionLine) { + throw new Error("戀人初期跳級要求戀人穩定期的親密表現應該被拒絕,且要有符合角色的退縮反應"); + } + const rejectedLogged = await prisma.romanceEventLog.findFirst({ + where: { characterId: ACCEPT_ID, userId: USER_ID, eventType: "MILESTONE_REJECTED", detail: "HUG" }, + }); + if (!rejectedLogged) { + throw new Error("跳級要求被拒絕時應該記入帳本"); + } + + // 累積正向事件進入戀人穩定期後,該表現才會被允許。 + for (const now of ["2026-03-05T00:00:00.000Z", "2026-03-06T00:00:00.000Z", "2026-03-07T00:00:00.000Z"]) { + await post(`/romance/${ACCEPT_ID}/event`, { userId: USER_ID, eventType: "ADMIRED_TRAIT", now }); + } + const stableHug = await post(`/romance/${ACCEPT_ID}/milestone`, { userId: USER_ID, milestoneType: "HUG", now: "2026-03-07T01:00:00.000Z" }); + if (!stableHug.allowed) { + throw new Error("進入戀人穩定期後,擁抱應該被允許"); + } + const milestoneMemory = await prisma.episodicMemory.findFirst({ + where: { characterId: ACCEPT_ID, relatedUserId: USER_ID, content: { contains: "第一次" } }, + }); + if (!milestoneMemory || milestoneMemory.weight < 2) { + throw new Error("第一次的親密表現里程碑應該做成高權重的事件級記憶"); + } + + // L-5 關係危機與修復:戀人階段的重大失望應該直接跳危機狀態,累積正向事件修復後回到戀人。 + const crisisResult = await post(`/romance/${ACCEPT_ID}/event`, { userId: USER_ID, eventType: "DISAPPOINTMENT", severe: true, now: "2026-03-08T00:00:00.000Z" }); + if (crisisResult.romanceStage !== "CRISIS") { + throw new Error("戀人階段的重大失望應該直接進入關係危機狀態"); + } + let repaired = crisisResult; + for (const now of ["2026-03-09T00:00:00.000Z", "2026-03-10T00:00:00.000Z", "2026-03-11T00:00:00.000Z"]) { + repaired = await post(`/romance/${ACCEPT_ID}/event`, { userId: USER_ID, eventType: "SHARED_HIGH_EMOTION_EXPERIENCE", now }); + } + if (repaired.romanceStage !== "PARTNER") { + throw new Error("累積足夠的正向修復事件後,關係危機應該回到戀人階段"); + } + + // 清理本次測試建立的角色(cascade 會一併清掉戀愛軸紀錄、年齡證據、記憶等)與共用使用者的 opt-in 設定。 + await prisma.character.deleteMany({ where: { id: { in: [...ALL_IDS, tooSoonId] } } }); + await prisma.adultModeConsent.deleteMany({ where: { userId: USER_ID } }); +} diff --git a/todo.md b/todo.md index 1e7ce7e..45f0203 100644 --- a/todo.md +++ b/todo.md @@ -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. 既有作品考據與輕小說章節管線