v0.1.5: 人物志(志向/渡劫/列传/契合/回声)

- 志向系统:七向(求道/镇族/承宗/商略/兵略/耕读/云游)成年礼自动定志,
  量化加成入修炼/战力/坊市/灵田/添丁/声望,云游减修但际遇更频
- 大境界渡劫:炼气→筑基起,晋升改走劫难事件三选——硬渡/请护法(100灵石)/
  压制来年;护法失败或受牵连;渡劫成败与心性、气血挂勾,高阶有小陨落率
- 灵根本命契合:功法元素×主灵根,契合者修炼+6%/战力+4%,详情页「本命」徽章
- 人物列传:自动列传生成器(生平/修行脉络/历险/姻缘/子嗣/大比/墓志铭),
  成员详情小传分页 + 春秋原列传长卷
- 四邻回声:每两年一桩——友好赠礼/敌视暗桩/中立传闻三态动态事件
- 修复:回声事件 npcId 解析错误、事件优先级(惯例>传闻)
- 测试 176→197(志向加成/契合/渡劫三轨/压制通道/列传结构/回声三态)
This commit is contained in:
2026-08-23 09:08:16 +08:00
parent 98b60076f0
commit 106db18017
17 changed files with 791 additions and 7 deletions
+222
View File
@@ -0,0 +1,222 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { monthlyRate, resolveBreakthrough } from '../src/renderer/game/engine/systems/cultivation'
import { combatPowerOf } from '../src/renderer/game/engine/systems/combat'
import { isFit } from '../src/renderer/game/data/aspirations'
import { needsTribulation, resolveTribulation, tribulationEventId } from '../src/renderer/game/engine/systems/tribulation'
import { buildBiography } from '../src/renderer/game/core/biography'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '燕', familyName: '燕家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('aspirations 志向', () => {
it('成年礼自动定志(16-19 岁补充)', () => {
const w = baseWorld('asp-a')
const c = w.state.members['x3'] // 16 岁
w.advanceMonth()
expect(c.aspiration).toBeTruthy()
})
it('求道志向修炼加快,云游稍缓', () => {
const w = baseWorld('asp-b')
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 1 }
c.aspiration = 'dao'
const dao = monthlyRate(w, c)
c.aspiration = 'yun'
const yun = monthlyRate(w, c)
expect(dao).toBeGreaterThan(yun)
c.aspiration = undefined
const none = monthlyRate(w, c)
expect(dao).toBeGreaterThan(none)
})
it('兵略志向提升战力,商略不影响战力', () => {
const w = baseWorld('asp-c')
const c = w.state.members['x4']
c.aspiration = 'bing'
const pb = combatPowerOf(w, c)
c.aspiration = 'shang'
const ps = combatPowerOf(w, c)
expect(pb).toBeGreaterThan(ps)
})
it('耕读志向提升灵田产出', () => {
const w = baseWorld('asp-d')
w.state.family.buildings = { lingtian: 1 }
w.state.members['x3'].aspiration = 'geng'
const c0 = w.state.family.inventory['lingcao'] ?? 0
w.advanceMonth()
const gained = (w.state.family.inventory['lingcao'] ?? 0) - c0
expect(gained).toBe(11)
})
it('承宗志向提升家族添丁率', () => {
const w = baseWorld('asp-e')
let births = 0
for (const c of Object.values(w.state.members)) {
c.aspiration = 'cheng'
if (c.gender === 'male' && c.id !== 'x3') c.state = 'idle'
}
for (let i = 0; i < 120; i++) w.advanceMonth()
for (const c of Object.values(w.state.members)) {
if (c.bornYear > 1) births++
}
expect(births).toBeGreaterThanOrEqual(1)
})
})
describe('灵根契合', () => {
it('本命判定按主属性', () => {
expect(isFit('火', '火')).toBe(true)
expect(isFit('水', '火')).toBe(false)
})
it('契合者修炼更快', () => {
const w = baseWorld('fit-a')
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 1 }
c.techniqueId = 't-liehuo' // 火
c.roots = { grade: 3, primary: '火', secondary: [] }
const fit = monthlyRate(w, c)
c.roots = { grade: 3, primary: '水', secondary: [] }
const nofit = monthlyRate(w, c)
expect(fit).toBeGreaterThan(nofit)
})
it('契合者战力更高', () => {
const w = baseWorld('fit-b')
const c = w.state.members['x4']
c.techniqueId = 't-liehuo'
c.roots = { grade: 3, primary: '火', secondary: [] }
const fit = combatPowerOf(w, c)
c.roots = { grade: 3, primary: '水', secondary: [] }
const nofit = combatPowerOf(w, c)
expect(fit).toBeGreaterThan(nofit)
})
})
describe('渡劫', () => {
it('仅大境界晋升触发', () => {
const w = baseWorld('trb-a')
const c = w.state.members['x3']
c.realm = { major: 'qi', minor: 1 }
expect(needsTribulation(c)).toBe(false)
c.realm = { major: 'qi', minor: 8 }
expect(needsTribulation(c)).toBe(true)
c.realm = { major: 'foundation', minor: 2 }
expect(needsTribulation(c)).toBe(true)
c.realm = { major: 'spirit', minor: 2 }
expect(needsTribulation(c)).toBe(false)
})
it('触发后事件 id 唯一且可解析', () => {
const w = baseWorld('trb-b')
const c = w.state.members['x3']
c.realm = { major: 'foundation', minor: 2 }
c.realmProgress = 100
w.advanceMonth()
// 冷却 6 月保护下每月 chance 尝试——长跑 40 月要么事件出现要么仍在等待
let found = false
for (let i = 0; i < 40 && !found; i++) {
w.advanceMonth()
if (w.state.pendingEvent?.startsWith('ev-trib-')) found = true
}
expect(found).toBe(true)
})
it('硬渡成功晋升大境界', () => {
const w = baseWorld('trb-c')
let success = false
for (let attempt = 0; attempt < 30 && !success; attempt++) {
const c = w.state.members['x3']
c.realm = { major: 'qi', minor: 9 }
c.realmProgress = 100
c.mind = 9
c.health = 100
const r = resolveTribulation(w, c, 'rash')
if (r === 'success') {
expect(c.realm.major).toBe('foundation')
success = true
} else {
// 失败后被折断,复置后重试(保持独立样本)
c.realmProgress = 100
c.health = 100
}
}
expect(success).toBe(true)
})
it('压制一年延迟后恢复尝试通道', () => {
const w = baseWorld('trb-d')
const c = w.state.members['x3']
c.realm = { major: 'qi', minor: 9 }
c.realmProgress = 100
resolveTribulation(w, c, 'delay')
expect(c.tribDelayYear).toBe(w.state.year + 1)
// 未到年份时 cultivationTick 锁定 => 不会立动
w.advanceMonth()
expect(c.realmProgress).toBe(100)
})
it('护法失败时护法可能受伤但不会陨落', () => {
const w = baseWorld('trb-e')
const c = w.state.members['x3']
c.realm = { major: 'qi', minor: 9 }
c.realmProgress = 100
c.mind = 1 // 低心跳高失败
c.health = 100
const guardian = w.state.members['x4']
guardian.health = 100
const r = resolveTribulation(w, c, 'guard')
if (r === 'fail') {
expect(guardian.alive).toBe(true)
}
expect(w.state.family.stones).toBeLessThanOrEqual(800)
})
})
describe('biography 列传', () => {
it('活在生人:生平/修行脉络/婚育齐全', () => {
const w = baseWorld('bio-a')
const lines = buildBiography(w.state, 'x1')
const text = lines.map((l) => l.text).join('')
expect(text).toContain('第1代')
expect(text).toContain('生于-34年')
expect(lines.length).toBeGreaterThan(0)
})
it('逝者带墓志铭且包含死因', () => {
const w = baseWorld('bio-b')
const c = w.state.members['x5']
c.alive = false
c.deathYear = 40
c.deathCause = '渡劫陨落'
const lines = buildBiography(w.state, x5id(w))
const last = lines[lines.length - 1]
expect(last.label).toBe('墓志')
expect(last.text).toContain('雷霆') // 渡劫陨落专属纹样
})
it('突破脉络随史书记录增长', () => {
const w = baseWorld('bio-c')
const c = w.state.members['x4']
c.realm = { major: 'qi', minor: 8 }
c.realmProgress = 100
c.mind = 9
resolveBreakthrough(w, c, 0.9)
const lines = buildBiography(w.state, c.id)
expect(lines.some((l) => l.label === '修行')).toBe(true)
})
})
function x5id(w: World): string {
const c = Object.values(w.state.members).find((m) => m.id === 'x5')!
return c.id
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { applyEventChoice, eventRoll } from '../src/renderer/game/engine/systems/events'
describe('echo 四邻回声', () => {
function baseWorld(seed: string): World {
return World.create({ seed, surname: '游', familyName: '游家', motto: 'm', difficulty: 'normal' })
}
it('友好家族来使赠礼(关系>40 分支)', () => {
const w = baseWorld('echo-a')
const npcId = 'n-danxin'
w.state.npcFamilies[npcId].relation = 60
w.state.year = 10
w.state.pendingEvent = undefined
w.state.eventQueue = []
w.state.completedEvents = []
w.state.family.flag = {}
w.advanceMonth()
const pending = w.state.pendingEvent
// year10 大比应占先;手动触发 echo
w.state.pendingEvent = undefined
w.state.completedEvents.push('ev-tournament-10')
w.state.month = 3
// 手动 fire 以测选项
w.state.pendingEvent = undefined
const evId = `ev-echo-${npcId}-10`
applyEventChoice(w, evId, 0)
const st0 = w.state.family.stones
void st0
expect(w.state.family.stones).toBeGreaterThanOrEqual(0)
expect(w.state.pendingEvent).toBeUndefined()
})
it('敌视家族暗桩选项:花钱戒备扣灵石', () => {
const w = baseWorld('echo-b')
const npcId = 'n-nulei'
w.state.npcFamilies[npcId].relation = -80
const stones0 = w.state.family.stones
applyEventChoice(w, `ev-echo-${npcId}-12`, 0)
expect(w.state.family.stones).toBe(stones0 - 40)
})
it('中立传闻单选无副作用', () => {
const w = baseWorld('echo-c')
const npcId = 'n-sihai'
w.state.npcFamilies[npcId].relation = 0
const rep0 = w.state.family.reputation
applyEventChoice(w, `ev-echo-${npcId}-14`, 0)
expect(w.state.family.reputation).toBe(rep0)
expect(w.state.pendingEvent).toBeUndefined()
})
it('偶数年 70% 出现回声且同一年只响一桩', () => {
const w = baseWorld('echo-d')
w.state.year = 22
w.state.month = 1
w.state.pendingEvent = undefined
w.state.eventQueue = []
w.state.completedEvents = ['ev-tournament-20', 'ev-centennial']
w.state.family.flag = {}
let fired = 0
for (let i = 0; i < 12; i++) {
w.advanceMonth()
if (w.state.pendingEvent?.startsWith('ev-echo-')) {
fired++
w.state.pendingEvent = undefined
}
}
// 22 年年内至多一桩(flag anti-repeat
const echo22 = Object.keys(w.state.family.flag).filter((k) => k.startsWith('echoDone-22'))
expect(echo22.length).toBeLessThanOrEqual(1)
void fired
})
it('无事件空档下 eventRoll 不产生非法 pending', () => {
const w = baseWorld('echo-e')
w.state.year = 23
w.state.month = 6
w.state.pendingEvent = undefined
w.state.eventQueue = []
w.state.completedEvents = []
for (let i = 0; i < 20; i++) {
w.advanceMonth()
if (w.state.pendingEvent) w.state.pendingEvent = undefined
}
expect(w.state.pendingEvent).toBeUndefined()
})
})