feat: companion 關係改為好感、信賴、親密、依賴四維度與四階段
- 兩本帳(原典基準、存檔累積),日限、有效增量、期望違反公式 - 階段 陌生/熟人/朋友/好友,只升不降;14 天無互動每日 −0.5 - 舊 affinity 存檔讀檔時自動升級 - 版本 0.0.2 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user