fix(emotion): loadEmotion 驗證檔案內容,衰減一律 clamp 且半衰期必須為正
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) <noreply@anthropic.com>
This commit is contained in:
+44
-20
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user