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
@@ -11,6 +11,7 @@ import { ChatModule } from "./chat/chat.module.js";
|
||||
import { ScheduleModule } from "./schedule/schedule.module.js";
|
||||
import { TaskModule } from "./task/task.module.js";
|
||||
import { RoomModule } from "./room/room.module.js";
|
||||
import { RomanceModule } from "./romance/romance.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,6 +26,7 @@ import { RoomModule } from "./room/room.module.js";
|
||||
ScheduleModule,
|
||||
TaskModule,
|
||||
RoomModule,
|
||||
RomanceModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface ArchetypeParams {
|
||||
invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3)
|
||||
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6/K-3)參考
|
||||
conversationBaseline: number; // K-4 群聊發言基線 0~1:不被點名、無特別理由時主動開口的傾向(元氣高、三無極低)
|
||||
romanceGainRate: number; // L-3 戀愛軸基礎曲線速度倍率:套用在心動事件的增減幅上(冷淡極慢、元氣快)
|
||||
romanceBigEventBonus: number; // L-3 高幅心動事件(共同經歷高情緒事件/被理解的瞬間)的額外倍率:>1 呈現「階梯狀跳升」(大小姐)
|
||||
romanceAmbiguousStallFactor: number; // L-3 曖昧階段的額外速度倍率:<1 代表卡在曖昧期打轉、怕改變現狀(元氣)
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS: ArchetypeParams = {
|
||||
@@ -17,6 +20,9 @@ const DEFAULT_PARAMS: ArchetypeParams = {
|
||||
invertedIntimacyExpression: false,
|
||||
traits: [],
|
||||
conversationBaseline: 0.4,
|
||||
romanceGainRate: 1,
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
};
|
||||
|
||||
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
@@ -27,6 +33,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
invertedIntimacyExpression: true,
|
||||
traits: ["denial-then-honest"],
|
||||
conversationBaseline: 0.5,
|
||||
// 心動值與外顯敵意同步上升——外顯部分交給既有的 G-3 tsundereGap 機制處理,這裡的曲線本身走一般速度。
|
||||
romanceGainRate: 1,
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
},
|
||||
冷淡: {
|
||||
emotionTriggerThreshold: 2.5,
|
||||
@@ -35,6 +45,9 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["flat-affect", "short-replies"],
|
||||
conversationBaseline: 0.15,
|
||||
romanceGainRate: 0.4, // 極緩慢線性,無明顯階段感
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
},
|
||||
天然呆: {
|
||||
emotionTriggerThreshold: 0.7,
|
||||
@@ -44,6 +57,9 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
// K-3:口風不緊——群聊中偶爾會不小心把私密記憶說出來,是這個原型的性格設定例外。
|
||||
traits: ["misreads-context", "loose-lipped"],
|
||||
conversationBaseline: 0.6,
|
||||
romanceGainRate: 1,
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
},
|
||||
元氣: {
|
||||
emotionTriggerThreshold: 0.7,
|
||||
@@ -52,6 +68,9 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["talkative", "fast-emotion-decay"],
|
||||
conversationBaseline: 0.85,
|
||||
romanceGainRate: 1.3, // 快速上升
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 0.3, // 但卡在曖昧期打轉——怕改變現狀
|
||||
},
|
||||
大小姐: {
|
||||
emotionTriggerThreshold: 1.3,
|
||||
@@ -60,6 +79,9 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["formal-address", "poor-loser"],
|
||||
conversationBaseline: 0.55,
|
||||
romanceGainRate: 0.5, // 平時矜持撐住,小事件幾乎不動
|
||||
romanceBigEventBonus: 2.2, // 某事件後大幅跳升——階梯狀曲線
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
},
|
||||
三無: {
|
||||
emotionTriggerThreshold: 3.5,
|
||||
@@ -68,6 +90,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
|
||||
invertedIntimacyExpression: false,
|
||||
traits: ["very-short-replies", "sudden-overflow"],
|
||||
conversationBaseline: 0.05,
|
||||
// 內在曲線走一般速度(心動值本身正常累積),幾無外顯訊號是外顯度(expressiveness)的職責,不是曲線本身變慢。
|
||||
romanceGainRate: 1,
|
||||
romanceBigEventBonus: 1,
|
||||
romanceAmbiguousStallFactor: 1,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { CharacterAgeEvidenceType } from "@kokorone/db";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
export interface AgeDetermination {
|
||||
canonAdult: boolean; // 只看正史(非後日談)證據:是否判定成年
|
||||
currentAdult: boolean; // 含後日談推演證據:現在是否成年
|
||||
isEpilogueAdult: boolean; // currentAdult && !canonAdult——正史未成年、後日談推演後才成年
|
||||
}
|
||||
|
||||
// L-6 只有「決定性」或「強」證據能單獨判定成年;外觀與社會呈現(APPEARANCE)僅供輔助,
|
||||
// 不採信「外表少女實為千年」類設定——不論其 impliesAdult 標記為何,永遠不足以單獨採信。
|
||||
const DECISIVE_OR_STRONG: CharacterAgeEvidenceType[] = ["OFFICIAL_STATED", "IN_STORY_STATUS", "ELAPSED_TIME"];
|
||||
|
||||
// L-6 角色年齡判定:年齡是年表上的屬性,依證據優先序判定,證據不足一律從嚴按未成年處理。
|
||||
@Injectable()
|
||||
export class AgeDeterminationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async determine(characterId: string): Promise<AgeDetermination> {
|
||||
const evidence = await this.prisma.client.characterAgeEvidence.findMany({ where: { characterId } });
|
||||
const canonAdult = this.isAdultFrom(evidence.filter((row) => !row.isEpilogue));
|
||||
const currentAdult = this.isAdultFrom(evidence);
|
||||
return { canonAdult, currentAdult, isEpilogueAdult: currentAdult && !canonAdult };
|
||||
}
|
||||
|
||||
private isAdultFrom(evidence: { evidenceType: CharacterAgeEvidenceType; impliesAdult: boolean }[]): boolean {
|
||||
return evidence.some((row) => DECISIVE_OR_STRONG.includes(row.evidenceType) && row.impliesAdult);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { RomanceEventType } from "@kokorone/db";
|
||||
|
||||
// L-1 戀愛軸衰減:遠短於友誼軸(E-4 的 30 天半衰期)——曖昧不推進會自然冷卻。
|
||||
export const ROMANCE_HALF_LIFE_DAYS = 5;
|
||||
|
||||
// L-5 階段門檻:朋友(0)→在意→曖昧,戀人/深度伴侶不是純靠數值自動跳,需要對應的關鍵事件(見 confession/milestone)。
|
||||
export const ROMANCE_STAGE_THRESHOLDS = { INTERESTED: 20, AMBIGUOUS: 50 };
|
||||
// 降級門檻留一點緩衝,避免數值剛好卡在門檻上下反覆跳動。
|
||||
export const ROMANCE_STAGE_DOWNGRADE_MARGIN = 5;
|
||||
|
||||
// L-2 告白:需要曖昧階段且心動值達門檻才可能成功;情緒低谷或剛發生失望事件會強制判定時機不對。
|
||||
export const CONFESSION_METER_THRESHOLD = 60;
|
||||
export const AWKWARD_PERIOD_DAYS = 3;
|
||||
export const REJECTION_DAMAGE_THRESHOLD = 3; // 連續失敗達此次數,才會真的傷關係(額外扣心動值)
|
||||
export const REJECTION_DAMAGE_METER_PENALTY = 15;
|
||||
export const IMMEDIATE_RECONFESS_PENALTY = 20; // 尷尬期內立即再推進:觸發警戒+心動值下降
|
||||
export const BAD_TIMING_DISAPPOINTMENT_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 小時內有失望事件視為時機不對
|
||||
|
||||
// L-5 推得太急:短時間內密集觸發正向心動事件,反而回落。
|
||||
export const OVER_EAGER_WINDOW_MS = 60 * 60 * 1000;
|
||||
export const OVER_EAGER_EVENT_COUNT = 3;
|
||||
export const OVER_EAGER_PENALTY_RATIO = -0.5; // 命中「推太急」時,原本的正向 delta 乘上這個比例(變成負的,即回落)
|
||||
|
||||
// L-5 關係危機:戀人階段單次失望幅度(未套用性格倍率前的原始值)達此負值,直接觸發危機狀態。
|
||||
export const CRISIS_DISAPPOINTMENT_RAW_DELTA_THRESHOLD = -20;
|
||||
// 危機狀態下,正向事件累積達此值即視為修復成功,回到戀人階段。
|
||||
export const CRISIS_REPAIR_METER_THRESHOLD = 40;
|
||||
|
||||
// L-4 戀人初期/穩定期:進入戀人階段後累積多少「正向心動事件或已允許的里程碑」才算穩定期。
|
||||
export const PARTNER_STABLE_EVENT_COUNT = 3;
|
||||
|
||||
export const ROMANCE_EVENT_BASE_DELTA: Record<
|
||||
Extract<
|
||||
RomanceEventType,
|
||||
"SHARED_HIGH_EMOTION_EXPERIENCE" | "UNDERSTOOD_MOMENT" | "ADMIRED_TRAIT" | "PERFECT_DISTANCE" | "DISAPPOINTMENT"
|
||||
>,
|
||||
number
|
||||
> = {
|
||||
SHARED_HIGH_EMOTION_EXPERIENCE: 15,
|
||||
UNDERSTOOD_MOMENT: 15,
|
||||
ADMIRED_TRAIT: 8,
|
||||
PERFECT_DISTANCE: 3,
|
||||
DISAPPOINTMENT: -12,
|
||||
};
|
||||
|
||||
export const ROMANCE_BIG_EVENT_TYPES: RomanceEventType[] = ["SHARED_HIGH_EMOTION_EXPERIENCE", "UNDERSTOOD_MOMENT"];
|
||||
export const ROMANCE_POSITIVE_EVENT_TYPES: RomanceEventType[] = [
|
||||
"SHARED_HIGH_EMOTION_EXPERIENCE",
|
||||
"UNDERSTOOD_MOMENT",
|
||||
"ADMIRED_TRAIT",
|
||||
"PERFECT_DISTANCE",
|
||||
];
|
||||
|
||||
export type MilestoneType =
|
||||
| "DISTANCE_SHORTENED"
|
||||
| "OCCASIONAL_CONTACT"
|
||||
| "LATE_NIGHT_TALK"
|
||||
| "HAND_HOLDING"
|
||||
| "HUG"
|
||||
| "FOREHEAD_KISS"
|
||||
| "CASUAL_INTIMACY";
|
||||
|
||||
// L-4 親密表現分層:對應「在意~曖昧 / 戀人初期 / 戀人穩定期 / 深度伴侶」四個開放層級(tier 1~4)。
|
||||
export const MILESTONE_MIN_TIER: Record<MilestoneType, number> = {
|
||||
DISTANCE_SHORTENED: 1,
|
||||
OCCASIONAL_CONTACT: 1,
|
||||
LATE_NIGHT_TALK: 1,
|
||||
HAND_HOLDING: 2,
|
||||
HUG: 3,
|
||||
FOREHEAD_KISS: 3,
|
||||
CASUAL_INTIMACY: 4,
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Archetype } from "../personality/archetype-params.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RomanceService } from "./romance.service.js";
|
||||
import { MILESTONE_MIN_TIER, PARTNER_STABLE_EVENT_COUNT, type MilestoneType } from "./constants.js";
|
||||
|
||||
export interface MilestoneResult {
|
||||
allowed: boolean;
|
||||
isFirstTime: boolean;
|
||||
reactionLine?: string;
|
||||
}
|
||||
|
||||
const WITHDRAWAL_LINES: Record<Archetype, string> = {
|
||||
傲嬌: "……你、你在說什麼啊!還太早了吧!",
|
||||
冷淡: "……還不是時候。",
|
||||
天然呆: "欸?現在還不行的說……",
|
||||
元氣: "欸欸欸?等等,這個有點太快了啦!",
|
||||
大小姐: "……哼,本小姐還沒答應到那個地步。",
|
||||
三無: "……還沒。",
|
||||
};
|
||||
const DEFAULT_WITHDRAWAL_LINE = "……現在還不是時候。";
|
||||
|
||||
function tierFor(stage: string, romanceEventsInStage: number): number {
|
||||
if (stage === "INTERESTED" || stage === "AMBIGUOUS") {
|
||||
return 1;
|
||||
}
|
||||
if (stage === "PARTNER") {
|
||||
return romanceEventsInStage >= PARTNER_STABLE_EVENT_COUNT ? 3 : 2;
|
||||
}
|
||||
if (stage === "DEEP_PARTNER") {
|
||||
return 4;
|
||||
}
|
||||
return 0; // NONE/CRISIS:尚未開放任何親密表現層級。
|
||||
}
|
||||
|
||||
// L-4 親密表現分層:階段推著表現走,不是使用者要求推著走——跳級要求得到的是符合該階段的退縮反應,
|
||||
// 且每個「第一次」做成事件級演出(高權重記憶)。
|
||||
@Injectable()
|
||||
export class MilestoneService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly romance: RomanceService,
|
||||
) {}
|
||||
|
||||
async attemptMilestone(
|
||||
characterId: string,
|
||||
userId: string,
|
||||
milestoneType: MilestoneType,
|
||||
now: Date = new Date(),
|
||||
): Promise<MilestoneResult> {
|
||||
const relationship = await this.romance.getState(characterId, userId, now);
|
||||
const currentTier = tierFor(relationship.romanceStage, relationship.romanceEventsInStage);
|
||||
const requiredTier = MILESTONE_MIN_TIER[milestoneType];
|
||||
|
||||
if (currentTier < requiredTier) {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
await this.prisma.client.romanceEventLog.create({
|
||||
data: { characterId, userId, eventType: "MILESTONE_REJECTED", delta: -1, detail: milestoneType, occurredAt: now },
|
||||
});
|
||||
const reactionLine = WITHDRAWAL_LINES[character.personalityArchetype as Archetype] ?? DEFAULT_WITHDRAWAL_LINE;
|
||||
return { allowed: false, isFirstTime: false, reactionLine };
|
||||
}
|
||||
|
||||
const priorReach = await this.prisma.client.romanceEventLog.findFirst({
|
||||
where: { characterId, userId, eventType: "MILESTONE_REACHED", detail: milestoneType },
|
||||
});
|
||||
const isFirstTime = !priorReach;
|
||||
|
||||
await this.prisma.client.romanceEventLog.create({
|
||||
data: { characterId, userId, eventType: "MILESTONE_REACHED", delta: 0, detail: milestoneType, occurredAt: now },
|
||||
});
|
||||
await this.prisma.client.relationship.update({
|
||||
where: { id: relationship.id },
|
||||
data: { romanceEventsInStage: relationship.romanceEventsInStage + 1, lastRomanceEventAt: now },
|
||||
});
|
||||
|
||||
if (isFirstTime) {
|
||||
// 事件級演出:每個「第一次」都值得高權重記憶(未來 O 群組立繪差分/P 群組語音演出可掛在同一筆事件上)。
|
||||
await this.prisma.client.episodicMemory.create({
|
||||
data: {
|
||||
characterId,
|
||||
content: `第一次的「${milestoneType}」`,
|
||||
occurredAt: now,
|
||||
emotionTag: "SHY",
|
||||
emotionIntensity: 0.8,
|
||||
source: "INTERACTION",
|
||||
weight: 3,
|
||||
relatedUserId: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { allowed: true, isFirstTime };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||
|
||||
export interface RomanceEligibility {
|
||||
romanceAxisEligible: boolean; // 戀愛軸是否可啟動(含後日談自然成年者)
|
||||
adultModeEligible: boolean; // 成人模式角色資格:僅正史成年,永久不含後日談推演成年
|
||||
isEpilogueAdult: boolean; // 正史未成年、後日談推演後才成年——永久限純愛尺度
|
||||
}
|
||||
|
||||
export interface AdultContentCheckResult {
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// L-7 內容尺度三層邊界:使用者分級(opt-in)、角色資格(僅正史成年)、硬邊界(永不可解除)。
|
||||
// 這是唯一的判定入口——任何想要放行成人內容的呼叫都必須經過 checkAdultContentAllowed,且無論結果一律記錄,
|
||||
// 不存在「在角色演出層說服她」繞過的可能:判定完全不看對話內容,只看角色資格與使用者驗證狀態。
|
||||
@Injectable()
|
||||
export class RomanceEligibilityService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ageDetermination: AgeDeterminationService,
|
||||
) {}
|
||||
|
||||
async getEligibility(characterId: string): Promise<RomanceEligibility> {
|
||||
const { canonAdult, currentAdult, isEpilogueAdult } = await this.ageDetermination.determine(characterId);
|
||||
return { romanceAxisEligible: currentAdult, adultModeEligible: canonAdult, isEpilogueAdult };
|
||||
}
|
||||
|
||||
async checkAdultContentAllowed(characterId: string, userId: string): Promise<AdultContentCheckResult> {
|
||||
const result = await this.evaluate(characterId, userId);
|
||||
await this.prisma.client.adultContentAttemptLog.create({
|
||||
data: { characterId, userId, allowed: result.allowed, reason: result.reason },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async evaluate(characterId: string, userId: string): Promise<AdultContentCheckResult> {
|
||||
const eligibility = await this.getEligibility(characterId);
|
||||
if (!eligibility.adultModeEligible) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: eligibility.isEpilogueAdult
|
||||
? "角色為正史未成年、後日談推演後才成年——成人模式資格永久排除,不隨任何開關或關係狀態改變"
|
||||
: "角色未通過正史成年判定,不具成人模式資格",
|
||||
};
|
||||
}
|
||||
const consent = await this.prisma.client.adultModeConsent.findUnique({ where: { userId } });
|
||||
if (!consent || !consent.verifiedAdult || !consent.optedIn) {
|
||||
return { allowed: false, reason: "使用者尚未完成成年驗證與明確 opt-in,預設全關" };
|
||||
}
|
||||
return { allowed: true, reason: "角色具正史成年資格,使用者已完成成年驗證並主動開啟成人模式" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||
import { RomanceService } from "./romance.service.js";
|
||||
import { ConfessionService } from "./confession.service.js";
|
||||
import { MilestoneService } from "./milestone.service.js";
|
||||
import { RomanceController } from "./romance.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RelationshipModule, EmotionModule],
|
||||
controllers: [RomanceController],
|
||||
providers: [AgeDeterminationService, RomanceEligibilityService, RomanceService, ConfessionService, MilestoneService],
|
||||
exports: [AgeDeterminationService, RomanceEligibilityService, RomanceService, ConfessionService, MilestoneService],
|
||||
})
|
||||
export class RomanceModule {}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { RomanceEventType, RomanceStage } from "@kokorone/db";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||
import {
|
||||
ROMANCE_HALF_LIFE_DAYS,
|
||||
ROMANCE_STAGE_THRESHOLDS,
|
||||
ROMANCE_STAGE_DOWNGRADE_MARGIN,
|
||||
ROMANCE_EVENT_BASE_DELTA,
|
||||
ROMANCE_BIG_EVENT_TYPES,
|
||||
ROMANCE_POSITIVE_EVENT_TYPES,
|
||||
OVER_EAGER_WINDOW_MS,
|
||||
OVER_EAGER_EVENT_COUNT,
|
||||
OVER_EAGER_PENALTY_RATIO,
|
||||
CRISIS_REPAIR_METER_THRESHOLD,
|
||||
} from "./constants.js";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
export type RomanceCurveEventType = keyof typeof ROMANCE_EVENT_BASE_DELTA;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export interface RecordRomanceEventOptions {
|
||||
now?: Date;
|
||||
// L-5 關係危機:只有「重大」失望(而非日常小失望)會讓戀人階段直接跳危機,需要修復事件才能回來。
|
||||
severe?: boolean;
|
||||
}
|
||||
|
||||
export interface RecordRomanceEventResult {
|
||||
applied: boolean; // false=戀愛軸未啟動(角色不具資格),本次事件被忽略——不是攔截,是機制本身未啟用
|
||||
romanceMeter: number;
|
||||
romanceStage: RomanceStage;
|
||||
delta: number;
|
||||
overEager: boolean;
|
||||
}
|
||||
|
||||
type RelationshipRow = Awaited<ReturnType<RelationshipService["getOrCreate"]>>;
|
||||
|
||||
// L-1/L-5 戀愛軸核心:與友誼軸(intimacy)獨立的事件驅動計量——衰減快、易回落,且推進曲線由性格原型決定。
|
||||
@Injectable()
|
||||
export class RomanceService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly relationship: RelationshipService,
|
||||
private readonly eligibility: RomanceEligibilityService,
|
||||
) {}
|
||||
|
||||
// 讀取目前狀態並套用衰減(惰性衰減:讀取時才補算,同 E-4 的作法),衰減結果會持久化。
|
||||
async getState(characterId: string, userId: string, now: Date = new Date()): Promise<RelationshipRow> {
|
||||
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||
return this.applyDecay(relationship, now);
|
||||
}
|
||||
|
||||
private async applyDecay(relationship: RelationshipRow, now: Date): Promise<RelationshipRow> {
|
||||
if (relationship.romanceMeter === 0) {
|
||||
return relationship;
|
||||
}
|
||||
const anchor = relationship.lastRomanceEventAt ?? relationship.createdAt;
|
||||
const elapsedMs = now.getTime() - anchor.getTime();
|
||||
if (elapsedMs <= 0) {
|
||||
return relationship;
|
||||
}
|
||||
const factor = Math.pow(0.5, elapsedMs / (ROMANCE_HALF_LIFE_DAYS * DAY_MS));
|
||||
const decayedMeter = Math.round(relationship.romanceMeter * factor);
|
||||
if (decayedMeter === relationship.romanceMeter) {
|
||||
return relationship;
|
||||
}
|
||||
const newStage = this.downgradeStageIfNeeded(relationship.romanceStage, decayedMeter);
|
||||
return this.prisma.client.relationship.update({
|
||||
where: { id: relationship.id },
|
||||
data: { romanceMeter: decayedMeter, romanceStage: newStage },
|
||||
});
|
||||
}
|
||||
|
||||
// 冷卻回落:曖昧/在意階段數值掉回門檻以下就退回上一階段,PARTNER/DEEP_PARTNER/CRISIS 不因單純衰減降級
|
||||
// ——退出戀人關係需要對應的關鍵事件(分手/危機),不是數字算出來的。
|
||||
private downgradeStageIfNeeded(stage: RomanceStage, meter: number): RomanceStage {
|
||||
if (stage === "AMBIGUOUS" && meter < ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS - ROMANCE_STAGE_DOWNGRADE_MARGIN) {
|
||||
return meter < ROMANCE_STAGE_THRESHOLDS.INTERESTED - ROMANCE_STAGE_DOWNGRADE_MARGIN ? "NONE" : "INTERESTED";
|
||||
}
|
||||
if (stage === "INTERESTED" && meter < ROMANCE_STAGE_THRESHOLDS.INTERESTED - ROMANCE_STAGE_DOWNGRADE_MARGIN) {
|
||||
return "NONE";
|
||||
}
|
||||
return stage;
|
||||
}
|
||||
|
||||
private advanceStageIfNeeded(stage: RomanceStage, meter: number): RomanceStage {
|
||||
if (stage === "NONE" && meter >= ROMANCE_STAGE_THRESHOLDS.INTERESTED) {
|
||||
return meter >= ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS ? "AMBIGUOUS" : "INTERESTED";
|
||||
}
|
||||
if (stage === "INTERESTED" && meter >= ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS) {
|
||||
return "AMBIGUOUS";
|
||||
}
|
||||
return stage; // 戀人只能透過告白事件推進(見 ConfessionService),不會單純因數值跨過門檻自動跳。
|
||||
}
|
||||
|
||||
async recordEvent(
|
||||
characterId: string,
|
||||
userId: string,
|
||||
eventType: RomanceCurveEventType,
|
||||
options: RecordRomanceEventOptions = {},
|
||||
): Promise<RecordRomanceEventResult> {
|
||||
const now = options.now ?? new Date();
|
||||
const eligibility = await this.eligibility.getEligibility(characterId);
|
||||
if (!eligibility.romanceAxisEligible) {
|
||||
// L-7:成年前戀愛軸完全關閉——不啟動、不累積,靜默忽略(這是機制未啟用,不是攔截「繞過嘗試」)。
|
||||
return { applied: false, romanceMeter: 0, romanceStage: "NONE", delta: 0, overEager: false };
|
||||
}
|
||||
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||
const relationship = await this.getState(characterId, userId, now);
|
||||
|
||||
let delta = ROMANCE_EVENT_BASE_DELTA[eventType] * archetypeParams.romanceGainRate;
|
||||
if (eventType === "DISAPPOINTMENT" && options.severe) {
|
||||
delta *= 2.5;
|
||||
}
|
||||
if (ROMANCE_BIG_EVENT_TYPES.includes(eventType)) {
|
||||
delta *= archetypeParams.romanceBigEventBonus;
|
||||
}
|
||||
if (relationship.romanceStage === "AMBIGUOUS") {
|
||||
delta *= archetypeParams.romanceAmbiguousStallFactor;
|
||||
}
|
||||
|
||||
let overEager = false;
|
||||
if (ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType)) {
|
||||
const recentPositiveCount = await this.prisma.client.romanceEventLog.count({
|
||||
where: {
|
||||
characterId,
|
||||
userId,
|
||||
eventType: { in: ROMANCE_POSITIVE_EVENT_TYPES },
|
||||
occurredAt: { gte: new Date(now.getTime() - OVER_EAGER_WINDOW_MS) },
|
||||
},
|
||||
});
|
||||
if (recentPositiveCount >= OVER_EAGER_EVENT_COUNT) {
|
||||
// L-5:推得太急反而回落——短時間內密集推進,正向 delta 反轉為回落。
|
||||
delta = Math.abs(delta) * OVER_EAGER_PENALTY_RATIO;
|
||||
overEager = true;
|
||||
}
|
||||
}
|
||||
|
||||
const nextMeter = clamp(relationship.romanceMeter + delta, 0, 100);
|
||||
let nextStage: RomanceStage;
|
||||
if (relationship.romanceStage === "PARTNER" && eventType === "DISAPPOINTMENT" && options.severe) {
|
||||
nextStage = "CRISIS"; // L-5 關係危機:戀人階段的重大失望,直接跳危機,需要修復事件才能回來。
|
||||
} else if (relationship.romanceStage === "CRISIS") {
|
||||
nextStage =
|
||||
ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType) && nextMeter >= CRISIS_REPAIR_METER_THRESHOLD
|
||||
? "PARTNER"
|
||||
: "CRISIS";
|
||||
} else if (relationship.romanceStage === "PARTNER" || relationship.romanceStage === "DEEP_PARTNER") {
|
||||
nextStage = relationship.romanceStage; // 一般心動事件不會讓已成立的戀人關係自動退回較早階段。
|
||||
} else {
|
||||
nextStage = this.downgradeStageIfNeeded(this.advanceStageIfNeeded(relationship.romanceStage, nextMeter), nextMeter);
|
||||
}
|
||||
|
||||
const staysInStage = nextStage === relationship.romanceStage;
|
||||
const eventsInStage = staysInStage
|
||||
? relationship.romanceEventsInStage + (ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType) ? 1 : 0)
|
||||
: 0;
|
||||
|
||||
await this.prisma.client.relationship.update({
|
||||
where: { id: relationship.id },
|
||||
data: {
|
||||
romanceMeter: nextMeter,
|
||||
romanceStage: nextStage,
|
||||
lastRomanceEventAt: now,
|
||||
romanceEventsInStage: eventsInStage,
|
||||
},
|
||||
});
|
||||
await this.prisma.client.romanceEventLog.create({
|
||||
data: { characterId, userId, eventType, delta, occurredAt: now },
|
||||
});
|
||||
|
||||
return { applied: true, romanceMeter: nextMeter, romanceStage: nextStage, delta, overEager };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user