Files
Kokorone/apps/api/src/romance/confession.service.ts
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

120 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ConfessionResult> {
// 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 };
}
}