Files
jsc-marketplace/plugins/jsc-mod-companion/hooks/companion/world.test.ts
T
adminandClaude Sonnet 5.5 7e6e323473 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>
2026-10-14 09:29:46 +00:00

435 lines
17 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 { 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)
})
})