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