Files
Kokorone/scripts/smoke/I.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

165 lines
7.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 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 破例規則:睡眠時段一般不回應,且不應無條件破例
// T38:每個獨立情境各自用一間 SOLO 房並在檢查完後結束,對應舊版「各自獨立 sessionId」的語意
// (避免工作記憶互相污染;房間結束後下一個情境會取得全新的房間)。
const sleepRoomA = await post("/room/solo", { userId: USER_ID, characterId: CHARACTER_ID });
const noExceptionResult = await post(`/room/${sleepRoomA.id}/message`, {
userId: USER_ID,
text: "在嗎",
now: WEEKDAY_SLEEP_TIME,
});
if (noExceptionResult.text !== "" || noExceptionResult.availability !== "NO_REPLY") {
throw new Error("條件不足時睡眠時段不應破例回應");
}
await post(`/room/${sleepRoomA.id}/end`, {});
// 條件滿足(親密度高+使用者情緒低落)時應觸發「深夜還陪你聊」破例
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 sleepRoomB = await post("/room/solo", { userId: USER_ID, characterId: CHARACTER_ID });
const exceptionResult = await post(`/room/${sleepRoomB.id}/message`, {
userId: USER_ID,
text: "我今天過得好難過,很失望",
now: WEEKDAY_SLEEP_TIME,
});
if (exceptionResult.exceptionType !== "LATE_NIGHT_CHAT" || !exceptionResult.text) {
throw new Error("條件滿足時應觸發「深夜還陪你聊」破例並正常生成回應");
}
await post(`/room/${sleepRoomB.id}/end`, {});
// 冷卻期內同類破例不應再次觸發
const sleepRoomC = await post("/room/solo", { userId: USER_ID, characterId: CHARACTER_ID });
const cooldownResult = await post(`/room/${sleepRoomC.id}/message`, {
userId: USER_ID,
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 會一併清掉作息表、破例紀錄、記憶等)與本次建立的三間 SOLO 房。
await prisma.room.deleteMany({ where: { id: { in: [sleepRoomA.id, sleepRoomB.id, sleepRoomC.id] } } });
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
}