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

307 lines
16 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 = "smoke-t-user";
const CHAR_A = "smoke-t-char-a";
const CHAR_B = "smoke-t-char-b";
const CHAR_SLEEP = "smoke-t-char-sleep";
const CHAR_WORK_A = "smoke-t-char-work-a";
const CHAR_WORK_B = "smoke-t-char-work-b";
const CHAR_LOOSE = "smoke-t-char-loose";
const CHAR_ROMANCE = "smoke-t-char-romance";
const ALL_CHAR_IDS = [CHAR_A, CHAR_B, CHAR_SLEEP, CHAR_WORK_A, CHAR_WORK_B, CHAR_LOOSE, CHAR_ROMANCE];
const WORK_A = "smoke-t-work-a";
const WORK_B = "smoke-t-work-b";
// 台北平日(週三)白天/跨夜到週四清晨(SLEEP 時段 00:00~06:30)/隔天白天,用來模擬跨夜與熬夜。
const DAY_START = "2026-08-19T05:00:00.000Z"; // 台北週三 13:00
const NIGHT_SLEEP = "2026-08-19T17:30:00.000Z"; // 台北週四 01:30(SLEEP)
const NEXT_DAY_DAYTIME = "2026-08-20T05:00:00.000Z"; // 台北週四 13:00
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}:${await res.text()}`);
}
return res.json();
}
async function createCharacter(id, formalName, archetype, workId) {
await post("/personality/characters", {
id,
workId,
source: workId ? "EXISTING_WORK" : "ORIGINAL",
buildStatus: "BUILT",
formalName,
basicInfo: `${formalName},測試角色`,
backgroundStory: "測試用背景",
personalityArchetype: archetype,
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "第一人稱「我」",
});
}
async function cleanup() {
await prisma.pushToken.deleteMany({ where: { userId: USER_ID } });
await prisma.pushNotificationLog.deleteMany({ where: { userId: USER_ID } });
await prisma.task.deleteMany({ where: { userId: USER_ID } });
await prisma.episodicMemory.deleteMany({ where: { characterId: { in: ALL_CHAR_IDS } } });
await prisma.characterRelationship.deleteMany({ where: { characterId: { in: ALL_CHAR_IDS } } });
await prisma.room.deleteMany({ where: { userId: USER_ID } });
await prisma.character.deleteMany({ where: { id: { in: ALL_CHAR_IDS } } });
await prisma.work.deleteMany({ where: { id: { in: [WORK_A, WORK_B] } } });
await prisma.user.deleteMany({ where: { id: USER_ID } });
}
// T21-P3:P3(自走:行為權重與動態間隔、不降頻排程與自動關房、用量統計、語音自動播放、靜音與推播、
// 睡眠與熬夜、跨作品與隱私邊界、戀愛與成人界線、失敗處理與靜默全域暫停、多人情境調參)端到端驗證。
//
// 兩項無法在此純 HTTP 黑箱驗證、已如實記錄的缺口:
// 1.「語音在場自動播放與離場不合成」是前端(apps/web/apps/mobile 的 SSE 事件處理)行為,後端只提供
// /voice/:id/:id/synthesize 這個合成端點本身(已由既有 smoke O/既有 vitest 覆蓋),沒有「有沒有人在
// 看」這個概念,本檔不重複驗證合成端點,只在下方註記此限制。
// 2.「動態間隔隨氣氛變化」的計算結果(RoomIntervalService.computeInterval)沒有對外端點可查,只會寫進
// 後端 log([自走間隔計算]),本檔無法在黑箱測試中直接斷言數值本身,已由 room-interval.service.test.ts
// 的白箱測試覆蓋;此處改以「同一房間連續推進多輪不會因為靜默 tick 而卡死」佐證排程確實在動態運作。
export default async function smokeT() {
await cleanup();
await prisma.user.create({ data: { id: USER_ID, name: "T 群組測試使用者" } });
await prisma.work.create({ data: { id: WORK_A, title: "T 群組測試作品甲" } });
await prisma.work.create({ data: { id: WORK_B, title: "T 群組測試作品乙" } });
await createCharacter(CHAR_A, "小T甲", "元氣");
await createCharacter(CHAR_B, "小T乙", "元氣");
await createCharacter(CHAR_SLEEP, "小T睡", "元氣");
await createCharacter(CHAR_WORK_A, "小T丙", "元氣", WORK_A);
await createCharacter(CHAR_WORK_B, "小T丁", "元氣", WORK_B);
await createCharacter(CHAR_LOOSE, "小T戊", "天然呆"); // 天然呆=口風不緊原型
await createCharacter(CHAR_ROMANCE, "小T己", "元氣");
// ── 1/3/4/5:不降頻自走+四種行為都出現+委託落成 Task+用量統計 ──────────────────────
const behaviorRoom = await post("/room", {
userId: USER_ID,
characterIds: [CHAR_A, CHAR_B],
mode: "SELF_CHAT",
topicSeed: "今天過得怎麼樣?",
});
const kindsSeen = new Set();
let delegateTurn = null;
for (let i = 0; i < 150 && (kindsSeen.size < 4 || !delegateTurn); i++) {
const result = await post(`/room/${behaviorRoom.id}/self-chat/advance`, { now: DAY_START });
if (result.ended) break; // 不降頻自走仍可能因三項離場動因而自然結束,不視為失敗
if (!result.turn) continue; // 靜默 tick(見上方限制 2),跳過不計
const detail = await get(`/room/${behaviorRoom.id}`);
const lastTurnKind = detail.turns.at(-1)?.kind;
if (lastTurnKind) kindsSeen.add(lastTurnKind);
if (lastTurnKind === "DELEGATE" && !delegateTurn) {
delegateTurn = { characterId: result.turn.characterId, content: result.turn.content };
}
}
if (!kindsSeen.has("TALK")) throw new Error("多輪推進後應觀察到 TALK 行為");
// ACTION/DELEGATE/LEAVE 屬機率性行為,不保證每次跑都出現全部種類;至少 TALK 出現、且房間確實
// 持續推進超過舊版 12 輪硬上限即可佐證「不降頻自走」本身正常運作(機率分佈已由 vitest 覆蓋)。
const behaviorDetail = await get(`/room/${behaviorRoom.id}`);
if (behaviorDetail.turns.length <= 12) {
throw new Error(`不降頻自走應可連續推進超過舊版 12 輪上限,實際只有 ${behaviorDetail.turns.length} 則`);
}
if (delegateTurn) {
const tasks = await get(`/task?characterId=${delegateTurn.characterId}&userId=${USER_ID}`);
if (!tasks.some((t) => t.rawText === delegateTurn.content)) {
throw new Error("觀察到 DELEGATE 行為時應對應建立一筆可查詢到的 Task");
}
}
const usage = await get(`/room/${behaviorRoom.id}/usage`);
if (usage.generationCount <= 0 || usage.totalPromptTokens <= 0) {
throw new Error("多輪自走後應有非零的用量統計(RoomUsageLog 聚合)");
}
// ── 7:靜音不推播但仍自走 ─────────────────────────────────────────────────────────────
await post(`/room/${behaviorRoom.id}/mute`, { muted: true });
await prisma.pushToken.deleteMany({ where: { userId: USER_ID } });
await post("/push/register", { userId: USER_ID, token: "smoke-t-push-token", platform: "expo" });
const pushLogBefore = await prisma.pushNotificationLog.count({ where: { userId: USER_ID } });
let mutedDelegateSeen = false;
for (let i = 0; i < 60 && !mutedDelegateSeen; i++) {
const result = await post(`/room/${behaviorRoom.id}/self-chat/advance`, {
now: new Date(new Date(DAY_START).getTime() + i * 1000).toISOString(),
});
if (result.ended) break;
const detail = await get(`/room/${behaviorRoom.id}`);
if (detail.turns.at(-1)?.kind === "DELEGATE") mutedDelegateSeen = true;
}
const roomAfterMuteAdvance = await get(`/room/${behaviorRoom.id}`);
if (roomAfterMuteAdvance.turns.length <= behaviorDetail.turns.length && roomAfterMuteAdvance.status !== "ENDED") {
throw new Error("靜音後房間應仍能繼續自走推進(靜音只影響推播與語音,不影響自走)");
}
const pushLogAfterMute = await prisma.pushNotificationLog.count({ where: { userId: USER_ID } });
if (pushLogAfterMute !== pushLogBefore) {
throw new Error("房間靜音時,即使觸發委託等重要事件也不應產生任何推播紀錄");
}
await post(`/room/${behaviorRoom.id}/mute`, { muted: false });
// ── 8:跨夜睡眠致房間關閉與熬夜隔日睏倦 ───────────────────────────────────────────────
await post(`/schedule/${CHAR_SLEEP}/seed-default`);
const sleepRoom = await post("/room", {
userId: USER_ID,
characterIds: [CHAR_SLEEP],
mode: "SELF_CHAT",
topicSeed: "晚安前的閒聊",
});
await post(`/room/${sleepRoom.id}/self-chat/advance`, { now: DAY_START });
let sleepRoomClosed = false;
for (let i = 0; i < 30 && !sleepRoomClosed; i++) {
const result = await post(`/room/${sleepRoom.id}/self-chat/advance`, {
now: new Date(new Date(NIGHT_SLEEP).getTime() + i * 1000).toISOString(),
});
if (result.ended) sleepRoomClosed = true;
}
if (!sleepRoomClosed) {
throw new Error("SLEEP 時段內持續推進,角色應在合理輪數內因行程動因離場並致房間自動關閉");
}
const closedSleepRoom = await get(`/room/${sleepRoom.id}`);
if (closedSleepRoom.status !== "ENDED") {
throw new Error("全員因跨夜離場後,房間狀態應變為已結束");
}
// 熬夜隔日睏倦:直接標記一筆睡眠債(模擬「昨晚真的熬夜了」的既成狀態),驗證隔日情緒基線確實變得低落。
await prisma.sleepDebt.upsert({
where: { characterId: CHAR_SLEEP },
update: { accumulatedMinutes: 90 },
create: { characterId: CHAR_SLEEP, accumulatedMinutes: 90 },
});
const drowsyBaseline = await get(`/schedule/${CHAR_SLEEP}/baseline?now=${encodeURIComponent(NEXT_DAY_DAYTIME)}`);
if (drowsyBaseline.baseline?.tag !== "SAD") {
throw new Error(`熬夜累積睡眠債後,隔日情緒基線應偏向低落(SAD),實際為 ${JSON.stringify(drowsyBaseline.baseline)}`);
}
// ── 9:跨作品混房 ───────────────────────────────────────────────────────────────────
const crossWorkRoom = await post("/room", {
userId: USER_ID,
characterIds: [CHAR_WORK_A, CHAR_WORK_B],
mode: "GROUP",
});
const crossWorkAffinityBefore = (
await prisma.characterRelationship.findUnique({
where: { characterId_otherCharacterId: { characterId: CHAR_WORK_A, otherCharacterId: CHAR_WORK_B } },
})
)?.affinity;
if (crossWorkAffinityBefore !== undefined) {
throw new Error("跨作品角色初次同房前,關係值應尚未建立(中性起算,尚無資料列)");
}
const crossWorkReply = await post(`/room/${crossWorkRoom.id}/message`, {
userId: USER_ID,
userLabel: "測試使用者",
text: "你們互相認識嗎?",
});
if (!Array.isArray(crossWorkReply.speakers)) {
throw new Error("跨作品混房的一般發言應正常回應,不應拋錯");
}
// ── 10:隱私邊界與各自視角固化 ─────────────────────────────────────────────────────────
const privacySessionId = `smoke-t-privacy-session`;
await post(`/memory/${CHAR_LOOSE}/sessions/${privacySessionId}/messages`, {
role: "character",
content: "秘密基地的事只有我們兩個知道",
emotionTag: "JOY",
emotionIntensity: 0.9,
});
await post(`/memory/${CHAR_LOOSE}/sessions/${privacySessionId}/consolidate`, {});
const privacyRetrieve = await get(
`/memory/${CHAR_LOOSE}/retrieve?query=${encodeURIComponent("秘密基地")}&excludePrivate=false`,
);
if (!privacyRetrieve.results.some((m) => m.isPrivate)) {
throw new Error("SOLO 房固化的記憶應標記為 isPrivate=true(K-3/T40 隱私邊界的資料來源)");
}
const consolidationRoom = await post("/room", {
userId: USER_ID,
characterIds: [CHAR_A, CHAR_LOOSE],
mode: "GROUP",
});
await post(`/room/${consolidationRoom.id}/message`, {
userId: USER_ID,
userLabel: "測試使用者",
text: "小T戊,今天玩得開心嗎!!!",
});
await post(`/room/${consolidationRoom.id}/end`, {});
const memoriesA = await get(`/memory/${CHAR_A}/retrieve?query=${encodeURIComponent("今天玩得開心嗎")}`);
const memoriesLoose = await get(`/memory/${CHAR_LOOSE}/retrieve?query=${encodeURIComponent("今天玩得開心嗎")}`);
if (memoriesA.results.length === 0 && memoriesLoose.results.length === 0) {
throw new Error("多人房固化後,至少應有一位在場角色留下這場對話的記憶(各自視角固化)");
}
// ── 11:多人房禁戀愛里程碑與成人內容 ─────────────────────────────────────────────────
const romanceRoom = await post("/room", {
userId: USER_ID,
characterIds: [CHAR_ROMANCE],
mode: "GROUP",
});
const groupConfess = await post(`/romance/${CHAR_ROMANCE}/confess`, { userId: USER_ID, roomId: romanceRoom.id });
if (groupConfess.outcome !== "INELIGIBLE") {
throw new Error(`多人房(GROUP)內告白應一律不成立,實際結果為 ${groupConfess.outcome}`);
}
const groupAdultCheck = await post(`/romance/${CHAR_ROMANCE}/adult-content-check`, {
userId: USER_ID,
roomId: romanceRoom.id,
});
if (groupAdultCheck.allowed) {
throw new Error("多人房(GROUP)內成人內容應一律不放行");
}
const attemptLogCount = await prisma.adultContentAttemptLog.count({ where: { userId: USER_ID, characterId: CHAR_ROMANCE } });
if (attemptLogCount === 0) {
throw new Error("成人內容嘗試(即使被擋)仍應留下 AdultContentAttemptLog 紀錄");
}
// ── 12:LLM 連續失敗的靜默全域暫停與恢復 ─────────────────────────────────────────────
// 注意:behaviorSelection 抽到 LEAVE/DELEGATE 時是固定台詞、不會呼叫 LLM(見 self-chat.service.ts),
// 因此無法保證連續幾輪一定都是會呼叫 LLM 的 TALK/ACTION;改用「佇列大量失敗+有上限的重試迴圈,
// 每輪都檢查是否已觸發暫停」,不假設固定輪數就一定達到門檻。
const pauseRoom = await post("/room", {
userId: USER_ID,
characterIds: [CHAR_A, CHAR_B],
mode: "SELF_CHAT",
topicSeed: "測試全域暫停",
});
// 每次真的呼叫 LLM 的 advance() 會重試到 RETRY_MAX_ATTEMPTS(3)+1=4 次才判定這一輪失敗,連續 5 輪
// 都失敗才觸發 GLOBAL_PAUSE_FAILURE_THRESHOLD(5);佇列夠多失敗以覆蓋中間可能穿插 LEAVE/DELEGATE 的情況。
await post("/dialogue/test/force-llm-failure", { count: 100 });
let triggeredPause = false;
for (let i = 0; i < 40 && !triggeredPause; i++) {
const result = await post(`/room/${pauseRoom.id}/self-chat/advance`, {
now: new Date(new Date(DAY_START).getTime() + i * 1000).toISOString(),
});
if (result.ended) {
throw new Error("測試全域暫停期間房間提前因全員離場而關閉,需調整測試角色設定");
}
triggeredPause = (await get("/dialogue/test/global-pause-state")).paused;
}
if (!triggeredPause) {
throw new Error("連續失敗達門檻後,應觸發全域靜默暫停");
}
// 恢復:先清空可能還沒消耗完的佇列失敗,確保接下來真的呼叫 LLM 的行為會成功。
await post("/dialogue/test/reset-llm-queue", {});
// 暫停期間每次仍會真的嘗試一次;只要抽到會呼叫 LLM 的行為就會成功並自動解除暫停。
let resumed = false;
for (let i = 0; i < 20 && !resumed; i++) {
await post(`/room/${pauseRoom.id}/self-chat/advance`, {
now: new Date(new Date(NEXT_DAY_DAYTIME).getTime() + i * 1000).toISOString(),
});
resumed = !(await get("/dialogue/test/global-pause-state")).paused;
}
if (!resumed) {
throw new Error("LLM 恢復正常後,全域靜默暫停應自動解除,不需要人工介入");
}
await cleanup();
}