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
+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 };
}
}