feat: companion 作息、主動發話、適應度與外部素材

- 作息與疲勞、想念累積與時間感,主動發話沿用 care.ts 的冷卻、日限、靜音
- 想家與適應度(9.13)
- 獨立腳本 fetch-materials.mjs 由系統排程寫入快取,hook 只讀、不連網
- 新增 /jsc-mod-companion:materials 與 setup location=、feeds=
- 天氣微調心情基準,新聞依適應度每天最多 2 則並寫入情節記憶
- 版本 0.0.5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 09:29:46 +00:00
co-authored by Claude Sonnet 5.5
parent eeed12741e
commit 7e6e323473
15 changed files with 1237 additions and 20 deletions
@@ -0,0 +1,434 @@
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('/status 相容', () => {
test('保留原有各行,並多出適應度與時間感', async () => {
const { c } = setup()
await c.ensureActivated()
const s = await c.command('status')
for (const k of ['心情 PAD:', '情緒(前 3 強', '好感:', '關係:', '待辦:', '基本資訊:', '適應度:', '時間感:']) expect(s).toContain(k)
})
})