feat: 完成 N 群組 — 後日談模式
錨定點抵達已出版內容盡頭時切入後日談區間;時間流速(即時同步/緩速/凍結, 另加一個非文件既定的 FAST 選項供一般角色使用)對後日談推演成年角色強制 上限 1:1,重用 L 群組 RomanceEligibilityService 的 isEpilogueAdult 判斷。 性格漂移僅在正史已有 >=2 筆 PersonalityTraitSnapshot 建立趨勢時才位移, 無證據則鎖定 0(不憑空轉向),原型字串本身從不被觸碰。人生階段推演與 角色群同步成長重用 M 群組的 Scene/ProjectionService(推演事件其實就是 sourceType=EPILOGUE 的場景,在場者投影後自然敘述互相呼應);重大轉折 需要正史目標關鍵字對應才生成,並以 EpilogueMilestoneLog 節流頻率。 正史回收(移除被取代的推演場景、匯入官方新場景並強制投影、互動記憶 結構性保留、記憶修正事件敘事化消化)與分支保留(永久標記、擋下後續 回收)。 修正兩個真實 bug:reclaim 原本借用 importScene 的錨定閥門,導致回收 回來的正史內容反而不被投影(後日談的『現在』本來就走在錨定點之前); 分支保留擋下重新匯入時用裸 Error 導致 500,改為 ConflictException。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c66799ee7c
commit
eccdcba45c
@@ -13,6 +13,7 @@ import { TaskModule } from "./task/task.module.js";
|
||||
import { RoomModule } from "./room/room.module.js";
|
||||
import { RomanceModule } from "./romance/romance.module.js";
|
||||
import { CanonModule } from "./canon/canon.module.js";
|
||||
import { EpilogueModule } from "./epilogue/epilogue.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -29,6 +30,7 @@ import { CanonModule } from "./canon/canon.module.js";
|
||||
RoomModule,
|
||||
RomanceModule,
|
||||
CanonModule,
|
||||
EpilogueModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ConflictException, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { SceneImportService, type ImportSceneInput } from "../canon/scene-import.service.js";
|
||||
import { ProjectionService } from "../canon/projection.service.js";
|
||||
import { TimeFlowService } from "./time-flow.service.js";
|
||||
|
||||
export interface ReclaimResult {
|
||||
removedEpilogueScenes: number;
|
||||
importedCanonScenes: number;
|
||||
affectedCharacterIds: string[];
|
||||
}
|
||||
|
||||
// N-5 官方續篇出版時:正史回收(推演區間被正史替換,互動記憶保留+敘事化的記憶修正)
|
||||
// 或分支保留(永久分岔,之後官方新內容都不匯入)。選擇權在使用者,這裡只提供兩條路徑各自的執行機制,
|
||||
// 「依推演深淺給預設建議」是呼叫端(UI/未來的 Q 群組 App)的責任,不在引擎層決定。
|
||||
@Injectable()
|
||||
export class CanonReclamationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sceneImport: SceneImportService,
|
||||
private readonly projection: ProjectionService,
|
||||
private readonly timeFlow: TimeFlowService,
|
||||
) {}
|
||||
|
||||
async reclaim(workId: string, newCanonScenes: ImportSceneInput[]): Promise<ReclaimResult> {
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||
if (work.branchedFromCanon) {
|
||||
throw new ConflictException("這個作品實例已經選擇分支保留,不再匯入官方新內容");
|
||||
}
|
||||
|
||||
const minNewOrder = Math.min(...newCanonScenes.map((scene) => scene.storyOrder));
|
||||
const superseded = await this.prisma.client.scene.findMany({
|
||||
where: { workId, sourceType: "EPILOGUE", storyOrder: { gte: minNewOrder } },
|
||||
include: { participants: true },
|
||||
});
|
||||
|
||||
const affectedCharacterIds = new Set<string>();
|
||||
for (const scene of superseded) {
|
||||
for (const presence of scene.participants) {
|
||||
affectedCharacterIds.add(presence.characterId);
|
||||
}
|
||||
// 清掉這場推演事件投影出的所有角色資料——同 M-7 reprojectScene 的刪除邏輯,一樣只篩
|
||||
// sourceSceneId/sceneId,使用者互動記憶(source=INTERACTION)從不帶這些欄位,不會被動到。
|
||||
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId: scene.id } });
|
||||
await this.prisma.client.sceneLine.deleteMany({ where: { sceneId: scene.id } });
|
||||
await this.prisma.client.scenePresence.deleteMany({ where: { sceneId: scene.id } });
|
||||
await this.prisma.client.scene.delete({ where: { id: scene.id } });
|
||||
}
|
||||
|
||||
// 敘事化消化:不假裝那段推演的人生沒發生過,而是讓她「發現其實不是這樣」。
|
||||
for (const characterId of affectedCharacterIds) {
|
||||
await this.prisma.client.episodicMemory.create({
|
||||
data: {
|
||||
characterId,
|
||||
content: "官方後來公開了那段時間真正發生的事,跟我原本以為的不太一樣……不過沒關係,那也是我曾經這樣想過的證明。",
|
||||
occurredAt: new Date(),
|
||||
emotionTag: "CALM",
|
||||
emotionIntensity: 0.4,
|
||||
source: "CORRECTION",
|
||||
weight: 1.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 這些場景取代的是「已經活過」的後日談時段,正史錨定點的知識邊界對它們沒有意義——即使場景
|
||||
// storyOrder 超過目前的 anchorStoryOrder(後日談本來就走在正史錨定點之前),也要直接投影,
|
||||
// 並把錨定點一併推進到覆蓋這批正史內容,讓「正史知識邊界」與「已經投影的正史」保持一致。
|
||||
let maxImportedOrder = work.anchorStoryOrder;
|
||||
for (const sceneInput of newCanonScenes) {
|
||||
const scene = await this.sceneImport.importScene(sceneInput);
|
||||
if (!scene.projected) {
|
||||
await this.projection.projectScene(scene.id);
|
||||
}
|
||||
maxImportedOrder = Math.max(maxImportedOrder, scene.storyOrder);
|
||||
}
|
||||
if (maxImportedOrder > work.anchorStoryOrder) {
|
||||
await this.prisma.client.work.update({ where: { id: workId }, data: { anchorStoryOrder: maxImportedOrder } });
|
||||
}
|
||||
await this.timeFlow.checkEpilogueTransition(workId);
|
||||
|
||||
return {
|
||||
removedEpilogueScenes: superseded.length,
|
||||
importedCanonScenes: newCanonScenes.length,
|
||||
affectedCharacterIds: [...affectedCharacterIds],
|
||||
};
|
||||
}
|
||||
|
||||
async branch(workId: string) {
|
||||
return this.prisma.client.work.update({ where: { id: workId }, data: { branchedFromCanon: true } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// N-1 時間流速:系統文件本身只定義了即時同步/緩速/凍結三種模式,皆不超過現實 1:1;
|
||||
// 這裡額外加一個「加速」選項給一般角色使用(給使用者跳過等待的彈性),
|
||||
// 後日談自然成年角色的「流速上限為現實 1:1」規則就是靠禁用這個選項來實作與驗收的。
|
||||
export type TimeFlowMode = "FAST" | "REALTIME" | "SLOW" | "FROZEN";
|
||||
export const TIME_FLOW_MODES: TimeFlowMode[] = ["FAST", "REALTIME", "SLOW", "FROZEN"];
|
||||
|
||||
// N-4 人生階段推演:重大轉折需低頻——同一角色兩次里程碑之間至少要間隔這麼多個年表單位。
|
||||
export const MILESTONE_MIN_STORY_GAP = 5;
|
||||
|
||||
// N-2 性格漂移:方向由正史成長軌跡外插,但幅度必須有上限(性格核心鎖定,不是改寫)。
|
||||
export const MAX_EXPRESSIVENESS_DRIFT = 0.15;
|
||||
export const MAX_HALF_LIFE_REDUCTION = 0.3; // 情緒衰減最多加快 30%
|
||||
export const HALF_LIFE_REDUCTION_PER_STORY_UNIT = 0.01;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { TimeFlowService } from "./time-flow.service.js";
|
||||
import { PersonalityDriftService } from "./personality-drift.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
import { CanonReclamationService } from "./canon-reclamation.service.js";
|
||||
import type { TimeFlowMode } from "./constants.js";
|
||||
import type { ImportSceneInput } from "../canon/scene-import.service.js";
|
||||
|
||||
@Controller("epilogue")
|
||||
export class EpilogueController {
|
||||
constructor(
|
||||
private readonly timeFlow: TimeFlowService,
|
||||
private readonly drift: PersonalityDriftService,
|
||||
private readonly lifeEvent: LifeEventService,
|
||||
private readonly reclamation: CanonReclamationService,
|
||||
) {}
|
||||
|
||||
@Post("works/:workId/check-transition")
|
||||
async checkTransition(@Param("workId") workId: string) {
|
||||
return this.timeFlow.checkEpilogueTransition(workId);
|
||||
}
|
||||
|
||||
@Post("works/:workId/time-flow")
|
||||
async setTimeFlow(@Param("workId") workId: string, @Body() body: { mode: TimeFlowMode }) {
|
||||
return this.timeFlow.setTimeFlow(workId, body.mode);
|
||||
}
|
||||
|
||||
@Get("characters/:characterId/drift")
|
||||
async getDrift(@Param("characterId") characterId: string, @Query("atStoryOrder") atStoryOrder: string) {
|
||||
return this.drift.computeDrift(characterId, Number(atStoryOrder));
|
||||
}
|
||||
|
||||
@Post("characters/:characterId/milestone")
|
||||
async proposeMilestone(
|
||||
@Param("characterId") characterId: string,
|
||||
@Body() body: { storyOrder: number; attendeeCharacterIds?: string[]; now?: string },
|
||||
) {
|
||||
return this.lifeEvent.proposeMilestone(
|
||||
characterId,
|
||||
body.storyOrder,
|
||||
body.attendeeCharacterIds ?? [],
|
||||
body.now ? new Date(body.now) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("works/:workId/reclaim")
|
||||
async reclaim(@Param("workId") workId: string, @Body() body: { newCanonScenes: ImportSceneInput[] }) {
|
||||
return this.reclamation.reclaim(workId, body.newCanonScenes);
|
||||
}
|
||||
|
||||
@Post("works/:workId/branch")
|
||||
async branch(@Param("workId") workId: string) {
|
||||
return this.reclamation.branch(workId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { RomanceModule } from "../romance/romance.module.js";
|
||||
import { CanonModule } from "../canon/canon.module.js";
|
||||
import { TimeFlowService } from "./time-flow.service.js";
|
||||
import { PersonalityDriftService } from "./personality-drift.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
import { CanonReclamationService } from "./canon-reclamation.service.js";
|
||||
import { EpilogueController } from "./epilogue.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RomanceModule, CanonModule],
|
||||
controllers: [EpilogueController],
|
||||
providers: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService],
|
||||
exports: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService],
|
||||
})
|
||||
export class EpilogueModule {}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ProjectionService } from "../canon/projection.service.js";
|
||||
import { MILESTONE_MIN_STORY_GAP } from "./constants.js";
|
||||
|
||||
export interface MilestoneTemplate {
|
||||
keyword: string;
|
||||
label: string;
|
||||
describe: (formalName: string) => string;
|
||||
}
|
||||
|
||||
// N-4 人生階段推演:依角色在正史裡確立的目標推演里程碑——沒有目標文字可對應的角色不生成重大轉折,
|
||||
// 避免無正史依據地捏造事件(憑空轉向)。
|
||||
const MILESTONE_TEMPLATES: MilestoneTemplate[] = [
|
||||
{ keyword: "麵包店", label: "籌備麵包店", describe: (name) => `${name}開始四處看店面,籌備她一直想開的麵包店。` },
|
||||
{ keyword: "麵包店", label: "麵包店開幕", describe: (name) => `${name}的麵包店終於開幕了,雖然忙得團團轉,但看起來很幸福。` },
|
||||
{ keyword: "考試", label: "考上目標學校", describe: (name) => `${name}收到了合格通知,離她的目標又近了一步。` },
|
||||
{ keyword: "畫家", label: "個展", describe: (name) => `${name}辦了第一次個人畫展,邀請了熟悉的朋友們。` },
|
||||
];
|
||||
|
||||
// N-3/N-4 人生階段推演與角色群同步成長:生成的里程碑直接重用 M 群組的場景資料庫與投影機制——
|
||||
// 一場戲的在場者是誰,誰就會得到對應的情節記憶,這正是「A 的婚禮 B 有出席」的實作基礎,
|
||||
// 不需要另外寫一套「多角色事件廣播」機制。
|
||||
@Injectable()
|
||||
export class LifeEventService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly projection: ProjectionService,
|
||||
) {}
|
||||
|
||||
private matchTemplate(goalsObsessions: string, excludeLabels: Set<string>): MilestoneTemplate | null {
|
||||
for (const template of MILESTONE_TEMPLATES) {
|
||||
if (goalsObsessions.includes(template.keyword) && !excludeLabels.has(template.label)) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 低頻節流 + 需要正史伏筆(goalsObsessions 裡要能對應到模板關鍵字)才會生成重大轉折。
|
||||
async proposeMilestone(
|
||||
characterId: string,
|
||||
storyOrder: number,
|
||||
attendeeCharacterIds: string[] = [],
|
||||
now: Date = new Date(),
|
||||
): Promise<{ scene: Awaited<ReturnType<PrismaService["client"]["scene"]["create"]>> | null; label: string | null }> {
|
||||
const lastMilestone = await this.prisma.client.epilogueMilestoneLog.findFirst({
|
||||
where: { characterId },
|
||||
orderBy: { storyOrder: "desc" },
|
||||
});
|
||||
if (lastMilestone && storyOrder - lastMilestone.storyOrder < MILESTONE_MIN_STORY_GAP) {
|
||||
return { scene: null, label: null }; // 低頻:離上次里程碑太近,不生成。
|
||||
}
|
||||
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const usedLabels = new Set(
|
||||
(await this.prisma.client.epilogueMilestoneLog.findMany({ where: { characterId } })).map((log) => log.label),
|
||||
);
|
||||
const template = this.matchTemplate(character.goalsObsessions, usedLabels);
|
||||
if (!template) {
|
||||
return { scene: null, label: null }; // 沒有正史伏筆可對應,不憑空捏造重大轉折。
|
||||
}
|
||||
|
||||
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId } });
|
||||
const formalName = alias?.formalName ?? character.id;
|
||||
const description = template.describe(formalName);
|
||||
|
||||
const scene = await this.generateSharedEvent(
|
||||
character.workId!,
|
||||
characterId,
|
||||
storyOrder,
|
||||
description,
|
||||
attendeeCharacterIds,
|
||||
now,
|
||||
);
|
||||
|
||||
await this.prisma.client.epilogueMilestoneLog.create({ data: { characterId, storyOrder, label: template.label } });
|
||||
return { scene, label: template.label };
|
||||
}
|
||||
|
||||
// N-3 角色群同步成長:一個推演事件的在場者可以是多個角色,投影後每個在場者都拿到對應的情節記憶,
|
||||
// 敘述互相呼應(同一份場景摘要)。事件生成即代表「現在」,不受 M-9 錨定點前置判斷節制(不會超前於現在)。
|
||||
async generateSharedEvent(
|
||||
workId: string,
|
||||
primaryCharacterId: string,
|
||||
storyOrder: number,
|
||||
description: string,
|
||||
attendeeCharacterIds: string[],
|
||||
now: Date = new Date(),
|
||||
) {
|
||||
const allPresent = [primaryCharacterId, ...attendeeCharacterIds.filter((id) => id !== primaryCharacterId)];
|
||||
const scene = await this.prisma.client.scene.create({
|
||||
data: {
|
||||
workId,
|
||||
volume: 0,
|
||||
chapterLabel: "後日談",
|
||||
storyOrder,
|
||||
sourceType: "EPILOGUE",
|
||||
summary: description,
|
||||
rawText: description,
|
||||
createdAt: now,
|
||||
},
|
||||
});
|
||||
for (const characterId of allPresent) {
|
||||
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
|
||||
}
|
||||
await this.prisma.client.sceneLine.create({
|
||||
data: { sceneId: scene.id, lineIndex: 0, lineType: "NARRATION", rawText: description, status: "CONFIRMED", confidence: 1 },
|
||||
});
|
||||
await this.projection.projectScene(scene.id);
|
||||
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||
import { MAX_EXPRESSIVENESS_DRIFT, MAX_HALF_LIFE_REDUCTION, HALF_LIFE_REDUCTION_PER_STORY_UNIT } from "./constants.js";
|
||||
|
||||
export interface DriftedPersonality {
|
||||
archetype: string; // 性格核心,從不改變
|
||||
expressiveness: number; // 已套用漂移的外顯度(G-1 原始值 ± 小幅位移)
|
||||
driftAmount: number; // 這次位移了多少(可為負)
|
||||
emotionHalfLifeMultiplier: number; // <1 代表情緒衰減變快
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
// N-2 性格漂移:性格核心(原型本身)鎖定不變,只有數值參數依正史已顯現的成長軌跡外插做小幅位移——
|
||||
// 沒有證據支持的方向不會憑空轉向(沒有至少兩筆正史快照時,漂移量為 0)。
|
||||
@Injectable()
|
||||
export class PersonalityDriftService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async computeDrift(characterId: string, atStoryOrder: number): Promise<DriftedPersonality> {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const base = getArchetypeParams(character.personalityArchetype);
|
||||
|
||||
const canonSnapshots = await this.prisma.client.personalityTraitSnapshot.findMany({
|
||||
where: { characterId, expressivenessOverride: { not: null } },
|
||||
orderBy: { atStoryOrder: "asc" },
|
||||
});
|
||||
|
||||
let trend = 0;
|
||||
let referenceOrder = atStoryOrder;
|
||||
if (canonSnapshots.length >= 2) {
|
||||
const first = canonSnapshots[0];
|
||||
const last = canonSnapshots[canonSnapshots.length - 1];
|
||||
if (last.atStoryOrder !== first.atStoryOrder) {
|
||||
trend = (last.expressivenessOverride! - first.expressivenessOverride!) / (last.atStoryOrder - first.atStoryOrder);
|
||||
}
|
||||
referenceOrder = last.atStoryOrder;
|
||||
}
|
||||
|
||||
const elapsed = Math.max(0, atStoryOrder - referenceOrder);
|
||||
const rawDrift = trend * elapsed;
|
||||
const driftAmount = Math.max(-MAX_EXPRESSIVENESS_DRIFT, Math.min(MAX_EXPRESSIVENESS_DRIFT, rawDrift));
|
||||
const expressiveness = clamp01(base.expressiveness + driftAmount);
|
||||
|
||||
const halfLifeReduction = Math.min(MAX_HALF_LIFE_REDUCTION, elapsed * HALF_LIFE_REDUCTION_PER_STORY_UNIT);
|
||||
|
||||
return {
|
||||
archetype: character.personalityArchetype,
|
||||
expressiveness,
|
||||
driftAmount,
|
||||
emotionHalfLifeMultiplier: 1 - halfLifeReduction,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
|
||||
import type { TimeFlowMode } from "./constants.js";
|
||||
|
||||
// N-1 區間切換與時間流速:錨定點抵達已出版內容盡頭後切入後日談;後日談成年角色的流速上限為現實 1:1,
|
||||
// 只能調慢或凍結——這條規則在引擎層執行(不是使用者介面隱藏選項),任何嘗試設定更快流速都會被拒絕。
|
||||
@Injectable()
|
||||
export class TimeFlowService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly eligibility: RomanceEligibilityService,
|
||||
) {}
|
||||
|
||||
// 錨定點推進後呼叫:若已達到(或超過)已出版內容的盡頭,切入後日談區間。
|
||||
async checkEpilogueTransition(workId: string) {
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||
if (work.inEpilogue) {
|
||||
return work;
|
||||
}
|
||||
const canonMax = await this.prisma.client.scene.aggregate({
|
||||
where: { workId, sourceType: { not: "EPILOGUE" } },
|
||||
_max: { storyOrder: true },
|
||||
});
|
||||
const maxCanonStoryOrder = canonMax._max.storyOrder ?? 0;
|
||||
if (maxCanonStoryOrder > 0 && work.anchorStoryOrder >= maxCanonStoryOrder) {
|
||||
return this.prisma.client.work.update({
|
||||
where: { id: work.id },
|
||||
data: { inEpilogue: true, epilogueStartStoryOrder: work.anchorStoryOrder },
|
||||
});
|
||||
}
|
||||
return work;
|
||||
}
|
||||
|
||||
async setTimeFlow(workId: string, mode: TimeFlowMode) {
|
||||
if (mode === "FAST") {
|
||||
const characters = await this.prisma.client.character.findMany({ where: { workId } });
|
||||
for (const character of characters) {
|
||||
const eligibility = await this.eligibility.getEligibility(character.id);
|
||||
if (eligibility.isEpilogueAdult) {
|
||||
throw new BadRequestException(
|
||||
`角色 ${character.id} 是正史未成年、後日談推演成年——時間流速上限為現實 1:1,不可設定快轉`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.prisma.client.work.update({ where: { id: workId }, data: { timeFlowMode: mode } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user