新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
120 lines
5.3 KiB
TypeScript
120 lines
5.3 KiB
TypeScript
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 };
|
||
}
|
||
}
|