- 狀態列的 PAD 條改為最強的兩種情緒(不含精力) - info 短版只列名字、關係、前 3 強情緒、適應度與待辦 - info --all 補上 12 格情緒與關係長條、人格 18 參數三層值、世界與時間感,基本資訊移到這裡 - 版本號改為 0.0.4 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
151 lines
6.2 KiB
TypeScript
151 lines
6.2 KiB
TypeScript
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').find(l => l.startsWith('情緒 '))!
|
||
expect(brief.slice(4).split('、')).toHaveLength(3)
|
||
const all = (await c.command('status --all')).split('\n').filter(l => l.startsWith(' ') && l.includes('\x1b['))
|
||
expect(all).toHaveLength(8)
|
||
})
|
||
})
|