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