Files
Kokorone/apps/api/src/romance/romance.controller.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

124 lines
4.1 KiB
TypeScript

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,
);
}
}