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 { isOutsideSoloRoom } from "./room-context-guard.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(), roomId?: string): Promise { // T42:房型已轉為 GROUP/LOBBY/SELF_CHAT 時,告白一律不可用——不消耗嘗試次數、不記錄拒絕。 if (await isOutsideSoloRoom(this.prisma, roomId)) { return { outcome: "INELIGIBLE", romanceMeter: 0, consecutiveRejections: 0 }; } 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 }; } }