From 94a86c970354fbae3bcb2020e7a5df07fdd5da80 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 09:26:47 +0000 Subject: [PATCH] =?UTF-8?q?fix(emotion):=20loadEmotion=20=E9=A9=97?= =?UTF-8?q?=E8=AD=89=E6=AA=94=E6=A1=88=E5=85=A7=E5=AE=B9=EF=BC=8C=E8=A1=B0?= =?UTF-8?q?=E6=B8=9B=E4=B8=80=E5=BE=8B=20clamp=20=E4=B8=94=E5=8D=8A?= =?UTF-8?q?=E8=A1=B0=E6=9C=9F=E5=BF=85=E9=A0=88=E7=82=BA=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emotion.json 是外部輸入(import/sync pull/手改都會進到這裡),但 loadEmotion() 用 `??=` 只補缺、不驗合法性,decayEmotion() 完全不 clamp。只要一份不合法的檔案: * baseline.joy = 5000 → decay 後 joy = 4378.79,mood 卡在 valence 100 / arousal 100 * levels.anger = "很生氣" → decay 後 anger = NaN,注入的上下文印出 valence NaN * half_life_minutes.joy ≤ 0 → factor 恆為 0,那個情緒從此累積不起來(連三次 +30 結果不變) CLI 的 --baseline 與 --apply 本來就有保護,破口純粹在檔案入口,所以擋在 loadEmotion(): levels/baseline clamp 到 0–100、非有限數字退回預設、half_life 非正數退回該情緒的預設值。 decayEmotion/decayEmotionBy 抽出共用的 applyDecay(),同樣重驗一次並 clamp 結果—— 它們是 export 的,呼叫端有可能餵手寫或匯入來的狀態,不能假設一定經過 loadEmotion()。 selftest +9:baseline 超界被 clamp、非數字不變 NaN、上下文不印 NaN、 四種壞掉的半衰期都退回預設、壞半衰期下情緒仍累積得起來、decayEmotionBy 同樣擋得住。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/persona-lib.mjs | 64 ++++++++++++++++++++++++++++------------- scripts/selftest.mjs | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 20 deletions(-) diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index bb90d47..524d6d6 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -362,16 +362,33 @@ export function defaultEmotionState(baseline = {}) { }; } +/** 0–100 之間的合法數字;不是數字(NaN/字串/null)就退回 `fallback`。 */ +function sanelevel(value, fallback) { + const num = Number(value); + return Number.isFinite(num) ? clamp(num) : clamp(fallback); +} + +/** + * 讀情緒狀態。**檔案是外部輸入**(`import`/`sync pull`/手改都會進到這裡), + * 所以不能只補缺、還要驗合法性——以前用 `??=` 只補 undefined,結果: + * + * * `baseline.joy = 5000` → 衰減後 joy 破千,mood 卡在 valence/arousal 100 + * * `levels.anger = "很生氣"` → 衰減後 anger = NaN,注入的上下文印出 valence NaN + * * `half_life_minutes.joy = 0` 或負數 → factor 恆為 0,那個情緒從此累積不起來 + * + * CLI 的 `--baseline`/`--apply` 本來就有保護,破口純粹在檔案入口,所以擋在這裡。 + */ export function loadEmotion(slug) { let state = readJson(emotionPath(slug)); if (!state || typeof state !== "object" || !state.levels) state = defaultEmotionState(); - state.baseline ??= {}; - state.levels ??= {}; - state.half_life_minutes ??= {}; + if (!state.baseline || typeof state.baseline !== "object") state.baseline = {}; + if (!state.levels || typeof state.levels !== "object") state.levels = {}; + if (!state.half_life_minutes || typeof state.half_life_minutes !== "object") state.half_life_minutes = {}; for (const key of EMOTION_KEYS) { - state.baseline[key] ??= DEFAULT_BASELINE[key]; - state.levels[key] ??= state.baseline[key]; - state.half_life_minutes[key] ??= EMOTIONS[key].halfLife; + state.baseline[key] = sanelevel(state.baseline[key], DEFAULT_BASELINE[key]); + state.levels[key] = sanelevel(state.levels[key], state.baseline[key]); + const half = Number(state.half_life_minutes[key]); + state.half_life_minutes[key] = Number.isFinite(half) && half > 0 ? half : EMOTIONS[key].halfLife; } return state; } @@ -390,18 +407,31 @@ export function updateEmotion(slug, mutate) { }); } +/** + * 把 `minutes` 分鐘的衰減直接套上去(decayEmotion 與 decayEmotionBy 共用)。 + * + * 每個值都重新驗過一次:這兩個函式是 export 的,呼叫端有可能餵進手寫或匯入來的狀態, + * 不能假設它一定經過 `loadEmotion()`。半衰期 ≤ 0 會讓 factor 恆為 0(那個情緒從此 + * 累積不起來),所以一律退回該情緒的預設半衰期。 + */ +function applyDecay(state, minutes) { + for (const key of EMOTION_KEYS) { + const rawHalf = Number(state.half_life_minutes?.[key]); + const half = Number.isFinite(rawHalf) && rawHalf > 0 ? rawHalf : EMOTIONS[key].halfLife; + const base = sanelevel(state.baseline?.[key], DEFAULT_BASELINE[key]); + const level = sanelevel(state.levels?.[key], base); + const factor = Math.pow(0.5, minutes / half); + state.levels[key] = Math.round(clamp(base + (level - base) * factor) * 100) / 100; + } + return state; +} + /** 情緒朝 baseline 指數衰減;半衰期依情緒種類不同。 */ export function decayEmotion(state, now = new Date()) { const last = parseIso(state.updated_at) ?? now; const minutes = Math.max(0, (now.getTime() - last.getTime()) / 60_000); if (minutes <= 0) return state; - for (const key of EMOTION_KEYS) { - const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife); - const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]); - const level = Number(state.levels[key] ?? base); - const factor = half > 0 ? Math.pow(0.5, minutes / half) : 0; - state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100; - } + applyDecay(state, minutes); state.updated_at = iso(now); return state; } @@ -416,13 +446,7 @@ export function decayEmotion(state, now = new Date()) { export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) { const mins = Math.max(0, Number(minutes) || 0); if (!mins) return state; - for (const key of EMOTION_KEYS) { - const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife); - const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]); - const level = Number(state.levels[key] ?? base); - const factor = half > 0 ? Math.pow(0.5, mins / half) : 0; - state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100; - } + applyDecay(state, mins); state.updated_at = nowIso(); return state; } diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 6460188..da1a668 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -843,6 +843,61 @@ console.log("\n並行寫入:read-modify-rewrite 不能吃掉同時 append 的 !fs.existsSync(staleLock), pl.fileLockPath(victim)); } +// --------------------------------------------------------------------------- // +// 情緒的檔案入口要驗證:emotion.json 是外部輸入(import/sync pull/手改都會進來), +// CLI 的 --baseline/--apply 有保護,破口在檔案這一側。 +console.log("\n情緒:不合法的 emotion.json 不能污染整個狀態"); +{ + cli(["create", "--persona", "badstate", "--session", "sess-bad-8888", "--name", "Bad", + "--creature", "壞掉的溫度計", "--vibe", "亂跳", "--emoji", "🌡"]); + const anHourAgo = pl.iso(new Date(Date.now() - 3_600_000)); + const write = (patch) => { + const st = pl.defaultEmotionState(); + st.updated_at = anHourAgo; + patch(st); + pl.writeJson(pl.emotionPath("badstate"), st); + }; + + // ① 超出範圍的 baseline:以前 decay 後 joy = 4378,mood 卡在 valence 100 / arousal 100 + write((st) => { st.baseline.joy = 5000; }); + const over = pl.decayEmotion(pl.loadEmotion("badstate")); + check("baseline 超出 0–100 會被 clamp(不會把 mood 頂到滿)", + over.baseline.joy === 100 && over.levels.joy <= 100 && pl.mood(over).valence < 100, + `baseline=${over.baseline.joy} joy=${over.levels.joy} valence=${pl.mood(over).valence}`); + + // ② 非數字的 levels:以前 decay 後 anger = NaN,注入的上下文直接印出 valence NaN + write((st) => { st.levels.anger = "很生氣"; st.levels.joy = null; }); + const nan = pl.decayEmotion(pl.loadEmotion("badstate")); + check("非數字的情緒值退回預設,不會變成 NaN", + Number.isFinite(nan.levels.anger) && Number.isFinite(nan.levels.joy), JSON.stringify(nan.levels)); + check("注入的上下文不會印出 NaN", + !pl.emotionBrief("badstate", nan).includes("NaN") && Number.isFinite(pl.mood(nan).valence), + pl.emotionBrief("badstate", nan)); + + // ③ 半衰期 ≤ 0:以前 factor 恆為 0,那個情緒從此累積不起來(連三次 +30 結果都一樣) + for (const bad of [-10, 0, "很久", null]) { + write((st) => { st.half_life_minutes.joy = bad; }); + check(`half_life=${JSON.stringify(bad)} 會退回預設半衰期`, + pl.loadEmotion("badstate").half_life_minutes.joy === pl.EMOTIONS.joy.halfLife); + } + write((st) => { st.half_life_minutes.joy = -10; }); + const grow = []; + for (let i = 0; i < 3; i += 1) { + cli(["emotion", "--session", "sess-bad-8888", "--apply", "joy=+30"]); + grow.push(pl.readJson(pl.emotionPath("badstate")).levels.joy); + } + check("半衰期壞掉時情緒仍然累積得起來(以前三次 +30 一動也不動)", + grow[1] > grow[0] && grow[2] > grow[1], grow.join(" → ")); + + // ④ 睡眠的固定衰減走同一條路 + write((st) => { st.half_life_minutes.sadness = 0; st.levels.sadness = 90; }); + const slept = pl.decayEmotionBy(pl.loadEmotion("badstate"), pl.SLEEP_DECAY_MINUTES); + check("decayEmotionBy 也擋得住壞掉的半衰期(不會一覺把情緒歸零)", + slept.levels.sadness > pl.DEFAULT_BASELINE.sadness && slept.levels.sadness < 90, + `sadness=${slept.levels.sadness}`); + cli(["release", "--session", "sess-bad-8888"]); +} + console.log("\n情緒:飽和、單輪預算、走向、偏差稽核"); let sat = pl.defaultEmotionState(); for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌");