feat: jsc-mod-companion 情緒 8 維度與舊存檔升級 #49
@@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
### `/jsc-mod-companion:status`
|
### `/jsc-mod-companion:status`
|
||||||
|
|
||||||
查看目前角色、心情、好感與待辦數量。
|
查看目前角色、心情、好感與待辦數量。情緒有 8 個維度(心情、精力、害羞、忌妒、想念、心跳、想家、不悅),預設列出前 3 強,`--all` 顯示完整數值(▰▱ 量表依維度上色);band 上的情緒名稱為目前最強的主情緒。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/jsc-mod-companion:status
|
/jsc-mod-companion:status
|
||||||
|
/jsc-mod-companion:status --all
|
||||||
```
|
```
|
||||||
|
|
||||||
### `/jsc-mod-companion:switch <角色>`
|
### `/jsc-mod-companion:switch <角色>`
|
||||||
@@ -99,3 +100,11 @@
|
|||||||
- 正則先判明確的情況(責罵、感謝、完成)。
|
- 正則先判明確的情況(責罵、感謝、完成)。
|
||||||
- 正則沒判出時,採用模型於回覆末尾補的 `<評估:標籤>`;標籤須在 `tags.ts` 的白名單內,缺失或不在白名單就當作沒有標籤。
|
- 正則沒判出時,採用模型於回覆末尾補的 `<評估:標籤>`;標籤須在 `tags.ts` 的白名單內,缺失或不在白名單就當作沒有標籤。
|
||||||
- 白名單約 20 個,參考 wiki 第 9.4 章;不含戀愛與親密類標籤。
|
- 白名單約 20 個,參考 wiki 第 9.4 章;不含戀愛與親密類標籤。
|
||||||
|
|
||||||
|
## 情緒模型
|
||||||
|
|
||||||
|
- 8 維度各有基準與半衰期(心情 8h、精力 6h、害羞 2h、忌妒 4h、想念 12h、心跳 1h、想家 24h、不悅 4h,神經質 50 時),數值集中在 `hooks/companion/emotion.ts`。
|
||||||
|
- 晝夜節律:精力基準在本地 15 點最高、凌晨 3 點最低(±0.1,依 `tz` 設定)。
|
||||||
|
- 事件的情感注入、疲勞易怒(精力低於 0.3 時負面變化 ×1.5)、正向習慣化(重複 ×0.7ⁿ)、負向敏感化(重複最多 ×2),計次以 6 小時半衰期遺忘。
|
||||||
|
- 舊的 PAD 存檔讀檔時自動映射(p→心情、a→精力、d→害羞)並升級,不需重建。
|
||||||
|
- 與第 9 章的差異:晝夜節律振幅、習慣化係數等數值為本實作自訂,尚未逐項對照第 9 章。
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { idleDecay, initialAffinity, interact } from './affinity'
|
|||||||
import { FUBUKI } from './defaults'
|
import { FUBUKI } from './defaults'
|
||||||
import { readJson, writeJson } from './fs'
|
import { readJson, writeJson } from './fs'
|
||||||
import type { Clock, Fs } from './fs'
|
import type { Clock, Fs } from './fs'
|
||||||
import { applyDelta, decay, keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
|
import { applyEvent, baselineOf, decayEmotions, dominant, fromPad, gauge, ranked, toPad } from './emotion'
|
||||||
|
import type { Emotions } from './emotion'
|
||||||
|
import { keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
|
||||||
import type { MoodEvent } from './mood'
|
import type { MoodEvent } from './mood'
|
||||||
import { extraversionOf, neuroticismOf } from './core/params'
|
import { extraversionOf, neuroticismOf } from './core/params'
|
||||||
import { extractTag } from './tags'
|
import { extractTag } from './tags'
|
||||||
@@ -28,6 +30,7 @@ export const DEFAULT_USER: UserSettings = {
|
|||||||
export type View = {
|
export type View = {
|
||||||
core: CompanionCore
|
core: CompanionCore
|
||||||
mood: Pad
|
mood: Pad
|
||||||
|
emotions: Emotions
|
||||||
label: string
|
label: string
|
||||||
kaomoji: string
|
kaomoji: string
|
||||||
affinity: number
|
affinity: number
|
||||||
@@ -51,14 +54,24 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
if (!core || !b) return core
|
if (!core || !b) return core
|
||||||
return { ...core, persona: core.persona ?? b.persona, attribute: core.attribute ?? b.attribute, extraversion: core.extraversion ?? b.extraversion, freq: core.freq ?? b.freq }
|
return { ...core, persona: core.persona ?? b.persona, attribute: core.attribute ?? b.attribute, extraversion: core.extraversion ?? b.extraversion, freq: core.freq ?? b.freq }
|
||||||
}
|
}
|
||||||
const getState = (id: string) => readJson<CompanionState>(fs, `${dir(id)}/state.json`)
|
const baseOf = (core: CompanionCore) => baselineOf(core.baseline, core.emotionBaseline)
|
||||||
|
// 讀檔時把舊的 PAD 存檔(mood)映射到八維度並寫回,不要求重建
|
||||||
|
const getState = async (id: string, core: CompanionCore): Promise<CompanionState | null> => {
|
||||||
|
const raw = await readJson<CompanionState & { mood?: Pad & { at: number } }>(fs, `${dir(id)}/state.json`)
|
||||||
|
if (!raw) return null
|
||||||
|
if (raw.emotion) return { ...raw, habit: raw.habit ?? {} }
|
||||||
|
const { mood, ...rest } = raw
|
||||||
|
const st: CompanionState = { ...rest, emotion: fromPad(mood ?? { ...core.baseline, at: clock.now() }, baseOf(core), core.baseline), habit: {} }
|
||||||
|
await putState(id, st)
|
||||||
|
return st
|
||||||
|
}
|
||||||
const putState = (id: string, s: CompanionState) => writeJson(fs, `${dir(id)}/state.json`, s)
|
const putState = (id: string, s: CompanionState) => writeJson(fs, `${dir(id)}/state.json`, s)
|
||||||
|
|
||||||
async function install(core: CompanionCore) {
|
async function install(core: CompanionCore) {
|
||||||
if (await getCore(core.id)) return
|
if (await getCore(core.id)) return
|
||||||
const now = clock.now()
|
const now = clock.now()
|
||||||
await writeJson(fs, `${dir(core.id)}/core.json`, core)
|
await writeJson(fs, `${dir(core.id)}/core.json`, core)
|
||||||
await putState(core.id, { mood: { ...core.baseline, at: now }, affinity: initialAffinity(now), todos: 0 })
|
await putState(core.id, { emotion: { ...baselineOf(core.baseline, core.emotionBaseline), at: now }, habit: {}, affinity: initialAffinity(now), todos: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一次啟用自動啟用預設角色;之後即使關閉也不會再自動啟用
|
// 第一次啟用自動啟用預設角色;之後即使關閉也不會再自動啟用
|
||||||
@@ -77,28 +90,33 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
const { active } = await getGlobal()
|
const { active } = await getGlobal()
|
||||||
if (!active) return null
|
if (!active) return null
|
||||||
const core = await getCore(active)
|
const core = await getCore(active)
|
||||||
const st = await getState(active)
|
const st = core && (await getState(active, core))
|
||||||
if (!core || !st) return null
|
if (!core || !st) return null
|
||||||
const now = clock.now()
|
const now = clock.now()
|
||||||
const mood = decay(st.mood, core.baseline, now - st.mood.at, neuroticismOf(core, st))
|
const base = baseOf(core)
|
||||||
|
const emotions = decayEmotions(st.emotion, base, now, neuroticismOf(core, st), tzMinutes((await getUser()).tz))
|
||||||
|
const mood = toPad(emotions, base, core.baseline)
|
||||||
|
const main = dominant(emotions)
|
||||||
const aff = idleDecay(st.affinity, now)
|
const aff = idleDecay(st.affinity, now)
|
||||||
const todos = (await readJson<{ done: boolean }[]>(fs, `${dir(active)}/todos.json`))?.filter(t => !t.done).length ?? 0
|
const todos = (await readJson<{ done: boolean }[]>(fs, `${dir(active)}/todos.json`))?.filter(t => !t.done).length ?? 0
|
||||||
return { core, mood, ...moodLabel(mood), affinity: aff.value, stage: STAGES[aff.stage]!, todos }
|
return { core, mood, emotions, ...(main ?? moodLabel(mood)), affinity: aff.value, stage: STAGES[aff.stage]!, todos }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mutate(fn: (core: CompanionCore, st: CompanionState, now: number) => CompanionState) {
|
async function mutate(fn: (core: CompanionCore, st: CompanionState, now: number) => CompanionState) {
|
||||||
const { active } = await getGlobal()
|
const { active } = await getGlobal()
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const core = await getCore(active)
|
const core = await getCore(active)
|
||||||
const st = await getState(active)
|
const st = core && (await getState(active, core))
|
||||||
if (!core || !st) return
|
if (!core || !st) return
|
||||||
await putState(active, fn(core, st, clock.now()))
|
await putState(active, fn(core, st, clock.now()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moodEvent(ev: MoodEvent) {
|
async function moodEvent(ev: MoodEvent) {
|
||||||
|
const tz = (await getUser()).tz
|
||||||
await mutate((core, st, now) => {
|
await mutate((core, st, now) => {
|
||||||
const cur = decay(st.mood, core.baseline, now - st.mood.at, neuroticismOf(core, st))
|
const cur = decayEmotions(st.emotion, baseOf(core), now, neuroticismOf(core, st), tzMinutes(tz))
|
||||||
return { ...st, mood: { ...applyDelta(cur, MOOD_EVENTS[ev]), at: now } }
|
const next = applyEvent(cur, st.habit, ev, now)
|
||||||
|
return { ...st, emotion: { ...next.emotions, at: now }, habit: next.habit }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +126,14 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
|
|
||||||
const bar = (v: number) => (v >= 0 ? '+' : '') + v.toFixed(2)
|
const bar = (v: number) => (v >= 0 ? '+' : '') + v.toFixed(2)
|
||||||
|
|
||||||
async function status(): Promise<string> {
|
async function status(all = false): Promise<string> {
|
||||||
const v = await view()
|
const v = await view()
|
||||||
if (!v) return '目前沒有啟用角色。'
|
if (!v) return '目前沒有啟用角色。'
|
||||||
const lines = [`${v.kaomoji} ${v.core.name}|${v.label}`]
|
const lines = [`${v.kaomoji} ${v.core.name}|${v.label}`]
|
||||||
lines.push(`心情 PAD:P ${bar(v.mood.p)} A ${bar(v.mood.a)} D ${bar(v.mood.d)}`)
|
lines.push(`心情 PAD:P ${bar(v.mood.p)} A ${bar(v.mood.a)} D ${bar(v.mood.d)}`)
|
||||||
|
const dims = ranked(v.emotions)
|
||||||
|
lines.push(all ? '情緒(完整):' : '情緒(前 3 強,/status --all 看完整):')
|
||||||
|
for (const d of all ? dims : dims.slice(0, 3)) lines.push(` ${gauge(d, v.emotions)}`)
|
||||||
lines.push(`好感:${v.stage}(${bar(v.affinity)})`)
|
lines.push(`好感:${v.stage}(${bar(v.affinity)})`)
|
||||||
lines.push(`待辦:${v.todos}`)
|
lines.push(`待辦:${v.todos}`)
|
||||||
lines.push('基本資訊:')
|
lines.push('基本資訊:')
|
||||||
@@ -166,7 +187,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
|
|
||||||
async function command(raw: string): Promise<string> {
|
async function command(raw: string): Promise<string> {
|
||||||
const [sub = '', ...rest] = raw.trim().split(/\s+/).filter(Boolean)
|
const [sub = '', ...rest] = raw.trim().split(/\s+/).filter(Boolean)
|
||||||
if (sub === 'status') return status()
|
if (sub === 'status') return status(rest.includes('--all'))
|
||||||
if (sub === 'switch') return rest[0] ? switchTo(rest[0]) : `用法:/jsc-mod-companion:switch <角色>。可用:${(await available()).join('、')}`
|
if (sub === 'switch') return rest[0] ? switchTo(rest[0]) : `用法:/jsc-mod-companion:switch <角色>。可用:${(await available()).join('、')}`
|
||||||
if (sub === 'off') {
|
if (sub === 'off') {
|
||||||
const g = await getGlobal()
|
const g = await getGlobal()
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { clamp, MOOD_EVENTS } from './mood'
|
||||||
|
import type { MoodEvent } from './mood'
|
||||||
|
import { bar } from './seg'
|
||||||
|
import type { Pad } from './types'
|
||||||
|
|
||||||
|
const HOUR = 3_600_000
|
||||||
|
|
||||||
|
// 八個情緒維度,值域皆為 0~1(心情、精力以 0.5 為中性)
|
||||||
|
export const DIMS = ['mood', 'energy', 'shy', 'jealous', 'missing', 'heartbeat', 'homesick', 'upset'] as const
|
||||||
|
export type Dim = (typeof DIMS)[number]
|
||||||
|
export type Emotions = Record<Dim, number>
|
||||||
|
export type EmotionState = Emotions & { at: number }
|
||||||
|
|
||||||
|
// 各維度半衰期(小時,神經質 50 時的值)
|
||||||
|
export const HALF_LIFE_H: Record<Dim, number> = {
|
||||||
|
mood: 8,
|
||||||
|
energy: 6,
|
||||||
|
shy: 2,
|
||||||
|
jealous: 4,
|
||||||
|
missing: 12,
|
||||||
|
heartbeat: 1,
|
||||||
|
homesick: 24,
|
||||||
|
upset: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DIM_INFO: Record<Dim, { name: string; kaomoji: string; ansi: string }> = {
|
||||||
|
mood: { name: '心情', kaomoji: '(´ᴗ`)', ansi: '32' },
|
||||||
|
energy: { name: '精力', kaomoji: '(≧▽≦)', ansi: '33' },
|
||||||
|
shy: { name: '害羞', kaomoji: '(๑>◡<๑)', ansi: '36' },
|
||||||
|
jealous: { name: '忌妒', kaomoji: '( ̄^ ̄)', ansi: '35' },
|
||||||
|
missing: { name: '想念', kaomoji: '(´;ω;`)', ansi: '34' },
|
||||||
|
heartbeat: { name: '心跳', kaomoji: '(*´∀`*)', ansi: '91' },
|
||||||
|
homesick: { name: '想家', kaomoji: '(´・_・`)', ansi: '90' },
|
||||||
|
upset: { name: '不悅', kaomoji: '(╬`益´)', ansi: '31' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心情、精力是雙向維度,主情緒標籤依高低而定
|
||||||
|
const POLES: Partial<Record<Dim, [low: string, high: string]>> = { mood: ['低落', '開心'], energy: ['疲倦', '有精神'] }
|
||||||
|
|
||||||
|
// 高神經質的角色情緒消退較慢:50 為基準,0→0.5 倍,100→1.5 倍
|
||||||
|
export const halfLifeMs = (dim: Dim, neuroticism: number) => HALF_LIFE_H[dim] * HOUR * (0.5 + neuroticism / 100)
|
||||||
|
|
||||||
|
export const clampDim = (x: number) => clamp(x, 0, 1)
|
||||||
|
|
||||||
|
/** 由 PAD 基線推得各維度基線;其餘維度基線為 0 */
|
||||||
|
export function baselineOf(pad: Pad, override: Partial<Emotions> = {}): Emotions {
|
||||||
|
return {
|
||||||
|
mood: clampDim((pad.p + 1) / 2),
|
||||||
|
energy: clampDim((pad.a + 1) / 2),
|
||||||
|
shy: clampDim(-pad.d),
|
||||||
|
jealous: 0,
|
||||||
|
missing: 0,
|
||||||
|
heartbeat: 0,
|
||||||
|
homesick: 0,
|
||||||
|
upset: 0,
|
||||||
|
...override,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 晝夜節律:精力基線在本地 15 點最高、凌晨 3 點最低,振幅 ±0.1 */
|
||||||
|
export function circadian(nowMs: number, tzOffsetMin: number): number {
|
||||||
|
const hour = (((nowMs / HOUR + tzOffsetMin / 60) % 24) + 24) % 24
|
||||||
|
return 0.1 * Math.cos(((hour - 15) / 24) * 2 * Math.PI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 讀檔時才計算:x = B + (x − B)·0.5^(Δ/τ),精力的 B 疊加晝夜節律
|
||||||
|
export function decayEmotions(s: EmotionState, baseline: Emotions, now: number, neuroticism: number, tzOffsetMin: number): Emotions {
|
||||||
|
const dt = Math.max(0, now - s.at)
|
||||||
|
const out = {} as Emotions
|
||||||
|
for (const d of DIMS) {
|
||||||
|
const b = d === 'energy' ? clampDim(baseline.energy + circadian(now, tzOffsetMin)) : baseline[d]
|
||||||
|
out[d] = clampDim(b + (s[d] - b) * Math.pow(0.5, dt / halfLifeMs(d, neuroticism)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由八維度推回 PAD,供記憶檢索與相容介面使用 */
|
||||||
|
export function toPad(e: Emotions, baseline: Emotions, basePad: Pad): Pad {
|
||||||
|
return {
|
||||||
|
p: clamp((e.mood - 0.5) * 2 - e.upset),
|
||||||
|
a: clamp((e.energy - 0.5) * 2),
|
||||||
|
d: clamp(basePad.d + baseline.shy - e.shy),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 舊 PAD 存檔 → 八維度(p→心情、a→精力、d→害羞,其餘維度取基線) */
|
||||||
|
export function fromPad(mood: Pad & { at: number }, baseline: Emotions, basePad: Pad): EmotionState {
|
||||||
|
return {
|
||||||
|
...baseline,
|
||||||
|
mood: clampDim((mood.p + 1) / 2),
|
||||||
|
energy: clampDim((mood.a + 1) / 2),
|
||||||
|
shy: clampDim(baseline.shy + basePad.d - mood.d),
|
||||||
|
at: mood.at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情感注入:事件(評估標籤)對各維度的增減。mood/upset/energy/shy 由 rules.ts 的 PAD 增減換算
|
||||||
|
// (p>0 → 心情 p/2;p<0 → 心情 0.3p、不悅 −0.4p;a/2 → 精力;−d → 害羞),合成後與 PAD 增減一致;
|
||||||
|
// 心跳、想念、想家等新維度的額外效果寫在 EXTRA
|
||||||
|
const EXTRA: Partial<Record<MoodEvent, Partial<Emotions>>> = {
|
||||||
|
thanks: { heartbeat: 0.05 },
|
||||||
|
return: { missing: -0.3, heartbeat: 0.1 },
|
||||||
|
lateNight: { homesick: 0.05 },
|
||||||
|
favorite: { heartbeat: 0.08 },
|
||||||
|
ignored: { missing: 0.1 },
|
||||||
|
cared: { heartbeat: 0.05, missing: -0.1 },
|
||||||
|
remembered: { heartbeat: 0.05 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emotionEffect(ev: MoodEvent): Partial<Emotions> {
|
||||||
|
const { p, a, d } = MOOD_EVENTS[ev]
|
||||||
|
return {
|
||||||
|
mood: p > 0 ? p / 2 : p * 0.3,
|
||||||
|
upset: p < 0 ? -p * 0.4 : 0,
|
||||||
|
energy: a / 2,
|
||||||
|
shy: -d,
|
||||||
|
...EXTRA[ev],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「壞」方向:心情下降,或不悅、忌妒上升
|
||||||
|
const isBad = (d: Dim, x: number) => (d === 'mood' ? x < 0 : (d === 'upset' || d === 'jealous') && x > 0)
|
||||||
|
|
||||||
|
export const TURN_CAP = 0.3
|
||||||
|
export const TIRED = 0.3
|
||||||
|
export const IRRITABLE = 1.5
|
||||||
|
export const HABITUATION = 0.7
|
||||||
|
export const SENSITIZATION = 0.25
|
||||||
|
export const SENSITIZATION_MAX = 2
|
||||||
|
export const HABIT_HALF_LIFE_H = 6
|
||||||
|
|
||||||
|
export type Habit = Partial<Record<MoodEvent, { n: number; at: number }>>
|
||||||
|
|
||||||
|
const habitCount = (h: Habit, ev: MoodEvent, now: number) => {
|
||||||
|
const r = h[ev]
|
||||||
|
return r ? r.n * Math.pow(0.5, Math.max(0, now - r.at) / (HABIT_HALF_LIFE_H * HOUR)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 套用事件:正向事件習慣化(重複受益遞減,0.7^n)、負向事件敏感化(重複受創放大,上限 2 倍)、
|
||||||
|
* 精力低於 0.3 時負面變化放大 1.5 倍(疲勞易怒);每個維度單回合變化上限 ±0.3。
|
||||||
|
* 計次隨時間以 6 小時半衰期遺忘。
|
||||||
|
*/
|
||||||
|
export function applyEvent(cur: Emotions, habit: Habit, ev: MoodEvent, now: number): { emotions: Emotions; habit: Habit } {
|
||||||
|
const n = habitCount(habit, ev, now)
|
||||||
|
const p = MOOD_EVENTS[ev].p
|
||||||
|
const gain = p > 0 ? Math.pow(HABITUATION, n) : 1
|
||||||
|
const harm = p < 0 ? Math.min(SENSITIZATION_MAX, 1 + SENSITIZATION * n) : 1
|
||||||
|
const irritable = cur.energy < TIRED ? IRRITABLE : 1
|
||||||
|
const out = { ...cur }
|
||||||
|
for (const [d, raw] of Object.entries(emotionEffect(ev)) as [Dim, number][]) {
|
||||||
|
const dx = raw * (isBad(d, raw) ? harm * irritable : gain)
|
||||||
|
out[d] = clampDim(out[d] + clamp(dx, -TURN_CAP, TURN_CAP))
|
||||||
|
}
|
||||||
|
return { emotions: out, habit: { ...habit, [ev]: { n: n + 1, at: now } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 強度:雙向維度為偏離中性的程度,其餘維度即其值 */
|
||||||
|
export const salience = (d: Dim, v: number) => (POLES[d] ? Math.abs(v - 0.5) * 2 : v)
|
||||||
|
|
||||||
|
export function ranked(e: Emotions): Dim[] {
|
||||||
|
return [...DIMS].sort((a, b) => salience(b, e[b]) - salience(a, e[a]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOMINANT_MIN = 0.25
|
||||||
|
|
||||||
|
/** 主情緒:強度達門檻的最強維度;否則 null,由 PAD 八分區標籤補位 */
|
||||||
|
export function dominant(e: Emotions): { dim: Dim; label: string; kaomoji: string } | null {
|
||||||
|
const dim = ranked(e)[0]!
|
||||||
|
if (salience(dim, e[dim]) < DOMINANT_MIN) return null
|
||||||
|
const poles = POLES[dim]
|
||||||
|
return { dim, label: poles ? poles[e[dim] >= 0.5 ? 1 : 0] : DIM_INFO[dim].name, kaomoji: DIM_INFO[dim].kaomoji }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /status 的情緒量表:▰▱ 依維度上色 */
|
||||||
|
export function gauge(d: Dim, e: Emotions): string {
|
||||||
|
const poles = POLES[d]
|
||||||
|
const name = poles ? `${DIM_INFO[d].name}(${poles[e[d] >= 0.5 ? 1 : 0]})` : DIM_INFO[d].name
|
||||||
|
return `${name} \x1b[${DIM_INFO[d].ansi}m${bar(e[d] * 100)}\x1b[0m ${Math.round(e[d] * 100)}`
|
||||||
|
}
|
||||||
@@ -4,31 +4,6 @@ import type { Pad } from './types'
|
|||||||
|
|
||||||
export const clamp = (x: number, lo = -1, hi = 1) => Math.max(lo, Math.min(hi, x))
|
export const clamp = (x: number, lo = -1, hi = 1) => Math.max(lo, Math.min(hi, x))
|
||||||
|
|
||||||
const HOUR = 3_600_000
|
|
||||||
const MOOD_HALF_LIFE_H = 8
|
|
||||||
|
|
||||||
// 高神經質的角色情緒消退較慢:50 為基準,0→0.5 倍,100→1.5 倍
|
|
||||||
export const halfLife = (neuroticism: number) => MOOD_HALF_LIFE_H * HOUR * (0.5 + neuroticism / 100)
|
|
||||||
|
|
||||||
// 讀檔時才計算:x = B + (x − B)·0.5^(Δ/τ)
|
|
||||||
export function decay(mood: Pad, baseline: Pad, elapsedMs: number, neuroticism: number): Pad {
|
|
||||||
if (elapsedMs <= 0) return { p: mood.p, a: mood.a, d: mood.d }
|
|
||||||
const k = Math.pow(0.5, elapsedMs / halfLife(neuroticism))
|
|
||||||
return {
|
|
||||||
p: baseline.p + (mood.p - baseline.p) * k,
|
|
||||||
a: baseline.a + (mood.a - baseline.a) * k,
|
|
||||||
d: baseline.d + (mood.d - baseline.d) * k,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 單軸每回合變化上限 ±0.3
|
|
||||||
export const TURN_CAP = 0.3
|
|
||||||
|
|
||||||
export function applyDelta(mood: Pad, delta: Pad): Pad {
|
|
||||||
const step = (x: number, dx: number) => clamp(x + clamp(dx, -TURN_CAP, TURN_CAP))
|
|
||||||
return { p: step(mood.p, delta.p), a: step(mood.a, delta.a), d: step(mood.d, delta.d) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 心情事件即評估標籤,增減量由 rules.ts 的規則表決定
|
// 心情事件即評估標籤,增減量由 rules.ts 的規則表決定
|
||||||
export const MOOD_EVENTS = RULES
|
export const MOOD_EVENTS = RULES
|
||||||
export type MoodEvent = Tag
|
export type MoodEvent = Tag
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ describe('缺失退回', () => {
|
|||||||
await a.ensureActivated()
|
await a.ensureActivated()
|
||||||
await a.onPrompt('今天好累')
|
await a.onPrompt('今天好累')
|
||||||
await a.onTurnComplete(false, '辛苦了\n<評估:soothe>')
|
await a.onTurnComplete(false, '辛苦了\n<評估:soothe>')
|
||||||
expect((await a.view())!.mood.p).toBeCloseTo(RULES.soothe.p, 5)
|
expect(Math.abs((await a.view())!.mood.p - RULES.soothe.p)).toBeLessThan(1e-5)
|
||||||
|
|
||||||
const b = mk()
|
const b = mk()
|
||||||
await b.ensureActivated()
|
await b.ensureActivated()
|
||||||
await b.onPrompt('謝謝你')
|
await b.onPrompt('謝謝你')
|
||||||
await b.onTurnComplete(false, '不客氣\n<評估:scold>')
|
await b.onTurnComplete(false, '不客氣\n<評估:scold>')
|
||||||
expect((await b.view())!.mood.p).toBeCloseTo(RULES.thanks.p, 5)
|
expect(Math.abs((await b.view())!.mood.p - RULES.thanks.p)).toBeLessThan(1e-5)
|
||||||
|
|
||||||
const c = mk()
|
const c = mk()
|
||||||
await c.ensureActivated()
|
await c.ensureActivated()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Attribute, Trigger } from './care'
|
import type { Attribute, Trigger } from './care'
|
||||||
|
import type { EmotionState, Emotions, Habit } from './emotion'
|
||||||
import type { ParamVec } from './core/params'
|
import type { ParamVec } from './core/params'
|
||||||
|
|
||||||
export type Pad = { p: number; a: number; d: number }
|
export type Pad = { p: number; a: number; d: number }
|
||||||
@@ -27,6 +28,8 @@ export type CompanionCore = {
|
|||||||
// 人格 18 參數的角色包基準;有值時神經質改由參數推導
|
// 人格 18 參數的角色包基準;有值時神經質改由參數推導
|
||||||
params?: ParamVec
|
params?: ParamVec
|
||||||
baseline: Pad
|
baseline: Pad
|
||||||
|
// 八維度個別基線(覆寫由 baseline 推得的值),角色專屬值放本機 core.json
|
||||||
|
emotionBaseline?: Partial<Emotions>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STAGES = ['陌生', '熟悉', '親近', '特別'] as const
|
export const STAGES = ['陌生', '熟悉', '親近', '特別'] as const
|
||||||
@@ -41,7 +44,8 @@ export type Affinity = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CompanionState = {
|
export type CompanionState = {
|
||||||
mood: Pad & { at: number }
|
emotion: EmotionState
|
||||||
|
habit: Habit
|
||||||
affinity: Affinity
|
affinity: Affinity
|
||||||
todos: number
|
todos: number
|
||||||
// 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效)
|
// 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效)
|
||||||
|
|||||||
Reference in New Issue
Block a user