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:
Jeffery
2026-08-13 17:18:53 +08:00
co-authored by Claude Sonnet 5
parent c66799ee7c
commit eccdcba45c
12 changed files with 692 additions and 6 deletions
+2
View File
@@ -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 } });
}
}
+13
View File
@@ -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);
}
}
+17
View File
@@ -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 {}
+113
View File
@@ -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 } });
}
}