feat: jsc-mod-companion 情緒擴充為 8 維度並自動升級舊 PAD 存檔
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { clamp } 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 的合成效果與舊 PAD 事件一致
|
||||
export const EMOTION_EVENTS = {
|
||||
thanks: { mood: 0.075, energy: 0.025, shy: -0.05, heartbeat: 0.05 },
|
||||
scold: { mood: -0.06, upset: 0.08, energy: 0.075, shy: 0.1 },
|
||||
success: { mood: 0.04, energy: 0.015 },
|
||||
toolFail: { mood: -0.02, upset: 0.04, energy: 0.05, shy: 0.08 },
|
||||
return: { mood: 0.05, energy: 0.04, missing: -0.3, heartbeat: 0.1 },
|
||||
lateNight: { mood: -0.025, energy: -0.05, homesick: 0.05 },
|
||||
favorite: { mood: 0.06, energy: 0.04, heartbeat: 0.08 },
|
||||
ignored: { mood: -0.025, energy: -0.015, missing: 0.1 },
|
||||
} as const satisfies Record<MoodEvent, Partial<Emotions>>
|
||||
|
||||
const POSITIVE: ReadonlySet<MoodEvent> = new Set(['thanks', 'success', 'return', 'favorite'])
|
||||
const NEGATIVE: ReadonlySet<MoodEvent> = new Set(['scold', 'toolFail', 'ignored'])
|
||||
// 「壞」方向:心情下降,或不悅、忌妒上升
|
||||
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 gain = POSITIVE.has(ev) ? Math.pow(HABITUATION, n) : 1
|
||||
const harm = NEGATIVE.has(ev) ? 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(EMOTION_EVENTS[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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user