Files
jsc-marketplace/plugins/jsc-mod-companion/hooks/companion/emotion.test.ts
T

151 lines
6.2 KiB
TypeScript
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 { describe, expect, test } from 'claude-code/testing'
import { createCompanion } from './core'
import { applyEvent, baselineOf, circadian, decayEmotions, dominant, fromPad, halfLifeMs, toPad } from './emotion'
import type { Emotions } from './emotion'
import { fakeClock, memoryFs } from './fs'
import type { CompanionCore } from './types'
const H = 3_600_000
const near = (a: number, b: number) => expect(Math.abs(a - b)).toBeLessThan(1e-9)
const ZERO = baselineOf({ p: 0, a: 0, d: 0 })
// 本地正午 12:00(tz +0000),晝夜節律不為 0 但不影響其他維度
const NOON = Date.UTC(2026, 9, 14, 12)
const MIKO: CompanionCore = {
id: 'miko',
name: '月見ミコ',
facts: { birthday: 'unknown', zodiac: 'unknown', bloodType: 'unknown', hair: 'unknown', face: 'unknown', height: 'unknown', weight: 'unknown', measurements: 'unknown' },
neuroticism: 50,
baseline: { p: 0, a: 0, d: 0 },
}
describe('衰減', () => {
test('各維度依自己的半衰期回到基線', () => {
const s = { ...ZERO, shy: 1, homesick: 1, mood: 1, at: NOON }
const e = decayEmotions(s, ZERO, NOON + 2 * H, 50, 0)
near(e.shy, 0.5) // 害羞半衰期 2 小時
near(e.homesick, Math.pow(0.5, 2 / 24)) // 想家半衰期 24 小時
near(e.mood, 0.5 + 0.5 * Math.pow(0.5, 2 / 8))
})
test('神經質影響半衰期,且久放會回到基線', () => {
expect(halfLifeMs('mood', 100)).toBeGreaterThan(halfLifeMs('mood', 0))
const e = decayEmotions({ ...ZERO, upset: 1, at: NOON }, ZERO, NOON + 30 * 24 * H, 50, 0)
expect(e.upset).toBeLessThan(0.001)
})
test('精力基線隨晝夜節律:下午高、凌晨低', () => {
const at = (h: number) => decayEmotions({ ...ZERO, at: 0 }, ZERO, Date.UTC(2026, 9, 14, h) + 365 * 24 * H, 50, 0).energy
expect(at(15)).toBeGreaterThan(at(3))
near(circadian(Date.UTC(2026, 9, 14, 15), 0), 0.1)
near(circadian(Date.UTC(2026, 9, 14, 7), 480), 0.1) // 時區換算:UTC 07 = 本地 15
})
})
describe('習慣化與敏感化', () => {
test('正向事件重複發生,效果遞減', () => {
let e: Emotions = ZERO
let habit = {}
const gains: number[] = []
for (let i = 0; i < 3; i++) {
const r = applyEvent(e, habit, 'thanks', NOON)
gains.push(r.emotions.mood - e.mood)
;({ emotions: e, habit } = r)
}
near(gains[0]!, 0.075)
near(gains[1]!, 0.075 * 0.7)
near(gains[2]!, 0.075 * 0.49)
})
test('習慣化隨時間遺忘:隔 6 小時後計次減半', () => {
const r1 = applyEvent(ZERO, {}, 'thanks', NOON)
const r2 = applyEvent(ZERO, r1.habit, 'thanks', NOON + 6 * H)
near(r2.emotions.mood - ZERO.mood, 0.075 * Math.pow(0.7, 0.5))
})
test('負向事件重複發生,傷害放大且上限 2 倍', () => {
let e: Emotions = { ...ZERO, mood: 1, energy: 0.6 }
let habit = {}
const drops: number[] = []
for (let i = 0; i < 6; i++) {
const r = applyEvent(e, habit, 'scold', NOON)
drops.push(e.mood - r.emotions.mood)
;({ emotions: e, habit } = r)
}
near(drops[0]!, 0.06)
near(drops[1]!, 0.06 * 1.25)
near(drops[5]!, 0.06 * 2)
})
test('疲勞易怒:精力低時負面變化放大 1.5 倍', () => {
const fresh = applyEvent({ ...ZERO, energy: 0.6 }, {}, 'toolFail', NOON).emotions
const tired = applyEvent({ ...ZERO, energy: 0.1 }, {}, 'toolFail', NOON).emotions
near(tired.upset - ZERO.upset, (fresh.upset - ZERO.upset) * 1.5)
})
test('情感注入:感謝帶動心跳,想念會在回來時消解', () => {
expect(applyEvent(ZERO, {}, 'thanks', NOON).emotions.heartbeat).toBeGreaterThan(0)
const missing = applyEvent(ZERO, {}, 'ignored', NOON).emotions
expect(applyEvent(missing, {}, 'return', NOON).emotions.missing).toBeLessThan(missing.missing)
})
test('每維度單回合變化不超過 0.3,值域 0~1', () => {
const r = applyEvent({ ...ZERO, mood: 0.05 }, { scold: { n: 50, at: NOON } }, 'scold', NOON).emotions
expect(r.mood).toBeGreaterThanOrEqual(0)
expect(r.upset).toBeLessThanOrEqual(0.3)
})
})
describe('主情緒', () => {
test('強度達門檻才採用,否則回到 PAD 八分區標籤', () => {
expect(dominant(ZERO)).toBe(null)
expect(dominant({ ...ZERO, shy: 0.8 })?.label).toBe('害羞')
expect(dominant({ ...ZERO, mood: 0.1 })?.label).toBe('低落')
})
})
describe('舊 PAD 存檔升級', () => {
test('PAD 映射到八維度並可還原', () => {
const base = baselineOf({ p: 0.2, a: 0.1, d: 0.1 })
const e = fromPad({ p: -0.5, a: 0.4, d: -0.3, at: 7 }, base, { p: 0.2, a: 0.1, d: 0.1 })
expect(e.at).toBe(7)
const pad = toPad(e, base, { p: 0.2, a: 0.1, d: 0.1 })
near(pad.p, -0.5)
near(pad.a, 0.4)
near(pad.d, -0.3)
})
test('讀檔時自動升級並寫回,狀態不變', async () => {
const fs = memoryFs()
const clock = fakeClock()
const root = '/h/.companion'
const now = clock.now()
const old = { mood: { p: 0.3, a: 0, d: 0, at: now }, affinity: { value: 0.1, stage: 0, dayKey: '', dayGain: 0, lastSeen: now }, todos: 0 }
fs.files.set(`${root}/global.json`, JSON.stringify({ active: 'miko', activatedOnce: true }))
fs.files.set(`${root}/companions/miko/core.json`, JSON.stringify(MIKO))
fs.files.set(`${root}/companions/miko/state.json`, JSON.stringify(old))
const c = createCompanion({ fs, clock, root, builtins: [MIKO] })
const v = (await c.view())!
near(v.mood.p, 0.3)
near(v.emotions.mood, 0.65)
await c.moodEvent('thanks')
const saved = JSON.parse(fs.files.get(`${root}/companions/miko/state.json`)!)
expect(saved.mood).toBeUndefined()
expect(saved.emotion.mood).toBeGreaterThan(0.65)
})
})
describe('/status', () => {
test('預設顯示前 3 強情緒,--all 顯示 8 維度', async () => {
const fs = memoryFs()
const c = createCompanion({ fs, clock: fakeClock(), root: '/h/.companion', builtins: [MIKO] })
await c.ensureActivated()
await c.moodEvent('scold')
const brief = (await c.command('status')).split('\n').filter(l => l.startsWith(' ') && l.includes('\x1b['))
expect(brief).toHaveLength(3)
const all = (await c.command('status --all')).split('\n').filter(l => l.startsWith(' ') && l.includes('\x1b['))
expect(all).toHaveLength(8)
})
})