diff --git a/plugins/jsc-mod-companion/.claude-plugin/plugin.json b/plugins/jsc-mod-companion/.claude-plugin/plugin.json index 624b2be..ae8c5c7 100644 --- a/plugins/jsc-mod-companion/.claude-plugin/plugin.json +++ b/plugins/jsc-mod-companion/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-mod-companion", - "version": "0.0.1", + "version": "0.0.2", "description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色", "license": "MIT", "author": { diff --git a/plugins/jsc-mod-companion/README.md b/plugins/jsc-mod-companion/README.md index 8ec9e5e..a9d60fe 100644 --- a/plugins/jsc-mod-companion/README.md +++ b/plugins/jsc-mod-companion/README.md @@ -82,6 +82,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 ba1438c..47cf70c 100644 --- a/plugins/jsc-mod-companion/hooks/companion/core.ts +++ b/plugins/jsc-mod-companion/hooks/companion/core.ts @@ -4,6 +4,7 @@ import { readJson, writeJson } from './fs' import type { Clock, Fs } from './fs' import { applyDelta, decay, 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' @@ -79,7 +80,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan const st = await getState(active) if (!core || !st) return null const now = clock.now() - const mood = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism) + const mood = decay(st.mood, core.baseline, now - st.mood.at, neuroticismOf(core, st)) const aff = idleDecay(st.affinity, now) 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 } @@ -96,7 +97,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan async function moodEvent(ev: MoodEvent) { await mutate((core, st, now) => { - const cur = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism) + const cur = decay(st.mood, core.baseline, now - st.mood.at, neuroticismOf(core, st)) return { ...st, mood: { ...applyDelta(cur, MOOD_EVENTS[ev]), at: now } } }) } @@ -201,10 +202,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 4d727cd..1c161b7 100644 --- a/plugins/jsc-mod-companion/hooks/companion/types.ts +++ b/plugins/jsc-mod-companion/hooks/companion/types.ts @@ -1,4 +1,5 @@ import type { Attribute, Trigger } from './care' +import type { ParamVec } from './core/params' export type Pad = { p: number; a: number; d: number } @@ -23,6 +24,8 @@ export type CompanionCore = { freq?: Partial> // 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值 neuroticism: number + // 人格 18 參數的角色包基準;有值時神經質改由參數推導 + params?: ParamVec baseline: Pad } @@ -41,6 +44,8 @@ export type CompanionState = { mood: Pad & { at: number } affinity: Affinity todos: number + // 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效) + params?: { save: Partial; today: Partial; dayKey: string } } export type UserSettings = {