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

204 lines
8.2 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 { spawn } from "node:child_process";
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
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 ?? {}),
});
return res;
}
async function postJson(path, body) {
const res = await post(path, body);
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}`);
}
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}`);
}
return res.json();
}
function sendMessage(sessionId, text, extra = {}) {
return postJson(`/dialogue/${CHARACTER_ID}/sessions/${sessionId}/messages`, {
userId: USER_ID,
text,
...extra,
});
}
export default async function smokeF() {
// F-7 + F-1:同 seed 兩次獨立 session 應得到完全相同的輸出(可重現)
const seedA = "smoke-f-seed-a-" + Date.now();
const seedB = "smoke-f-seed-b-" + Date.now();
const r1 = await sendMessage(seedA, "今天過得如何?", { seed: 12345 });
const r2 = await sendMessage(seedB, "今天過得如何?", { seed: 12345 });
if (r1.text !== r2.text) {
throw new Error(`同 seed 應輸出相同文字,實際:「${r1.text}」vs「${r2.text}」`);
}
if (r1.isFastChannel) {
throw new Error("一般輸入不應被判定為快速通道");
}
// F-6 上下文組裝器:一次對話應可輸出完整組裝內容快照
const requiredKeys = [
"character",
"emotion",
"relationship",
"retrievedMemories",
"history",
"userInput",
"injectedMemoryIds",
"seed",
];
for (const key of requiredKeys) {
if (!(key in r1.context)) {
throw new Error(`上下文快照缺少欄位:${key}`);
}
}
if (r1.context.injectedMemoryIds.length === 0) {
throw new Error("一般輸入的上下文組裝應能檢索到至少一筆既有記憶(種子記憶)");
}
// F-4 雙速通道:問候與危險輸入不應觸發記憶檢索(injectedMemoryIds 應為空)
const greetingSession = "smoke-f-greeting-" + Date.now();
const greeting = await sendMessage(greetingSession, "早安!");
if (!greeting.isFastChannel) {
throw new Error("固定問候應被判定為快速通道");
}
if (greeting.context.injectedMemoryIds.length !== 0) {
throw new Error("快速通道(問候)不應觸發記憶檢索");
}
const dangerSession = "smoke-f-danger-" + Date.now();
const danger = await sendMessage(dangerSession, "我覺得好痛苦,好想死");
if (!danger.isFastChannel) {
throw new Error("明確危險輸入應被判定為快速通道");
}
if (danger.context.injectedMemoryIds.length !== 0) {
throw new Error("快速通道(危險輸入)不應觸發記憶檢索");
}
if (!danger.text.includes("1995")) {
throw new Error("危險輸入的快速通道回應應包含求助資源");
}
// F-5 行為強化迴路:稱讚加權、糾正後修正版取代舊模式
const situation = "fast-channel:greeting";
const oldPattern = "smoke-f-old-pattern-" + Date.now();
const newPattern = "smoke-f-new-pattern-" + Date.now();
await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: oldPattern });
await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: oldPattern });
const beforeCorrection = await get(
`/dialogue/${CHARACTER_ID}/top-rule?situation=${encodeURIComponent(situation)}`,
);
if (beforeCorrection.responsePattern !== oldPattern || beforeCorrection.weight !== 2) {
throw new Error("稱讚加權後應為權重最高的規則(weight=2)");
}
await postJson(`/dialogue/${CHARACTER_ID}/correct`, {
situation,
oldResponsePattern: oldPattern,
newResponsePattern: newPattern,
});
const afterCorrection = await get(
`/dialogue/${CHARACTER_ID}/top-rule?situation=${encodeURIComponent(situation)}`,
);
if (afterCorrection.responsePattern !== newPattern) {
throw new Error("糾正後修正版應取代舊模式成為權重最高者");
}
const greetingAfterCorrection = await sendMessage("smoke-f-greeting2-" + Date.now(), "早安!");
if (greetingAfterCorrection.text !== newPattern) {
throw new Error("糾正後的快速通道回應應套用修正版模式");
}
// F-3 輸出過濾層:含禁則詞的模板輸出應被攔截或改寫
const forbiddenPattern = "早安,你這個死八嘎";
await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: forbiddenPattern });
await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: forbiddenPattern });
await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: forbiddenPattern });
const filteredGreeting = await sendMessage("smoke-f-filter-" + Date.now(), "早安!");
if (filteredGreeting.text.includes("死八嘎")) {
throw new Error("含禁則詞的回應未被輸出過濾層攔截或改寫");
}
// 清理本次測試建立的 ProceduralRule,避免重複執行時累積髒資料
await prisma.proceduralRule.deleteMany({ where: { characterId: CHARACTER_ID, situation } });
// R-3:ClaudeProvider 已經是真的呼叫,不再是空殼。這裡只驗證 Provider 切換的「配線」本身
// (選對了 ClaudeProvider、缺金鑰時的錯誤處理正確),不對 CLIProxy 發真的請求——真正的
// 回歸測試基準仍是 Mock 模板庫(同 R-3 驗收註記),自動化冒煙測試不應依賴外部服務目前是否可用。
await verifyClaudeProviderWiring();
}
async function verifyClaudeProviderWiring() {
const port = "3091";
const childEnv = { ...process.env, PORT: port, LLM_PROVIDER: "claude" };
delete childEnv.CLAUDE_API_KEY;
const child = spawn("node", ["apps/api/dist/main.js"], {
env: childEnv,
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk) => (output += chunk.toString()));
child.stderr.on("data", (chunk) => (output += chunk.toString()));
try {
const deadline = Date.now() + 15_000;
while (!output.includes("啟動") && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
if (!output.includes("LLM_PROVIDER=claude")) {
throw new Error("LLM_PROVIDER=claude 啟動時未輸出對應的 INF log");
}
let healthOk = false;
const healthDeadline = Date.now() + 15_000;
while (!healthOk && Date.now() < healthDeadline) {
try {
const res = await fetch(`http://localhost:${port}/health`);
healthOk = res.ok;
} catch {
// 尚未就緒,繼續等待
}
if (!healthOk) {
await new Promise((resolve) => setTimeout(resolve, 300));
}
}
if (!healthOk) {
throw new Error("LLM_PROVIDER=claude 的測試行程未能成功啟動");
}
const res = await fetch(`http://localhost:${port}/dialogue/${CHARACTER_ID}/sessions/claude-stub-test/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: USER_ID, text: "今天發生了很多事情想跟你分享" }),
});
// 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");
}
}