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
+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 } });
}