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:
co-authored by
Claude Sonnet 5
parent
6125d6e27f
commit
cd948f0026
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user