Files
Kokorone/apps/api/src/romance/romance-eligibility.service.ts
T
JefferyandClaude Sonnet 5 cd948f0026 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>
2026-08-13 16:26:55 +08:00

56 lines
2.7 KiB
TypeScript

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: "角色具正史成年資格,使用者已完成成年驗證並主動開啟成人模式" };
}
}