新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
307 lines
16 KiB
JavaScript
307 lines
16 KiB
JavaScript
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();
|
||
}
|