Files
Kokorone/scripts/smoke/N.mjs
JefferyandClaude Sonnet 5 eccdcba45c 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>
2026-08-13 17:18:53 +08:00

229 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } });
}