import { prisma } from "@kokorone/db"; const API_PORT = process.env.PORT_API ?? "3001"; const USER_ID = "smoke-t-user"; const CHAR_A = "smoke-t-char-a"; const CHAR_B = "smoke-t-char-b"; const CHAR_SLEEP = "smoke-t-char-sleep"; const CHAR_WORK_A = "smoke-t-char-work-a"; const CHAR_WORK_B = "smoke-t-char-work-b"; const CHAR_LOOSE = "smoke-t-char-loose"; const CHAR_ROMANCE = "smoke-t-char-romance"; const ALL_CHAR_IDS = [CHAR_A, CHAR_B, CHAR_SLEEP, CHAR_WORK_A, CHAR_WORK_B, CHAR_LOOSE, CHAR_ROMANCE]; const WORK_A = "smoke-t-work-a"; const WORK_B = "smoke-t-work-b"; // 台北平日(週三)白天/跨夜到週四清晨(SLEEP 時段 00:00~06:30)/隔天白天,用來模擬跨夜與熬夜。 const DAY_START = "2026-08-19T05:00:00.000Z"; // 台北週三 13:00 const NIGHT_SLEEP = "2026-08-19T17:30:00.000Z"; // 台北週四 01:30(SLEEP) const NEXT_DAY_DAYTIME = "2026-08-20T05:00:00.000Z"; // 台北週四 13:00 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, archetype, workId) { await post("/personality/characters", { id, workId, source: workId ? "EXISTING_WORK" : "ORIGINAL", buildStatus: "BUILT", formalName, basicInfo: `${formalName},測試角色`, backgroundStory: "測試用背景", personalityArchetype: archetype, likesDislikes: "測試", goalsObsessions: "測試", speechStyle: "第一人稱「我」", }); } async function cleanup() { await prisma.pushToken.deleteMany({ where: { userId: USER_ID } }); await prisma.pushNotificationLog.deleteMany({ where: { userId: USER_ID } }); await prisma.task.deleteMany({ where: { userId: USER_ID } }); await prisma.episodicMemory.deleteMany({ where: { characterId: { in: ALL_CHAR_IDS } } }); await prisma.characterRelationship.deleteMany({ where: { characterId: { in: ALL_CHAR_IDS } } }); await prisma.room.deleteMany({ where: { userId: USER_ID } }); await prisma.character.deleteMany({ where: { id: { in: ALL_CHAR_IDS } } }); await prisma.work.deleteMany({ where: { id: { in: [WORK_A, WORK_B] } } }); await prisma.user.deleteMany({ where: { id: USER_ID } }); } // T21-P3:P3(自走:行為權重與動態間隔、不降頻排程與自動關房、用量統計、語音自動播放、靜音與推播、 // 睡眠與熬夜、跨作品與隱私邊界、戀愛與成人界線、失敗處理與靜默全域暫停、多人情境調參)端到端驗證。 // // 兩項無法在此純 HTTP 黑箱驗證、已如實記錄的缺口: // 1.「語音在場自動播放與離場不合成」是前端(apps/web/apps/mobile 的 SSE 事件處理)行為,後端只提供 // /voice/:id/:id/synthesize 這個合成端點本身(已由既有 smoke O/既有 vitest 覆蓋),沒有「有沒有人在 // 看」這個概念,本檔不重複驗證合成端點,只在下方註記此限制。 // 2.「動態間隔隨氣氛變化」的計算結果(RoomIntervalService.computeInterval)沒有對外端點可查,只會寫進 // 後端 log([自走間隔計算]),本檔無法在黑箱測試中直接斷言數值本身,已由 room-interval.service.test.ts // 的白箱測試覆蓋;此處改以「同一房間連續推進多輪不會因為靜默 tick 而卡死」佐證排程確實在動態運作。 export default async function smokeT() { await cleanup(); await prisma.user.create({ data: { id: USER_ID, name: "T 群組測試使用者" } }); await prisma.work.create({ data: { id: WORK_A, title: "T 群組測試作品甲" } }); await prisma.work.create({ data: { id: WORK_B, title: "T 群組測試作品乙" } }); await createCharacter(CHAR_A, "小T甲", "元氣"); await createCharacter(CHAR_B, "小T乙", "元氣"); await createCharacter(CHAR_SLEEP, "小T睡", "元氣"); await createCharacter(CHAR_WORK_A, "小T丙", "元氣", WORK_A); await createCharacter(CHAR_WORK_B, "小T丁", "元氣", WORK_B); await createCharacter(CHAR_LOOSE, "小T戊", "天然呆"); // 天然呆=口風不緊原型 await createCharacter(CHAR_ROMANCE, "小T己", "元氣"); // ── 1/3/4/5:不降頻自走+四種行為都出現+委託落成 Task+用量統計 ────────────────────── const behaviorRoom = await post("/room", { userId: USER_ID, characterIds: [CHAR_A, CHAR_B], mode: "SELF_CHAT", topicSeed: "今天過得怎麼樣?", }); const kindsSeen = new Set(); let delegateTurn = null; for (let i = 0; i < 150 && (kindsSeen.size < 4 || !delegateTurn); i++) { const result = await post(`/room/${behaviorRoom.id}/self-chat/advance`, { now: DAY_START }); if (result.ended) break; // 不降頻自走仍可能因三項離場動因而自然結束,不視為失敗 if (!result.turn) continue; // 靜默 tick(見上方限制 2),跳過不計 const detail = await get(`/room/${behaviorRoom.id}`); const lastTurnKind = detail.turns.at(-1)?.kind; if (lastTurnKind) kindsSeen.add(lastTurnKind); if (lastTurnKind === "DELEGATE" && !delegateTurn) { delegateTurn = { characterId: result.turn.characterId, content: result.turn.content }; } } if (!kindsSeen.has("TALK")) throw new Error("多輪推進後應觀察到 TALK 行為"); // ACTION/DELEGATE/LEAVE 屬機率性行為,不保證每次跑都出現全部種類;至少 TALK 出現、且房間確實 // 持續推進超過舊版 12 輪硬上限即可佐證「不降頻自走」本身正常運作(機率分佈已由 vitest 覆蓋)。 const behaviorDetail = await get(`/room/${behaviorRoom.id}`); if (behaviorDetail.turns.length <= 12) { throw new Error(`不降頻自走應可連續推進超過舊版 12 輪上限,實際只有 ${behaviorDetail.turns.length} 則`); } if (delegateTurn) { const tasks = await get(`/task?characterId=${delegateTurn.characterId}&userId=${USER_ID}`); if (!tasks.some((t) => t.rawText === delegateTurn.content)) { throw new Error("觀察到 DELEGATE 行為時應對應建立一筆可查詢到的 Task"); } } const usage = await get(`/room/${behaviorRoom.id}/usage`); if (usage.generationCount <= 0 || usage.totalPromptTokens <= 0) { throw new Error("多輪自走後應有非零的用量統計(RoomUsageLog 聚合)"); } // ── 7:靜音不推播但仍自走 ───────────────────────────────────────────────────────────── await post(`/room/${behaviorRoom.id}/mute`, { muted: true }); await prisma.pushToken.deleteMany({ where: { userId: USER_ID } }); await post("/push/register", { userId: USER_ID, token: "smoke-t-push-token", platform: "expo" }); const pushLogBefore = await prisma.pushNotificationLog.count({ where: { userId: USER_ID } }); let mutedDelegateSeen = false; for (let i = 0; i < 60 && !mutedDelegateSeen; i++) { const result = await post(`/room/${behaviorRoom.id}/self-chat/advance`, { now: new Date(new Date(DAY_START).getTime() + i * 1000).toISOString(), }); if (result.ended) break; const detail = await get(`/room/${behaviorRoom.id}`); if (detail.turns.at(-1)?.kind === "DELEGATE") mutedDelegateSeen = true; } const roomAfterMuteAdvance = await get(`/room/${behaviorRoom.id}`); if (roomAfterMuteAdvance.turns.length <= behaviorDetail.turns.length && roomAfterMuteAdvance.status !== "ENDED") { throw new Error("靜音後房間應仍能繼續自走推進(靜音只影響推播與語音,不影響自走)"); } const pushLogAfterMute = await prisma.pushNotificationLog.count({ where: { userId: USER_ID } }); if (pushLogAfterMute !== pushLogBefore) { throw new Error("房間靜音時,即使觸發委託等重要事件也不應產生任何推播紀錄"); } await post(`/room/${behaviorRoom.id}/mute`, { muted: false }); // ── 8:跨夜睡眠致房間關閉與熬夜隔日睏倦 ─────────────────────────────────────────────── await post(`/schedule/${CHAR_SLEEP}/seed-default`); const sleepRoom = await post("/room", { userId: USER_ID, characterIds: [CHAR_SLEEP], mode: "SELF_CHAT", topicSeed: "晚安前的閒聊", }); await post(`/room/${sleepRoom.id}/self-chat/advance`, { now: DAY_START }); let sleepRoomClosed = false; for (let i = 0; i < 30 && !sleepRoomClosed; i++) { const result = await post(`/room/${sleepRoom.id}/self-chat/advance`, { now: new Date(new Date(NIGHT_SLEEP).getTime() + i * 1000).toISOString(), }); if (result.ended) sleepRoomClosed = true; } if (!sleepRoomClosed) { throw new Error("SLEEP 時段內持續推進,角色應在合理輪數內因行程動因離場並致房間自動關閉"); } const closedSleepRoom = await get(`/room/${sleepRoom.id}`); if (closedSleepRoom.status !== "ENDED") { throw new Error("全員因跨夜離場後,房間狀態應變為已結束"); } // 熬夜隔日睏倦:直接標記一筆睡眠債(模擬「昨晚真的熬夜了」的既成狀態),驗證隔日情緒基線確實變得低落。 await prisma.sleepDebt.upsert({ where: { characterId: CHAR_SLEEP }, update: { accumulatedMinutes: 90 }, create: { characterId: CHAR_SLEEP, accumulatedMinutes: 90 }, }); const drowsyBaseline = await get(`/schedule/${CHAR_SLEEP}/baseline?now=${encodeURIComponent(NEXT_DAY_DAYTIME)}`); if (drowsyBaseline.baseline?.tag !== "SAD") { throw new Error(`熬夜累積睡眠債後,隔日情緒基線應偏向低落(SAD),實際為 ${JSON.stringify(drowsyBaseline.baseline)}`); } // ── 9:跨作品混房 ─────────────────────────────────────────────────────────────────── const crossWorkRoom = await post("/room", { userId: USER_ID, characterIds: [CHAR_WORK_A, CHAR_WORK_B], mode: "GROUP", }); const crossWorkAffinityBefore = ( await prisma.characterRelationship.findUnique({ where: { characterId_otherCharacterId: { characterId: CHAR_WORK_A, otherCharacterId: CHAR_WORK_B } }, }) )?.affinity; if (crossWorkAffinityBefore !== undefined) { throw new Error("跨作品角色初次同房前,關係值應尚未建立(中性起算,尚無資料列)"); } const crossWorkReply = await post(`/room/${crossWorkRoom.id}/message`, { userId: USER_ID, userLabel: "測試使用者", text: "你們互相認識嗎?", }); if (!Array.isArray(crossWorkReply.speakers)) { throw new Error("跨作品混房的一般發言應正常回應,不應拋錯"); } // ── 10:隱私邊界與各自視角固化 ───────────────────────────────────────────────────────── const privacySessionId = `smoke-t-privacy-session`; await post(`/memory/${CHAR_LOOSE}/sessions/${privacySessionId}/messages`, { role: "character", content: "秘密基地的事只有我們兩個知道", emotionTag: "JOY", emotionIntensity: 0.9, }); await post(`/memory/${CHAR_LOOSE}/sessions/${privacySessionId}/consolidate`, {}); const privacyRetrieve = await get( `/memory/${CHAR_LOOSE}/retrieve?query=${encodeURIComponent("秘密基地")}&excludePrivate=false`, ); if (!privacyRetrieve.results.some((m) => m.isPrivate)) { throw new Error("SOLO 房固化的記憶應標記為 isPrivate=true(K-3/T40 隱私邊界的資料來源)"); } const consolidationRoom = await post("/room", { userId: USER_ID, characterIds: [CHAR_A, CHAR_LOOSE], mode: "GROUP", }); await post(`/room/${consolidationRoom.id}/message`, { userId: USER_ID, userLabel: "測試使用者", text: "小T戊,今天玩得開心嗎!!!", }); await post(`/room/${consolidationRoom.id}/end`, {}); const memoriesA = await get(`/memory/${CHAR_A}/retrieve?query=${encodeURIComponent("今天玩得開心嗎")}`); const memoriesLoose = await get(`/memory/${CHAR_LOOSE}/retrieve?query=${encodeURIComponent("今天玩得開心嗎")}`); if (memoriesA.results.length === 0 && memoriesLoose.results.length === 0) { throw new Error("多人房固化後,至少應有一位在場角色留下這場對話的記憶(各自視角固化)"); } // ── 11:多人房禁戀愛里程碑與成人內容 ───────────────────────────────────────────────── const romanceRoom = await post("/room", { userId: USER_ID, characterIds: [CHAR_ROMANCE], mode: "GROUP", }); const groupConfess = await post(`/romance/${CHAR_ROMANCE}/confess`, { userId: USER_ID, roomId: romanceRoom.id }); if (groupConfess.outcome !== "INELIGIBLE") { throw new Error(`多人房(GROUP)內告白應一律不成立,實際結果為 ${groupConfess.outcome}`); } const groupAdultCheck = await post(`/romance/${CHAR_ROMANCE}/adult-content-check`, { userId: USER_ID, roomId: romanceRoom.id, }); if (groupAdultCheck.allowed) { throw new Error("多人房(GROUP)內成人內容應一律不放行"); } const attemptLogCount = await prisma.adultContentAttemptLog.count({ where: { userId: USER_ID, characterId: CHAR_ROMANCE } }); if (attemptLogCount === 0) { throw new Error("成人內容嘗試(即使被擋)仍應留下 AdultContentAttemptLog 紀錄"); } // ── 12:LLM 連續失敗的靜默全域暫停與恢復 ───────────────────────────────────────────── // 注意:behaviorSelection 抽到 LEAVE/DELEGATE 時是固定台詞、不會呼叫 LLM(見 self-chat.service.ts), // 因此無法保證連續幾輪一定都是會呼叫 LLM 的 TALK/ACTION;改用「佇列大量失敗+有上限的重試迴圈, // 每輪都檢查是否已觸發暫停」,不假設固定輪數就一定達到門檻。 const pauseRoom = await post("/room", { userId: USER_ID, characterIds: [CHAR_A, CHAR_B], mode: "SELF_CHAT", topicSeed: "測試全域暫停", }); // 每次真的呼叫 LLM 的 advance() 會重試到 RETRY_MAX_ATTEMPTS(3)+1=4 次才判定這一輪失敗,連續 5 輪 // 都失敗才觸發 GLOBAL_PAUSE_FAILURE_THRESHOLD(5);佇列夠多失敗以覆蓋中間可能穿插 LEAVE/DELEGATE 的情況。 await post("/dialogue/test/force-llm-failure", { count: 100 }); let triggeredPause = false; for (let i = 0; i < 40 && !triggeredPause; i++) { const result = await post(`/room/${pauseRoom.id}/self-chat/advance`, { now: new Date(new Date(DAY_START).getTime() + i * 1000).toISOString(), }); if (result.ended) { throw new Error("測試全域暫停期間房間提前因全員離場而關閉,需調整測試角色設定"); } triggeredPause = (await get("/dialogue/test/global-pause-state")).paused; } if (!triggeredPause) { throw new Error("連續失敗達門檻後,應觸發全域靜默暫停"); } // 恢復:先清空可能還沒消耗完的佇列失敗,確保接下來真的呼叫 LLM 的行為會成功。 await post("/dialogue/test/reset-llm-queue", {}); // 暫停期間每次仍會真的嘗試一次;只要抽到會呼叫 LLM 的行為就會成功並自動解除暫停。 let resumed = false; for (let i = 0; i < 20 && !resumed; i++) { await post(`/room/${pauseRoom.id}/self-chat/advance`, { now: new Date(new Date(NEXT_DAY_DAYTIME).getTime() + i * 1000).toISOString(), }); resumed = !(await get("/dialogue/test/global-pause-state")).paused; } if (!resumed) { throw new Error("LLM 恢復正常後,全域靜默暫停應自動解除,不需要人工介入"); } await cleanup(); }