feat: companion 關係 4 維度與階段(到好友) #50
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-mod-companion",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
### `/jsc-mod-companion:status`
|
||||
|
||||
查看目前角色、心情、關係階段與四維度(好感、信賴、親密、依賴)與待辦數量。
|
||||
|
||||
查看目前角色、心情、好感與待辦數量。情緒有 8 個維度(心情、精力、害羞、忌妒、想念、心跳、想家、不悅),預設列出前 3 強,`--all` 顯示完整數值(▰▱ 量表依維度上色);band 上的情緒名稱為目前最強的主情緒。
|
||||
|
||||
```text
|
||||
@@ -83,6 +85,23 @@
|
||||
/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);增量沿用原有的「有意義互動/被罵」判斷,尚未接標籤管線。
|
||||
|
||||
## 人格 18 參數(與第 9 章的差異)
|
||||
|
||||
人格參數依 wiki 9.2 實作於 `hooks/companion/core/params.ts`:
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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('緊湊模式只剩「顏文字 名字 心情」', () => {
|
||||
|
||||
@@ -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<typeof setup>['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<string>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -16,7 +17,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: '',
|
||||
@@ -33,7 +34,7 @@ export type View = {
|
||||
emotions: Emotions
|
||||
label: string
|
||||
kaomoji: string
|
||||
affinity: number
|
||||
relation: Dims
|
||||
stage: string
|
||||
todos: number
|
||||
}
|
||||
@@ -55,13 +56,18 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
return { ...core, persona: core.persona ?? b.persona, attribute: core.attribute ?? b.attribute, extraversion: core.extraversion ?? b.extraversion, freq: core.freq ?? b.freq }
|
||||
}
|
||||
const baseOf = (core: CompanionCore) => baselineOf(core.baseline, core.emotionBaseline)
|
||||
// 讀檔時把舊的 PAD 存檔(mood)映射到八維度並寫回,不要求重建
|
||||
// 讀檔時把舊存檔升級並寫回,不要求重建:PAD(mood)映射到八維度;單一好感(affinity)升成關係四維度
|
||||
const getState = async (id: string, core: CompanionCore): Promise<CompanionState | null> => {
|
||||
const raw = await readJson<CompanionState & { mood?: Pad & { at: number } }>(fs, `${dir(id)}/state.json`)
|
||||
const raw = await readJson<Omit<CompanionState, 'emotion' | 'relation'> & { emotion?: CompanionState['emotion']; relation?: CompanionState['relation']; mood?: Pad & { at: number }; affinity?: LegacyAffinity }>(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: {} }
|
||||
if (raw.emotion && raw.relation) return { ...raw, habit: raw.habit ?? {} } as CompanionState
|
||||
const { mood, affinity, ...rest } = raw
|
||||
const st = {
|
||||
...rest,
|
||||
emotion: raw.emotion ?? fromPad(mood ?? { ...core.baseline, at: clock.now() }, baseOf(core), core.baseline),
|
||||
habit: raw.habit ?? {},
|
||||
relation: raw.relation ?? (affinity ? migrateAffinity(affinity) : initialRelation(clock.now())),
|
||||
} as CompanionState
|
||||
await putState(id, st)
|
||||
return st
|
||||
}
|
||||
@@ -71,7 +77,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
if (await getCore(core.id)) return
|
||||
const now = clock.now()
|
||||
await writeJson(fs, `${dir(core.id)}/core.json`, core)
|
||||
await putState(core.id, { emotion: { ...baselineOf(core.baseline, core.emotionBaseline), at: now }, habit: {}, affinity: initialAffinity(now), todos: 0 })
|
||||
await putState(core.id, { emotion: { ...baselineOf(core.baseline, core.emotionBaseline), at: now }, habit: {}, relation: initialRelation(now), todos: 0 })
|
||||
}
|
||||
|
||||
// 第一次啟用自動啟用預設角色;之後即使關閉也不會再自動啟用
|
||||
@@ -97,9 +103,9 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
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 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, emotions, ...(main ?? moodLabel(mood)), affinity: aff.value, stage: STAGES[aff.stage]!, todos }
|
||||
return { core, mood, emotions, ...(main ?? moodLabel(mood)), relation: effective(rel), stage: STAGES[rel.stage]!, todos }
|
||||
}
|
||||
|
||||
async function mutate(fn: (core: CompanionCore, st: CompanionState, now: number) => CompanionState) {
|
||||
@@ -121,7 +127,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)
|
||||
@@ -134,7 +140,8 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
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}(${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)) {
|
||||
|
||||
@@ -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<Record<Dim, number>>[] = [
|
||||
{ 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<RelationTraits> = { 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)) }
|
||||
}
|
||||
@@ -28,25 +28,33 @@ export type CompanionCore = {
|
||||
// 人格 18 參數的角色包基準;有值時神經質改由參數推導
|
||||
params?: ParamVec
|
||||
baseline: Pad
|
||||
relationTraits?: RelationTraits
|
||||
// 八維度個別基線(覆寫由 baseline 推得的值),角色專屬值放本機 core.json
|
||||
emotionBaseline?: Partial<Emotions>
|
||||
}
|
||||
|
||||
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 = {
|
||||
emotion: EmotionState
|
||||
habit: Habit
|
||||
affinity: Affinity
|
||||
relation: Relation
|
||||
todos: number
|
||||
// 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效)
|
||||
params?: { save: Partial<ParamVec>; today: Partial<ParamVec>; dayKey: string }
|
||||
|
||||
Reference in New Issue
Block a user