Files
Kokorone/scripts/smoke/U.mjs
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

263 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const USER_ID = "smoke-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();
}