- 狀態列的 PAD 條改為最強的兩種情緒(不含精力) - info 短版只列名字、關係、前 3 強情緒、適應度與待辦 - info --all 補上 12 格情緒與關係長條、人格 18 參數三層值、世界與時間感,基本資訊移到這裡 - 版本號改為 0.0.4 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
443 lines
18 KiB
TypeScript
443 lines
18 KiB
TypeScript
import { describe, expect, test } from 'claude-code/testing'
|
||
|
||
import { missingAccrual } from './absence'
|
||
import { grow, growthRate, homesickBase, initialAdapt, markFirst, newsQuota } from './adapt'
|
||
import { line } from './care'
|
||
import type { Attribute, Stage, Trigger } from './care'
|
||
import { createCompanion } from './core'
|
||
import { fakeClock, memoryFs } from './fs'
|
||
import { readMaterials, weatherShift } from './materials'
|
||
import { energyShift, inSleepWindow, periodOf } from './schedule'
|
||
import type { CompanionCore } from './types'
|
||
|
||
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 },
|
||
}
|
||
const H = 3_600_000
|
||
const DAY = 24 * H
|
||
const ROOT = '/h/.companion'
|
||
const MATS = `${ROOT}/materials.json`
|
||
// 假時鐘預設:UTC 2026-10-12 00:00 = 本地(+0800)08:00,剛好是起床時間
|
||
|
||
function setup() {
|
||
const fs = memoryFs()
|
||
const clock = fakeClock()
|
||
return { fs, clock, c: createCompanion({ fs, clock, root: ROOT, builtins: [MIKO] }) }
|
||
}
|
||
const item = (title: string, at: number, link = `https://n.example/${title}`) => ({ title, summary: `${title}的摘要`, link, at })
|
||
const put = (fs: ReturnType<typeof memoryFs>, data: unknown) => fs.write(MATS, JSON.stringify(data))
|
||
|
||
describe('作息與疲勞', () => {
|
||
const r = { wake: '08:00', sleep: '23:00', tzOffsetMin: 480 }
|
||
const at = (h: number) => Date.UTC(2026, 9, 12, h - 8, 0)
|
||
|
||
test('醒太久才累:12 小時內不變,之後每小時 −0.02,上限 −0.15', () => {
|
||
expect(energyShift(at(14), 0, r)).toBe(0)
|
||
expect(energyShift(at(22), 0, r)).toBeCloseTo(-0.04, 5) // 醒 14 小時
|
||
expect(energyShift(at(22), 0, { ...r, sleep: '23:59' })).toBeCloseTo(-0.04, 5)
|
||
})
|
||
|
||
test('連續工作超過 1 小時才疲勞,上限 −0.15', () => {
|
||
expect(energyShift(at(12), 3 * H, r)).toBeCloseTo(-0.06, 5)
|
||
expect(energyShift(at(12), 20 * H, r)).toBeCloseTo(-0.15, 5)
|
||
})
|
||
|
||
test('使用者睡眠時段仍在線(熬夜)精力基準更低;剛起床稍有回復', () => {
|
||
expect(inSleepWindow(at(26), 480, '08:00', '23:00')).toBe(true)
|
||
expect(energyShift(at(26), 0, r)).toBeCloseTo(-0.1, 5)
|
||
expect(energyShift(at(8), 0, r)).toBeCloseTo(0.03, 5)
|
||
})
|
||
|
||
test('時間感:本地時段', () => {
|
||
expect(periodOf(at(3), 480)).toBe('深夜')
|
||
expect(periodOf(at(10), 480)).toBe('上午')
|
||
expect(periodOf(at(20), 480)).toBe('夜晚')
|
||
})
|
||
|
||
test('連續工作拖累 view 的精力', async () => {
|
||
const { c, fs, clock } = setup()
|
||
await c.ensureActivated()
|
||
clock.advance(6 * H)
|
||
const base = (await c.view())!.emotions.energy
|
||
await fs.write(`${ROOT}/companions/miko/care.json`, JSON.stringify({ workStart: clock.now() - 4 * H, lastActivity: clock.now() - 60_000 }))
|
||
expect((await c.view())!.emotions.energy).toBeLessThan(base - 0.01)
|
||
// 閒置超過門檻就不算連續工作
|
||
await fs.write(`${ROOT}/companions/miko/care.json`, JSON.stringify({ workStart: clock.now() - 4 * H, lastActivity: clock.now() - 2 * H }))
|
||
expect((await c.view())!.emotions.energy).toBeCloseTo(base, 5)
|
||
})
|
||
})
|
||
|
||
describe('想念累積與時間感', () => {
|
||
test('2 小時內不累積,之後依速度增加,上限 0.8', () => {
|
||
expect(missingAccrual(2 * H)).toBe(0)
|
||
expect(missingAccrual(12 * H)).toBeCloseTo(0.3, 5)
|
||
expect(missingAccrual(1000 * H)).toBe(0.8)
|
||
})
|
||
|
||
test('想念累積速度參數越高,累積越快', () => {
|
||
expect(missingAccrual(10 * H, 100)).toBeGreaterThan(missingAccrual(10 * H, 0))
|
||
})
|
||
|
||
test('離開越久 view 的想念越高', async () => {
|
||
const { c, clock } = setup()
|
||
await c.ensureActivated()
|
||
expect((await c.view())!.emotions.missing).toBe(0)
|
||
clock.advance(12 * H)
|
||
const v = (await c.view())!
|
||
expect(v.emotions.missing).toBeCloseTo(0.3, 2)
|
||
expect(v.absentMs).toBe(12 * H)
|
||
})
|
||
|
||
test('久別回來:想念存下來並被「回來」事件降低,下次不再累積', async () => {
|
||
const { c, clock } = setup()
|
||
await c.ensureActivated()
|
||
clock.advance(24 * H)
|
||
await c.onPrompt('我回來了,今天要處理一些事情')
|
||
const v = (await c.view())!
|
||
expect(v.emotions.missing).toBeLessThan(0.6)
|
||
expect(v.emotions.missing).toBeGreaterThan(0.1)
|
||
expect(v.absentMs).toBe(0)
|
||
})
|
||
|
||
test('想念高時回來,主動說出口(miss_you);同一冷卻內不重複', async () => {
|
||
const { c, clock } = setup()
|
||
await c.ensureActivated()
|
||
clock.advance(24 * H)
|
||
const first = await c.onPrompt('我回來了,今天要處理一些事情')
|
||
expect(first.some(x => x.startsWith('[care:miss_you]'))).toBe(true)
|
||
clock.advance(26 * H)
|
||
const second = await c.onPrompt('又隔了很久,再回來一次試試')
|
||
expect(second.some(x => x.startsWith('[care:miss_you]'))).toBe(true)
|
||
clock.advance(H)
|
||
const third = await c.onPrompt('一個小時後馬上又說話了')
|
||
expect(third.some(x => x.startsWith('[care:miss_you]'))).toBe(false)
|
||
})
|
||
|
||
test('靜音期間不主動說出想念', async () => {
|
||
const { c, clock } = setup()
|
||
await c.ensureActivated()
|
||
await c.command('quiet 2000')
|
||
clock.advance(24 * H)
|
||
const out = await c.onPrompt('我回來了,今天要處理一些事情')
|
||
expect(out.some(x => x.startsWith('[care:miss_you]'))).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('適應度', () => {
|
||
const tz = 480
|
||
const t0 = Date.UTC(2026, 9, 12)
|
||
|
||
test('每日 ΔA = k(1 − A/100),k 隨主動度與想像力', () => {
|
||
expect(growthRate(50, 50)).toBeCloseTo(2.25, 5)
|
||
expect(growthRate(100, 100)).toBeCloseTo(3, 5)
|
||
const a = grow(initialAdapt(t0, tz), t0 + DAY, tz, 2.25)
|
||
expect(a.value).toBeCloseTo(2.25, 5)
|
||
})
|
||
|
||
test('跨多日的閉式結果與逐日累加一致,先快後慢', () => {
|
||
let step = initialAdapt(t0, tz)
|
||
for (let i = 1; i <= 9; i++) step = grow(step, t0 + i * DAY, tz, 2.25)
|
||
const jump = grow(initialAdapt(t0, tz), t0 + 9 * DAY, tz, 2.25)
|
||
expect(jump.value).toBeCloseTo(step.value, 6)
|
||
const d27 = grow(initialAdapt(t0, tz), t0 + 27 * DAY, tz, 2.25).value
|
||
expect(d27 - jump.value).toBeLessThan(2 * (jump.value - 0) * 2)
|
||
expect(d27).toBeLessThan(60)
|
||
})
|
||
|
||
test('同一天不重複成長;本地第一次事件各 +2、只算一次', () => {
|
||
const a = initialAdapt(t0, tz)
|
||
expect(grow(a, t0 + H, tz, 2.25)).toBe(a)
|
||
const w = markFirst(a, 'weather')
|
||
expect(w.value).toBe(2)
|
||
expect(markFirst(w, 'weather')).toBe(w)
|
||
expect(markFirst(w, 'news').value).toBe(4)
|
||
})
|
||
|
||
test('想家基準隨適應度下降', () => {
|
||
expect(homesickBase(0)).toBe(0.25)
|
||
expect(homesickBase(80)).toBeCloseTo(0.05, 5)
|
||
})
|
||
|
||
test('新聞配額:適應度 < 40 不聊,之後 1 則,≥ 70 才到 2 則', () => {
|
||
expect(newsQuota(39, 2)).toBe(0)
|
||
expect(newsQuota(40, 2)).toBe(1)
|
||
expect(newsQuota(70, 2)).toBe(2)
|
||
expect(newsQuota(99, 1)).toBe(1)
|
||
})
|
||
|
||
test('view 的適應度隨天數成長並寫進存檔;想家基準也隨之降低', async () => {
|
||
const { c, clock } = setup()
|
||
await c.ensureActivated()
|
||
await c.onPrompt('第一天先打個招呼,開始工作')
|
||
const day0 = (await c.view())!
|
||
expect(day0.adapt).toBe(0)
|
||
clock.advance(10 * DAY)
|
||
const v = (await c.view())!
|
||
expect(v.adapt).toBeGreaterThan(15)
|
||
expect(v.adapt).toBeLessThan(25)
|
||
expect((await c.command('status'))).toContain('適應度 ')
|
||
})
|
||
})
|
||
|
||
describe('外部素材快取', () => {
|
||
const now = Date.UTC(2026, 9, 12)
|
||
|
||
test('檔案缺失或損壞當作沒查', async () => {
|
||
const fs = memoryFs()
|
||
expect(await readMaterials(fs, MATS, now)).toEqual({ weather: null, news: [] })
|
||
await fs.write(MATS, '{壞掉')
|
||
expect(await readMaterials(fs, MATS, now)).toEqual({ weather: null, news: [] })
|
||
await put(fs, { weather: 'x', news: 'y' })
|
||
expect(await readMaterials(fs, MATS, now)).toEqual({ weather: null, news: [] })
|
||
})
|
||
|
||
test('天氣 6 小時、新聞 24 小時過期', async () => {
|
||
const fs = memoryFs()
|
||
await put(fs, { weather: item('晴', now - 5 * H), news: [item('新', now - 23 * H), item('舊', now - 25 * H)] })
|
||
const m = await readMaterials(fs, MATS, now)
|
||
expect(m.weather?.title).toBe('晴')
|
||
expect(m.news.map(n => n.title)).toEqual(['新'])
|
||
await put(fs, { weather: item('晴', now - 7 * H), news: [] })
|
||
expect((await readMaterials(fs, MATS, now)).weather).toBeNull()
|
||
})
|
||
|
||
test('天氣只微調心情基準,未知狀況與缺失為 0', () => {
|
||
expect(weatherShift(item('晴', now))).toBeGreaterThan(0)
|
||
expect(weatherShift(item('雷雨', now))).toBeLessThan(0)
|
||
expect(Math.abs(weatherShift(item('雷雨', now)))).toBeLessThanOrEqual(0.06)
|
||
expect(weatherShift(item('彩虹', now))).toBe(0)
|
||
expect(weatherShift(null)).toBe(0)
|
||
})
|
||
|
||
test('沒有 setup location 時,快取存在也不啟用', async () => {
|
||
const { c, fs, clock } = setup()
|
||
await c.ensureActivated()
|
||
await put(fs, { weather: item('晴', clock.now()), news: [] })
|
||
expect((await c.view())!.weather).toBeNull()
|
||
})
|
||
|
||
test('啟用後天氣微調心情基準;過期後恢復', async () => {
|
||
const { c, fs, clock } = setup()
|
||
await c.ensureActivated()
|
||
const before = (await c.view())!.emotions.mood
|
||
await c.command('setup location=25.03,121.56')
|
||
await put(fs, { weather: item('晴', clock.now()), news: [] })
|
||
const v = (await c.view())!
|
||
expect(v.weather).toBe('晴')
|
||
expect(v.emotions.mood).toBeCloseTo(before, 1)
|
||
// 雷雨把心情基準往下拉:很久之後的心情比晴天低
|
||
await put(fs, { weather: item('雷雨', clock.now()), news: [] })
|
||
clock.advance(2 * 60_000) // 超過素材的讀取快取間隔
|
||
expect((await c.view())!.weather).toBe('雷雨')
|
||
clock.advance(7 * H)
|
||
expect((await c.view())!.weather).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('天氣微調心情基準', () => {
|
||
async function moodAfter(title: string) {
|
||
const { c, fs, clock } = setup()
|
||
await c.ensureActivated()
|
||
await c.command('setup location=25,121')
|
||
await put(fs, { weather: item(title, clock.now()), news: [] })
|
||
clock.advance(5 * H) // 在天氣有效的 6 小時內
|
||
return (await c.view())!.emotions.mood
|
||
}
|
||
|
||
test('晴天的心情基準高於雷雨', async () => {
|
||
const sunny = await moodAfter('晴')
|
||
const storm = await moodAfter('雷雨')
|
||
expect(sunny).toBeGreaterThan(storm)
|
||
expect(sunny - storm).toBeLessThan(0.11)
|
||
})
|
||
})
|
||
|
||
describe('主動發話台詞', () => {
|
||
const attrs: Attribute[] = ['tsundere', 'genki', 'calm', 'gentle']
|
||
const stages: Stage[] = ['stranger', 'familiar', 'close', 'special']
|
||
const triggers: Trigger[] = ['miss_you', 'homesick', 'news']
|
||
|
||
test('新增的三種觸發,每個屬性與階段都有台詞,且帶暱稱、不責備', () => {
|
||
for (const t of triggers) for (const a of attrs) for (const st of stages) {
|
||
const text = line(t, a, st, '阿JSC', '某則新聞')
|
||
expect(text).toContain('阿JSC')
|
||
expect(text.length).toBeGreaterThan(5)
|
||
expect(/怎麼都不|為什麼不|害我|都是你/.test(text)).toBe(false)
|
||
if (t === 'news') expect(text).toContain('某則新聞')
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('想家', () => {
|
||
test('想家到會說出口時主動說;適應度高時基準低、不會自己升高', async () => {
|
||
const { c, fs, clock } = setup()
|
||
await c.ensureActivated()
|
||
const path = `${ROOT}/companions/miko/state.json`
|
||
const st = JSON.parse((await fs.read(path))!)
|
||
st.emotion.homesick = 0.6
|
||
st.emotion.at = clock.now()
|
||
await fs.write(path, JSON.stringify(st))
|
||
const out = await c.onPrompt('今天要開始處理工作了,內容夠長')
|
||
expect(out.some(x => x.startsWith('[care:homesick]'))).toBe(true)
|
||
expect((await c.view())!.emotions.homesick).toBeGreaterThan(0.2)
|
||
})
|
||
})
|
||
|
||
describe('新聞與主動發話', () => {
|
||
// 適應度夠高才聊新聞:直接寫進存檔
|
||
async function ready(adapt: number) {
|
||
const s = setup()
|
||
await s.c.ensureActivated()
|
||
await s.c.command('setup location=25,121')
|
||
const path = `${ROOT}/companions/miko/state.json`
|
||
const st = JSON.parse((await s.fs.read(path))!)
|
||
st.adapt = { value: adapt, day: Math.floor((s.clock.now() + 480 * 60_000) / DAY), firsts: [] }
|
||
await s.fs.write(path, JSON.stringify(st))
|
||
await put(s.fs, { weather: null, news: [item('甲', s.clock.now()), item('乙', s.clock.now()), item('丙', s.clock.now())] })
|
||
return s
|
||
}
|
||
const spoke = (out: string[]) => out.filter(x => x.startsWith('[care:news]'))
|
||
|
||
test('適應度不夠不聊新聞', async () => {
|
||
const { c } = await ready(10)
|
||
expect(spoke(await c.onPrompt('今天要開始處理工作了'))).toHaveLength(0)
|
||
})
|
||
|
||
test('適應度夠就聊,同一則不重複,並寫入情節記憶', async () => {
|
||
const { c, fs } = await ready(50)
|
||
const out = spoke(await c.onPrompt('今天要開始處理工作了'))
|
||
expect(out).toHaveLength(1)
|
||
expect(out[0]).toContain('甲')
|
||
const mem = JSON.parse((await fs.read(`${ROOT}/companions/miko/memory.json`))!)
|
||
expect(JSON.stringify(mem)).toContain('聊到時事:甲')
|
||
expect(JSON.stringify(mem)).toContain('episodic')
|
||
})
|
||
|
||
test('每天最多 2 則(適應度 ≥ 70),隔天重新計算,且不重複同一則', async () => {
|
||
const { c, clock } = await ready(80)
|
||
const titles: string[] = []
|
||
for (let i = 0; i < 4; i++) {
|
||
const out = spoke(await c.onPrompt(`第 ${i} 次說話,內容夠長了`))
|
||
if (out[0]) titles.push(out[0])
|
||
clock.advance(5 * H) // 超過新聞冷卻
|
||
}
|
||
expect(titles.length).toBe(2)
|
||
expect(titles[0]).toContain('甲')
|
||
expect(titles[1]).toContain('乙')
|
||
})
|
||
|
||
test('適應度 40–70 每天只有 1 則', async () => {
|
||
const { c, clock } = await ready(50)
|
||
const a = spoke(await c.onPrompt('第一次說話,內容夠長了'))
|
||
clock.advance(5 * H)
|
||
const b = spoke(await c.onPrompt('第二次說話,內容夠長了'))
|
||
expect(a).toHaveLength(1)
|
||
expect(b).toHaveLength(0)
|
||
})
|
||
|
||
test('新聞過期就當作沒查', async () => {
|
||
const { c, fs, clock } = await ready(80)
|
||
await put(fs, { weather: null, news: [item('舊聞', clock.now() - 25 * H)] })
|
||
clock.advance(0)
|
||
expect(spoke(await c.onPrompt('今天要開始處理工作了'))).toHaveLength(0)
|
||
})
|
||
|
||
test('第一次用到新聞算本地第一次事件,適應度 +2', async () => {
|
||
const { c } = await ready(50)
|
||
await c.onPrompt('今天要開始處理工作了')
|
||
expect((await c.view())!.adapt).toBeCloseTo(52, 0)
|
||
})
|
||
})
|
||
|
||
describe('hook 不連網', () => {
|
||
test('讀取素材、說話、tick、指令都不呼叫 fetch', async () => {
|
||
const real = globalThis.fetch
|
||
let called = 0
|
||
globalThis.fetch = (() => {
|
||
called++
|
||
throw new Error('hook 不應連網')
|
||
}) as never
|
||
try {
|
||
const { c, fs, clock } = setup()
|
||
await c.ensureActivated()
|
||
await c.command('setup location=25,121 feeds=https://n.example/rss')
|
||
await put(fs, { weather: item('晴', clock.now()), news: [item('甲', clock.now())] })
|
||
await c.view()
|
||
await c.prompt()
|
||
await c.onPrompt('今天要開始處理工作了,內容夠長')
|
||
await c.tick()
|
||
await c.command('materials list')
|
||
await c.end()
|
||
} finally {
|
||
globalThis.fetch = real
|
||
}
|
||
expect(called).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe('setup 與 materials 指令', () => {
|
||
test('location 與 feeds 可設定並顯示;feeds 須為 http(s) 網址', async () => {
|
||
const { c } = setup()
|
||
await c.ensureActivated()
|
||
const ok = await c.command('setup location=台北 feeds=https://a.example/rss,http://b.example/feed')
|
||
expect(ok).toContain('location=台北')
|
||
expect(ok).toContain('feeds=https://a.example/rss,http://b.example/feed')
|
||
expect((await c.getUser()).feeds).toEqual(['https://a.example/rss', 'http://b.example/feed'])
|
||
expect(await c.command('setup feeds=ftp://x')).toContain('無法解析')
|
||
expect((await c.getUser()).feeds).toHaveLength(2)
|
||
})
|
||
|
||
test('預設 RSS 為空清單、location 為空', async () => {
|
||
const { c } = setup()
|
||
const u = await c.getUser()
|
||
expect(u.location).toBe('')
|
||
expect(u.feeds).toEqual([])
|
||
})
|
||
|
||
test('未啟用時 materials 提示先設定 location', async () => {
|
||
const { c } = setup()
|
||
await c.ensureActivated()
|
||
expect(await c.command('materials')).toContain('location=')
|
||
})
|
||
|
||
test('materials 顯示快取狀態;list 列出新聞標題與連結;過期顯示沒有資料', async () => {
|
||
const { c, fs, clock } = setup()
|
||
await c.ensureActivated()
|
||
await c.command('setup location=25,121')
|
||
expect(await c.command('materials')).toContain('沒有資料或已過期')
|
||
await put(fs, { weather: item('晴', clock.now() - H), news: [item('甲', clock.now())] })
|
||
const s = await c.command('materials')
|
||
expect(s).toContain('天氣:晴')
|
||
expect(s).toContain('新聞:1 則')
|
||
const l = await c.command('materials list')
|
||
expect(l).toContain('甲')
|
||
expect(l).toContain('https://n.example/甲')
|
||
clock.advance(25 * H)
|
||
expect(await c.command('materials')).toContain('沒有資料或已過期')
|
||
})
|
||
})
|
||
|
||
describe('info 輸出', () => {
|
||
test('短版只有名字、關係、情緒、適應度與待辦', async () => {
|
||
const { c } = setup()
|
||
await c.ensureActivated()
|
||
const s = await c.command('status')
|
||
for (const k of ['關係 ', '情緒 ', '適應度 ', '待辦 ', '--all']) expect(s).toContain(k)
|
||
for (const k of ['PAD', '基本資訊', '時間感']) expect(s).not.toContain(k)
|
||
})
|
||
|
||
test('--all 多出關係長條、人格參數、世界與時間感', async () => {
|
||
const { c } = setup()
|
||
await c.ensureActivated()
|
||
const s = await c.command('status --all')
|
||
for (const k of ['情緒(8 維度)', '關係(', '人格(', '世界', '基本資訊', '現在是', '距離上次互動']) expect(s).toContain(k)
|
||
})
|
||
})
|