import { prisma } from "@kokorone/db"; const API_PORT = process.env.PORT_API ?? "3001"; const CHARACTER_ID = "smoke-i-character"; const USER_ID = "seed-user-primary"; // 固定測試時間軸(皆為 UTC,對應 Asia/Taipei 當地時間,註解為當地時間): const WEEKDAY_CLASS_TIME = "2026-08-13T02:00:00.000Z"; // 週四 10:00(上課,BUSY) const WEEKDAY_FREE_TIME = "2026-08-13T14:00:00.000Z"; // 週四 22:00(自由時間,FREE) const WEEKDAY_SLEEP_TIME = "2026-08-12T18:00:00.000Z"; // 週四 02:00(睡眠,SLEEP) const WEEKDAY_WAKING_TIME = "2026-08-12T22:40:00.000Z"; // 週四 06:40(睡眠剛結束 10 分鐘) 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}`); } return res.json(); } export default async function smokeI() { await prisma.character.deleteMany({ where: { id: CHARACTER_ID } }); await post("/personality/characters", { id: CHARACTER_ID, source: "ORIGINAL", buildStatus: "BUILT", formalName: "作息測試角色", basicInfo: "作息測試角色,17 歲高中生", backgroundStory: "普通的高中生活", personalityArchetype: "元氣", likesDislikes: "喜歡:運動;討厭:早起", goalsObsessions: "測試用", speechStyle: "第一人稱「我」", initialRelationship: { userId: USER_ID, intimacy: 25, trust: 10 }, }); // I-1 作息表資料結構:可為角色寫入一份高中生作息表 await post(`/schedule/${CHARACTER_ID}/seed-default`, {}); const slotCount = await prisma.scheduleSlot.count({ where: { characterId: CHARACTER_ID } }); if (slotCount === 0) { throw new Error("seed-default 未寫入任何作息時段"); } // I-2/I-3 時段狀態 → 回應可用度 + 時間感知(星期幾/幾點) const busyInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_CLASS_TIME)}`); if (busyInfo.availability !== "DELAYED") { throw new Error(`上課時段應對應 DELAYED,實際為 ${busyInfo.availability}`); } const freeInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_FREE_TIME)}`); if (freeInfo.availability !== "NORMAL") { throw new Error(`自由時間應對應 NORMAL,實際為 ${freeInfo.availability}`); } const sleepInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_SLEEP_TIME)}`); if (sleepInfo.availability !== "NO_REPLY") { throw new Error(`睡眠時段應對應 NO_REPLY,實際為 ${sleepInfo.availability}`); } const wakingInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_WAKING_TIME)}`); if (!wakingInfo.isWaking) { throw new Error("剛醒時間點應標記 isWaking"); } // I-4 破例規則:睡眠時段一般不回應,且不應無條件破例 const sleepSessionA = "smoke-i-sleep-a-" + Date.now(); const noExceptionResult = await post(`/chat/${CHARACTER_ID}`, { userId: USER_ID, sessionId: sleepSessionA, text: "在嗎", now: WEEKDAY_SLEEP_TIME, }); if (noExceptionResult.text !== "" || noExceptionResult.availability !== "NO_REPLY") { throw new Error("條件不足時睡眠時段不應破例回應"); } // 條件滿足(親密度高+使用者情緒低落)時應觸發「深夜還陪你聊」破例 await prisma.relationship.update({ where: { characterId_userId: { characterId: CHARACTER_ID, userId: USER_ID } }, data: { intimacy: 70 }, }); await prisma.emotionState.update({ where: { characterId: CHARACTER_ID }, data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date(WEEKDAY_SLEEP_TIME) }, }); const sleepSessionB = "smoke-i-sleep-b-" + Date.now(); const exceptionResult = await post(`/chat/${CHARACTER_ID}`, { userId: USER_ID, sessionId: sleepSessionB, text: "我今天過得好難過,很失望", now: WEEKDAY_SLEEP_TIME, }); if (exceptionResult.exceptionType !== "LATE_NIGHT_CHAT" || !exceptionResult.text) { throw new Error("條件滿足時應觸發「深夜還陪你聊」破例並正常生成回應"); } // 冷卻期內同類破例不應再次觸發 const sleepSessionC = "smoke-i-sleep-c-" + Date.now(); const cooldownResult = await post(`/chat/${CHARACTER_ID}`, { userId: USER_ID, sessionId: sleepSessionC, text: "我今天過得好難過,很失望", now: WEEKDAY_SLEEP_TIME, }); if (cooldownResult.exceptionType !== null || cooldownResult.availability !== "NO_REPLY") { throw new Error("冷卻期內同類破例不應再次觸發"); } // I-5 作息驅動情緒基線:同一輸入在不同時段得到不同情緒起點 const afterClub = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent("2026-08-13T10:15:00.000Z")}`); if (afterClub.baseline?.tag !== "SAD") { throw new Error("社團剛結束應有偏低落的情緒基線"); } const weekendBaseline = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent("2026-08-15T06:00:00.000Z")}`); if (weekendBaseline.baseline?.tag !== "JOY") { throw new Error("放假日應有偏愉悅的情緒基線"); } const normalBaseline = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent(WEEKDAY_FREE_TIME)}`); if (normalBaseline.baseline !== null) { throw new Error("一般時段不應有特殊情緒基線"); } // I-6 離線事件生成:依作息生成的事件必須符合角色作息表(不可生成沒有的活動) // 先清掉前面 I-4 測試(在 02:00 觸發對話)順帶生成的「今天」離線事件,確保本節從乾淨狀態開始。 await prisma.episodicMemory.deleteMany({ where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" } }); await prisma.emotionState.update({ where: { characterId: CHARACTER_ID }, data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date() }, }); const offlineEventTime = "2026-08-13T10:15:00.000Z"; // 週四 18:15,社團活動(16:00-18:00)剛結束 await post(`/schedule/${CHARACTER_ID}/offline-event`, { now: offlineEventTime }); const offlineMemories = await prisma.episodicMemory.findMany({ where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" }, }); if (offlineMemories.length !== 1) { throw new Error(`應生成恰好一筆離線事件,實際為 ${offlineMemories.length} 筆`); } if (!offlineMemories[0].content.includes("社團")) { throw new Error("離線事件內容應符合當天已發生的固定行程(社團活動)"); } // 同一天再次觸發不應重複生成 await post(`/schedule/${CHARACTER_ID}/offline-event`, { now: "2026-08-13T11:00:00.000Z" }); const offlineMemoriesAgain = await prisma.episodicMemory.count({ where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" }, }); if (offlineMemoriesAgain !== 1) { throw new Error("同一天不應重複生成離線事件"); } // 清理本次測試建立的角色(cascade 會一併清掉作息表、破例紀錄、記憶等) await prisma.character.deleteMany({ where: { id: CHARACTER_ID } }); }