diff --git a/plugins/jsc-mod-companion/README.md b/plugins/jsc-mod-companion/README.md
index 56230a2..00be158 100644
--- a/plugins/jsc-mod-companion/README.md
+++ b/plugins/jsc-mod-companion/README.md
@@ -83,6 +83,16 @@
/jsc-mod-companion:ingest chapter1.md 章節編號 1
```
+## 人格 18 參數(與第 9 章的差異)
+
+人格參數依 wiki 9.2 實作於 `hooks/companion/core/params.ts`:
+
+- 三層值:角色包基準(`core.json` 的 `params`)+存檔偏移(每次 ±1,上限 ±15)+今日偏移(N(0,5),上限 ±10),總值限制在 0–100。
+- 衍生值:有效心防、有效害羞、有效口是心非,公式同 9.2;信賴與親密以 0–100 輸入。
+- Big Five 保留為推導值:有 `params` 時,神經質(心情半衰期)與外向性(關心頻率)改由 18 參數推導,加權為設計啟發式;沒有 `params` 的舊角色沿用 `neuroticism`、`extraversion`。ingest 的 Big Five 推論與證據機制不變。
+- 姓名、性別、年齡、核心信念不可由 AI 改動,`filterAiPatch` 會剔除這些欄位。
+- 目前只提供計算與儲存欄位(`state.json` 的 `params`);每日抽樣、存檔偏移累積的觸發時機留待後續工作項目。
+
## 評估標籤
使用者每句話會對應到一個評估標籤,再由 `rules.ts` 的規則表換成心情增減量;模型只負責分類,不給數值。
diff --git a/plugins/jsc-mod-companion/hooks/companion/core.ts b/plugins/jsc-mod-companion/hooks/companion/core.ts
index edd3001..3c9233a 100644
--- a/plugins/jsc-mod-companion/hooks/companion/core.ts
+++ b/plugins/jsc-mod-companion/hooks/companion/core.ts
@@ -6,6 +6,7 @@ import { applyEvent, baselineOf, decayEmotions, dominant, fromPad, gauge, ranked
import type { Emotions } from './emotion'
import { keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
import type { MoodEvent } from './mood'
+import { extraversionOf, neuroticismOf } from './core/params'
import { extractTag } from './tags'
import { createCare } from './care'
import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care'
@@ -93,7 +94,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
if (!core || !st) return null
const now = clock.now()
const base = baseOf(core)
- const emotions = decayEmotions(st.emotion, base, now, core.neuroticism, tzMinutes((await getUser()).tz))
+ 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)
@@ -113,7 +114,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
async function moodEvent(ev: MoodEvent) {
const tz = (await getUser()).tz
await mutate((core, st, now) => {
- const cur = decayEmotions(st.emotion, baseOf(core), now, core.neuroticism, tzMinutes(tz))
+ const cur = decayEmotions(st.emotion, baseOf(core), now, neuroticismOf(core, st), tzMinutes(tz))
const next = applyEvent(cur, st.habit, ev, now)
return { ...st, emotion: { ...next.emotions, at: now }, habit: next.habit }
})
@@ -222,10 +223,11 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
const v = await view()
if (!v) return null
const u = await getUser()
+ const st = await getState(v.core.id)
const qs = hourOf(u.quiet.start)
const qe = hourOf(u.quiet.end)
ctx = {
- profile: { attribute: v.core.attribute ?? 'gentle', extraversion: v.core.extraversion ?? 50, stage: CARE_STAGES[STAGES.indexOf(v.stage as (typeof STAGES)[number])] ?? 'stranger', nickname: u.nickname, freq: v.core.freq },
+ profile: { attribute: v.core.attribute ?? 'gentle', extraversion: extraversionOf(v.core, st ?? undefined), stage: CARE_STAGES[STAGES.indexOf(v.stage as (typeof STAGES)[number])] ?? 'stranger', nickname: u.nickname, freq: v.core.freq },
settings: { dailyCap: u.careLimit, tzOffsetMin: tzMinutes(u.tz), quietHours: qs !== null && qe !== null ? { start: qs, end: qe } : undefined },
}
if (bundle?.id !== v.core.id) {
diff --git a/plugins/jsc-mod-companion/hooks/companion/core/params.test.ts b/plugins/jsc-mod-companion/hooks/companion/core/params.test.ts
new file mode 100644
index 0000000..a2bf32d
--- /dev/null
+++ b/plugins/jsc-mod-companion/hooks/companion/core/params.test.ts
@@ -0,0 +1,120 @@
+import { describe, test } from 'claude-code/testing'
+import assert from '../assert'
+
+import { applyInference, initPersonality } from './personality'
+import {
+ baseFor, deriveBig5, effective, extraversionOf, filterAiPatch, initLayers, neuroticismOf, nudgeSave, PARAMS, rollToday, value, values,
+} from './params'
+
+const near = (a: number, b: number, eps = 1) => assert.ok(Math.abs(a - b) <= eps, `${a} 應接近 ${b}`)
+
+describe('18 參數三層值', () => {
+ test('共 18 個參數,無屬性時基準為 50', () => {
+ assert.equal(PARAMS.length, 18)
+ assert.ok(Object.values(baseFor([])).every(v => v === 50))
+ })
+
+ test('存檔偏移每次 ±1,上限 ±15', () => {
+ let l = initLayers(baseFor([]))
+ for (let i = 0; i < 40; i++) l = nudgeSave(l, 'shy', 1)
+ assert.equal(l.save.shy, 15)
+ assert.equal(value(l, 'shy'), 65)
+ l = nudgeSave(l, 'shy', -1)
+ assert.equal(l.save.shy, 14)
+ for (let i = 0; i < 60; i++) l = nudgeSave(l, 'guard', -1)
+ assert.equal(l.save.guard, -15)
+ })
+
+ test('今日偏移限制在 ±10,且可由 rng 重現', () => {
+ const seq = (s: number) => () => ((s = (s * 16807) % 2147483647) / 2147483647)
+ const a = rollToday(initLayers(baseFor([])), seq(7))
+ const b = rollToday(initLayers(baseFor([])), seq(7))
+ assert.deepEqual(a.today, b.today)
+ assert.ok(PARAMS.every(p => Math.abs(a.today[p]!) <= 10))
+ // 極端抽樣也被截斷
+ const c = rollToday(initLayers(baseFor([])), (() => { let n = 0; return () => (n++ % 2 === 0 ? 1e-12 : 0) })())
+ assert.ok(PARAMS.every(p => Math.abs(c.today[p]!) <= 10))
+ assert.ok(PARAMS.some(p => Math.abs(c.today[p]!) === 10))
+ })
+
+ test('今日偏移的標準差約為 5', () => {
+ let s = 12345
+ const rng = () => ((s = (s * 16807) % 2147483647) / 2147483647)
+ const xs: number[] = []
+ for (let i = 0; i < 300; i++) xs.push(...Object.values(rollToday(initLayers(baseFor([])), rng).today) as number[])
+ const mean = xs.reduce((a, b) => a + b, 0) / xs.length
+ const sd = Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length)
+ near(mean, 0, 0.3)
+ near(sd, 4.7, 0.6) // 截斷在 ±2σ 使標準差略小於 5
+ })
+
+ test('參數值 = 基準 + 存檔 + 今日,並限制在 0–100', () => {
+ let l = initLayers({ ...baseFor([]), shy: 95 })
+ for (let i = 0; i < 15; i++) l = nudgeSave(l, 'shy', 1)
+ l = { ...l, today: { shy: 8 } }
+ assert.equal(value(l, 'shy'), 100)
+ assert.equal(values(l).blush, 50)
+ })
+})
+
+describe('有效參數', () => {
+ const l = initLayers({ ...baseFor([]), guard: 50, shy: 80, tsun: 70 })
+ test('有效心防:心防 × (1 − 0.5 × 信賴),心防 50、信賴 90 約 28', () => {
+ near(effective(l, 90, 0).guard, 27.5, 0.01)
+ assert.equal(effective(l, 0, 0).guard, 50)
+ })
+ test('有效害羞:害羞 × (1 − 0.3 × 親密)', () => {
+ near(effective(l, 0, 100).shy, 56, 0.01)
+ })
+ test('有效口是心非:口是心非 × (1 − 0.4 × 親密)', () => {
+ near(effective(l, 0, 50).tsun, 56, 0.01)
+ near(effective(l, 0, 100).tsun, 42, 0.01)
+ })
+ test('信賴與親密超出範圍時被截斷', () => {
+ assert.equal(effective(l, 500, 500).guard, 25)
+ })
+})
+
+describe('Big Five 推導值與 ingest 相容', () => {
+ test('心防、忌妒越高,推導的神經質越高', () => {
+ const calm = deriveBig5(values(initLayers(baseFor(['onee']))))
+ const edgy = deriveBig5(values(initLayers(baseFor(['yandere']))))
+ assert.ok(edgy.N > calm.N)
+ })
+ test('主動度與玩心越高,推導的外向性越高', () => {
+ assert.ok(deriveBig5(values(initLayers(baseFor(['genki'])))).E > deriveBig5(values(initLayers(baseFor(['dandere'])))).E)
+ })
+ test('推導值落在 0–100', () => {
+ for (const a of ['tsundere', 'genki', 'kuudere', 'dandere', 'yandere', 'onee']) {
+ assert.ok(Object.values(deriveBig5(values(initLayers(baseFor([a]))))).every(x => x >= 0 && x <= 100))
+ }
+ })
+ test('ingest 的 Big Five 推論機制不受影響', () => {
+ const p = applyInference(initPersonality(['tsundere']), { N: { value: 99, evidence: [{ chapter: '1', quote: 'x' }] } })
+ assert.equal(p.dims.N.value, 85)
+ })
+})
+
+describe('核心設定保護', () => {
+ test('姓名、性別、年齡、核心信念不可由 AI 改動', () => {
+ const r = filterAiPatch({ name: '新名', gender: 'm', age: 30, coreBelief: 'x', shy: 60 })
+ assert.deepEqual(r.rejected.sort(), ['age', 'coreBelief', 'gender', 'name'])
+ assert.deepEqual(r.allowed, { shy: 60 })
+ })
+})
+
+describe('Big Five 推導值接上半衰期與關心頻率', () => {
+ const core = { neuroticism: 40, extraversion: 60 }
+ test('沒有參數時沿用舊欄位', () => {
+ assert.equal(neuroticismOf(core), 40)
+ assert.equal(extraversionOf(core), 60)
+ })
+ test('有參數時改由三層值推導,存檔偏移會影響結果', () => {
+ const params = baseFor(['tsundere'])
+ const a = neuroticismOf({ ...core, params })
+ const b = neuroticismOf({ ...core, params }, { params: { save: { guard: 15, jealous: 15 }, today: {} } })
+ assert.notEqual(a, 40)
+ assert.ok(b > a)
+ assert.equal(extraversionOf({ ...core, params }), deriveBig5(params).E)
+ })
+})
diff --git a/plugins/jsc-mod-companion/hooks/companion/core/params.ts b/plugins/jsc-mod-companion/hooks/companion/core/params.ts
new file mode 100644
index 0000000..d37efb3
--- /dev/null
+++ b/plugins/jsc-mod-companion/hooks/companion/core/params.ts
@@ -0,0 +1,127 @@
+import type { Vec } from './personality'
+
+/** 第 9.2 章的 18 個人格參數,0–100,越高越多 */
+export const PARAMS = [
+ 'shy', 'blush', 'clingy', 'tsun', 'tone',
+ 'jealous', 'depend', 'guard', 'recover', 'miss',
+ 'initiative', 'assertive', 'playful', 'caring', 'gentle',
+ 'cute', 'health', 'imagine',
+] as const
+export type Param = (typeof PARAMS)[number]
+export type ParamVec = Record
+
+export const PARAM_LABELS: Record = {
+ shy: '害羞傾向', blush: '臉紅傾向', clingy: '撒嬌傾向', tsun: '口是心非度', tone: '語尾與顏文字使用度',
+ jealous: '忌妒敏感度', depend: '依賴傾向', guard: '心防', recover: '釋懷速度', miss: '想念累積速度',
+ initiative: '主動度', assertive: '自我主張', playful: '玩心', caring: '照顧欲', gentle: '溫柔度',
+ cute: '服裝可愛偏好', health: '體質', imagine: '想像力',
+}
+
+export const SAVE_STEP = 1
+export const SAVE_LIMIT = 15
+export const TODAY_SIGMA = 5
+export const TODAY_LIMIT = 10
+
+/** 三層值:角色包基準(本機 core.json)、存檔偏移與今日偏移(state.json) */
+export type ParamLayers = { base: ParamVec; save: Partial; today: Partial }
+
+const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x))
+
+const NEUTRAL = Object.fromEntries(PARAMS.map(p => [p, 50])) as ParamVec
+
+/** 屬性標籤對 18 參數的先驗(設計啟發式);未列出的參數維持 50 */
+export const ARCHETYPE_PARAMS: Record> = {
+ tsundere: { shy: 75, blush: 75, tsun: 85, guard: 65, assertive: 65, gentle: 35, jealous: 65, recover: 40 },
+ genki: { shy: 25, tone: 80, initiative: 85, playful: 75, depend: 55, guard: 25, recover: 75, health: 75, gentle: 60 },
+ kuudere: { shy: 30, blush: 25, tone: 20, initiative: 25, guard: 65, tsun: 40, playful: 25, gentle: 40 },
+ dandere: { shy: 85, blush: 80, tone: 30, initiative: 20, guard: 70, depend: 60, assertive: 25, gentle: 65 },
+ yandere: { jealous: 90, depend: 85, guard: 75, recover: 20, miss: 85, tsun: 35, caring: 75, assertive: 70 },
+ onee: { gentle: 85, caring: 85, shy: 25, blush: 25, playful: 65, guard: 40, recover: 70, assertive: 60 },
+}
+
+/** 以屬性標籤產生角色包基準:多個標籤取平均 */
+export function baseFor(archetypes: string[]): ParamVec {
+ const known = archetypes.filter(a => ARCHETYPE_PARAMS[a])
+ const out = { ...NEUTRAL }
+ if (!known.length) return out
+ for (const p of PARAMS) out[p] = known.reduce((s, a) => s + (ARCHETYPE_PARAMS[a]![p] ?? 50), 0) / known.length
+ return out
+}
+
+export function initLayers(base: ParamVec): ParamLayers {
+ return { base: { ...base }, save: {}, today: {} }
+}
+
+/** 存檔偏移:每次 ±1,上限 ±15 */
+export function nudgeSave(l: ParamLayers, p: Param, dir: 1 | -1): ParamLayers {
+ const next = clamp((l.save[p] ?? 0) + dir * SAVE_STEP, -SAVE_LIMIT, SAVE_LIMIT)
+ return { ...l, save: { ...l.save, [p]: next } }
+}
+
+/** 每日抽樣今日偏移:N(0, 5),限制 ±10;rng 回傳 [0,1),可注入以便測試 */
+export function rollToday(l: ParamLayers, rng: () => number = Math.random): ParamLayers {
+ const today: Partial = {}
+ for (const p of PARAMS) {
+ const u1 = Math.max(rng(), 1e-12)
+ const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * rng())
+ today[p] = clamp(z * TODAY_SIGMA, -TODAY_LIMIT, TODAY_LIMIT)
+ }
+ return { ...l, today }
+}
+
+export function value(l: ParamLayers, p: Param): number {
+ return clamp(l.base[p] + clamp(l.save[p] ?? 0, -SAVE_LIMIT, SAVE_LIMIT) + clamp(l.today[p] ?? 0, -TODAY_LIMIT, TODAY_LIMIT), 0, 100)
+}
+
+export function values(l: ParamLayers): ParamVec {
+ return Object.fromEntries(PARAMS.map(p => [p, value(l, p)])) as ParamVec
+}
+
+/** 信賴與親密皆為 0–100 */
+export function effective(l: ParamLayers, trust: number, intimacy: number) {
+ const t = clamp(trust, 0, 100) / 100
+ const i = clamp(intimacy, 0, 100) / 100
+ return {
+ guard: value(l, 'guard') * (1 - 0.5 * t),
+ shy: value(l, 'shy') * (1 - 0.3 * i),
+ tsun: value(l, 'tsun') * (1 - 0.4 * i),
+ }
+}
+
+/** Big Five 推導值:沿用半衰期(N)與關心頻率(E);加權為設計啟發式 */
+export function deriveBig5(v: ParamVec): Vec {
+ const inv = (x: number) => 100 - x
+ const avg = (xs: [number, number][]) => xs.reduce((s, [x, w]) => s + x * w, 0)
+ return {
+ O: avg([[v.imagine, 0.5], [v.playful, 0.3], [v.initiative, 0.2]]),
+ C: avg([[v.caring, 0.4], [v.assertive, 0.3], [inv(v.playful), 0.3]]),
+ E: avg([[v.initiative, 0.35], [v.playful, 0.25], [v.tone, 0.2], [inv(v.shy), 0.2]]),
+ A: avg([[v.gentle, 0.4], [v.caring, 0.3], [inv(v.assertive), 0.15], [inv(v.tsun), 0.15]]),
+ N: avg([[v.guard, 0.25], [v.jealous, 0.25], [v.depend, 0.2], [v.shy, 0.15], [inv(v.recover), 0.15]]),
+ }
+}
+
+/** 核心設定:AI 不可改動 */
+export const LOCKED_FIELDS = ['name', 'gender', 'age', 'coreBelief'] as const
+
+/** 套用 AI 的修改提案:鎖定欄位一律剔除並回報,其餘原樣放行 */
+export function filterAiPatch>(patch: T): { allowed: Partial; rejected: string[] } {
+ const allowed: Record = {}
+ const rejected: string[] = []
+ for (const [k, v] of Object.entries(patch)) {
+ if ((LOCKED_FIELDS as readonly string[]).includes(k)) rejected.push(k)
+ else allowed[k] = v
+ }
+ return { allowed: allowed as Partial, rejected }
+}
+
+type WithParams = { params?: ParamVec; neuroticism?: number; extraversion?: number }
+type StateParams = { params?: { save: Partial; today: Partial } }
+
+/** 有角色包基準時,用三層值推導 Big Five;否則維持 core.json 的舊欄位 */
+function derived(core: WithParams, st?: StateParams) {
+ if (!core.params) return null
+ return deriveBig5(values({ base: core.params, save: st?.params?.save ?? {}, today: st?.params?.today ?? {} }))
+}
+export const neuroticismOf = (core: WithParams, st?: StateParams) => derived(core, st)?.N ?? core.neuroticism ?? 50
+export const extraversionOf = (core: WithParams, st?: StateParams) => derived(core, st)?.E ?? core.extraversion ?? 50
diff --git a/plugins/jsc-mod-companion/hooks/companion/types.ts b/plugins/jsc-mod-companion/hooks/companion/types.ts
index 0e4c12b..7bc0214 100644
--- a/plugins/jsc-mod-companion/hooks/companion/types.ts
+++ b/plugins/jsc-mod-companion/hooks/companion/types.ts
@@ -1,5 +1,6 @@
import type { Attribute, Trigger } from './care'
import type { EmotionState, Emotions, Habit } from './emotion'
+import type { ParamVec } from './core/params'
export type Pad = { p: number; a: number; d: number }
@@ -24,6 +25,8 @@ export type CompanionCore = {
freq?: Partial>
// 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值
neuroticism: number
+ // 人格 18 參數的角色包基準;有值時神經質改由參數推導
+ params?: ParamVec
baseline: Pad
// 八維度個別基線(覆寫由 baseline 推得的值),角色專屬值放本機 core.json
emotionBaseline?: Partial
@@ -45,6 +48,8 @@ export type CompanionState = {
habit: Habit
affinity: Affinity
todos: number
+ // 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效)
+ params?: { save: Partial; today: Partial; dayKey: string }
}
export type UserSettings = {