v0.1.5: 人物志(志向/渡劫/列传/契合/回声)
- 志向系统:七向(求道/镇族/承宗/商略/兵略/耕读/云游)成年礼自动定志, 量化加成入修炼/战力/坊市/灵田/添丁/声望,云游减修但际遇更频 - 大境界渡劫:炼气→筑基起,晋升改走劫难事件三选——硬渡/请护法(100灵石)/ 压制来年;护法失败或受牵连;渡劫成败与心性、气血挂勾,高阶有小陨落率 - 灵根本命契合:功法元素×主灵根,契合者修炼+6%/战力+4%,详情页「本命」徽章 - 人物列传:自动列传生成器(生平/修行脉络/历险/姻缘/子嗣/大比/墓志铭), 成员详情小传分页 + 春秋原列传长卷 - 四邻回声:每两年一桩——友好赠礼/敌视暗桩/中立传闻三态动态事件 - 修复:回声事件 npcId 解析错误、事件优先级(惯例>传闻) - 测试 176→197(志向加成/契合/渡劫三轨/压制通道/列传结构/回声三态)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user