import { prisma } from "@kokorone/db"; const API_PORT = process.env.PORT_API ?? "3001"; const USER_ID = "seed-user-primary"; const GENKI_ID = "smoke-o-genki"; // 元氣:外顯度高 const COOL_ID = "smoke-o-cool"; // 三無:外顯度極低 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(); } async function createCharacter(id, archetype, intimacy, stage) { await post("/personality/characters", { id, source: "ORIGINAL", buildStatus: "BUILT", formalName: id, basicInfo: "測試角色", backgroundStory: "測試", personalityArchetype: archetype, likesDislikes: "測試", goalsObsessions: "測試", speechStyle: "第一人稱「我」", initialRelationship: { userId: USER_ID, intimacy, trust: intimacy }, }); if (stage) { await prisma.relationship.update({ where: { characterId_userId: { characterId: id, userId: USER_ID } }, data: { stage }, }); } } async function setEmotion(characterId, tag, value) { const dims = { calm: 0, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 }; dims[tag.toLowerCase()] = value; await prisma.emotionState.update({ where: { characterId }, data: dims }); } export default async function smokeO() { await prisma.character.deleteMany({ where: { id: { in: [GENKI_ID, COOL_ID] } } }); await createCharacter(GENKI_ID, "元氣", 10, "STRANGER"); await createCharacter(COOL_ID, "三無", 10, "STRANGER"); // O-1 差分資產結構:manifest 應該能以佔位資產組出完整立繪需要的所有圖層。 const manifest = await get(`/tachie/${GENKI_ID}/manifest`); if (manifest.bodyPoses.length < 3 || manifest.bodyPoses.length > 5) { throw new Error("身體姿勢差分數量應在 3~5 種之間"); } if (manifest.costumes.length < 2) { throw new Error("服裝差分至少應有 2 套"); } if ( manifest.expressionParts.eyebrows.length === 0 || manifest.expressionParts.eyes.length === 0 || manifest.expressionParts.mouths.length === 0 ) { throw new Error("表情差分(眉/眼/口)應該都有可用選項"); } if (manifest.effects.length === 0 || manifest.microMotions.length < 2) { throw new Error("效果層與微動態(眨眼/呼吸)應該都有定義"); } // M-1 服裝目錄若已登錄,manifest 應該優先採用那份目錄(而不是通用預設兩套)——立繪服裝層 // 只能從目錄取用、不可自創,manifest 回傳的內容就是這條規則的具體體現。 const work = await prisma.work.create({ data: { title: "服裝目錄測試作品" } }); await post("/canon/illustrations", { workId: work.id, volume: 1, type: "BW_ILLUSTRATION", description: "測試插圖", costumeLabels: [{ characterId: GENKI_ID, label: "特殊活動服" }], }); const manifestWithCatalog = await get(`/tachie/${GENKI_ID}/manifest`); if (!manifestWithCatalog.costumes.includes("特殊活動服") || manifestWithCatalog.costumes.length !== 1) { throw new Error("已登錄 M-1 服裝目錄時,manifest 應該優先採用那份目錄,而不是退回通用預設"); } // O-4 情緒 → 表情對照:六狀態應該各自對應到不同的眉/眼/口/姿勢組合。 const emotionTags = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"]; await prisma.relationship.update({ where: { characterId_userId: { characterId: GENKI_ID, userId: USER_ID } }, data: { stage: "BONDED", intimacy: 90 }, }); const templates = new Map(); for (const tag of emotionTags) { await setEmotion(GENKI_ID, tag, 80); const expr = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`); if (expr.rawEmotionTag !== tag) { throw new Error(`情緒設為 ${tag} 時,表情解析應該反映相同的情緒`); } templates.set(tag, `${expr.eyebrow}/${expr.eye}/${expr.mouth}/${expr.pose}`); } if (new Set(templates.values()).size !== emotionTags.length) { throw new Error("六種情緒狀態應該各自對應到不同的表情差分組合"); } // O-4 外顯度縮放:同樣的愉悅情緒,元氣角色的差分幅度應該遠大於三無角色。 await prisma.relationship.update({ where: { characterId_userId: { characterId: COOL_ID, userId: USER_ID } }, data: { stage: "BONDED", intimacy: 90 }, }); await setEmotion(GENKI_ID, "JOY", 90); await setEmotion(COOL_ID, "JOY", 90); const genkiExpr = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`); const coolExpr = await get(`/tachie/${COOL_ID}/${USER_ID}/expression`); if (!(genkiExpr.magnitude > coolExpr.magnitude * 3)) { throw new Error( `外顯度低的角色差分幅度應該明顯小於元氣角色,實際為 元氣=${genkiExpr.magnitude} 三無=${coolExpr.magnitude}`, ); } // O-3 親密度解鎖:低親密度時,稀有情緒差分(例如彆扭)不可用,應該降級為基本表情。 await prisma.relationship.update({ where: { characterId_userId: { characterId: GENKI_ID, userId: USER_ID } }, data: { stage: "STRANGER", intimacy: 5 }, }); await setEmotion(GENKI_ID, "GRUMPY", 80); const lockedExpr = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`); if (!lockedExpr.lockedFallbackApplied || lockedExpr.emotionTag !== "CALM" || lockedExpr.rawEmotionTag !== "GRUMPY") { throw new Error("陌生階段時,彆扭表情應該被降級為基本表情,但情緒引擎的真實判定仍應可查(故意不一致)"); } await prisma.relationship.update({ where: { characterId_userId: { characterId: GENKI_ID, userId: USER_ID } }, data: { stage: "CLOSE_AMBIGUOUS", intimacy: 65 }, }); const unlockedExpr = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`); if (unlockedExpr.lockedFallbackApplied || unlockedExpr.emotionTag !== "GRUMPY") { throw new Error("達到摯友階段後,彆扭表情應該被解鎖"); } // O-3 哭臉列為最深層:即使情緒引擎判定深度低落,未達羈絆階段也不會出現哭臉特效。 await setEmotion(GENKI_ID, "SAD", 90); const closeSad = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`); if (closeSad.usedCryingVariant) { throw new Error("哭臉是羈絆階段才解鎖的最深層差分,摯友階段不應該出現"); } await prisma.relationship.update({ where: { characterId_userId: { characterId: GENKI_ID, userId: USER_ID } }, data: { stage: "BONDED", intimacy: 90 }, }); const bondedSad = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`); if (!bondedSad.usedCryingVariant || bondedSad.effect !== "淚") { throw new Error("羈絆階段的深度低落應該解鎖哭臉特效"); } // O-2 視線方向:思考時視線飄上、迴避/害羞時視線撇開、警戒時直視。 const thinkingGaze = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression?isThinking=true`); if (thinkingGaze.gaze !== "UP") { throw new Error("思考時視線應該飄上"); } const deflectingGaze = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression?isDeflecting=true`); if (deflectingGaze.gaze !== "AWAY") { throw new Error("迴避時視線應該撇開"); } await setEmotion(GENKI_ID, "ALERT", 80); const alertGaze = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`); if (alertGaze.gaze !== "DIRECT") { throw new Error("警戒時視線應該直視"); } // O-2 表情早於文字:情緒引擎的更新(emotion.processInput)發生在文字生成之前, // 因此聊天訊息送出後,表情查詢應該已經反映這一輪的新情緒,不需要等文字回覆完成才更新。 await prisma.relationship.update({ where: { characterId_userId: { characterId: GENKI_ID, userId: USER_ID } }, data: { stage: "BONDED", intimacy: 90 }, }); await setEmotion(GENKI_ID, "CALM", 100); const chatResult = await post(`/chat/${GENKI_ID}`, { userId: USER_ID, sessionId: "smoke-o-session", text: "謝謝你,我今天好開心!", }); const exprAfterChat = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`); if (chatResult.context?.emotion.dominant !== "JOY" || exprAfterChat.emotionTag !== "JOY") { throw new Error("聊天訊息觸發的情緒轉變,應該同時反映在對話上下文與獨立查詢的表情狀態上"); } // 清理本次測試建立的角色與作品(cascade 會一併清掉關係、情緒狀態、插圖索引等)。 await prisma.character.deleteMany({ where: { id: { in: [GENKI_ID, COOL_ID] } } }); await prisma.work.delete({ where: { id: work.id } }); }