From eccdcba45ce732664efb5cd03d633b4e51e62194 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 13 Aug 2026 17:18:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20N=20=E7=BE=A4?= =?UTF-8?q?=E7=B5=84=20=E2=80=94=20=E5=BE=8C=E6=97=A5=E8=AB=87=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 錨定點抵達已出版內容盡頭時切入後日談區間;時間流速(即時同步/緩速/凍結, 另加一個非文件既定的 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 --- apps/api/src/app.module.ts | 2 + .../src/epilogue/canon-reclamation.service.ts | 94 ++++++++ apps/api/src/epilogue/constants.ts | 13 + apps/api/src/epilogue/epilogue.controller.ts | 55 +++++ apps/api/src/epilogue/epilogue.module.ts | 17 ++ apps/api/src/epilogue/life-event.service.ts | 113 +++++++++ .../src/epilogue/personality-drift.service.ts | 57 +++++ apps/api/src/epilogue/time-flow.service.ts | 49 ++++ .../migration.sql | 32 +++ prisma/schema.prisma | 18 ++ scripts/smoke/N.mjs | 228 ++++++++++++++++++ todo.md | 20 +- 12 files changed, 692 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/epilogue/canon-reclamation.service.ts create mode 100644 apps/api/src/epilogue/constants.ts create mode 100644 apps/api/src/epilogue/epilogue.controller.ts create mode 100644 apps/api/src/epilogue/epilogue.module.ts create mode 100644 apps/api/src/epilogue/life-event.service.ts create mode 100644 apps/api/src/epilogue/personality-drift.service.ts create mode 100644 apps/api/src/epilogue/time-flow.service.ts create mode 100644 prisma/migrations/20260813090238_add_epilogue_mode/migration.sql create mode 100644 scripts/smoke/N.mjs diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7559e57..5809d39 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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], }) diff --git a/apps/api/src/epilogue/canon-reclamation.service.ts b/apps/api/src/epilogue/canon-reclamation.service.ts new file mode 100644 index 0000000..23f669e --- /dev/null +++ b/apps/api/src/epilogue/canon-reclamation.service.ts @@ -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 { + 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(); + 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 } }); + } +} diff --git a/apps/api/src/epilogue/constants.ts b/apps/api/src/epilogue/constants.ts new file mode 100644 index 0000000..7b61451 --- /dev/null +++ b/apps/api/src/epilogue/constants.ts @@ -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; diff --git a/apps/api/src/epilogue/epilogue.controller.ts b/apps/api/src/epilogue/epilogue.controller.ts new file mode 100644 index 0000000..bddc799 --- /dev/null +++ b/apps/api/src/epilogue/epilogue.controller.ts @@ -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); + } +} diff --git a/apps/api/src/epilogue/epilogue.module.ts b/apps/api/src/epilogue/epilogue.module.ts new file mode 100644 index 0000000..11ece4c --- /dev/null +++ b/apps/api/src/epilogue/epilogue.module.ts @@ -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 {} diff --git a/apps/api/src/epilogue/life-event.service.ts b/apps/api/src/epilogue/life-event.service.ts new file mode 100644 index 0000000..663fa67 --- /dev/null +++ b/apps/api/src/epilogue/life-event.service.ts @@ -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): 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> | 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 } }); + } +} diff --git a/apps/api/src/epilogue/personality-drift.service.ts b/apps/api/src/epilogue/personality-drift.service.ts new file mode 100644 index 0000000..af2597e --- /dev/null +++ b/apps/api/src/epilogue/personality-drift.service.ts @@ -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 { + 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, + }; + } +} diff --git a/apps/api/src/epilogue/time-flow.service.ts b/apps/api/src/epilogue/time-flow.service.ts new file mode 100644 index 0000000..731bef0 --- /dev/null +++ b/apps/api/src/epilogue/time-flow.service.ts @@ -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 } }); + } +} diff --git a/prisma/migrations/20260813090238_add_epilogue_mode/migration.sql b/prisma/migrations/20260813090238_add_epilogue_mode/migration.sql new file mode 100644 index 0000000..23c1f99 --- /dev/null +++ b/prisma/migrations/20260813090238_add_epilogue_mode/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0eaf8a3..74fd5f9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/scripts/smoke/N.mjs b/scripts/smoke/N.mjs new file mode 100644 index 0000000..ce74a89 --- /dev/null +++ b/scripts/smoke/N.mjs @@ -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 } }); +} diff --git a/todo.md b/todo.md index e4e380e..8bfef60 100644 --- a/todo.md +++ b/todo.md @@ -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. 立繪子系統