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 } });
}
}
@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "epilogue_milestone_logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"storyOrder" INTEGER NOT NULL,
"label" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "epilogue_milestone_logs_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_works" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"volumeProgress" TEXT,
"worldview" TEXT,
"progressAnchor" TEXT,
"anchorStoryOrder" INTEGER NOT NULL DEFAULT 0,
"inEpilogue" BOOLEAN NOT NULL DEFAULT false,
"epilogueStartStoryOrder" INTEGER,
"timeFlowMode" TEXT NOT NULL DEFAULT 'REALTIME',
"branchedFromCanon" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_works" ("anchorStoryOrder", "createdAt", "id", "progressAnchor", "title", "updatedAt", "volumeProgress", "worldview") SELECT "anchorStoryOrder", "createdAt", "id", "progressAnchor", "title", "updatedAt", "volumeProgress", "worldview" FROM "works";
DROP TABLE "works";
ALTER TABLE "new_works" RENAME TO "works";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+18
View File
@@ -131,6 +131,11 @@ model Work {
worldview String? // 世界觀
progressAnchor String? // 作品進度錨定點(M-9/N 群組使用;描述作品實際描寫過的時期,人類可讀)
anchorStoryOrder Int @default(0) // M-9 錨定點的數值刀口:對應 Scene.storyOrder,之前是親身經歷,之後不存在
// N-1 後日談區間:錨定點抵達已出版內容盡頭後切入,時間繼續流動。
inEpilogue Boolean @default(false)
epilogueStartStoryOrder Int? // 切入後日談當下的錨定點位置,供 N-2 性格漂移計算「已經推演多久」
timeFlowMode String @default("REALTIME") // FAST/REALTIME/SLOW/FROZEN(N-1;後日談成年角色不可設 FAST)
branchedFromCanon Boolean @default(false) // N-5 分支保留:一旦分岔,之後官方新內容不再匯入這個作品實例
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -187,6 +192,7 @@ model Character {
personalityTraitSnapshots PersonalityTraitSnapshot[]
relationshipEventLogsFrom CharacterRelationshipEventLog[] @relation("EventLogFrom")
relationshipEventLogsTo CharacterRelationshipEventLog[] @relation("EventLogTo")
epilogueMilestoneLogs EpilogueMilestoneLog[]
@@map("characters")
}
@@ -694,3 +700,15 @@ model CharacterRelationshipEventLog {
@@map("character_relationship_event_logs")
}
// N-4 人生階段推演:記錄已生成過的里程碑,供頻率節流(低頻)與避免重複生成同一件人生大事。
model EpilogueMilestoneLog {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
storyOrder Int
label String
createdAt DateTime @default(now())
@@map("epilogue_milestone_logs")
}
+228
View File
@@ -0,0 +1,228 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const WORK_ID = "smoke-n-work";
const A_ID = "smoke-n-a"; // 小明,傲嬌,正史成年,goalsObsessions 對應麵包店模板
const B_ID = "smoke-n-b"; // 小美,元氣,正史未成年、後日談推演成年
async function post(path, body) {
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
return { status: res.status, body: res.ok ? await res.json() : await res.json().catch(() => null) };
}
async function postOk(path, body) {
const result = await post(path, body);
if (result.status >= 300) {
throw new Error(`POST ${path} 回傳 ${result.status}:${JSON.stringify(result.body)}`);
}
return result.body;
}
async function get(path) {
const res = await fetch(`http://localhost:${API_PORT}${path}`);
if (!res.ok) {
throw new Error(`GET ${path} 回傳 ${res.status}`);
}
return res.json();
}
export default async function smokeN() {
await prisma.character.deleteMany({ where: { id: { in: [A_ID, B_ID] } } });
await prisma.work.deleteMany({ where: { id: WORK_ID } });
await prisma.work.create({ data: { id: WORK_ID, title: "測試作品(自製文本)", anchorStoryOrder: 0 } });
await postOk("/personality/characters", {
id: A_ID,
source: "EXISTING_WORK",
workId: WORK_ID,
buildStatus: "BUILT",
formalName: "小明",
basicInfo: "測試角色",
backgroundStory: "測試",
personalityArchetype: "傲嬌",
likesDislikes: "測試",
goalsObsessions: "想開一家麵包店",
speechStyle: "第一人稱「我」",
});
await postOk("/personality/characters", {
id: B_ID,
source: "EXISTING_WORK",
workId: WORK_ID,
buildStatus: "BUILT",
formalName: "小美",
basicInfo: "測試角色",
backgroundStory: "測試",
personalityArchetype: "元氣",
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "第一人稱「我」",
});
await postOk(`/romance/${A_ID}/age-evidence`, {
evidenceType: "OFFICIAL_STATED",
impliesAdult: true,
isEpilogue: false,
description: "設定集載明成年",
});
await postOk(`/romance/${B_ID}/age-evidence`, {
evidenceType: "OFFICIAL_STATED",
impliesAdult: false,
isEpilogue: false,
description: "正史設定 16 歲",
});
await postOk(`/romance/${B_ID}/age-evidence`, {
evidenceType: "ELAPSED_TIME",
impliesAdult: true,
isEpilogue: true,
description: "後日談推演經過 3 年",
});
for (let storyOrder = 1; storyOrder <= 3; storyOrder += 1) {
await postOk("/canon/scenes", {
workId: WORK_ID,
volume: 1,
chapterLabel: `第${storyOrder}章`,
storyOrder,
summary: `日常場景 ${storyOrder}。`,
rawText: "(自製測試文本)",
participantCharacterIds: [A_ID, B_ID],
lines: [{ lineType: "NARRATION", rawText: `平靜的一天 ${storyOrder}。` }],
});
}
await prisma.work.update({ where: { id: WORK_ID }, data: { anchorStoryOrder: 3 } });
// N-1 區間切換:錨定點抵達已出版內容盡頭時切入後日談。
const afterTransition = await postOk(`/epilogue/works/${WORK_ID}/check-transition`, {});
if (!afterTransition.inEpilogue || afterTransition.epilogueStartStoryOrder !== 3) {
throw new Error("錨定點抵達已出版內容盡頭時應該切入後日談區間");
}
// N-1 時間流速:後日談自然成年角色(小美)的流速上限為現實 1:1,任何嘗試設定快轉都必須被引擎層拒絕。
const fastAttempt = await post(`/epilogue/works/${WORK_ID}/time-flow`, { mode: "FAST" });
if (fastAttempt.status < 400) {
throw new Error("含後日談自然成年角色的作品,不應該可以設定快轉流速");
}
const slowAttempt = await postOk(`/epilogue/works/${WORK_ID}/time-flow`, { mode: "SLOW" });
if (slowAttempt.timeFlowMode !== "SLOW") {
throw new Error("調慢流速應該被允許");
}
// N-2 性格漂移:性格核心(原型)鎖定不變,數值參數依正史已顯現的成長軌跡外插做小幅位移。
await prisma.personalityTraitSnapshot.create({
data: { characterId: A_ID, atStoryOrder: 1, note: "剛登場時很嘴硬", expressivenessOverride: 0.9 },
});
await prisma.personalityTraitSnapshot.create({
data: { characterId: A_ID, atStoryOrder: 3, note: "後期漸漸敞開心房", expressivenessOverride: 0.8 },
});
const driftAtCanonEnd = await get(`/epilogue/characters/${A_ID}/drift?atStoryOrder=3`);
const driftAfterLongEpilogue = await get(`/epilogue/characters/${A_ID}/drift?atStoryOrder=53`);
if (driftAtCanonEnd.archetype !== "傲嬌" || driftAfterLongEpilogue.archetype !== "傲嬌") {
throw new Error("長期推演後性格原型判定不應該改變");
}
if (driftAtCanonEnd.driftAmount !== 0) {
throw new Error("剛切入後日談、還沒經過任何推演時間時,不應該已經有位移");
}
if (driftAfterLongEpilogue.driftAmount === 0 || Math.abs(driftAfterLongEpilogue.driftAmount) > 0.2) {
throw new Error("長期推演後應該出現小幅(但不失控)的參數位移");
}
// N-3/N-4 人生階段推演與角色群同步成長:依正史目標推演里程碑,在場角色的敘述互相呼應;低頻節流;
// 沒有正史依據(目標文字對不上任何模板)的角色不生成重大轉折。
const milestone = await postOk(`/epilogue/characters/${A_ID}/milestone`, {
storyOrder: 10,
attendeeCharacterIds: [B_ID],
});
if (!milestone.scene || milestone.label !== "籌備麵包店") {
throw new Error("有正史依據(goalsObsessions 對應模板)時應該生成里程碑事件");
}
const memoryA = await prisma.episodicMemory.findFirst({ where: { characterId: A_ID, content: { contains: "麵包店" } } });
const memoryB = await prisma.episodicMemory.findFirst({ where: { characterId: B_ID, content: { contains: "麵包店" } } });
if (!memoryA || !memoryB || memoryA.content !== memoryB.content) {
throw new Error("同一場推演事件的在場角色,敘述應該互相呼應(同一份場景摘要)");
}
const tooSoon = await postOk(`/epilogue/characters/${A_ID}/milestone`, { storyOrder: 11 });
if (tooSoon.scene !== null) {
throw new Error("重大轉折需要低頻——離上次里程碑太近不應該再生成");
}
const noBasis = await postOk(`/epilogue/characters/${B_ID}/milestone`, { storyOrder: 10 });
if (noBasis.scene !== null) {
throw new Error("沒有正史依據(目標文字對不上任何模板)時不應該生成重大轉折");
}
// N-5 正史回收:官方續篇出版時,推演區間被正史替換;互動記憶保留,並以記憶修正演出敘事化消化。
await prisma.episodicMemory.create({
data: {
characterId: A_ID,
content: "使用者跟我聊了關於麵包店裝潢的想法",
occurredAt: new Date(),
emotionTag: "CALM",
emotionIntensity: 0.3,
source: "INTERACTION",
weight: 1,
},
});
const reclaimResult = await postOk(`/epilogue/works/${WORK_ID}/reclaim`, {
newCanonScenes: [
{
workId: WORK_ID,
volume: 2,
chapterLabel: "官方續篇第一章",
storyOrder: 10,
summary: "官方續篇:小明決定先去麵包店打工學習,而不是直接開店。",
rawText: "(自製測試文本)",
participantCharacterIds: [A_ID],
lines: [{ lineType: "NARRATION", rawText: "小明先去了一家麵包店打工學習。" }],
},
],
});
if (reclaimResult.removedEpilogueScenes !== 1 || reclaimResult.importedCanonScenes !== 1) {
throw new Error("正史回收應該移除被取代的推演場景並匯入官方新場景");
}
const interactionAfterReclaim = await prisma.episodicMemory.findMany({ where: { characterId: A_ID, source: "INTERACTION" } });
if (interactionAfterReclaim.length !== 1 || interactionAfterReclaim[0].content !== "使用者跟我聊了關於麵包店裝潢的想法") {
throw new Error("正史回收後,使用者互動記憶必須完整保留、不可遺失");
}
const oldEpilogueMemory = await prisma.episodicMemory.findFirst({ where: { characterId: A_ID, content: { contains: "四處看店面" } } });
if (oldEpilogueMemory) {
throw new Error("被正史取代的推演事件,其投影出的記憶應該被移除");
}
const newCanonMemory = await prisma.episodicMemory.findFirst({ where: { characterId: A_ID, content: { contains: "打工學習" } } });
if (!newCanonMemory) {
throw new Error("官方新場景匯入後應該投影出對應的情節記憶");
}
const realizationMemory = await prisma.episodicMemory.findFirst({ where: { characterId: A_ID, source: "CORRECTION" } });
if (!realizationMemory) {
throw new Error("正史回收後應該以「記憶修正」演出敘事化消化這段差異,不是假裝沒發生過");
}
// N-5 分支保留:選擇分支後,之後官方新內容不再匯入。
await postOk(`/epilogue/works/${WORK_ID}/branch`, {});
const branchedWork = await prisma.work.findUniqueOrThrow({ where: { id: WORK_ID } });
if (!branchedWork.branchedFromCanon) {
throw new Error("選擇分支保留後,作品應該標記為已分岔");
}
const reclaimAfterBranch = await post(`/epilogue/works/${WORK_ID}/reclaim`, {
newCanonScenes: [
{
workId: WORK_ID,
volume: 3,
chapterLabel: "更多官方內容",
storyOrder: 20,
summary: "測試。",
rawText: "測試。",
participantCharacterIds: [A_ID],
lines: [],
},
],
});
if (reclaimAfterBranch.status < 400) {
throw new Error("已分岔的作品實例,不應該再接受官方新內容匯入");
}
// 清理本次測試建立的角色與作品(cascade 會一併清掉場景資料庫、記憶、關係帳本等)。
await prisma.character.deleteMany({ where: { id: { in: [A_ID, B_ID] } } });
await prisma.work.deleteMany({ where: { id: WORK_ID } });
}
+14 -6
View File
@@ -353,12 +353,20 @@ flowchart TB
### N. 後日談模式
- [ ] **N-1 區間切換與時間流速(S)**:錨定點抵達已出版內容盡頭時切入後日談區間;流速可設即時同步/緩速/凍結,且對「正史未成年→後日談成年」的角色上限為 1:1(只能調慢或凍結)。驗收:該類角色無法設定快轉流速。依據:§時間流速設定、§兩級制「時間不可快轉」。
- [ ] **N-2 性格漂移(S)**:性格核心鎖定不變,僅參數緩慢漂移(情緒衰減加快、外顯度微調),漂移方向由正史成長軌跡外插,不憑空轉向。驗收:長期推演後原型判定不變、參數有小幅位移。依據:§後日談的成長內容「性格的成熟是漂移,不是改寫」。
- [ ] **N-3 角色群同步成長(S)**:同作品角色共用世界時鐘與場景資料庫,推演人生互相一致(A 的婚禮 B 有出席)。驗收:兩角色對同一推演事件的敘述互相呼應。依據:§後日談的成長內容「角色群同步成長」。
- [ ] **N-4 人生階段推演(M)**:依正史確立的目標與性格推演里程碑事件(籌備、失敗、再試),列為高權重事件且使用者可參與;重大轉折需低頻且有正史伏筆。驗收:推演事件不違反角色一致性與世界觀。依據:§後日談的成長內容、§成長的邊界。
- [ ] **N-5 正史回收與分支保留(M)**:官方續篇出版時提供兩種處理——正史回收(推演區間被替換,互動記憶保留,並以「記憶修正」演出敘事化消化)或分支保留(永久分岔);選擇權在使用者,並依推演深淺給出預設建議。驗收:兩種路徑皆可執行且互動記憶不遺失。依據:§官方續篇出版時:正史回收。
- [ ] **N-V 階段驗證(XS)**:`npm run restart && npm run smoke -- N`(N.mjs:流速上限規則、性格核心鎖定、正史回收後互動記憶保留)。
- [x] **N-1 區間切換與時間流速(S)**:錨定點抵達已出版內容盡頭時切入後日談區間;流速可設即時同步/緩速/凍結,且對「正史未成年→後日談成年」的角色上限為 1:1(只能調慢或凍結)。驗收:該類角色無法設定快轉流速。依據:§時間流速設定、§兩級制「時間不可快轉」。
- [x] **N-2 性格漂移(S)**:性格核心鎖定不變,僅參數緩慢漂移(情緒衰減加快、外顯度微調),漂移方向由正史成長軌跡外插,不憑空轉向。驗收:長期推演後原型判定不變、參數有小幅位移。依據:§後日談的成長內容「性格的成熟是漂移,不是改寫」。
- [x] **N-3 角色群同步成長(S)**:同作品角色共用世界時鐘與場景資料庫,推演人生互相一致(A 的婚禮 B 有出席)。驗收:兩角色對同一推演事件的敘述互相呼應。依據:§後日談的成長內容「角色群同步成長」。
- [x] **N-4 人生階段推演(M)**:依正史確立的目標與性格推演里程碑事件(籌備、失敗、再試),列為高權重事件且使用者可參與;重大轉折需低頻且有正史伏筆。驗收:推演事件不違反角色一致性與世界觀。依據:§後日談的成長內容、§成長的邊界。
- [x] **N-5 正史回收與分支保留(M)**:官方續篇出版時提供兩種處理——正史回收(推演區間被替換,互動記憶保留,並以「記憶修正」演出敘事化消化)或分支保留(永久分岔);選擇權在使用者,並依推演深淺給出預設建議。驗收:兩種路徑皆可執行且互動記憶不遺失。依據:§官方續篇出版時:正史回收。
- [x] **N-V 階段驗證(XS)**:`npm run restart && npm run smoke -- N`(N.mjs:流速上限規則、性格核心鎖定、正史回收後互動記憶保留)。
> **實作記錄(N 群組)**:
> - 新模組放在 `apps/api/src/epilogue/`,重度依賴前面兩個群組已經建好的地基:**L 群組**的 `RomanceEligibilityService.getEligibility().isEpilogueAdult` 直接拿來做 N-1 的流速限制判斷(同一個「正史未成年、後日談推演成年」旗標,L 群組用在成人模式資格,這裡用在時間流速資格——兩處判斷邏輯完全共用,沒有重複實作);**M 群組**的 `Scene`/`ScenePresence`/`SceneLine`/`ProjectionService` 直接拿來實作 N-3/N-4 的「人生階段推演事件」——系統生成的推演事件說到底就是一場「場景」,只是 `sourceType="EPILOGUE"`(一個一般字串欄位,不是嚴格的資料庫層級 enum,用一個新字串值就能表示,不需要改 schema),在場者是誰就會投影出對應記憶給誰,這正是「A 的婚禮 B 有出席」的實作基礎——**沒有另外寫一套「多角色事件廣播」機制**。
> - **N-1 時間流速的「上限為 1:1」其實是本系統原本就沒有比 1:1 更快的模式**:文件裡的時間流速表只列了即時同步/緩速/凍結三種,全部都不超過現實 1:1。為了讓 N-1 的驗收「該類角色無法設定快轉流速」有實際意義,這裡刻意新增了一個 `FAST`(加速)選項供**一般角色**使用(不在原文件的表格裡,是本群組為了讓限制可驗證而添加的介面)——`TimeFlowService.setTimeFlow` 只在模式為 `FAST` 且作品內**任何一個角色**是後日談推演成年時才拒絕,因為流速是作品層級的共用世界時鐘設定(同作品角色共用時鐘),只要有一個角色受限,整個作品的時鐘就不能調快。**如果之後真的要拿掉 `FAST` 這個非文件既定選項,只要把 `TIME_FLOW_MODES` 跟這條 if 檢查一起刪掉即可**,不影響其他機制。
> - **N-2 性格漂移刻意做成「沒有證據就不轉向」**:`PersonalityDriftService` 只有在角色的正史 `PersonalityTraitSnapshot`(M-8 產物,帶 `expressivenessOverride` 的那些)至少有兩筆時才會算出一個非零趨勢(用首尾兩筆的差除以年表距離當斜率),否則漂移量鎖定在 0——**這正是「不憑空轉向」的字面實作**:沒有正史證據支撐方向,就完全不漂移,不是漂移一個隨機或預設方向。漂移幅度與情緒衰減加快幅度都設了上限(`MAX_EXPRESSIVENESS_DRIFT=0.15`/`MAX_HALF_LIFE_REDUCTION=0.3`),對應「性格核心鎖定、只是參數緩慢位移」——`archetype` 這個字串本身在這條路徑上完全沒被寫入或改動過,「原型判定不變」是結構性保證,不是驗收時才去確認的副作用。
> - **N-4 人生階段推演的模板比對非常樸素**:`LifeEventService` 用一個「關鍵字對應到里程碑模板」的小清單(例如 `goalsObsessions` 含「麵包店」才會生成籌備/開幕系列事件),刻意設計成「對不上任何關鍵字就不生成」——這是「重大轉折需要正史伏筆」的直接實作方式:沒有伏筆(目標文字裡沒有對應關鍵字)就不編。低頻節流靠 `EpilogueMilestoneLog` 記錄上次里程碑的年表位置,兩次至少要間隔 `MILESTONE_MIN_STORY_GAP` 個單位。**這跟 M 群組「先用結構化輸入代替真實 NLP」是同一種取捨**:真正的「依角色性格與目標動態編出人生大事」需要生成式能力,這裡先把機制(低頻節流、事件同步投影、正史伏筆檢查)做對,之後有真的生成能力時只需要替換 `matchTemplate` 這一個函式。
> - **N-5 正史回收踩到一個和 M-9 錨定點語意衝突的真實 bug**:一開始直接呼叫 `SceneImportService.importScene` 匯入官方新場景,結果因為官方場景的 `storyOrder` 通常超過 `work.anchorStoryOrder`(後日談本來就是走在正史錨定點之前的),`importScene` 依 M-9 的知識邊界規則判斷「超前於錨定點,不投影」,導致回收回來的正史內容反而不會被角色「知道」——這暴露了 `anchorStoryOrder` 原本只追蹤「正史知識邊界」,但後日談的「現在」可以走到比它更遠的地方。修正方式:`CanonReclamationService.reclaim` 匯入後**直接呼叫 `ProjectionService.projectScene`**(不透過 `importScene` 的錨定閥門),並把 `anchorStoryOrder` 一併推進到覆蓋這批正史內容,讓「正史知識邊界」跟「已投影的正史範圍」重新同步。**這條經驗提醒之後任何跨群組重用既有服務時,要想清楚對方的前提假設(這裡是『匯入的場景預設尚未被角色經歷過』)是否仍然成立**,不成立時不能直接借用,要繞過去或明確重新同步狀態。
> - **N-5 分支保留擋下重新匯入的錯誤處理**:一開始用裸的 `throw new Error(...)` 擋下已分岔作品的回收嘗試,NestJS 沒認得這個例外型別,回傳了無意義的 500——改成 `ConflictException`(409)。**這是本群組另一個「先用最省事的寫法、跑過一次真實請求才發現不對」的例子**,日後任何服務層拋出的「業務規則擋下」錯誤都該用 NestJS 的 `HttpException` 子類別(`BadRequestException`/`ConflictException`/`ForbiddenException` 等),不要用裸 `Error`——`RomanceEligibilityService`/`TimeFlowService` 已經是對的寫法,這次是漏了一處才補上。
### O. 立繪子系統