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
+2
View File
@@ -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],
})
@@ -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<Archetype, ArchetypeParams> = {
@@ -27,6 +33,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
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<Archetype, ArchetypeParams> = {
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<Archetype, ArchetypeParams> = {
// 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<Archetype, ArchetypeParams> = {
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<Archetype, ArchetypeParams> = {
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<Archetype, ArchetypeParams> = {
invertedIntimacyExpression: false,
traits: ["very-short-replies", "sudden-overflow"],
conversationBaseline: 0.05,
// 內在曲線走一般速度(心動值本身正常累積),幾無外顯訊號是外顯度(expressiveness)的職責,不是曲線本身變慢。
romanceGainRate: 1,
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 1,
},
};
@@ -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);
}
}
+113
View File
@@ -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<ConfessionResult> {
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,
};
+95
View File
@@ -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<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(),
): Promise<MilestoneResult> {
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,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<RomanceEligibility> {
const { canonAdult, currentAdult, isEpilogueAdult } = await this.ageDetermination.determine(characterId);
return { romanceAxisEligible: currentAdult, adultModeEligible: canonAdult, isEpilogueAdult };
}
async checkAdultContentAllowed(characterId: string, userId: string): Promise<AdultContentCheckResult> {
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<AdultContentCheckResult> {
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: "角色具正史成年資格,使用者已完成成年驗證並主動開啟成人模式" };
}
}
+123
View File
@@ -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,
);
}
}
+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 };
}
}