Files
Kokorone/scripts/smoke/O.mjs
T
JefferyandClaude Sonnet 5 0f3d16059c feat: 完成 O 群組 — 立繪子系統
後端(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>
2026-08-13 17:46:50 +08:00

200 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } });
}