feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+10
-3
@@ -186,9 +186,16 @@ async function verifyClaudeProviderWiring() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: USER_ID, text: "今天發生了很多事情想跟你分享" }),
|
||||
});
|
||||
// 沒有金鑰應該在呼叫 generate() 時就快速、確定性地失敗(不對 CLIProxy 發任何請求)。
|
||||
if (res.status < 500) {
|
||||
throw new Error(`缺少 CLAUDE_API_KEY 時應該呼叫失敗,實際回傳 ${res.status}`);
|
||||
// T30(後補的全域靜默暫停機制)讓 generateWithResilience() 一律吞掉 provider 拋出的例外並回傳
|
||||
// null,呼叫端據此比照既有 NO_REPLY 語意回傳空字串——這是刻意的設計(暫停中/重試全部失敗時
|
||||
// 不對使用者顯示任何錯誤提示),所以缺少 CLAUDE_API_KEY 不會再讓這個端點回 5xx,而是回 2xx 但
|
||||
// text 為空字串;本項驗證的重點改為「金鑰未設定時,ClaudeProvider 確實沒有假造出一句正常回覆」。
|
||||
if (!res.ok) {
|
||||
throw new Error(`缺少 CLAUDE_API_KEY 時端點應仍正常回應(由 T30 靜默略過),實際回傳 ${res.status}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (body.text !== "") {
|
||||
throw new Error(`缺少 CLAUDE_API_KEY 時應該完全沒有真的生成回覆,實際回傳 text=${JSON.stringify(body.text)}`);
|
||||
}
|
||||
} finally {
|
||||
child.kill("SIGKILL");
|
||||
|
||||
+10
-9
@@ -26,7 +26,9 @@ async function get(path) {
|
||||
}
|
||||
|
||||
export default async function smokeH() {
|
||||
const sessionId = "smoke-h-" + Date.now();
|
||||
// T38:1:1 聊天併入 SOLO 房,roomId 取代舊版由呼叫端自行產生的 sessionId。
|
||||
const room = await post("/room/solo", { userId: USER_ID, characterId: CHARACTER_ID });
|
||||
const sessionId = room.id; // 沿用既有 /memory/:characterId/sessions/:sessionId/messages 端點(sessionId 本就是不透明鍵)
|
||||
|
||||
await prisma.emotionState.update({
|
||||
where: { characterId: CHARACTER_ID },
|
||||
@@ -36,9 +38,8 @@ export default async function smokeH() {
|
||||
const intimacyBefore = before.relationship.intimacy;
|
||||
|
||||
// H-1 對話 API:一輪完整對話回傳回應、情緒狀態與親密度變化
|
||||
const result = await post(`/chat/${CHARACTER_ID}`, {
|
||||
const result = await post(`/room/${room.id}/message`, {
|
||||
userId: USER_ID,
|
||||
sessionId,
|
||||
text: "謝謝你,我今天好開心!",
|
||||
});
|
||||
if (!result.text || typeof result.text !== "string") {
|
||||
@@ -51,21 +52,21 @@ export default async function smokeH() {
|
||||
throw new Error("對話後親密度應有變化(累積日常互動)");
|
||||
}
|
||||
|
||||
// H-1 session 生命週期:POST /session/:id/end 觸發睡眠固化,工作記憶應被清空
|
||||
// H-1 房間生命週期:POST /room/:roomId/end 觸發睡眠固化,工作記憶應被清空
|
||||
const contextBefore = await get(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`);
|
||||
if (contextBefore.context.length === 0) {
|
||||
throw new Error("對話後工作記憶應保留本輪內容,session 結束前不應為空");
|
||||
throw new Error("對話後工作記憶應保留本輪內容,房間結束前不應為空");
|
||||
}
|
||||
await post(`/session/${sessionId}/end`, { characterId: CHARACTER_ID });
|
||||
await post(`/room/${room.id}/end`, {});
|
||||
const contextAfter = await get(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`);
|
||||
if (contextAfter.context.length !== 0) {
|
||||
throw new Error("session 結束後應觸發睡眠固化並清空工作記憶");
|
||||
throw new Error("房間結束後應觸發睡眠固化並清空工作記憶");
|
||||
}
|
||||
|
||||
// H-2~H-5:對話頁面應包含情緒晶片、親密度顯示、心跳波形、立繪佔位區塊的結構標記
|
||||
const pageRes = await fetch(`http://localhost:${WEB_PORT}/chat`);
|
||||
const pageRes = await fetch(`http://localhost:${WEB_PORT}/room/solo`);
|
||||
if (!pageRes.ok) {
|
||||
throw new Error(`GET /chat 回傳 ${pageRes.status}`);
|
||||
throw new Error(`GET /room/solo 回傳 ${pageRes.status}`);
|
||||
}
|
||||
const html = await pageRes.text();
|
||||
if (!html.includes("親密度")) {
|
||||
|
||||
+12
-10
@@ -72,16 +72,18 @@ export default async function smokeI() {
|
||||
}
|
||||
|
||||
// I-4 破例規則:睡眠時段一般不回應,且不應無條件破例
|
||||
const sleepSessionA = "smoke-i-sleep-a-" + Date.now();
|
||||
const noExceptionResult = await post(`/chat/${CHARACTER_ID}`, {
|
||||
// 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,
|
||||
sessionId: sleepSessionA,
|
||||
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({
|
||||
@@ -92,22 +94,21 @@ export default async function smokeI() {
|
||||
where: { characterId: CHARACTER_ID },
|
||||
data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date(WEEKDAY_SLEEP_TIME) },
|
||||
});
|
||||
const sleepSessionB = "smoke-i-sleep-b-" + Date.now();
|
||||
const exceptionResult = await post(`/chat/${CHARACTER_ID}`, {
|
||||
const sleepRoomB = await post("/room/solo", { userId: USER_ID, characterId: CHARACTER_ID });
|
||||
const exceptionResult = await post(`/room/${sleepRoomB.id}/message`, {
|
||||
userId: USER_ID,
|
||||
sessionId: sleepSessionB,
|
||||
text: "我今天過得好難過,很失望",
|
||||
now: WEEKDAY_SLEEP_TIME,
|
||||
});
|
||||
if (exceptionResult.exceptionType !== "LATE_NIGHT_CHAT" || !exceptionResult.text) {
|
||||
throw new Error("條件滿足時應觸發「深夜還陪你聊」破例並正常生成回應");
|
||||
}
|
||||
await post(`/room/${sleepRoomB.id}/end`, {});
|
||||
|
||||
// 冷卻期內同類破例不應再次觸發
|
||||
const sleepSessionC = "smoke-i-sleep-c-" + Date.now();
|
||||
const cooldownResult = await post(`/chat/${CHARACTER_ID}`, {
|
||||
const sleepRoomC = await post("/room/solo", { userId: USER_ID, characterId: CHARACTER_ID });
|
||||
const cooldownResult = await post(`/room/${sleepRoomC.id}/message`, {
|
||||
userId: USER_ID,
|
||||
sessionId: sleepSessionC,
|
||||
text: "我今天過得好難過,很失望",
|
||||
now: WEEKDAY_SLEEP_TIME,
|
||||
});
|
||||
@@ -157,6 +158,7 @@ export default async function smokeI() {
|
||||
throw new Error("同一天不應重複生成離線事件");
|
||||
}
|
||||
|
||||
// 清理本次測試建立的角色(cascade 會一併清掉作息表、破例紀錄、記憶等)
|
||||
// 清理本次測試建立的角色(cascade 會一併清掉作息表、破例紀錄、記憶等)與本次建立的三間 SOLO 房。
|
||||
await prisma.room.deleteMany({ where: { id: { in: [sleepRoomA.id, sleepRoomB.id, sleepRoomC.id] } } });
|
||||
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
|
||||
}
|
||||
|
||||
+16
-11
@@ -99,9 +99,10 @@ export default async function smokeK() {
|
||||
}
|
||||
|
||||
// K-2 群聊行為變化:同角色、同輸入,在一對一與群聊應輸出不同(群聊更矜持,命中不同親密度分層的模板)。
|
||||
const oneOnOneReply = await post(`/chat/${GENKI_ID}`, {
|
||||
// T38:一對一改走 SOLO 房(DialogueService 管線),群聊仍走 GROUP 房(RoomGenerationService 管線)。
|
||||
const soloRoom = await post("/room/solo", { userId: USER_ID, characterId: GENKI_ID });
|
||||
const oneOnOneReply = await post(`/room/${soloRoom.id}/message`, {
|
||||
userId: USER_ID,
|
||||
sessionId: "smoke-k-11-session",
|
||||
text: "今天過得如何呀",
|
||||
});
|
||||
const groupIntimacyRoom = await post("/room", { userId: USER_ID, characterIds: [GENKI_ID], mode: "GROUP" });
|
||||
@@ -162,7 +163,9 @@ export default async function smokeK() {
|
||||
throw new Error("被虧的那位(小元)記憶權重應該高於旁觀者(小冷)");
|
||||
}
|
||||
|
||||
// K-6 角色自聊:話題種子驅動、發言權機制沿用、空轉偵測與輪數上限確保一定會收斂(不會無限跑下去)。
|
||||
// K-6/T6 角色自聊:話題種子驅動、發言權機制沿用;行為種類(TALK/ACTION/DELEGATE/LEAVE)由
|
||||
// BehaviorSelectionService 依人格參數+情緒/行程/話題狀態算權重抽樣決定,不再受舊版 12 輪硬上限
|
||||
// 節制——測試角色沒有設定行程/極端情緒,三項離開動因皆不成立,驗證的是「可以持續推進超過舊上限」。
|
||||
const selfChatRoom = await post("/room", {
|
||||
userId: USER_ID,
|
||||
characterIds: [GENKI_ID, TSUNDERE_ID, COOL_ID],
|
||||
@@ -171,8 +174,9 @@ export default async function smokeK() {
|
||||
});
|
||||
let ended = false;
|
||||
let turns = 0;
|
||||
const SAFETY_CAP = 30; // 遠大於 SELF_CHAT_MAX_TURNS,純粹防止測試本身無限迴圈
|
||||
for (let i = 0; i < SAFETY_CAP && !ended; i++) {
|
||||
const PAST_OLD_CAP = 20; // 大於舊版 SELF_CHAT_MAX_TURNS(12),驗證不再被舊上限強制結束
|
||||
const OLD_CAP = 12;
|
||||
for (let i = 0; i < PAST_OLD_CAP && !ended; i++) {
|
||||
const result = await post(`/room/${selfChatRoom.id}/self-chat/advance`, {});
|
||||
if (result.ended) {
|
||||
ended = true;
|
||||
@@ -180,11 +184,11 @@ export default async function smokeK() {
|
||||
turns += 1;
|
||||
}
|
||||
}
|
||||
if (!ended) {
|
||||
throw new Error("角色自聊必須在有限輪數內收斂結束,不應該無限進行下去");
|
||||
}
|
||||
if (turns === 0) {
|
||||
throw new Error("角色自聊應該至少產生過一輪對話");
|
||||
// 測試角色沒有設定行程/極端情緒,LEAVE 只可能因話題耗盡觸發;三人須「各自都」耗盡話題才會整室結束,
|
||||
// 這在短短 20 輪內發生的機率極低,因此正常情況下不應該提前結束——若真的提前結束也至少要確認曾經推進
|
||||
// 超過舊版 12 輪上限,而不是被舊上限機制卡住(該機制已整個移除,理論上不會再發生)。
|
||||
if (turns <= OLD_CAP) {
|
||||
throw new Error(`角色自聊應可連續推進超過舊版 ${OLD_CAP} 輪上限,實際只推進了 ${turns} 輪就停止`);
|
||||
}
|
||||
|
||||
// 使用者插話即切換為群聊模式。
|
||||
@@ -205,6 +209,7 @@ export default async function smokeK() {
|
||||
throw new Error("使用者插話後應該切換為群聊模式");
|
||||
}
|
||||
|
||||
// 清理本次測試建立的角色(cascade 會一併清掉關係、記憶等)。
|
||||
// 清理本次測試建立的角色(cascade 會一併清掉關係、記憶等)與本次建立的 SOLO 房。
|
||||
await prisma.room.deleteMany({ where: { id: soloRoom.id } });
|
||||
await prisma.character.deleteMany({ where: { id: { in: [GENKI_ID, TSUNDERE_ID, COOL_ID] } } });
|
||||
}
|
||||
|
||||
+6
-2
@@ -118,7 +118,9 @@ export default async function smokeL() {
|
||||
}
|
||||
|
||||
// L-1 雙軸模型:純聊天量只推升親密度,不推升心動值;未成年角色的戀愛軸不會被事件啟動。
|
||||
await post(`/chat/${ADULT_ID}`, { userId: USER_ID, sessionId: "smoke-l-chat", text: "嗨嗨嗨,今天過得如何呀" });
|
||||
// T38:1:1 聊天改走 SOLO 房。
|
||||
const chatRoom = await post("/room/solo", { userId: USER_ID, characterId: ADULT_ID });
|
||||
await post(`/room/${chatRoom.id}/message`, { userId: USER_ID, text: "嗨嗨嗨,今天過得如何呀" });
|
||||
const stateAfterChat = await get(`/romance/${ADULT_ID}/${USER_ID}/state`);
|
||||
if (stateAfterChat.romanceMeter !== 0) {
|
||||
throw new Error("純聊天量不應該推升心動值");
|
||||
@@ -240,7 +242,9 @@ export default async function smokeL() {
|
||||
throw new Error("累積足夠的正向修復事件後,關係危機應該回到戀人階段");
|
||||
}
|
||||
|
||||
// 清理本次測試建立的角色(cascade 會一併清掉戀愛軸紀錄、年齡證據、記憶等)與共用使用者的 opt-in 設定。
|
||||
// 清理本次測試建立的角色(cascade 會一併清掉戀愛軸紀錄、年齡證據、記憶等)、本次建立的房間,
|
||||
// 與共用使用者的 opt-in 設定。
|
||||
await prisma.room.deleteMany({ where: { id: chatRoom.id } });
|
||||
await prisma.character.deleteMany({ where: { id: { in: [...ALL_IDS, tooSoonId] } } });
|
||||
await prisma.adultModeConsent.deleteMany({ where: { userId: USER_ID } });
|
||||
}
|
||||
|
||||
+4
-3
@@ -183,9 +183,9 @@ export default async function smokeO() {
|
||||
data: { stage: "BONDED", intimacy: 90 },
|
||||
});
|
||||
await setEmotion(GENKI_ID, "CALM", 100);
|
||||
const chatResult = await post(`/chat/${GENKI_ID}`, {
|
||||
const chatRoom = await post("/room/solo", { userId: USER_ID, characterId: GENKI_ID });
|
||||
const chatResult = await post(`/room/${chatRoom.id}/message`, {
|
||||
userId: USER_ID,
|
||||
sessionId: "smoke-o-session",
|
||||
text: "謝謝你,我今天好開心!",
|
||||
});
|
||||
const exprAfterChat = await get(`/tachie/${GENKI_ID}/${USER_ID}/expression`);
|
||||
@@ -193,7 +193,8 @@ export default async function smokeO() {
|
||||
throw new Error("聊天訊息觸發的情緒轉變,應該同時反映在對話上下文與獨立查詢的表情狀態上");
|
||||
}
|
||||
|
||||
// 清理本次測試建立的角色與作品(cascade 會一併清掉關係、情緒狀態、插圖索引等)。
|
||||
// 清理本次測試建立的角色與作品(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 } });
|
||||
}
|
||||
|
||||
+7
-5
@@ -27,7 +27,7 @@ async function get(path) {
|
||||
|
||||
// Q-1/Q-2/Q-3 目前都是行動 APP(React Native + Expo,apps/mobile)消費既有的 API 契約,
|
||||
// 沒有為 Q 群組新增角色/對話端點——這裡驗證的是 Q 群組唯一新增的後端功能:推播通知(Q-3),
|
||||
// 以及 APP 賴以運作的既有契約仍然完整(/health、/chat、/characters、/schedule)。
|
||||
// 以及 APP 賴以運作的既有契約仍然完整(/health、/room(T38 起取代 /chat)、/characters、/schedule)。
|
||||
// APP 本身的畫面/導覽/實際串接已在本群組以 `expo start --web` + 無頭瀏覽器手動驗證過
|
||||
// (見 todo.md 實作記錄),不在這個純後端 smoke test 的範圍內。
|
||||
export default async function smokeQ() {
|
||||
@@ -54,10 +54,11 @@ export default async function smokeQ() {
|
||||
initialRelationship: { userId: USER_ID, intimacy: 20, trust: 10 },
|
||||
});
|
||||
|
||||
// Q-2 驗收(後端契約部分):聊天分頁依賴的 /chat 端點能完成一輪對話並回傳更新後的關係/情緒狀態。
|
||||
const chatResult = await post(`/chat/${CHAR_ID}`, {
|
||||
// Q-2 驗收(後端契約部分):聊天分頁依賴的房間端點(T38 併入 SOLO 房後取代舊版 /chat)能完成
|
||||
// 一輪對話並回傳更新後的關係/情緒狀態。
|
||||
const soloRoom = await post("/room/solo", { userId: USER_ID, characterId: CHAR_ID });
|
||||
const chatResult = await post(`/room/${soloRoom.id}/message`, {
|
||||
userId: USER_ID,
|
||||
sessionId: "smoke-q-session",
|
||||
text: "嗨,最近好嗎?",
|
||||
});
|
||||
if (!chatResult.text || !chatResult.context) {
|
||||
@@ -111,7 +112,8 @@ export default async function smokeQ() {
|
||||
throw new Error("任務觸發的推播內容應該包含提醒訊息本身");
|
||||
}
|
||||
|
||||
// 清理本次測試建立的角色與推播資料(cascade 會一併清掉任務、關係、情緒狀態等)。
|
||||
// 清理本次測試建立的角色與推播資料(cascade 會一併清掉任務、關係、情緒狀態等)與本次建立的 SOLO 房。
|
||||
await prisma.room.deleteMany({ where: { id: soloRoom.id } });
|
||||
await prisma.character.deleteMany({ where: { id: CHAR_ID } });
|
||||
await prisma.pushToken.deleteMany({ where: { token: TOKEN } });
|
||||
await prisma.pushNotificationLog.deleteMany({ where: { userId: USER_ID, title: { in: ["測試標題", "心音 Kokorone"] } } });
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { prisma } from "@kokorone/db";
|
||||
|
||||
const API_PORT = process.env.PORT_API ?? "3001";
|
||||
const USER_ID = "smoke-r-user";
|
||||
const CHAR_A = "smoke-r-char-a";
|
||||
const CHAR_B = "smoke-r-char-b";
|
||||
|
||||
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) {
|
||||
await post("/personality/characters", {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
formalName,
|
||||
basicInfo: `${formalName},測試角色`,
|
||||
backgroundStory: "測試用背景",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "第一人稱「我」",
|
||||
initialRelationship: { userId: USER_ID, intimacy: 50, trust: 50 },
|
||||
});
|
||||
}
|
||||
|
||||
// P1 端到端驗證:建房與取回、房間持久化(供重啟後接續推進的依據)、大廳常駐房在場流轉、
|
||||
// 離開後重進、1:1 併入 SOLO 房後關房仍觸發睡眠固化。
|
||||
export default async function smokeR() {
|
||||
await prisma.room.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B] } } });
|
||||
await prisma.user.upsert({ where: { id: USER_ID }, update: {}, create: { id: USER_ID, name: "R 群組測試使用者" } });
|
||||
await createCharacter(CHAR_A, "小R甲");
|
||||
await createCharacter(CHAR_B, "小R乙");
|
||||
|
||||
// T3/T12 驗收:建房與取回。
|
||||
const room = await post("/room", { userId: USER_ID, characterIds: [CHAR_A, CHAR_B], mode: "GROUP" });
|
||||
const fetched = await get(`/room/${room.id}`);
|
||||
if (fetched.id !== room.id || fetched.participants.length !== 2) {
|
||||
throw new Error("建房後應能立即取回相同房間與完整在場名單");
|
||||
}
|
||||
|
||||
// T37 驗收:對話會更新 lastAdvancedAt——這是重啟後排程據以判斷「是否該立刻接續推進」的依據;
|
||||
// 資料直接落在 DB(非行程記憶體),故任何重新連線(含 api 重啟後的新行程)都能讀到同一筆值。
|
||||
// 「api 行程真的重啟後房間仍能取回」已於 T37 由人工實際重啟驗證覆蓋(見該項備註的 6 小時停機模擬)。
|
||||
await post(`/room/${room.id}/message`, { userId: USER_ID, userLabel: "測試", text: "哈囉大家" });
|
||||
const roomRow = await prisma.room.findUnique({ where: { id: room.id } });
|
||||
if (!roomRow?.lastAdvancedAt) {
|
||||
throw new Error("推進後 lastAdvancedAt 應被更新並持久化,供重啟恢復判斷使用");
|
||||
}
|
||||
|
||||
// T24 驗收:大廳常駐房在場流轉——進房後大廳在場名單少一人。
|
||||
const lobbyBefore = await get(`/room/lobby?userId=${USER_ID}`);
|
||||
if (!lobbyBefore.roster.includes(CHAR_A)) {
|
||||
throw new Error("尚未進房前,角色應在大廳在場名單中");
|
||||
}
|
||||
const soloRoom = await post("/room/solo", { userId: USER_ID, characterId: CHAR_A });
|
||||
const lobbyAfterEnter = await get(`/room/lobby?userId=${USER_ID}`);
|
||||
if (lobbyAfterEnter.roster.includes(CHAR_A)) {
|
||||
throw new Error("進房後大廳在場名單應該少一人");
|
||||
}
|
||||
|
||||
// T12 驗收:離開後重進,對話歷程完整且角色仍在場。
|
||||
await post(`/room/${soloRoom.id}/message`, { userId: USER_ID, text: "你好呀" });
|
||||
const reentered = await get(`/room/${soloRoom.id}`);
|
||||
if (reentered.turns.length === 0) {
|
||||
throw new Error("重新進入房間應能看到先前對話歷程");
|
||||
}
|
||||
if (!reentered.participants.includes(CHAR_A)) {
|
||||
throw new Error("重新進入房間時角色應仍在場");
|
||||
}
|
||||
|
||||
// T38 驗收:1:1 併入 SOLO 房後,關房仍觸發睡眠固化(工作記憶清空)並自動回到大廳。
|
||||
const workingMemBefore = await get(`/memory/${CHAR_A}/sessions/${soloRoom.id}/messages`);
|
||||
if (workingMemBefore.context.length === 0) {
|
||||
throw new Error("關房前工作記憶應保留本輪對話內容");
|
||||
}
|
||||
await post(`/room/${soloRoom.id}/end`, {});
|
||||
const workingMemAfter = await get(`/memory/${CHAR_A}/sessions/${soloRoom.id}/messages`);
|
||||
if (workingMemAfter.context.length !== 0) {
|
||||
throw new Error("關房應觸發睡眠固化並清空工作記憶");
|
||||
}
|
||||
const lobbyAfterLeave = await get(`/room/lobby?userId=${USER_ID}`);
|
||||
if (!lobbyAfterLeave.roster.includes(CHAR_A)) {
|
||||
throw new Error("關房後角色應自動回到大廳");
|
||||
}
|
||||
|
||||
// 清理本次測試建立的角色、房間與使用者。
|
||||
await prisma.room.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B] } } });
|
||||
await prisma.user.deleteMany({ where: { id: USER_ID } });
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { prisma } from "@kokorone/db";
|
||||
|
||||
const API_PORT = process.env.PORT_API ?? "3001";
|
||||
const USER_ID = "smoke-s-user";
|
||||
const NOW = "2026-08-19T05:00:00.000Z"; // 週三 13:00 台北,FREE 時段
|
||||
|
||||
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, likesDislikes, intimacy = 50) {
|
||||
await post("/personality/characters", {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
formalName,
|
||||
basicInfo: `${formalName},測試角色`,
|
||||
backgroundStory: "測試用背景",
|
||||
personalityArchetype: archetype,
|
||||
likesDislikes,
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "第一人稱「我」",
|
||||
initialRelationship: { userId: USER_ID, intimacy, trust: 50 },
|
||||
});
|
||||
}
|
||||
|
||||
// 讀取一段 SSE 串流直到收滿 expectedTurnCount 個 turn 事件或逾時,回傳收到的 turn 事件陣列(依到達順序)。
|
||||
async function collectStreamTurns(roomId, afterSeq, expectedTurnCount, timeoutMs = 8000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const collected = [];
|
||||
try {
|
||||
const resp = await fetch(`http://localhost:${API_PORT}/room/${roomId}/stream?afterSeq=${afterSeq}`, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (collected.length < expectedTurnCount) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let frameBreak = buffer.indexOf("\n\n");
|
||||
while (frameBreak !== -1) {
|
||||
const frame = buffer.slice(0, frameBreak);
|
||||
buffer = buffer.slice(frameBreak + 2);
|
||||
let eventType = "message";
|
||||
let id = null;
|
||||
const dataLines = [];
|
||||
for (const line of frame.split("\n")) {
|
||||
if (line.startsWith("event:")) eventType = line.slice(6).trim();
|
||||
else if (line.startsWith("id:")) id = line.slice(3).trim();
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (eventType === "turn" && dataLines.length > 0) {
|
||||
collected.push({ seq: Number(id), data: JSON.parse(dataLines.join("\n")) });
|
||||
}
|
||||
frameBreak = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
reader.cancel().catch(() => undefined);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return collected;
|
||||
}
|
||||
|
||||
const GENKI_LIKE = "運動、甜食";
|
||||
const COOL_LIKE = "安靜、看書";
|
||||
|
||||
const CHAR_IDS = [
|
||||
"smoke-s-genki",
|
||||
"smoke-s-cool",
|
||||
"smoke-s-sleeper",
|
||||
"smoke-s-delegate",
|
||||
"smoke-s-genki2",
|
||||
"smoke-s-invite-a",
|
||||
"smoke-s-invite-b",
|
||||
"smoke-s-invite-target",
|
||||
"smoke-s-invite-target2",
|
||||
];
|
||||
|
||||
// T21-P2:P2(大廳呼叫、多方邀請裁決、SSE 續傳、未讀跳轉、SOLO→GROUP 房型切換)端到端驗證。
|
||||
export default async function smokeS() {
|
||||
await prisma.room.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.callAttemptLog.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.characterRelationship.deleteMany({ where: { characterId: { in: CHAR_IDS } } });
|
||||
await prisma.scheduleSlot.deleteMany({ where: { characterId: { in: CHAR_IDS } } });
|
||||
await prisma.character.deleteMany({ where: { id: { in: CHAR_IDS } } });
|
||||
await prisma.user.upsert({ where: { id: USER_ID }, update: {}, create: { id: USER_ID, name: "S 群組測試使用者" } });
|
||||
|
||||
// 1. 答應:大廳喊一位元氣角色,應答應並開 SOLO 房。
|
||||
await createCharacter("smoke-s-genki", "測試S元氣", "元氣", GENKI_LIKE);
|
||||
const acceptResult = await post("/lobby/say", { userId: USER_ID, text: "測試S元氣,你在嗎?", now: NOW });
|
||||
if (!acceptResult.accepted.includes("smoke-s-genki") || acceptResult.mode !== "SOLO" || !acceptResult.roomId) {
|
||||
throw new Error("答應情境:應答應並降級開 SOLO 房");
|
||||
}
|
||||
|
||||
// 2. 降級為單人房:同時喊一位會答應、一位會婉拒的角色,應只有答應者入房、開 SOLO。
|
||||
await createCharacter("smoke-s-genki2", "測試S元氣二", "元氣", GENKI_LIKE);
|
||||
await createCharacter("smoke-s-cool", "測試S三無", "三無", COOL_LIKE);
|
||||
const downgradeResult = await post("/lobby/say", {
|
||||
userId: USER_ID,
|
||||
text: "測試S元氣二、測試S三無,你們在嗎?",
|
||||
now: NOW,
|
||||
});
|
||||
if (
|
||||
!downgradeResult.accepted.includes("smoke-s-genki2") ||
|
||||
!downgradeResult.declined.includes("smoke-s-cool") ||
|
||||
downgradeResult.mode !== "SOLO"
|
||||
) {
|
||||
throw new Error("降級情境:僅一位答應時應降級開 SOLO 房");
|
||||
}
|
||||
|
||||
// 3. 全部不答應:只喊會婉拒的角色,不開房,但要有角色台詞(非系統錯誤)。
|
||||
const declineAllResult = await post("/lobby/say", { userId: USER_ID, text: "測試S三無,你在嗎?", now: NOW });
|
||||
if (declineAllResult.roomId !== null || !declineAllResult.message || /error|Error|錯誤/.test(declineAllResult.message)) {
|
||||
throw new Error("全部不答應情境:不應開房,且應回傳角色台詞而非系統錯誤");
|
||||
}
|
||||
|
||||
// 4. 關係最近者代答:喊睡著的角色,由關係最近且清醒未入房者代答,訊息附上下次可找的時間。
|
||||
await createCharacter("smoke-s-sleeper", "測試S睡神", "三無", COOL_LIKE);
|
||||
await createCharacter("smoke-s-delegate", "測試S代答", "元氣", GENKI_LIKE);
|
||||
await prisma.scheduleSlot.create({
|
||||
data: { characterId: "smoke-s-sleeper", dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "SLEEP" },
|
||||
});
|
||||
await post("/room/relationship/smoke-s-sleeper", { otherCharacterId: "smoke-s-delegate", affinity: 90 });
|
||||
const delegateResult = await post("/lobby/say", { userId: USER_ID, text: "測試S睡神,你在嗎?", now: NOW });
|
||||
if (delegateResult.delegatedBy !== "smoke-s-delegate" || !delegateResult.message?.includes("15:00")) {
|
||||
throw new Error("代答情境:應由關係最近且清醒未入房者代答,並附上次可找時間");
|
||||
}
|
||||
|
||||
// 5. 多方邀請兩處都收到回覆且只入一間:房內角色各自邀請同一目標,裁決後只入關係較好的那間。
|
||||
await createCharacter("smoke-s-invite-a", "測試S邀請甲", "元氣", GENKI_LIKE);
|
||||
await createCharacter("smoke-s-invite-b", "測試S邀請乙", "元氣", GENKI_LIKE);
|
||||
await createCharacter("smoke-s-invite-target", "測試S被邀角", "元氣", GENKI_LIKE);
|
||||
const roomA = await post("/room", { userId: USER_ID, characterIds: ["smoke-s-invite-a"], mode: "GROUP" });
|
||||
const roomB = await post("/room", { userId: USER_ID, characterIds: ["smoke-s-invite-b"], mode: "GROUP" });
|
||||
await post("/room/relationship/smoke-s-invite-target", { otherCharacterId: "smoke-s-invite-a", affinity: 50 });
|
||||
await post("/room/relationship/smoke-s-invite-target", { otherCharacterId: "smoke-s-invite-b", affinity: 95 });
|
||||
await post(`/room/${roomA.id}/invite`, { characterId: "smoke-s-invite-target", invitedByCharacterId: "smoke-s-invite-a", now: NOW });
|
||||
await post(`/room/${roomB.id}/invite`, { characterId: "smoke-s-invite-target", invitedByCharacterId: "smoke-s-invite-b", now: NOW });
|
||||
await post(`/room/invitations/smoke-s-invite-target/resolve`, { now: NOW });
|
||||
const roomAAfterInvite = await get(`/room/${roomA.id}`);
|
||||
const roomBAfterInvite = await get(`/room/${roomB.id}`);
|
||||
if (!roomAAfterInvite.turns.some((t) => t.speakerId === "smoke-s-invite-target")) {
|
||||
throw new Error("多方邀請情境:落選的房間也應收到該角色的回覆(婉拒台詞)");
|
||||
}
|
||||
if (!roomBAfterInvite.participants.includes("smoke-s-invite-target") || roomAAfterInvite.participants.includes("smoke-s-invite-target")) {
|
||||
throw new Error("多方邀請情境:應只入關係較好的那間,另一間不應在場");
|
||||
}
|
||||
|
||||
// 6. 1:1 升級為多人房後規則改變:邀請角色加入 SOLO 房,應轉為 GROUP,且不會因為對方離開而降回 SOLO。
|
||||
// 用全新的元氣角色當被邀角色(conversationBaseline 高,中性關係下應答應),避免與第 5 項已入房的
|
||||
// smoke-s-invite-target 衝突(R5:已在其他非大廳房間在場者邀請一律直接婉拒,不會進入待裁決佇列)。
|
||||
await createCharacter("smoke-s-invite-target2", "測試S被邀角二", "元氣", GENKI_LIKE);
|
||||
const soloForUpgrade = await post("/room/solo", { userId: USER_ID, characterId: "smoke-s-genki" });
|
||||
await post(`/room/${soloForUpgrade.id}/invite`, {
|
||||
characterId: "smoke-s-invite-target2",
|
||||
invitedByCharacterId: "smoke-s-genki",
|
||||
now: NOW,
|
||||
});
|
||||
await post(`/room/invitations/smoke-s-invite-target2/resolve`, { now: NOW });
|
||||
const upgradedRoom = await get(`/room/${soloForUpgrade.id}`);
|
||||
if (upgradedRoom.mode !== "GROUP") {
|
||||
throw new Error("房型切換情境:SOLO 房被邀請角色答應後應轉為 GROUP");
|
||||
}
|
||||
await prisma.roomMember.updateMany({
|
||||
where: { roomId: soloForUpgrade.id, characterId: "smoke-s-invite-target2" },
|
||||
data: { leftAt: new Date(), activeCharacterId: null, leaveReason: "SMOKE_TEST" },
|
||||
});
|
||||
const afterLeaveRoom = await prisma.room.findUnique({ where: { id: soloForUpgrade.id } });
|
||||
if (afterLeaveRoom.mode !== "GROUP") {
|
||||
throw new Error("房型切換情境:對方離開後不應自動降回 SOLO");
|
||||
}
|
||||
|
||||
// 7. 未讀跳轉:送兩則訊息後不回報已讀,unreadCount 應等於總則數;回報已讀後歸零。
|
||||
const unreadRoom = await post("/room/solo", { userId: USER_ID, characterId: "smoke-s-genki2" });
|
||||
await post(`/room/${unreadRoom.id}/message`, { userId: USER_ID, userLabel: "測試", text: "第一句", now: NOW });
|
||||
await post(`/room/${unreadRoom.id}/message`, { userId: USER_ID, userLabel: "測試", text: "第二句", now: NOW });
|
||||
const beforeRead = await get(`/room/${unreadRoom.id}`);
|
||||
if (beforeRead.unread.unreadCount !== beforeRead.turnCount || beforeRead.unread.firstUnreadSeq !== 0) {
|
||||
throw new Error("未讀跳轉情境:未回報已讀前,未讀數應等於總則數、第一則未讀應是第 0 則");
|
||||
}
|
||||
await post(`/room/${unreadRoom.id}/read`, { userId: USER_ID, lastReadSeq: beforeRead.turnCount - 1 });
|
||||
const afterRead = await get(`/room/${unreadRoom.id}`);
|
||||
if (afterRead.unread.unreadCount !== 0 || afterRead.unread.firstUnreadSeq !== null) {
|
||||
throw new Error("未讀跳轉情境:回報讀到底後,未讀數應歸零");
|
||||
}
|
||||
|
||||
// 8. SSE 續傳不漏訊息:斷線期間(未連線)發生的 turn,重新連線帶 afterSeq 應完整補齊、無重複無遺漏。
|
||||
const sseRoom = await post("/room/solo", { userId: USER_ID, characterId: "smoke-s-delegate" });
|
||||
const beforeStream = await get(`/room/${sseRoom.id}`);
|
||||
const lastSeqBefore = beforeStream.turns.length > 0 ? beforeStream.turns[beforeStream.turns.length - 1].seq : -1;
|
||||
await post(`/room/${sseRoom.id}/message`, { userId: USER_ID, userLabel: "測試", text: "斷線期間的話", now: NOW });
|
||||
const afterMissed = await get(`/room/${sseRoom.id}`);
|
||||
const missedTurns = afterMissed.turns.filter((t) => t.seq > lastSeqBefore);
|
||||
const replayed = await collectStreamTurns(sseRoom.id, lastSeqBefore, missedTurns.length);
|
||||
if (replayed.length !== missedTurns.length) {
|
||||
throw new Error(`SSE 續傳情境:應補齊 ${missedTurns.length} 則錯過的訊息,實際收到 ${replayed.length} 則`);
|
||||
}
|
||||
const replayedSeqs = replayed.map((r) => r.seq).sort((a, b) => a - b);
|
||||
const missedSeqs = missedTurns.map((t) => t.seq).sort((a, b) => a - b);
|
||||
if (JSON.stringify(replayedSeqs) !== JSON.stringify(missedSeqs)) {
|
||||
throw new Error("SSE 續傳情境:補齊的 seq 應與實際錯過的 seq 完全一致,不可重複也不可遺漏");
|
||||
}
|
||||
|
||||
// 清理本次測試建立的資料。
|
||||
await prisma.room.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.callAttemptLog.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.characterRelationship.deleteMany({ where: { characterId: { in: CHAR_IDS } } });
|
||||
await prisma.scheduleSlot.deleteMany({ where: { characterId: { in: CHAR_IDS } } });
|
||||
await prisma.character.deleteMany({ where: { id: { in: CHAR_IDS } } });
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { prisma } from "@kokorone/db";
|
||||
|
||||
const API_PORT = process.env.PORT_API ?? "3001";
|
||||
const USER_ID = "smoke-u-user";
|
||||
const WORK_ID = "smoke-u-work";
|
||||
const CHAR_A = "smoke-u-char-a";
|
||||
const CHAR_B = "smoke-u-char-b";
|
||||
const CHAR_C = "smoke-u-char-c";
|
||||
const CHAR_BAKER = "smoke-u-char-baker";
|
||||
const CHAR_FAST = "smoke-u-char-fast";
|
||||
const CHAR_REALTIME = "smoke-u-char-realtime";
|
||||
const WORK_REALTIME = "smoke-u-work-realtime";
|
||||
const ALL_CHAR_IDS = [CHAR_A, CHAR_B, CHAR_C, CHAR_BAKER, CHAR_FAST, CHAR_REALTIME];
|
||||
|
||||
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, workId, goalsObsessions = "測試") {
|
||||
await post("/personality/characters", {
|
||||
id,
|
||||
workId,
|
||||
source: workId ? "EXISTING_WORK" : "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
formalName,
|
||||
basicInfo: `${formalName},測試角色`,
|
||||
backgroundStory: "測試用背景",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions,
|
||||
speechStyle: "第一人稱「我」",
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
const charIds = ALL_CHAR_IDS;
|
||||
await prisma.epilogueMilestoneLog.deleteMany({ where: { characterId: { in: charIds } } });
|
||||
await prisma.characterEmotionBaseline.deleteMany({ where: { characterId: { in: charIds } } });
|
||||
await prisma.proceduralRule.deleteMany({ where: { characterId: { in: charIds } } });
|
||||
await prisma.emotionState.deleteMany({ where: { characterId: { in: charIds } } });
|
||||
await prisma.sleepDebt.deleteMany({ where: { characterId: { in: charIds } } });
|
||||
await prisma.scheduleSlot.deleteMany({ where: { characterId: { in: charIds } } });
|
||||
await prisma.episodicMemory.deleteMany({ where: { characterId: { in: charIds } } });
|
||||
await prisma.roomTurn.deleteMany({ where: { speakerCharacterId: { in: charIds } } });
|
||||
await prisma.room.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.scene.deleteMany({ where: { workId: { in: [WORK_ID, WORK_REALTIME] } } });
|
||||
await prisma.character.deleteMany({ where: { id: { in: charIds } } });
|
||||
await prisma.work.deleteMany({ where: { id: { in: [WORK_ID, WORK_REALTIME] } } });
|
||||
await prisma.user.deleteMany({ where: { id: USER_ID } });
|
||||
}
|
||||
|
||||
// T21-P4:P4(匯入:兩階段裁決阻斷、每章結局偵測與確認、日後談續匯入採過去記憶、情緒基線與行為
|
||||
// 規則連動、時間流速界線與自主里程碑)端到端驗證,走過一次「匯入章節→裁決→確認結局→日後談續
|
||||
// 匯入」主流程。
|
||||
export default async function smokeU() {
|
||||
await cleanup();
|
||||
await prisma.user.create({ data: { id: USER_ID, name: "U 群組測試使用者" } });
|
||||
await prisma.work.create({ data: { id: WORK_ID, title: "U 群組測試作品", anchorStoryOrder: 100 } });
|
||||
await createCharacter(CHAR_A, "小U甲", WORK_ID);
|
||||
await createCharacter(CHAR_B, "小U乙", WORK_ID);
|
||||
await createCharacter(CHAR_C, "小U丙", WORK_ID);
|
||||
await createCharacter(CHAR_BAKER, "小U丁", WORK_ID, "她一直想開一間屬於自己的麵包店。");
|
||||
|
||||
// ── 1/2:含爭議章節在裁決前不更動角色資料;裁決後才投影並偵測結局 ─────────────────────
|
||||
const disputedScene = await post("/canon/scenes", {
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "U 群組爭議章",
|
||||
storyOrder: 1,
|
||||
summary: "三人在場的一段對話。全劇終。",
|
||||
rawText: "(smoke 測試文本)",
|
||||
participantCharacterIds: [CHAR_A, CHAR_B, CHAR_C],
|
||||
lines: [{ lineType: "DIALOGUE", rawText: "這句話是誰說的呢?" }],
|
||||
});
|
||||
if (!disputedScene.pendingAdjudication || disputedScene.projected) {
|
||||
throw new Error("三人在場、無明示標記的台詞應被隔離,場景應暫不投影");
|
||||
}
|
||||
// 角色建立時會依 backgroundStory 種一筆無關記憶(F 群組既有行為),故不能用「該角色是否有任何記憶」
|
||||
// 判斷;改為直接檢查這個場景自己有沒有投影出任何 sourceSceneId 對應到它的記憶。
|
||||
const memoriesBeforeAdjudication = await prisma.episodicMemory.count({ where: { sourceSceneId: disputedScene.id } });
|
||||
if (memoriesBeforeAdjudication > 0) {
|
||||
throw new Error("裁決前不應有任何角色資料被投影更動");
|
||||
}
|
||||
const pendingBefore = await get(`/canon/works/${WORK_ID}/pending-adjudication`);
|
||||
if (pendingBefore.length !== 1) {
|
||||
throw new Error(`裁決前應有 1 筆待裁決場景,實際為 ${pendingBefore.length}`);
|
||||
}
|
||||
const quarantinedLine = disputedScene.lines.find((l) => l.status === "QUARANTINED");
|
||||
await post(`/canon/scene-lines/${quarantinedLine.id}/adjudicate`, { speakerCharacterId: CHAR_A });
|
||||
const pendingAfter = await get(`/canon/works/${WORK_ID}/pending-adjudication`);
|
||||
if (pendingAfter.length !== 0) {
|
||||
throw new Error("裁決完成後,該場景不應再出現在待裁決清單");
|
||||
}
|
||||
const sceneAfterAdjudication = await get(`/canon/scenes/${disputedScene.id}`);
|
||||
if (!sceneAfterAdjudication.projected) {
|
||||
throw new Error("裁決完成後應接續完成投影");
|
||||
}
|
||||
|
||||
// ── 3/4/5:每章 LLM 偵測結局並提示;確認後切日後談;拒絕後不重複提示 ─────────────────
|
||||
const endingsAfterAdjudication = await get(`/canon/works/${WORK_ID}/ending-detections`);
|
||||
if (endingsAfterAdjudication.length !== 1) {
|
||||
throw new Error(`裁決完成觸發投影後應偵測到 1 筆待確認結局,實際為 ${endingsAfterAdjudication.length}`);
|
||||
}
|
||||
if (!endingsAfterAdjudication[0].evidence) {
|
||||
throw new Error("結局偵測待確認清單應附判定理由(evidence)");
|
||||
}
|
||||
const workBeforeConfirm = await get("/canon/works").then((all) => all.find((w) => w.id === WORK_ID));
|
||||
if (workBeforeConfirm.inEpilogue) {
|
||||
throw new Error("結局偵測成立後,未確認前不應直接切入後日談");
|
||||
}
|
||||
await post(`/epilogue/ending-detections/${endingsAfterAdjudication[0].id}/confirm`, {});
|
||||
const workAfterConfirm = await get("/canon/works").then((all) => all.find((w) => w.id === WORK_ID));
|
||||
if (!workAfterConfirm.inEpilogue) {
|
||||
throw new Error("確認結局偵測後,作品應切入後日談");
|
||||
}
|
||||
const endingsAfterConfirm = await get(`/canon/works/${WORK_ID}/ending-detections`);
|
||||
if (endingsAfterConfirm.length !== 0) {
|
||||
throw new Error("確認後該筆偵測不應再出現在待確認清單");
|
||||
}
|
||||
// 拒絕不重複提示:另一場景匯入結局訊號後拒絕,同一場景不應再次出現在待確認清單。
|
||||
const secondEndingScene = await post("/canon/scenes", {
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "U 群組第二個結局章",
|
||||
storyOrder: 101,
|
||||
summary: "大結局。",
|
||||
rawText: "(smoke 測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "故事到此結束。" }],
|
||||
});
|
||||
const secondEndings = await get(`/canon/works/${WORK_ID}/ending-detections`);
|
||||
const secondLog = secondEndings.find((e) => e.sceneId === secondEndingScene.id);
|
||||
if (!secondLog) throw new Error("第二個結局章節應同樣觸發待確認提示");
|
||||
await post(`/epilogue/ending-detections/${secondLog.id}/reject`, {});
|
||||
const endingsAfterReject = await get(`/canon/works/${WORK_ID}/ending-detections`);
|
||||
if (endingsAfterReject.some((e) => e.sceneId === secondEndingScene.id)) {
|
||||
throw new Error("拒絕後同一場景不應再重複提示");
|
||||
}
|
||||
|
||||
// ── 6/7/8:日後談續匯入只補過去記憶且不動推演現狀;情緒基線不覆寫當下情緒;行為規則新增與加權 ──
|
||||
const memoriesBeforeContinuedImport = await prisma.episodicMemory.count({ where: { sourceSceneId: sceneAfterAdjudication.id } });
|
||||
const anchorBeforeContinuedImport = workAfterConfirm.anchorStoryOrder;
|
||||
const continuedScene = await post("/canon/scenes", {
|
||||
workId: WORK_ID,
|
||||
volume: 2,
|
||||
chapterLabel: "U 群組後日談續匯入章",
|
||||
storyOrder: anchorBeforeContinuedImport + 10,
|
||||
summary: "小U丙失去了摯友,非常難過委屈。",
|
||||
rawText: "(smoke 測試文本)",
|
||||
participantCharacterIds: [CHAR_C],
|
||||
lines: [
|
||||
{ lineType: "NARRATION", rawText: "小U丙難過地離開了。" },
|
||||
{ lineType: "ACTION", rawText: "小U丙蹲在原地哭了很久。", hasExplicitMarker: true, markerCharacterId: CHAR_C },
|
||||
],
|
||||
});
|
||||
if (!continuedScene.projected) {
|
||||
throw new Error("後日談期間續匯入正史章節應直接投影,不受知識邊界卡住");
|
||||
}
|
||||
const workAfterContinuedImport = await get("/canon/works").then((all) => all.find((w) => w.id === WORK_ID));
|
||||
if (workAfterContinuedImport.anchorStoryOrder !== anchorBeforeContinuedImport + 10) {
|
||||
throw new Error("續匯入後錨定點應推進到覆蓋這批正史內容");
|
||||
}
|
||||
const epilogueSceneAfter = await get(`/canon/scenes/${sceneAfterAdjudication.id}`);
|
||||
if (!epilogueSceneAfter.projected) {
|
||||
throw new Error("續匯入不應影響既有已投影場景的投影狀態");
|
||||
}
|
||||
const memoriesAfterContinuedImport = await prisma.episodicMemory.count({ where: { sourceSceneId: sceneAfterAdjudication.id } });
|
||||
if (memoriesAfterContinuedImport !== memoriesBeforeContinuedImport) {
|
||||
throw new Error("續匯入不應改動既有場景投影出的記憶數量(角色現狀應完全不變)");
|
||||
}
|
||||
const correctionMemories = await prisma.episodicMemory.count({ where: { characterId: CHAR_A, source: "CORRECTION" } });
|
||||
if (correctionMemories !== 0) {
|
||||
throw new Error("日後談續匯入不應寫入敘事化的記憶修正(那是 reclaim() 才做的事)");
|
||||
}
|
||||
|
||||
// 情緒基線:續匯入的悲劇章節應合成出偏低落的基線,但不覆寫「當下」明確的情緒訊號。
|
||||
// 固定台北平日白天(週三 13:00),避免真的在週末/假日跑這個 smoke 時基線被假日 JOY 蓋過。
|
||||
const sadBaseline = await get(`/schedule/${CHAR_C}/baseline?now=${encodeURIComponent("2026-08-19T05:00:00.000Z")}`);
|
||||
if (sadBaseline.baseline?.tag !== "SAD") {
|
||||
throw new Error(`匯入悲劇章節後角色整體基調應偏低落,實際為 ${JSON.stringify(sadBaseline.baseline)}`);
|
||||
}
|
||||
const emotionAfterInput = await post(`/emotion/${CHAR_C}/input`, { text: "太開心了!!!中大獎了!!!" });
|
||||
if (!(emotionAfterInput.state.joy > emotionAfterInput.state.calm) || emotionAfterInput.state.sad !== 0) {
|
||||
throw new Error(
|
||||
`本輪對話有明確情緒訊號(開心)時,不應被章節匯入累積出的長期基調(低落)覆寫,實際狀態為 ${JSON.stringify(emotionAfterInput.state)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 行為規則新增與加權:ACTION 描寫首次出現新增規則,重複匯入相同情境+回應模式應加權而非重複新增。
|
||||
const topRuleAfterFirst = await get(`/dialogue/${CHAR_C}/top-rule?situation=${encodeURIComponent("小U丙失去了摯友,非常難過委屈。")}`);
|
||||
if (!topRuleAfterFirst || topRuleAfterFirst.weight !== 1) {
|
||||
throw new Error(`首次出現的行為規則應以權重 1 新增,實際為 ${JSON.stringify(topRuleAfterFirst)}`);
|
||||
}
|
||||
await post("/canon/scenes", {
|
||||
workId: WORK_ID,
|
||||
volume: 2,
|
||||
chapterLabel: "U 群組後日談續匯入章(二)",
|
||||
storyOrder: anchorBeforeContinuedImport + 11,
|
||||
summary: "小U丙失去了摯友,非常難過委屈。",
|
||||
rawText: "(smoke 測試文本)",
|
||||
participantCharacterIds: [CHAR_C],
|
||||
lines: [{ lineType: "ACTION", rawText: "小U丙蹲在原地哭了很久。", hasExplicitMarker: true, markerCharacterId: CHAR_C }],
|
||||
});
|
||||
const topRuleAfterSecond = await get(`/dialogue/${CHAR_C}/top-rule?situation=${encodeURIComponent("小U丙失去了摯友,非常難過委屈。")}`);
|
||||
if (!(topRuleAfterSecond.weight > 1)) {
|
||||
throw new Error("相同情境與回應模式再次出現時,應強化既有規則權重,而不是重複新增");
|
||||
}
|
||||
|
||||
// ── 9/10:FAST 作品的行程與睡眠仍走真實時間;自主里程碑可追溯 ─────────────────────────
|
||||
await prisma.work.create({ data: { id: WORK_REALTIME, title: "U 群組對照作品(REALTIME)", timeFlowMode: "REALTIME" } });
|
||||
await createCharacter(CHAR_FAST, "小U戊", WORK_ID);
|
||||
await createCharacter(CHAR_REALTIME, "小U己", WORK_REALTIME);
|
||||
await prisma.work.update({ where: { id: WORK_ID }, data: { timeFlowMode: "FAST" } });
|
||||
for (const characterId of [CHAR_FAST, CHAR_REALTIME]) {
|
||||
await prisma.scheduleSlot.create({ data: { characterId, dayType: "WEEKDAY", startMinute: 0, endMinute: 390, status: "SLEEP" } });
|
||||
}
|
||||
const sleepCheckNow = "2026-08-18T18:00:00.000Z"; // 台北週四 02:00,落在 SLEEP 時段內
|
||||
const fastInfo = await get(`/schedule/${CHAR_FAST}/info?now=${encodeURIComponent(sleepCheckNow)}`);
|
||||
const realtimeInfo = await get(`/schedule/${CHAR_REALTIME}/info?now=${encodeURIComponent(sleepCheckNow)}`);
|
||||
if (fastInfo.slot?.status !== "SLEEP" || fastInfo.slot?.status !== realtimeInfo.slot?.status) {
|
||||
throw new Error("FAST 作品角色的行程判定應與 REALTIME 作品角色完全一致,不受 timeFlowMode 影響");
|
||||
}
|
||||
|
||||
const milestoneRoom = await post("/room", {
|
||||
userId: USER_ID,
|
||||
characterIds: [CHAR_BAKER],
|
||||
mode: "SELF_CHAT",
|
||||
topicSeed: "後日談自走測試",
|
||||
});
|
||||
const t0 = "2026-08-19T00:00:00.000Z";
|
||||
await post(`/room/${milestoneRoom.id}/self-chat/advance`, { now: t0 }); // 第一次 tick:只建立推演進度時鐘起點
|
||||
await post(`/room/${milestoneRoom.id}/self-chat/advance`, {
|
||||
now: new Date(new Date(t0).getTime() + 24 * 60 * 60 * 1000).toISOString(), // 一個現實日後(FAST:3 storyOrder/日)
|
||||
});
|
||||
const milestones = await get(`/epilogue/works/${WORK_ID}/milestones`);
|
||||
if (!milestones.some((m) => m.characterId === CHAR_BAKER)) {
|
||||
throw new Error("自走一段時間後,應能自主成立且可追溯到里程碑紀錄");
|
||||
}
|
||||
const lobbyAfterMilestone = await get(`/lobby?userId=${USER_ID}`);
|
||||
const lobbyWork = lobbyAfterMilestone.works.find((w) => w.id === WORK_ID);
|
||||
if (!lobbyWork?.milestones?.some((m) => m.characterId === CHAR_BAKER)) {
|
||||
throw new Error("自主成立的里程碑應同步呈現在大廳作品卡片上");
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { prisma } from "@kokorone/db";
|
||||
|
||||
const API_PORT = process.env.PORT_API ?? "3001";
|
||||
const USER_ID = "smoke-v-user";
|
||||
const OTHER_USER_ID = "smoke-v-other-user";
|
||||
const CHAR_A = "smoke-v-char-a"; // 被套用的角色
|
||||
const CHAR_B = "smoke-v-char-b";
|
||||
const ALL_CHAR_IDS = [CHAR_A, CHAR_B];
|
||||
|
||||
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) {
|
||||
await post("/personality/characters", {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
formalName,
|
||||
basicInfo: `${formalName},測試角色`,
|
||||
backgroundStory: "測試用背景",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "第一人稱「我」",
|
||||
initialRelationship: { userId: USER_ID, intimacy: 50, trust: 50 },
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.episodicMemory.deleteMany({ where: { characterId: { in: ALL_CHAR_IDS } } });
|
||||
await prisma.characterRelationship.deleteMany({ where: { characterId: { in: ALL_CHAR_IDS } } });
|
||||
await prisma.roomInvitation.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.pendingLobbyCall.deleteMany({ where: { userId: { in: [USER_ID, OTHER_USER_ID] } } });
|
||||
await prisma.callAttemptLog.deleteMany({ where: { userId: { in: [USER_ID, OTHER_USER_ID] } } });
|
||||
await prisma.roomTurn.deleteMany({ where: { room: { userId: { in: [USER_ID, OTHER_USER_ID] } } } });
|
||||
await prisma.roomMember.deleteMany({ where: { characterId: { in: ALL_CHAR_IDS } } });
|
||||
await prisma.room.deleteMany({ where: { userId: { in: [USER_ID, OTHER_USER_ID] } } });
|
||||
await prisma.character.deleteMany({ where: { id: { in: ALL_CHAR_IDS } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: [USER_ID, OTHER_USER_ID] } } });
|
||||
}
|
||||
|
||||
// T21-P5:P5(人格套用,R8)端到端驗證——套用期間該角色不自走、他人當本人對待、記憶與關係記在
|
||||
// 她名下、被邀請/被呼叫時由使用者代決(不出現系統代答)、退出後恢復自走。人工實際跑過一次套用
|
||||
// 主流程(套用→以她的身分在房內發話→被邀請並代她決定→退出套用)於本項 wiki 完成記錄中一併記載。
|
||||
export default async function smokeV() {
|
||||
await cleanup();
|
||||
await prisma.user.create({ data: { id: USER_ID, name: "V 群組測試使用者" } });
|
||||
await prisma.user.create({ data: { id: OTHER_USER_ID, name: "V 群組另一位測試使用者" } });
|
||||
await createCharacter(CHAR_A, "小V甲");
|
||||
await createCharacter(CHAR_B, "小V乙");
|
||||
|
||||
// ── 1/5:套用期間該角色不自走;退出後恢復自走 ──────────────────────────────────────
|
||||
const room = await post("/room", { userId: USER_ID, characterIds: [CHAR_A, CHAR_B], mode: "SELF_CHAT", topicSeed: "今天過得怎麼樣?" });
|
||||
await post("/possession/enter", { userId: USER_ID, characterId: CHAR_A });
|
||||
const statusAfterEnter = await get(`/possession/status?userId=${USER_ID}`);
|
||||
if (statusAfterEnter.possessingCharacterId !== CHAR_A) {
|
||||
throw new Error("套用後狀態應反映目前套用中的角色");
|
||||
}
|
||||
let sawAutonomousSpeechByA = false;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = await post(`/room/${room.id}/self-chat/advance`, { now: new Date(Date.now() + i * 1000).toISOString() });
|
||||
if (result.turn?.characterId === CHAR_A) sawAutonomousSpeechByA = true;
|
||||
if (result.ended) break;
|
||||
}
|
||||
if (sawAutonomousSpeechByA) {
|
||||
throw new Error("套用期間該角色不應被自走機制選為發言者");
|
||||
}
|
||||
|
||||
// ── 2/3:以她的身分發話,其他角色反應與她本人在場一致;固化後記憶與關係記在她名下 ─────
|
||||
const speakResult = await post(`/possession/${room.id}/message`, { userId: USER_ID, text: "太開心了!好棒!太好了!" });
|
||||
for (const speaker of speakResult.speakers) {
|
||||
if (speaker.characterId === CHAR_A) {
|
||||
throw new Error("套用中的角色不應出現在自己這輪發話的回應者清單裡");
|
||||
}
|
||||
}
|
||||
const roomAfterSpeak = await get(`/room/${room.id}`);
|
||||
if (!roomAfterSpeak.turns.some((t) => t.speakerId === CHAR_A && t.content === "太開心了!好棒!太好了!")) {
|
||||
throw new Error("以套用角色的身分發話應逐字寫入她自己的 RoomTurn");
|
||||
}
|
||||
await post(`/room/${room.id}/end`, {});
|
||||
const memoriesAfterEnd = await prisma.episodicMemory.count({ where: { characterId: CHAR_A } });
|
||||
if (memoriesAfterEnd === 0) {
|
||||
throw new Error("套用期間產生的對話固化後,記憶應照常記在她名下");
|
||||
}
|
||||
|
||||
// ── 4:被邀請時由使用者代決,未出現系統代答 ─────────────────────────────────────────
|
||||
await post("/possession/enter", { userId: USER_ID, characterId: CHAR_A }); // 上個房間已結束,重新套用
|
||||
const inviterRoom = await post("/room", { userId: OTHER_USER_ID, characterIds: [CHAR_B], mode: "GROUP" });
|
||||
await post(`/room/${inviterRoom.id}/invite`, { characterId: CHAR_A, invitedByCharacterId: CHAR_B });
|
||||
await post(`/room/invitations/${CHAR_A}/resolve`, {});
|
||||
const pendingAfterResolve = await get(`/possession/pending-invitations?userId=${USER_ID}`);
|
||||
if (pendingAfterResolve.length !== 1 || pendingAfterResolve[0].status !== "PENDING") {
|
||||
throw new Error("套用中角色被邀請時,人格系統不應自動裁決,應維持待決交由使用者決定");
|
||||
}
|
||||
const roomBeforeDecision = await get(`/room/${inviterRoom.id}`);
|
||||
if (roomBeforeDecision.turns.length !== 0) {
|
||||
throw new Error("套用中角色被邀請時,裁決前不應出現任何系統代答的台詞");
|
||||
}
|
||||
await post(`/possession/invitations/${pendingAfterResolve[0].id}/decide`, { accept: true });
|
||||
const roomAfterDecision = await get(`/room/${inviterRoom.id}`);
|
||||
if (!roomAfterDecision.participants.includes(CHAR_A)) {
|
||||
throw new Error("使用者代她接受邀請後,她應實際入房");
|
||||
}
|
||||
|
||||
// 被呼叫(大廳)時同樣由使用者代決:另一位使用者喊她,應記一筆待決而非人格系統自動判定。
|
||||
await post(`/room/${inviterRoom.id}/end`, {}); // 先讓她離場,避免 R5 擋下後續的呼叫判定
|
||||
const callResult = await post("/lobby/say", { userId: OTHER_USER_ID, text: "小V甲,你在嗎?" });
|
||||
if (callResult.accepted.length !== 0 || callResult.declined.length !== 0 || callResult.message !== null) {
|
||||
throw new Error("套用中角色被呼叫時,不應出現任何人格系統的接受/婉拒判定或代答台詞");
|
||||
}
|
||||
if (!callResult.pendingPossessedCharacterIds.includes(CHAR_A)) {
|
||||
throw new Error("套用中角色被呼叫時,應標記為待套用者決定");
|
||||
}
|
||||
const pendingCalls = await get(`/possession/pending-calls?userId=${USER_ID}`);
|
||||
if (pendingCalls.length !== 1 || pendingCalls[0].characterId !== CHAR_A) {
|
||||
throw new Error("待決呼叫應歸戶給正在套用她的使用者,而不是打這通呼叫的使用者");
|
||||
}
|
||||
await post(`/possession/lobby-calls/${pendingCalls[0].id}/decide`, { accept: false });
|
||||
if ((await get(`/possession/pending-calls?userId=${USER_ID}`)).length !== 0) {
|
||||
throw new Error("婉拒後待決呼叫應消除");
|
||||
}
|
||||
|
||||
// ── 5:退出套用後恢復自走 ─────────────────────────────────────────────────────────
|
||||
await post("/possession/exit", { userId: USER_ID });
|
||||
const statusAfterExit = await get(`/possession/status?userId=${USER_ID}`);
|
||||
if (statusAfterExit.possessingCharacterId !== null) {
|
||||
throw new Error("退出套用後狀態應變回未套用");
|
||||
}
|
||||
const resumedRoom = await post("/room", { userId: USER_ID, characterIds: [CHAR_A, CHAR_B], mode: "SELF_CHAT", topicSeed: "退出套用後的話題" });
|
||||
let sawAutonomousSpeechByAAfterExit = false;
|
||||
for (let i = 0; i < 30 && !sawAutonomousSpeechByAAfterExit; i++) {
|
||||
const result = await post(`/room/${resumedRoom.id}/self-chat/advance`, { now: new Date(Date.now() + i * 1000).toISOString() });
|
||||
if (result.turn?.characterId === CHAR_A) sawAutonomousSpeechByAAfterExit = true;
|
||||
if (result.ended) break;
|
||||
}
|
||||
if (!sawAutonomousSpeechByAAfterExit) {
|
||||
throw new Error("退出套用後,該角色應恢復自走並終究輪到她自己開口");
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
}
|
||||
+13
-2
@@ -13,10 +13,17 @@ async function listGroupFiles() {
|
||||
return files.filter((f) => f.endsWith(".mjs") && f !== "run.mjs").sort();
|
||||
}
|
||||
|
||||
async function runOne(file) {
|
||||
// 連續跑多個群組時,偶爾會在某個群組剛結束、下一個群組第一個請求時遇到暫時性的連線錯誤
|
||||
// (observed:長時間跑(如 T 群組的重試/全域暫停測試)結束後,緊接著的下一個群組第一個 fetch
|
||||
// 偶發 "fetch failed"/連線被對方關閉——與程式邏輯無關,是連線在群組交接瞬間的暫時性狀態)。
|
||||
// 每個群組檔案的既有慣例是「一進來就先 cleanup() 清掉自己上一輪可能留下的資料」,因此整個
|
||||
// default() 重跑一次是安全、可重複的,不會因為半途失敗留下的殘留資料造成誤判。
|
||||
const TRANSIENT_ERROR_PATTERN = /fetch failed|ECONNRESET|ECONNREFUSED|socket|other side closed|network/i;
|
||||
|
||||
async function runOne(file, attempt = 1) {
|
||||
const group = file.replace(/\.mjs$/, "");
|
||||
const modUrl = pathToFileURL(path.join(SMOKE_DIR, file)).href;
|
||||
log("冒煙測試", "INF", `開始執行 ${group} 群組冒煙測試`);
|
||||
log("冒煙測試", "INF", `開始執行 ${group} 群組冒煙測試${attempt > 1 ? `(第 ${attempt} 次嘗試)` : ""}`);
|
||||
try {
|
||||
const mod = await import(modUrl);
|
||||
if (typeof mod.default !== "function") {
|
||||
@@ -27,6 +34,10 @@ async function runOne(file) {
|
||||
return true;
|
||||
} catch (err) {
|
||||
log("冒煙測試", "ERR", `${group} 群組冒煙測試失敗:${err.message}`);
|
||||
if (attempt === 1 && TRANSIENT_ERROR_PATTERN.test(err.message)) {
|
||||
log("冒煙測試", "WRN", `${group} 疑似暫時性連線錯誤,重試一次`);
|
||||
return runOne(file, attempt + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user