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 } });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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. 立繪子系統
|
||||
|
||||
|
||||
Reference in New Issue
Block a user