import { prisma } from "@kokorone/db"; const API_PORT = process.env.PORT_API ?? "3001"; const USER_ID = "smoke-u-user"; const WORK_ID = "smoke-u-work"; const CHAR_A = "smoke-u-char-a"; const CHAR_B = "smoke-u-char-b"; const CHAR_C = "smoke-u-char-c"; const CHAR_BAKER = "smoke-u-char-baker"; const CHAR_FAST = "smoke-u-char-fast"; const CHAR_REALTIME = "smoke-u-char-realtime"; const WORK_REALTIME = "smoke-u-work-realtime"; const ALL_CHAR_IDS = [CHAR_A, CHAR_B, CHAR_C, CHAR_BAKER, CHAR_FAST, CHAR_REALTIME]; 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 ?? {}), }); if (!res.ok) { throw new Error(`POST ${path} 回傳 ${res.status}:${await res.text()}`); } return res.json(); } async function get(path) { const res = await fetch(`http://localhost:${API_PORT}${path}`); if (!res.ok) { throw new Error(`GET ${path} 回傳 ${res.status}:${await res.text()}`); } return res.json(); } async function createCharacter(id, formalName, workId, goalsObsessions = "測試") { await post("/personality/characters", { id, workId, source: workId ? "EXISTING_WORK" : "ORIGINAL", buildStatus: "BUILT", formalName, basicInfo: `${formalName},測試角色`, backgroundStory: "測試用背景", personalityArchetype: "元氣", likesDislikes: "測試", goalsObsessions, speechStyle: "第一人稱「我」", }); } async function cleanup() { const charIds = ALL_CHAR_IDS; await prisma.epilogueMilestoneLog.deleteMany({ where: { characterId: { in: charIds } } }); await prisma.characterEmotionBaseline.deleteMany({ where: { characterId: { in: charIds } } }); await prisma.proceduralRule.deleteMany({ where: { characterId: { in: charIds } } }); await prisma.emotionState.deleteMany({ where: { characterId: { in: charIds } } }); await prisma.sleepDebt.deleteMany({ where: { characterId: { in: charIds } } }); await prisma.scheduleSlot.deleteMany({ where: { characterId: { in: charIds } } }); await prisma.episodicMemory.deleteMany({ where: { characterId: { in: charIds } } }); await prisma.roomTurn.deleteMany({ where: { speakerCharacterId: { in: charIds } } }); await prisma.room.deleteMany({ where: { userId: USER_ID } }); await prisma.scene.deleteMany({ where: { workId: { in: [WORK_ID, WORK_REALTIME] } } }); await prisma.character.deleteMany({ where: { id: { in: charIds } } }); await prisma.work.deleteMany({ where: { id: { in: [WORK_ID, WORK_REALTIME] } } }); await prisma.user.deleteMany({ where: { id: USER_ID } }); } // T21-P4:P4(匯入:兩階段裁決阻斷、每章結局偵測與確認、日後談續匯入採過去記憶、情緒基線與行為 // 規則連動、時間流速界線與自主里程碑)端到端驗證,走過一次「匯入章節→裁決→確認結局→日後談續 // 匯入」主流程。 export default async function smokeU() { await cleanup(); await prisma.user.create({ data: { id: USER_ID, name: "U 群組測試使用者" } }); await prisma.work.create({ data: { id: WORK_ID, title: "U 群組測試作品", anchorStoryOrder: 100 } }); await createCharacter(CHAR_A, "小U甲", WORK_ID); await createCharacter(CHAR_B, "小U乙", WORK_ID); await createCharacter(CHAR_C, "小U丙", WORK_ID); await createCharacter(CHAR_BAKER, "小U丁", WORK_ID, "她一直想開一間屬於自己的麵包店。"); // ── 1/2:含爭議章節在裁決前不更動角色資料;裁決後才投影並偵測結局 ───────────────────── const disputedScene = await post("/canon/scenes", { workId: WORK_ID, volume: 1, chapterLabel: "U 群組爭議章", storyOrder: 1, summary: "三人在場的一段對話。全劇終。", rawText: "(smoke 測試文本)", participantCharacterIds: [CHAR_A, CHAR_B, CHAR_C], lines: [{ lineType: "DIALOGUE", rawText: "這句話是誰說的呢?" }], }); if (!disputedScene.pendingAdjudication || disputedScene.projected) { throw new Error("三人在場、無明示標記的台詞應被隔離,場景應暫不投影"); } // 角色建立時會依 backgroundStory 種一筆無關記憶(F 群組既有行為),故不能用「該角色是否有任何記憶」 // 判斷;改為直接檢查這個場景自己有沒有投影出任何 sourceSceneId 對應到它的記憶。 const memoriesBeforeAdjudication = await prisma.episodicMemory.count({ where: { sourceSceneId: disputedScene.id } }); if (memoriesBeforeAdjudication > 0) { throw new Error("裁決前不應有任何角色資料被投影更動"); } const pendingBefore = await get(`/canon/works/${WORK_ID}/pending-adjudication`); if (pendingBefore.length !== 1) { throw new Error(`裁決前應有 1 筆待裁決場景,實際為 ${pendingBefore.length}`); } const quarantinedLine = disputedScene.lines.find((l) => l.status === "QUARANTINED"); await post(`/canon/scene-lines/${quarantinedLine.id}/adjudicate`, { speakerCharacterId: CHAR_A }); const pendingAfter = await get(`/canon/works/${WORK_ID}/pending-adjudication`); if (pendingAfter.length !== 0) { throw new Error("裁決完成後,該場景不應再出現在待裁決清單"); } const sceneAfterAdjudication = await get(`/canon/scenes/${disputedScene.id}`); if (!sceneAfterAdjudication.projected) { throw new Error("裁決完成後應接續完成投影"); } // ── 3/4/5:每章 LLM 偵測結局並提示;確認後切日後談;拒絕後不重複提示 ───────────────── const endingsAfterAdjudication = await get(`/canon/works/${WORK_ID}/ending-detections`); if (endingsAfterAdjudication.length !== 1) { throw new Error(`裁決完成觸發投影後應偵測到 1 筆待確認結局,實際為 ${endingsAfterAdjudication.length}`); } if (!endingsAfterAdjudication[0].evidence) { throw new Error("結局偵測待確認清單應附判定理由(evidence)"); } const workBeforeConfirm = await get("/canon/works").then((all) => all.find((w) => w.id === WORK_ID)); if (workBeforeConfirm.inEpilogue) { throw new Error("結局偵測成立後,未確認前不應直接切入後日談"); } await post(`/epilogue/ending-detections/${endingsAfterAdjudication[0].id}/confirm`, {}); const workAfterConfirm = await get("/canon/works").then((all) => all.find((w) => w.id === WORK_ID)); if (!workAfterConfirm.inEpilogue) { throw new Error("確認結局偵測後,作品應切入後日談"); } const endingsAfterConfirm = await get(`/canon/works/${WORK_ID}/ending-detections`); if (endingsAfterConfirm.length !== 0) { throw new Error("確認後該筆偵測不應再出現在待確認清單"); } // 拒絕不重複提示:另一場景匯入結局訊號後拒絕,同一場景不應再次出現在待確認清單。 const secondEndingScene = await post("/canon/scenes", { workId: WORK_ID, volume: 1, chapterLabel: "U 群組第二個結局章", storyOrder: 101, summary: "大結局。", rawText: "(smoke 測試文本)", participantCharacterIds: [CHAR_A], lines: [{ lineType: "NARRATION", rawText: "故事到此結束。" }], }); const secondEndings = await get(`/canon/works/${WORK_ID}/ending-detections`); const secondLog = secondEndings.find((e) => e.sceneId === secondEndingScene.id); if (!secondLog) throw new Error("第二個結局章節應同樣觸發待確認提示"); await post(`/epilogue/ending-detections/${secondLog.id}/reject`, {}); const endingsAfterReject = await get(`/canon/works/${WORK_ID}/ending-detections`); if (endingsAfterReject.some((e) => e.sceneId === secondEndingScene.id)) { throw new Error("拒絕後同一場景不應再重複提示"); } // ── 6/7/8:日後談續匯入只補過去記憶且不動推演現狀;情緒基線不覆寫當下情緒;行為規則新增與加權 ── const memoriesBeforeContinuedImport = await prisma.episodicMemory.count({ where: { sourceSceneId: sceneAfterAdjudication.id } }); const anchorBeforeContinuedImport = workAfterConfirm.anchorStoryOrder; const continuedScene = await post("/canon/scenes", { workId: WORK_ID, volume: 2, chapterLabel: "U 群組後日談續匯入章", storyOrder: anchorBeforeContinuedImport + 10, summary: "小U丙失去了摯友,非常難過委屈。", rawText: "(smoke 測試文本)", participantCharacterIds: [CHAR_C], lines: [ { lineType: "NARRATION", rawText: "小U丙難過地離開了。" }, { lineType: "ACTION", rawText: "小U丙蹲在原地哭了很久。", hasExplicitMarker: true, markerCharacterId: CHAR_C }, ], }); if (!continuedScene.projected) { throw new Error("後日談期間續匯入正史章節應直接投影,不受知識邊界卡住"); } const workAfterContinuedImport = await get("/canon/works").then((all) => all.find((w) => w.id === WORK_ID)); if (workAfterContinuedImport.anchorStoryOrder !== anchorBeforeContinuedImport + 10) { throw new Error("續匯入後錨定點應推進到覆蓋這批正史內容"); } const epilogueSceneAfter = await get(`/canon/scenes/${sceneAfterAdjudication.id}`); if (!epilogueSceneAfter.projected) { throw new Error("續匯入不應影響既有已投影場景的投影狀態"); } const memoriesAfterContinuedImport = await prisma.episodicMemory.count({ where: { sourceSceneId: sceneAfterAdjudication.id } }); if (memoriesAfterContinuedImport !== memoriesBeforeContinuedImport) { throw new Error("續匯入不應改動既有場景投影出的記憶數量(角色現狀應完全不變)"); } const correctionMemories = await prisma.episodicMemory.count({ where: { characterId: CHAR_A, source: "CORRECTION" } }); if (correctionMemories !== 0) { throw new Error("日後談續匯入不應寫入敘事化的記憶修正(那是 reclaim() 才做的事)"); } // 情緒基線:續匯入的悲劇章節應合成出偏低落的基線,但不覆寫「當下」明確的情緒訊號。 // 固定台北平日白天(週三 13:00),避免真的在週末/假日跑這個 smoke 時基線被假日 JOY 蓋過。 const sadBaseline = await get(`/schedule/${CHAR_C}/baseline?now=${encodeURIComponent("2026-08-19T05:00:00.000Z")}`); if (sadBaseline.baseline?.tag !== "SAD") { throw new Error(`匯入悲劇章節後角色整體基調應偏低落,實際為 ${JSON.stringify(sadBaseline.baseline)}`); } const emotionAfterInput = await post(`/emotion/${CHAR_C}/input`, { text: "太開心了!!!中大獎了!!!" }); if (!(emotionAfterInput.state.joy > emotionAfterInput.state.calm) || emotionAfterInput.state.sad !== 0) { throw new Error( `本輪對話有明確情緒訊號(開心)時,不應被章節匯入累積出的長期基調(低落)覆寫,實際狀態為 ${JSON.stringify(emotionAfterInput.state)}`, ); } // 行為規則新增與加權:ACTION 描寫首次出現新增規則,重複匯入相同情境+回應模式應加權而非重複新增。 const topRuleAfterFirst = await get(`/dialogue/${CHAR_C}/top-rule?situation=${encodeURIComponent("小U丙失去了摯友,非常難過委屈。")}`); if (!topRuleAfterFirst || topRuleAfterFirst.weight !== 1) { throw new Error(`首次出現的行為規則應以權重 1 新增,實際為 ${JSON.stringify(topRuleAfterFirst)}`); } await post("/canon/scenes", { workId: WORK_ID, volume: 2, chapterLabel: "U 群組後日談續匯入章(二)", storyOrder: anchorBeforeContinuedImport + 11, summary: "小U丙失去了摯友,非常難過委屈。", rawText: "(smoke 測試文本)", participantCharacterIds: [CHAR_C], lines: [{ lineType: "ACTION", rawText: "小U丙蹲在原地哭了很久。", hasExplicitMarker: true, markerCharacterId: CHAR_C }], }); const topRuleAfterSecond = await get(`/dialogue/${CHAR_C}/top-rule?situation=${encodeURIComponent("小U丙失去了摯友,非常難過委屈。")}`); if (!(topRuleAfterSecond.weight > 1)) { throw new Error("相同情境與回應模式再次出現時,應強化既有規則權重,而不是重複新增"); } // ── 9/10:FAST 作品的行程與睡眠仍走真實時間;自主里程碑可追溯 ───────────────────────── await prisma.work.create({ data: { id: WORK_REALTIME, title: "U 群組對照作品(REALTIME)", timeFlowMode: "REALTIME" } }); await createCharacter(CHAR_FAST, "小U戊", WORK_ID); await createCharacter(CHAR_REALTIME, "小U己", WORK_REALTIME); await prisma.work.update({ where: { id: WORK_ID }, data: { timeFlowMode: "FAST" } }); for (const characterId of [CHAR_FAST, CHAR_REALTIME]) { await prisma.scheduleSlot.create({ data: { characterId, dayType: "WEEKDAY", startMinute: 0, endMinute: 390, status: "SLEEP" } }); } const sleepCheckNow = "2026-08-18T18:00:00.000Z"; // 台北週四 02:00,落在 SLEEP 時段內 const fastInfo = await get(`/schedule/${CHAR_FAST}/info?now=${encodeURIComponent(sleepCheckNow)}`); const realtimeInfo = await get(`/schedule/${CHAR_REALTIME}/info?now=${encodeURIComponent(sleepCheckNow)}`); if (fastInfo.slot?.status !== "SLEEP" || fastInfo.slot?.status !== realtimeInfo.slot?.status) { throw new Error("FAST 作品角色的行程判定應與 REALTIME 作品角色完全一致,不受 timeFlowMode 影響"); } const milestoneRoom = await post("/room", { userId: USER_ID, characterIds: [CHAR_BAKER], mode: "SELF_CHAT", topicSeed: "後日談自走測試", }); const t0 = "2026-08-19T00:00:00.000Z"; await post(`/room/${milestoneRoom.id}/self-chat/advance`, { now: t0 }); // 第一次 tick:只建立推演進度時鐘起點 await post(`/room/${milestoneRoom.id}/self-chat/advance`, { now: new Date(new Date(t0).getTime() + 24 * 60 * 60 * 1000).toISOString(), // 一個現實日後(FAST:3 storyOrder/日) }); const milestones = await get(`/epilogue/works/${WORK_ID}/milestones`); if (!milestones.some((m) => m.characterId === CHAR_BAKER)) { throw new Error("自走一段時間後,應能自主成立且可追溯到里程碑紀錄"); } const lobbyAfterMilestone = await get(`/lobby?userId=${USER_ID}`); const lobbyWork = lobbyAfterMilestone.works.find((w) => w.id === WORK_ID); if (!lobbyWork?.milestones?.some((m) => m.characterId === CHAR_BAKER)) { throw new Error("自主成立的里程碑應同步呈現在大廳作品卡片上"); } await cleanup(); }