後端(apps/api/src/tachie/):立繪 manifest(身體姿勢/服裝/表情眉眼口/ 效果層/微動態的資產目錄,服裝目錄有 M-1 登錄時優先採用);表情解析依 情緒引擎六狀態對照眉/眼/口/效果/姿勢,依 RelationshipStage 分層解鎖 (陌生~認識/朋友/摯友/羈絆,哭臉限羈絆才會出現),依角色外顯度縮放 差分幅度(magnitude = expressiveness × 情緒強度);emotionTag(實際 套用)與 rawEmotionTag(情緒引擎真實判定)分離,讓文字生成與表情解析 維持兩條獨立資料流,「文字與立繪故意不一致」是這個設計的自然結果, 不需要額外邏輯;視線方向依思考/迴避/警戒切換。 前端(apps/web/components/TachieStage.tsx):以 PixiJS 實作分層合成 與眨眼/呼吸微動態,預留 TachieRenderer 介面供未來 Live2D Cubism SDK 實作(未引入任何 Live2D 相依或授權,純架構佔位);接上 ChatView 取代 H 群組的佔位立繪,聊天回應後先查詢表情、延遲約 220ms 才顯示文字, 在非串流架構下盡力模擬「表情先變化、文字後出現」。 以 scratchpad 暫裝 playwright-core 實測瀏覽器行為(本環境無 chromium-cli),確認情緒變化即時反映在立繪、微動態持續運作。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
200 lines
8.8 KiB
JavaScript
200 lines
8.8 KiB
JavaScript
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 } });
|
||
}
|