Files
Kokorone/scripts/smoke/O.mjs
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

201 lines
8.9 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 chatRoom = await post("/room/solo", { userId: USER_ID, characterId: GENKI_ID });
const chatResult = await post(`/room/${chatRoom.id}/message`, {
userId: USER_ID,
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.room.deleteMany({ where: { id: chatRoom.id } });
await prisma.character.deleteMany({ where: { id: { in: [GENKI_ID, COOL_ID] } } });
await prisma.work.delete({ where: { id: work.id } });
}