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 7f488f4..8feb96f 100644 --- a/plugins/jsc-mod-companion/README.md +++ b/plugins/jsc-mod-companion/README.md @@ -4,7 +4,7 @@ ### `/jsc-mod-companion:status` -查看目前角色、心情、好感與待辦數量。 +查看目前角色、心情、關係階段與四維度(好感、信賴、親密、依賴)與待辦數量。 ```text /jsc-mod-companion:status @@ -81,3 +81,20 @@ ```text /jsc-mod-companion:ingest chapter1.md 章節編號 1 ``` + +## 關係與階段 + +關係有好感、信賴、親密、依賴四個維度(0–100),各有「原典基準」(朋友線為 0)與「存檔累積」兩本帳,有效值為兩者相加。階段為 陌生、熟人、朋友、好友,只升不降: + +| 階段 | 條件 | +| --- | --- | +| 熟人 | 好感 ≥ 15 | +| 朋友 | 好感 ≥ 30、信賴 ≥ 25 | +| 好友 | 好感 ≥ 50、信賴 ≥ 45、親密 ≥ 30 | + +- 每日增量有日限(好感 4,信賴、親密、依賴依角色 `relationTraits` 的心防、害羞、撒嬌、依賴傾向調整);有效增量 = 原始增量 × (1 − 今日累計 / 日限) × (1 − 目前值 / 100)。 +- 被罵時好感 −3,信賴依期望違反公式扣除(越親密扣越多,單次上限 −30)。 +- 14 天無互動後,每個維度每天 −0.5;階段不會降低。 +- 舊版單一好感值(`affinity`)的存檔在讀檔時自動升級:好感值保留,其餘維度依比例推估,階段不低於原本。 + +與第 9 章的差異:不實作曖昧、戀人、穩定戀人與冷戰子狀態;負向事件目前只有「被罵」一種嚴重度(0.4);增量沿用原有的「有意義互動/被罵」判斷,尚未接標籤管線。 diff --git a/plugins/jsc-mod-companion/hooks/companion/affinity.ts b/plugins/jsc-mod-companion/hooks/companion/affinity.ts deleted file mode 100644 index 4f741af..0000000 --- a/plugins/jsc-mod-companion/hooks/companion/affinity.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { clamp } from './mood' -import type { Affinity } from './types' - -const DAY = 86_400_000 - -export const GAIN = 0.01 -export const DAILY_CAP = 0.05 -export const LOSS = 0.03 -const IDLE_GRACE_DAYS = 7 -const IDLE_HALF_LIFE_DAYS = 30 - -// 升階門檻;降階門檻 = 升階門檻 − 0.05,形成遲滯 -export const UP = [0.3, 0.55, 0.8] -export const HYSTERESIS = 0.05 - -export const dayKey = (ms: number) => new Date(ms).toISOString().slice(0, 10) - -export const initialAffinity = (now: number): Affinity => ({ value: 0, stage: 0, dayKey: dayKey(now), dayGain: 0, lastSeen: now }) - -export function stageFor(value: number, current: number): number { - let s = current - while (s < UP.length && value >= UP[s]!) s++ - while (s > 0 && value < UP[s - 1]! - HYSTERESIS) s-- - return s -} - -// 離開超過 7 天後,以 30 天半衰期回到 0 -export function idleDecay(a: Affinity, now: number): Affinity { - const idleDays = (now - a.lastSeen) / DAY - if (idleDays <= IDLE_GRACE_DAYS) return a - const value = a.value * Math.pow(0.5, (idleDays - IDLE_GRACE_DAYS) / IDLE_HALF_LIFE_DAYS) - return { ...a, value, stage: stageFor(value, a.stage) } -} - -export function interact(prev: Affinity, kind: 'meaningful' | 'negative', now: number): Affinity { - let a = idleDecay(prev, now) - const key = dayKey(now) - if (a.dayKey !== key) a = { ...a, dayKey: key, dayGain: 0 } - let { value, dayGain } = a - if (kind === 'meaningful') { - const gain = Math.max(0, Math.min(GAIN, DAILY_CAP - dayGain)) - value += gain - dayGain += gain - } else { - value -= LOSS - } - value = clamp(value) - return { value, dayGain, dayKey: key, lastSeen: now, stage: stageFor(value, a.stage) } -} diff --git a/plugins/jsc-mod-companion/hooks/companion/band.test.ts b/plugins/jsc-mod-companion/hooks/companion/band.test.ts index cb89699..a70d774 100644 --- a/plugins/jsc-mod-companion/hooks/companion/band.test.ts +++ b/plugins/jsc-mod-companion/hooks/companion/band.test.ts @@ -9,8 +9,8 @@ const VIEW: View = { mood: { p: 0.5, a: 0, d: -0.5 }, label: '放鬆', kaomoji: '(´ᴗ`)', - affinity: 0.35, - stage: '熟悉', + relation: { affection: 35, trust: 20, intimacy: 10, dependence: 5 }, + stage: '熟人', todos: 2, } @@ -18,7 +18,7 @@ const text = (v: View, cols: number, compact = false, bubble?: string) => fitCom describe('角色行', () => { test('寬版顯示顏文字、名字、心情、PAD 條、階段、待辦、氣泡', () => { - expect(text(VIEW, 200, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆 · P▰▰▱ A▰▰▱ D▰▱▱ · 熟悉 · ☐2 · 💬 早安') + expect(text(VIEW, 200, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆 · P▰▰▱ A▰▰▱ D▰▱▱ · 熟人 · ☐2 · 💬 早安') }) test('緊湊模式只剩「顏文字 名字 心情」', () => { diff --git a/plugins/jsc-mod-companion/hooks/companion/companion.test.ts b/plugins/jsc-mod-companion/hooks/companion/companion.test.ts index 8f96bfe..34d1880 100644 --- a/plugins/jsc-mod-companion/hooks/companion/companion.test.ts +++ b/plugins/jsc-mod-companion/hooks/companion/companion.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'claude-code/testing' import { createCompanion } from './core' import { fakeClock, memoryFs } from './fs' +import { dailyLimits, effective, gain, initialRelation, interact, SEVERITY, stageFor, trustLoss } from './relation' import type { CompanionCore } from './types' // 測試用原創範例角色,不依賴網路或受版權保護的資料 @@ -136,50 +137,150 @@ describe('心情', () => { }) }) -describe('好感度', () => { - test('從 0 開始,每次 +0.01,每天上限 +0.05', async () => { +describe('關係四維度與階段', () => { + const rel = async (c: ReturnType['c']) => (await c.view())!.relation + + test('從 0 開始,同一天的增量受日限約束', async () => { const { c } = setup() await c.ensureActivated() - expect((await c.view())!.affinity).toBe(0) + expect(await rel(c)).toEqual({ affection: 0, trust: 0, intimacy: 0, dependence: 0 }) + for (let i = 0; i < 50; i++) await c.affinityEvent('meaningful') + const r = await rel(c) + const lim = dailyLimits() + expect(r.affection).toBeLessThanOrEqual(lim.affection + 1e-9) + expect(r.trust).toBeLessThanOrEqual(lim.trust + 1e-9) + expect(r.intimacy).toBeLessThanOrEqual(lim.intimacy + 1e-9) + expect(r.dependence).toBeLessThanOrEqual(lim.dependence + 1e-9) + expect(r.affection).toBeGreaterThan(lim.affection * 0.7) + }) + + test('日限公式依人格傾向調整', () => { + expect(dailyLimits()).toEqual({ affection: 4, trust: 5 * 0.7, intimacy: 3.5 * 0.8 * 1.2, dependence: 2.5 * 1 }) + expect(dailyLimits({ guard: 1 }).trust).toBeCloseTo(2) + expect(dailyLimits({ guard: 0 }).trust).toBe(5) + }) + + test('有效增量 = 原始增量 × (1 − 今日累計/日限) × (1 − 目前值/100)', () => { + let r = initialRelation(0) + r = gain(r, 'affection', 3) + expect(r.save.affection).toBeCloseTo(3) + r = gain(r, 'affection', 3) + expect(r.save.affection).toBeCloseTo(3 + 3 * (1 - 3 / 4) * (1 - 3 / 100)) + const hi = { ...initialRelation(0), save: { affection: 50, trust: 0, intimacy: 0, dependence: 0 } } + expect(gain(hi, 'affection', 3).save.affection).toBeCloseTo(50 + 3 * 0.5) + }) + + test('隔天日限重置', async () => { + const { c, clock } = setup() + await c.ensureActivated() for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful') - expect(Math.abs((await c.view())!.affinity - (0.05))).toBeLessThan(1e-9) - }) - - test('隔天可繼續累積;負面事件 −0.03', async () => { - const { c, clock } = setup() - await c.ensureActivated() - for (let i = 0; i < 6; i++) await c.affinityEvent('meaningful') + const day1 = (await rel(c)).affection clock.advance(DAY) - await c.affinityEvent('meaningful') - expect(Math.abs((await c.view())!.affinity - (0.06))).toBeLessThan(1e-9) - await c.affinityEvent('negative') - expect(Math.abs((await c.view())!.affinity - (0.03))).toBeLessThan(1e-9) + for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful') + expect((await rel(c)).affection).toBeGreaterThan(day1 + 3) }) - test('離開超過 7 天後緩慢回到 0', async () => { - const { c, clock } = setup() - await c.ensureActivated() - for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful') - clock.advance(7 * DAY) - expect(Math.abs((await c.view())!.affinity - (0.05))).toBeLessThan(1e-9) - clock.advance(30 * DAY) - expect(Math.abs((await c.view())!.affinity - (0.025))).toBeLessThan(1e-9) + test('期望違反:被罵扣好感 3,信賴扣得更多,越親密越多,單次上限 30', () => { + const r0 = { ...initialRelation(0), save: { affection: 20, trust: 40, intimacy: 0, dependence: 0 } } + const a = interact(r0, 'negative', 0) + expect(a.save.affection).toBeCloseTo(17) + expect(a.save.trust).toBeCloseTo(40 - 3 * 1.7) + const close = interact({ ...r0, save: { ...r0.save, intimacy: 100 } }, 'negative', 0) + expect(40 - close.save.trust).toBeGreaterThan(40 - a.save.trust) + expect(trustLoss(3, SEVERITY.lie, 100, 1)).toBeLessThanOrEqual(30) + expect(trustLoss(100, SEVERITY.lie, 100, 1)).toBe(30) + expect(trustLoss(3, SEVERITY.lie, 0)).toBeGreaterThan(trustLoss(3, SEVERITY.broken, 0)) + expect(interact(initialRelation(0), 'negative', 0).save.trust).toBeCloseTo(0) }) - test('階段切換有遲滯', async () => { + test('升階條件逐一檢查', () => { + const d = (affection: number, trust: number, intimacy: number) => ({ affection, trust, intimacy, dependence: 0 }) + expect(stageFor(d(14, 99, 99), 0)).toBe(0) + expect(stageFor(d(15, 0, 0), 0)).toBe(1) + expect(stageFor(d(30, 24, 0), 0)).toBe(1) + expect(stageFor(d(30, 25, 0), 0)).toBe(2) + expect(stageFor(d(50, 45, 29), 0)).toBe(2) + expect(stageFor(d(50, 45, 30), 0)).toBe(3) + expect(stageFor(d(100, 100, 100), 0)).toBe(3) // 不實作曖昧以上 + }) + + test('只升不降:數值回落、被罵都不降階', async () => { const { c, clock } = setup() await c.ensureActivated() expect((await c.view())!.stage).toBe('陌生') - for (let d = 0; d < 6; d++) { - for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful') + const seen = new Set() + for (let d = 0; d < 60; d++) { + for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful') + seen.add((await c.view())!.stage) clock.advance(DAY) } - expect(Math.abs((await c.view())!.affinity - (0.3))).toBeLessThan(1e-9) - expect((await c.view())!.stage).toBe('熟悉') - await c.affinityEvent('negative') // 0.27,仍在遲滯區間 - expect((await c.view())!.stage).toBe('熟悉') - await c.affinityEvent('negative') // 0.24,低於降階門檻 0.25 - expect((await c.view())!.stage).toBe('陌生') + expect([...seen]).toEqual(['陌生', '熟人', '朋友', '好友']) + for (let i = 0; i < 40; i++) await c.affinityEvent('negative') + expect((await rel(c)).trust).toBeLessThan(45) + expect((await c.view())!.stage).toBe('好友') + }) + + test('久未互動 14 天內不變,之後每天 −0.5,階段不降', async () => { + const { c, clock } = setup() + await c.ensureActivated() + for (let d = 0; d < 20; d++) { + for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful') + clock.advance(DAY) + } + clock.advance(-DAY) + const before = await rel(c) + const stage = (await c.view())!.stage + clock.advance(14 * DAY) + expect(await rel(c)).toEqual(before) + clock.advance(10 * DAY) + expect((await rel(c)).affection).toBeCloseTo(before.affection - 5) + expect((await rel(c)).trust).toBeCloseTo(before.trust - 5) + clock.advance(1000 * DAY) + expect((await rel(c)).affection).toBe(0) + expect((await c.view())!.stage).toBe(stage) + }) + + test('原典基準與存檔累積兩本帳相加', () => { + const base = { affection: 20, trust: 10, intimacy: 0, dependence: 0 } + const r = interact(initialRelation(0, base), 'meaningful', 0) + expect(effective(r).affection).toBeCloseTo(20 + 3 * (1 - 20 / 100)) + expect(r.save.affection).toBeLessThan(3) + expect(r.stage).toBe(1) + }) +}) + +describe('舊 affinity 存檔升級', () => { + const legacy = (value: number, stage: number, at: number) => ({ + mood: { p: 0, a: 0, d: 0, at }, + affinity: { value, stage, dayKey: '2026-10-14', dayGain: 0.02, lastSeen: at }, + todos: 0, + }) + + const cases: [number, number, string][] = [ + [0.05, 0, '陌生'], + [0.3, 1, '熟人'], + [0.55, 2, '朋友'], + [0.8, 3, '好友'], + ] + for (const [value, stage, name] of cases) test(`value ${value}、stage ${stage} → ${name},好感值保留`, async () => { + const { c, fs, clock } = setup() + await c.ensureActivated() + await fs.write(`${ROOT}/companions/miko/state.json`, JSON.stringify(legacy(value, stage, clock.now()))) + const v = (await c.view())! + expect(v.stage).toBe(name) + expect(v.relation.affection).toBeCloseTo(value * 100) + const saved = JSON.parse((await fs.read(`${ROOT}/companions/miko/state.json`))!) + expect(saved.affinity).toBeUndefined() + expect(saved.relation.stage).toBe(stage) + expect(saved.relation.dayGain.affection).toBeCloseTo(2) + }) + + test('升級後可繼續互動', async () => { + const { c, fs, clock } = setup() + await c.ensureActivated() + await fs.write(`${ROOT}/companions/miko/state.json`, JSON.stringify(legacy(0.3, 1, clock.now()))) + await c.affinityEvent('meaningful') + expect((await c.view())!.relation.affection).toBeGreaterThan(30) }) }) @@ -242,12 +343,12 @@ describe('語氣注入', () => { const { c } = setup() await c.ensureActivated() await c.onPrompt('請幫我看一下這個函式') - expect(Math.abs((await c.view())!.affinity - 0.01)).toBeLessThan(1e-9) + expect((await c.view())!.relation.affection).toBeCloseTo(3) await c.onPrompt('謝謝你') expect((await c.view())!.mood.p).toBeGreaterThan(0.1) - const before = (await c.view())!.affinity + const before = (await c.view())!.relation.affection await c.onPrompt('你很煩') - expect((await c.view())!.affinity).toBeLessThan(before) + expect((await c.view())!.relation.affection).toBeLessThan(before) expect((await c.view())!.mood.p).toBeLessThan(0.1) }) }) diff --git a/plugins/jsc-mod-companion/hooks/companion/core.ts b/plugins/jsc-mod-companion/hooks/companion/core.ts index 15369f8..74aa771 100644 --- a/plugins/jsc-mod-companion/hooks/companion/core.ts +++ b/plugins/jsc-mod-companion/hooks/companion/core.ts @@ -1,4 +1,5 @@ -import { idleDecay, initialAffinity, interact } from './affinity' +import { effective, idleDecay, initialRelation, interact, migrateAffinity } from './relation' +import type { LegacyAffinity } from './relation' import { FUBUKI } from './defaults' import { readJson, writeJson } from './fs' import type { Clock, Fs } from './fs' @@ -12,7 +13,7 @@ import { buildPrompt } from './prompt' import { createTodos } from './todo' import type { TodoList } from './todo' import { STAGES } from './types' -import type { CompanionCore, CompanionState, Global, Pad, UserSettings } from './types' +import type { CompanionCore, CompanionState, Dims, Global, Pad, UserSettings } from './types' export const DEFAULT_USER: UserSettings = { nickname: '', @@ -28,7 +29,7 @@ export type View = { mood: Pad label: string kaomoji: string - affinity: number + relation: Dims stage: string todos: number } @@ -49,14 +50,23 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan 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 } } - const getState = (id: string) => readJson(fs, `${dir(id)}/state.json`) + // 舊存檔只有單一 affinity:讀檔時升級成四維度並寫回 + const getState = async (id: string): Promise => { + const st = await readJson & { relation?: CompanionState['relation']; affinity?: LegacyAffinity }>(fs, `${dir(id)}/state.json`) + if (!st) return null + if (st.relation) return st as CompanionState + const { affinity, ...rest } = st + const up: CompanionState = { ...rest, relation: affinity ? migrateAffinity(affinity) : initialRelation(clock.now()) } + await writeJson(fs, `${dir(id)}/state.json`, up) + return up + } const putState = (id: string, s: CompanionState) => writeJson(fs, `${dir(id)}/state.json`, s) async function install(core: CompanionCore) { if (await getCore(core.id)) return const now = clock.now() 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, { mood: { ...core.baseline, at: now }, relation: initialRelation(now), todos: 0 }) } // 第一次啟用自動啟用預設角色;之後即使關閉也不會再自動啟用 @@ -79,9 +89,9 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan if (!core || !st) return null const now = clock.now() const mood = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism) - const aff = idleDecay(st.affinity, now) + const rel = idleDecay(st.relation, 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 } + return { core, mood, ...moodLabel(mood), relation: effective(rel), stage: STAGES[rel.stage]!, todos } } async function mutate(fn: (core: CompanionCore, st: CompanionState, now: number) => CompanionState) { @@ -101,7 +111,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan } async function affinityEvent(kind: 'meaningful' | 'negative') { - await mutate((_, st, now) => ({ ...st, affinity: interact(st.affinity, kind, now) })) + await mutate((core, st, now) => ({ ...st, relation: interact(st.relation, kind, now, core.relationTraits) })) } const bar = (v: number) => (v >= 0 ? '+' : '') + v.toFixed(2) @@ -111,7 +121,8 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan if (!v) return '目前沒有啟用角色。' 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(`好感:${v.stage}(${bar(v.affinity)})`) + lines.push(`好感:${v.stage}(${v.relation.affection.toFixed(1)})`) + lines.push(`關係:好感 ${v.relation.affection.toFixed(1)} 信賴 ${v.relation.trust.toFixed(1)} 親密 ${v.relation.intimacy.toFixed(1)} 依賴 ${v.relation.dependence.toFixed(1)}`) lines.push(`待辦:${v.todos}`) lines.push('基本資訊:') for (const [k, f] of Object.entries(v.core.facts)) { diff --git a/plugins/jsc-mod-companion/hooks/companion/relation.ts b/plugins/jsc-mod-companion/hooks/companion/relation.ts new file mode 100644 index 0000000..af4c850 --- /dev/null +++ b/plugins/jsc-mod-companion/hooks/companion/relation.ts @@ -0,0 +1,108 @@ +import { clamp } from './mood' +import type { Dims, Relation, RelationTraits } from './types' + +const DAY = 86_400_000 + +export const DIMS = ['affection', 'trust', 'intimacy', 'dependence'] as const +export type Dim = (typeof DIMS)[number] + +const zero = (): Dims => ({ affection: 0, trust: 0, intimacy: 0, dependence: 0 }) + +// 第 9.6 章:階段只升不降,條件全部滿足才升級(本里程碑只做到好友) +export const STAGE_RULES: Partial>[] = [ + { affection: 15 }, + { affection: 30, trust: 25 }, + { affection: 50, trust: 45, intimacy: 30 }, +] + +// 一般互動的原始增量(一般聊天 ±3)與失去信賴的基礎值 +export const MEANINGFUL: Dims = { affection: 3, trust: 2, intimacy: 1.5, dependence: 1 } +export const NEGATIVE_AFFECTION = -3 +export const NEGATIVE_TRUST = 3 +export const SEVERITY = { broken: 0.3, scold: 0.4, boundary: 0.6, lie: 1 } as const +export const TRUST_LOSS_CAP = 30 + +const IDLE_GRACE_DAYS = 14 +const IDLE_DAILY_LOSS = 0.5 + +const DEFAULT_TRAITS: Required = { guard: 0.5, shy: 0.5, coy: 0.5, clingy: 0.5 } + +export const dayKey = (ms: number) => new Date(ms).toISOString().slice(0, 10) + +export function initialRelation(now: number, base: Dims = zero()): Relation { + return { base, save: zero(), stage: 0, dayKey: dayKey(now), dayGain: zero(), lastSeen: now } +} + +export function effective(r: Relation): Dims { + const out = zero() + for (const d of DIMS) out[d] = clamp(r.base[d] + r.save[d], 0, 100) + return out +} + +// 社會滲透日限(Altman 與 Taylor) +export function dailyLimits(t: RelationTraits = {}): Dims { + const { guard, shy, coy, clingy } = { ...DEFAULT_TRAITS, ...t } + return { + affection: 4, + trust: 5 * (1 - guard * 0.6), + intimacy: 3.5 * (1 - shy * 0.4) * (1 + coy * 0.4), + dependence: 2.5 * (0.7 + clingy * 0.6), + } +} + +// 條件全部滿足才升級;只升不降,所以數值回落時階段不變 +export function stageFor(eff: Dims, current: number): number { + let s = current + while (s < STAGE_RULES.length && DIMS.every(d => eff[d] >= (STAGE_RULES[s]![d] ?? 0))) s++ + return s +} + +// 久未互動超過 14 天後,每天 −0.5;階段不會降低 +export function idleDecay(r: Relation, now: number): Relation { + const days = Math.floor((now - r.lastSeen) / DAY) - IDLE_GRACE_DAYS + if (days <= 0) return r + const save = zero() + for (const d of DIMS) save[d] = Math.max(-r.base[d], r.save[d] - days * IDLE_DAILY_LOSS) + return { ...r, save } +} + +const withDay = (r: Relation, now: number): Relation => { + const key = dayKey(now) + return r.dayKey === key ? r : { ...r, dayKey: key, dayGain: zero() } +} + +// 有效增量 = 原始增量 × max(0, 1 − 今日累計 / 日限) × (1 − 目前值 / 100) +export function gain(r: Relation, d: Dim, raw: number, traits?: RelationTraits): Relation { + const limit = dailyLimits(traits)[d] + const applied = raw * Math.max(0, 1 - r.dayGain[d] / limit) * (1 - effective(r)[d] / 100) + return { ...r, save: { ...r.save, [d]: r.save[d] + applied }, dayGain: { ...r.dayGain, [d]: r.dayGain[d] + applied } } +} + +// 期望違反(Burgoon):越親密、心防越高,信賴損失越大;單次上限 −30 +export function trustLoss(base: number, severity: number, intimacy: number, guard = DEFAULT_TRAITS.guard): number { + return Math.min(TRUST_LOSS_CAP, base * (1.5 + 0.5 * severity) * (1 + intimacy / 100) * (0.7 + 0.6 * guard)) +} + +export function interact(prev: Relation, kind: 'meaningful' | 'negative', now: number, traits?: RelationTraits, severity: number = SEVERITY.scold): Relation { + let r = withDay(idleDecay(prev, now), now) + if (kind === 'meaningful') { + for (const d of DIMS) r = gain(r, d, MEANINGFUL[d], traits) + } else { + const loss = trustLoss(NEGATIVE_TRUST, severity, effective(r).intimacy, traits?.guard) + r = { ...r, save: { ...r.save, affection: r.save.affection + NEGATIVE_AFFECTION, trust: r.save.trust - loss } } + } + const save = { ...r.save } + for (const d of DIMS) save[d] = clamp(save[d], -r.base[d], 100 - r.base[d]) + r = { ...r, save, lastSeen: now } + return { ...r, stage: stageFor(effective(r), r.stage) } +} + +// 舊版單一好感值(0–1)→ 四維度。其餘維度依比例推估,使新階段不低於舊階段 +export type LegacyAffinity = { value: number; stage: number; dayKey: string; dayGain: number; lastSeen: number } + +export function migrateAffinity(a: LegacyAffinity): Relation { + const v = a.value * 100 + const save: Dims = { affection: v, trust: v * 0.8, intimacy: v * 0.8, dependence: v * 0.4 } + const r: Relation = { base: zero(), save, stage: 0, dayKey: a.dayKey, dayGain: { ...zero(), affection: a.dayGain * 100 }, lastSeen: a.lastSeen } + return { ...r, stage: Math.max(Math.min(a.stage, STAGE_RULES.length), stageFor(effective(r), 0)) } +} diff --git a/plugins/jsc-mod-companion/hooks/companion/types.ts b/plugins/jsc-mod-companion/hooks/companion/types.ts index 4d727cd..b23a04e 100644 --- a/plugins/jsc-mod-companion/hooks/companion/types.ts +++ b/plugins/jsc-mod-companion/hooks/companion/types.ts @@ -24,22 +24,30 @@ export type CompanionCore = { // 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值 neuroticism: number baseline: Pad + relationTraits?: RelationTraits } -export const STAGES = ['陌生', '熟悉', '親近', '特別'] as const +export const STAGES = ['陌生', '熟人', '朋友', '好友'] as const export type Stage = (typeof STAGES)[number] -export type Affinity = { - value: number +export type Dims = { affection: number; trust: number; intimacy: number; dependence: number } + +// 兩本帳:base 是原典基準(朋友線為 0),save 是這個存檔累積;有效值為兩者相加 +export type Relation = { + base: Dims + save: Dims stage: number dayKey: string - dayGain: number + dayGain: Dims lastSeen: number } +// 0–1:心防、害羞傾向、撒嬌傾向、依賴傾向,預設 0.5 +export type RelationTraits = { guard?: number; shy?: number; coy?: number; clingy?: number } + export type CompanionState = { mood: Pad & { at: number } - affinity: Affinity + relation: Relation todos: number }