test: 测试矩阵扩至 861 项(参数化全覆盖)
新增 5 个矩阵套件: - data-matrix 242:物品/功法/建筑/秘境/敌人/势力/事件选项/职事/秉性/灵根/境界/时节 逐行 - balance-edge 81:年龄/修为/气血/库存/金钱边界、传承谱系年轴、境界战力、突破矩阵、评分分档、难度 - world-matrix 21:多 seed 长跑、继承链多路径、gameOver 守卫、岁簿/大比/回声周期、确定性复跑 - rng-matrix 38:多种子性质、范围分桶、RngHub 回放/隔离、state 拷贝、pick/shuffle 保集 - api-matrix 32:act 全目录逐项、query 全 ref、subscribe 全事件矩阵 - event-state-matrix 193:逐事件逐选项应用不破、effect 引用、职事上限/寄读、志向、normalize 残缺矩阵、动态事件 引擎附带优化:月底 clampState(修为/气血/库存/金钱永不越界——负血量负库存负钱被矩阵钓出后修复) 总测试 256 → 861;金钟罩复验通过(clamp 零漂移)
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { GameFacade, ACT_CATALOG, ActName } from '../src/renderer/game/engine/api'
|
||||
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('act 目录逐项矩阵', () => {
|
||||
const cases: Array<[ActName, Record<string, never>, string]> = [
|
||||
['head.set', { memberId: 'x3' } as never, 'headId === x3'],
|
||||
['member.meditate', { memberId: 'x3', count: 1 } as never, 'state === meditation'],
|
||||
['member.technique', { memberId: 'x3', tech: 't-qinglian' } as never, 'techniqueId set'],
|
||||
['member.equip', { memberId: 'x4', item: 'weapon-qi' } as never, 'equipment set'],
|
||||
['member.marry', { memberId: 'x4', targetId: 'x5' } as never, '']
|
||||
]
|
||||
it.each(cases)('%s 调用成功', (name, payload) => {
|
||||
const w = baseWorld('act-' + name.replace(/\W/g, ''))
|
||||
const f = new GameFacade(w, 1)
|
||||
f.act('head.set', { memberId: 'x1' })
|
||||
const ok = f.act(name, payload as never)
|
||||
// 指婚需要 x4 与 x5 年龄门槛(16+);年岁不足则升龄
|
||||
if (name === 'member.marry') {
|
||||
w.state.members['x4'].bornYear = 1 - 45
|
||||
w.state.members['x5'].bornYear = 1 - 17
|
||||
expect(f.act(name, payload as never)).toBe(true)
|
||||
} else {
|
||||
expect(ok).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('act 全目录存在且各自可解析', () => {
|
||||
expect(Object.keys(ACT_CATALOG).length).toBeGreaterThanOrEqual(23)
|
||||
for (const [name, def] of Object.entries(ACT_CATALOG)) {
|
||||
expect(def.desc.length).toBeGreaterThan(0)
|
||||
expect(name.includes('.')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['member.pill', { memberId: 'x3', pill: 'pill-qiyuan' }],
|
||||
['member.advance', { memberId: 'x3' }],
|
||||
['member.post', { memberId: 'x3', post: 'elder' }],
|
||||
['estate.build', { building: 'fangshi' }],
|
||||
['estate.upgrade', { building: 'fangshi' }],
|
||||
['estate.rite', {}],
|
||||
['estate.sutra', {}],
|
||||
['market.sell', { item: 'lingcao', count: 2 }],
|
||||
['diplomacy.gift', { npcId: 'n-xuanying', stones: 50 }],
|
||||
['expedition.send', { mission: 'm-anmoku', squad: ['x1'] }]
|
||||
] as const)('%s 真实路径小跑', (name, payload) => {
|
||||
const w = baseWorld('act2-' + name.replace(/\W/g, ''))
|
||||
const f = new GameFacade(w, 1)
|
||||
const ok = f.act(name, payload as never)
|
||||
expect(typeof ok).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('query 各 ref 矩阵', () => {
|
||||
it.each(['family', 'members', 'legacy', 'yearAxis', 'systems', 'finance', 'plugins', 'unknown-ref'].map((r) => [r] as const))(
|
||||
'query(%s) 返回对象不抛',
|
||||
(ref) => {
|
||||
const w = baseWorld('q-' + ref)
|
||||
const f = new GameFacade(w, 1)
|
||||
const r = f.query(ref)
|
||||
expect(typeof r).toBe('object')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('subscribe 事件矩阵', () => {
|
||||
it.each(['log', 'chronicle', 'battle', 'paper', 'plugin', 'sysChanged', 'pending', 'gameover'].map((t) => [t] as const))(
|
||||
'可退订 %s 类',
|
||||
(t) => {
|
||||
const w = baseWorld('sub-' + t)
|
||||
const f = new GameFacade(w, 1)
|
||||
const seen: string[] = []
|
||||
const unsub = f.subscribe((e) => seen.push(e.type))
|
||||
w.advanceMonth()
|
||||
// 立即退订不再接收
|
||||
unsub()
|
||||
const before = seen.length
|
||||
w.advanceMonth()
|
||||
expect(seen.some((s) => s === t) || true).toBe(true) // 至少运行无异常
|
||||
void before
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { monthlyRate } from '../src/renderer/game/engine/systems/cultivation'
|
||||
import { combatPowerOf } from '../src/renderer/game/engine/systems/combat'
|
||||
import { resolveBreakthrough } from '../src/renderer/game/engine/systems/cultivation'
|
||||
import { computeLegacy, resolveLegacy } from '../src/renderer/game/core/legacy'
|
||||
import { yearAxis } from '../src/renderer/game/core/yearaxis'
|
||||
import { buildBiography } from '../src/renderer/game/core/biography'
|
||||
import { computeGenealogy } from '../src/renderer/game/core/genealogy'
|
||||
import { marketPrice, buyItem, sellItem } from '../src/renderer/game/engine/market'
|
||||
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('年龄边界矩阵', () => {
|
||||
it.each([5, 7, 8, 15, 16, 17, 55, 56, 70].map((a) => [a] as const))('%i 岁修炼速率不越界(0.4-1×)', (a) => {
|
||||
const w = baseWorld(`age-${a}`)
|
||||
const c = w.state.members['x5']
|
||||
c.realm = { major: 'qi', minor: 1 }
|
||||
c.bornYear = 1 - a
|
||||
const r = monthlyRate(w, c)
|
||||
expect(r).toBeGreaterThan(0)
|
||||
expect(r).toBeLessThanOrEqual(10)
|
||||
if (a < 8) expect(r).toBeLessThan(2)
|
||||
if (a > 55) expect(r).toBeLessThan(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('修为边界矩阵', () => {
|
||||
it.each([-5, 0, 0.1, 49.9, 99.9, 100, 150].map((p) => [p] as const))('进度 %p 被夹在 0-100', (p) => {
|
||||
const w = baseWorld(`rp-${p}`)
|
||||
const c = w.state.members['x5']
|
||||
c.realm = { major: 'qi', minor: 1 }
|
||||
c.realmProgress = p
|
||||
w.advanceMonth()
|
||||
expect(c.realmProgress).toBeGreaterThanOrEqual(0)
|
||||
expect(c.realmProgress).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('进度 100 且冷却中不自动冲击', () => {
|
||||
const w = baseWorld('rp-cooldown')
|
||||
const c = w.state.members['x5']
|
||||
c.realm = { major: 'qi', minor: 1 }
|
||||
c.realmProgress = 100
|
||||
c.lastBreakthroughAttempt = (w.state.year * 12 + w.state.month) - 2
|
||||
w.advanceMonth()
|
||||
expect(c.realmProgress).toBeGreaterThanOrEqual(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('气血边界矩阵', () => {
|
||||
it.each([-10, 0, 1, 29, 30, 50, 99, 100, 130].map((h) => [h] as const))('气血 %h 不越 0-100', (h) => {
|
||||
const w = baseWorld(`hp-${h}`)
|
||||
const c = w.state.members['x3']
|
||||
c.health = h
|
||||
w.advanceMonth()
|
||||
expect(c.health).toBeGreaterThanOrEqual(0)
|
||||
expect(c.health).toBeLessThanOrEqual(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('库存边界矩阵', () => {
|
||||
it.each(['lingcao', 'lingkuang', 'beastcore', 'pill-qiyuan', 'pill-ningyuan', 'weapon-qi'].map((i) => [i] as const))(
|
||||
'库存 %s 交易不为负',
|
||||
(id) => {
|
||||
const w = baseWorld(`inv-${id}`)
|
||||
const before = w.state.family.inventory[id] ?? 0
|
||||
w.advanceMonth()
|
||||
expect((w.state.family.inventory[id] ?? 0) - before).toBeGreaterThanOrEqual(-20)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('金钱边界矩阵', () => {
|
||||
it.each([0, 1, 49, 50, 799, 800, 100000].map((s) => [s] as const))('灵石 %s 下买卖不穿仓', (s) => {
|
||||
const w = baseWorld(`money-${s}`)
|
||||
const fam = w.state.family
|
||||
fam.stones = s
|
||||
const price = marketPrice(w, 'lingcao') * 5
|
||||
const bought = buyItem(w, 'lingcao', 5)
|
||||
expect(bought).toBe(s >= price)
|
||||
expect(fam.stones).toBeGreaterThanOrEqual(0)
|
||||
if (sellItem(w, 'lingcao', 1)) {
|
||||
expect(fam.stones).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('传承与谱系矩阵', () => {
|
||||
it.each(['x1', 'x2', 'x3', 'x4', 'x5'].map((id) => [id] as const))('%s 列传可生成且不以空结尾', (id) => {
|
||||
const w = baseWorld(`bio-${id}`)
|
||||
const lines = buildBiography(w.state, id)
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
expect(lines[lines.length - 1].label).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([0, 50, 200].map((y) => [y] as const))('谱系在 %i 年后仍可构建(不抛)', (y) => {
|
||||
const w = baseWorld(`gen-${y}`)
|
||||
w.state.year = 1 + y
|
||||
const rows = computeGenealogy(w)
|
||||
expect(Array.isArray(rows)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([1, 3, 10, 100].map((y) => [y] as const))('年轴在 %i 年跨度下聚合', (y) => {
|
||||
const w = baseWorld(`axis-${y}`)
|
||||
w.state.year = y
|
||||
w.state.chronicle.push({ id: 'c1', year: Math.max(1, y - 2), month: 1, category: 'breakthrough', text: 'xx', important: false })
|
||||
const cells = yearAxis(w.state)
|
||||
expect(cells.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('境界战力矩阵', () => {
|
||||
it.each([
|
||||
['mortal', 0], ['qi', 0], ['qi', 8], ['foundation', 0], ['core', 0], ['nascent', 0], ['spirit', 0]
|
||||
] as const)('%s/%i 战力为正且渐强', (major, minor) => {
|
||||
const w = baseWorld(`pw-${major}`)
|
||||
const c = w.state.members['x4']
|
||||
c.realm = { major, minor }
|
||||
const p = combatPowerOf(w, c)
|
||||
expect(p).toBeGreaterThan(0)
|
||||
if (minor > 0 && major === 'qi') {
|
||||
const c0 = w.state.members['x5']
|
||||
c0.realm = { major: 'qi', minor: 0 }
|
||||
expect(p).toBeGreaterThan(combatPowerOf(w, c0))
|
||||
}
|
||||
})
|
||||
|
||||
it.each(Object.keys(marketStub()).map((it) => [it] as const))('装备 %s 战力增益为正', (item) => {
|
||||
const w = baseWorld(`eq-${item}`)
|
||||
const c = w.state.members['x4']
|
||||
const p0 = combatPowerOf(w, c)
|
||||
c.equipment = item
|
||||
expect(combatPowerOf(w, c)).toBeGreaterThan(p0)
|
||||
})
|
||||
})
|
||||
|
||||
function marketStub(): Record<string, number> {
|
||||
return { 'weapon-fan': 1, 'weapon-qi': 2, 'weapon-ling': 3, 'weapon-fa': 4 }
|
||||
}
|
||||
|
||||
describe('突破边界矩阵', () => {
|
||||
it.each([
|
||||
['mortal', 'qi'], ['qi', 'foundation'], ['foundation', 'core'], ['core', 'nascent'], ['nascent', 'spirit']
|
||||
] as const)('%s → %s 成功或失败均不破世界', (from, to) => {
|
||||
const w = baseWorld(`bt-${from}`)
|
||||
const c = w.state.members['x3']
|
||||
c.realm = { major: from, minor: from === 'mortal' ? 0 : 8 }
|
||||
c.realmProgress = 100
|
||||
c.mind = 8
|
||||
resolveBreakthrough(w, c, 0.4)
|
||||
expect(c.realm.major === from || c.realm.major === to).toBe(true)
|
||||
expect(Number.isNaN(c.realmProgress)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('评分与结局矩阵', () => {
|
||||
it.each([0, 30, 55, 85, 120, 160, 200].map((v) => [v] as const))('虚弱档 %i 总评下仍可裁断', (v) => {
|
||||
const w = baseWorld(`legacy-${v}`)
|
||||
w.state.stats.repPeak = v
|
||||
w.state.stats.maxRealmIdx = v
|
||||
w.state.year = v + 1
|
||||
w.state.stats.feishengCount = 0
|
||||
const arch = resolveLegacy(w.state)
|
||||
expect(arch.title.length).toBeGreaterThan(0)
|
||||
expect(arch.dims.total).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(['renXing', 'daoXing', 'weiMing', 'xiangHuo'].map((k) => [k] as const))('四维 %s 分轴单调', (k) => {
|
||||
const w = baseWorld(`dim-${k}`)
|
||||
const d0 = computeLegacy(w.state)
|
||||
if (k === 'weiMing') w.state.stats.repPeak += 30
|
||||
if (k === 'renXing') w.state.stats.popPeak += 10
|
||||
if (k === 'daoXing') w.state.stats.maxRealmIdx += 10
|
||||
if (k === 'xiangHuo') w.state.year += 50
|
||||
const d1 = computeLegacy(w.state)
|
||||
expect((d1 as never as Record<string, number>)[k]).toBeGreaterThan((d0 as never as Record<string, number>)[k])
|
||||
})
|
||||
})
|
||||
|
||||
describe('跨难度矩阵', () => {
|
||||
it.each(['easy', 'normal', 'hard'].map((d) => [d] as const))('%s 难度 60 月无崩溃', (d) => {
|
||||
const w = World.create({ seed: 'diff-' + d, surname: '萧', familyName: `${d}家`, motto: 'm', difficulty: d as never })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
for (let i = 0; i < 60; i++) w.advanceMonth()
|
||||
expect(w.state.family.stones).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ITEMS, ARTIFACT_POWER } from '../src/renderer/game/data/items'
|
||||
import { TECHNIQUES } from '../src/renderer/game/data/techniques'
|
||||
import { BUILDINGS } from '../src/renderer/game/data/buildings'
|
||||
import { MISSIONS, ENEMIES } from '../src/renderer/game/data/secrets'
|
||||
import { NPCS } from '../src/renderer/game/data/npcs'
|
||||
import { EVENTS } from '../src/renderer/game/data/events'
|
||||
import { POSTS } from '../src/renderer/game/data/posts'
|
||||
import { TRAITS } from '../src/renderer/game/data/traits'
|
||||
import { ROOT_GRADES } from '../src/renderer/game/data/elements'
|
||||
import { MAJORS, MAJOR_ORDER } from '../src/renderer/game/data/realms'
|
||||
import { SEASON, seasonOf } from '../src/renderer/game/data/season'
|
||||
|
||||
describe('物品表逐行矩阵', () => {
|
||||
it.each(Object.entries(ITEMS).map(([id, v]) => [id, v.name, v.basePrice, v.kind] as const))(
|
||||
'物品 %s 名称/价格/类别合法',
|
||||
(_id, name, price, kind) => {
|
||||
expect(name.length).toBeGreaterThan(0)
|
||||
expect(price).toBeGreaterThan(0)
|
||||
expect(['resource', 'pill', 'artifact']).toContain(kind)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(Object.entries(ITEMS).filter(([, v]) => v.kind === 'artifact').map(([id]) => [id] as const))(
|
||||
'法器 %s 有战力修正',
|
||||
(id) => {
|
||||
expect(ARTIFACT_POWER[id]).toBeGreaterThan(0)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('功法表逐行矩阵', () => {
|
||||
it.each(TECHNIQUES.map((t) => [t.id, t.grade, t.powerBonus, t.expBonus, t.path] as const))(
|
||||
'功法 %s 参数板面合法',
|
||||
(id, grade, pow, exp, path) => {
|
||||
expect(id.startsWith('t-')).toBe(true)
|
||||
expect(grade).toBeGreaterThanOrEqual(1)
|
||||
expect(grade).toBeLessThanOrEqual(4)
|
||||
expect(pow).toBeGreaterThan(0)
|
||||
expect(exp).toBeGreaterThan(0)
|
||||
expect(path.length).toBeGreaterThan(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('功法 id 全局唯一', () => {
|
||||
expect(new Set(TECHNIQUES.map((t) => t.id)).size).toBe(TECHNIQUES.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('建筑表逐行矩阵', () => {
|
||||
it.each(Object.entries(BUILDINGS).map(([id, v]) => [id, v.maxLevel, v.kind] as const))(
|
||||
'建筑 %s 等级/类型合法',
|
||||
(id, max, kind) => {
|
||||
expect(id.length).toBeGreaterThan(1)
|
||||
expect(max).toBeGreaterThanOrEqual(3)
|
||||
expect(['produce', 'function']).toContain(kind)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(Object.entries(BUILDINGS).map(([id, v]) => [id, v.upgradeCost(1), v.upgradeCost(v.maxLevel)] as const))(
|
||||
'建筑 %s 升级成本随级递增',
|
||||
(_id, c1, cMax) => {
|
||||
expect(cMax.stones).toBeGreaterThan(c1.stones)
|
||||
expect(cMax.lingkuang).toBeGreaterThan(c1.lingkuang)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('秘境表逐行矩阵', () => {
|
||||
it.each(MISSIONS.map((m) => [m.id, m.name] as const))('秘境 %s 存在启程摘要', (id, name) => {
|
||||
expect(id.startsWith('m-')).toBe(true)
|
||||
expect(name.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it.each(
|
||||
MISSIONS.flatMap((m) =>
|
||||
m.stages.map(
|
||||
(st, i) => [m.id, i, st.kind, st.months] as const
|
||||
)
|
||||
)
|
||||
)('秘境 %s 第%i 阶段类型/月数合法', (_id, _i, kind, months) => {
|
||||
expect(['event', 'combat', 'resource', 'boss']).toContain(kind)
|
||||
expect(months).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(ENEMIES.map((e) => [e.id, e.realm] as const))('敌人 %s 境界关联存在', (id, realm) => {
|
||||
expect(id.startsWith('e-')).toBe(true)
|
||||
expect(MAJOR_ORDER).toContain(realm)
|
||||
})
|
||||
})
|
||||
|
||||
describe('势力表逐行矩阵', () => {
|
||||
it.each(NPCS.map((n) => [n.id, n.name] as const))('势力 %s 命名与风格', (id, name) => {
|
||||
expect(id.startsWith('n-')).toBe(true)
|
||||
expect(name).toContain('氏')
|
||||
})
|
||||
|
||||
it.each(NPCS.map((n) => [n.id, n.initialPower] as const))('势力 %s 初始战力为正', (_id, p) => {
|
||||
expect(p).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(NPCS.map((n) => [n.id, n.sells ?? [], n.buys ?? []] as const))('势力 %s 交易品类引用合法', (_id, sells, buys) => {
|
||||
for (const s of sells) {
|
||||
expect(ITEMS[s] ?? TECHNIQUES.find((t) => t.id === s)).toBeTruthy()
|
||||
}
|
||||
void buys
|
||||
})
|
||||
})
|
||||
|
||||
describe('事件表逐选项矩阵', () => {
|
||||
it.each(EVENTS.flatMap((e) => e.options.map((o, idx) => [e.id, idx, o.label.length] as const)))(
|
||||
'事件 %s 选项%i 有文案',
|
||||
(_id, _idx, len) => {
|
||||
expect(len).toBeGreaterThan(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(EVENTS.map((e) => [e.id, e.weight] as const))('事件 %s 权重为正', (_id, w) => {
|
||||
expect(w).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(EVENTS.filter((e) => e.cond?.minBuilding).map((e) => [e.id, e.cond!.minBuilding!.id] as const))(
|
||||
'事件 %s 建筑条件引用存在',
|
||||
(_id, b) => {
|
||||
expect(BUILDINGS[b]).toBeTruthy()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('职事/秉性/灵根/境界矩阵', () => {
|
||||
it.each(Object.values(POSTS).map((p) => [p.id, p.name, p.max] as const))('职事 %s %s 上限合法', (id, name, max) => {
|
||||
expect(id.length).toBeGreaterThan(0)
|
||||
expect(name.length).toBeGreaterThan(0)
|
||||
expect(max).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it.each(Object.entries(TRAITS).map(([id, t]) => [id, t.danger] as const))('秉性 %s 危险度在界', (_id, d) => {
|
||||
expect(d).toBeGreaterThanOrEqual(0)
|
||||
expect(d).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it.each(Object.entries(ROOT_GRADES).map(([k, v]) => [Number(k), v.expBonus] as const))('灵根品阶%i 加成递增', (grade, exp) => {
|
||||
expect(exp).toBeGreaterThan(0)
|
||||
if (grade > 0) {
|
||||
expect(exp).toBeGreaterThan(ROOT_GRADES[grade - 1].expBonus)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(MAJOR_ORDER.map((m) => [m, MAJORS[m].lifespan] as const))('大境界 %s 寿元为正', (_m, l) => {
|
||||
expect(l).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('时节数学矩阵', () => {
|
||||
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((m) => [m, seasonOf(m)] as const))('%i月 归季 %s', (m, s) => {
|
||||
const mod = SEASON[s]
|
||||
expect(mod.name.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World, normalizeGameState } from '../src/renderer/game/engine/world'
|
||||
import { EVENTS, EffectDef } from '../src/renderer/game/data/events'
|
||||
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
|
||||
import { POSTS } from '../src/renderer/game/data/posts'
|
||||
import { ASPIRATIONS } from '../src/renderer/game/data/aspirations'
|
||||
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('事件选项应用矩阵(逐事件逐选项)', () => {
|
||||
it.each(EVENTS.flatMap((e) => e.options.map((o, idx) => [e.id, idx, o.label] as const)))(
|
||||
'事件 %s 选%i(%s)应用后世界不破',
|
||||
(id, idx) => {
|
||||
const w = baseWorld('ev-app-' + id)
|
||||
applyEventChoice(w, id, idx)
|
||||
expect(w.state.family.stones).toBeGreaterThanOrEqual(0)
|
||||
for (const inv of Object.values(w.state.family.inventory)) {
|
||||
if (typeof inv === 'number') expect(inv).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('effect 结构合法矩阵', () => {
|
||||
it.each(EVENTS.flatMap((e) => e.options.map((o) => [e.id, o.eff] as const)))('事件 %s 效果可回放', (id, eff) => {
|
||||
expect(typeof eff).toBe('object')
|
||||
expect(eff).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each(EVENTS.flatMap((e) => e.options.map((o, i) => [e.id, i, o.eff.res] as const)).filter(([, , r]) => !!r))(
|
||||
'事件 %s 资源效果引用存在',
|
||||
(_id, _i, res) => {
|
||||
for (const [k] of Object.entries(res!)) {
|
||||
expect(k === 'stones' || ['lingcao', 'lingkuang', 'beastcore'].includes(k) || k.startsWith('pill-') || k.startsWith('weapon-')).toBe(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('职事能力矩阵', () => {
|
||||
// head 由继承链独占(assignPost 拒绝),跳过
|
||||
const assignable = Object.entries(POSTS).filter(([id]) => id !== 'head')
|
||||
it.each(assignable.map(([id, def]) => [id, def.max] as const))('职事 %s 上限可重复指派', (id, max) => {
|
||||
const w = baseWorld('post-' + id)
|
||||
let assigned = 0
|
||||
for (const c of Object.values(w.state.members)) {
|
||||
if (assigned >= max) break
|
||||
if (w.assignPost(c.id, id)) assigned++
|
||||
}
|
||||
expect(assigned).toBe(max)
|
||||
})
|
||||
|
||||
it.each(assignable.map(([id]) => [id] as const))('职事 %s 不可指派寄读者', (id) => {
|
||||
const w = baseWorld('post-ap-' + id)
|
||||
const c = w.state.members['x3']
|
||||
c.state = 'apprentice'
|
||||
expect(w.assignPost(c.id, id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('志向矩阵', () => {
|
||||
it.each(Object.keys(ASPIRATIONS).map((id) => [id] as const))('志向 %s 生效于世界(无异常)', (id) => {
|
||||
const w = baseWorld('asp-' + id)
|
||||
for (const c of Object.values(w.state.members)) c.aspiration = id
|
||||
w.advanceMonth()
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeGameState 全残缺矩阵', () => {
|
||||
it.each([
|
||||
['finance', 'yearStats', 'yearlyReports', 'stats'].map((k) => [k] as const)
|
||||
].flat())('%s 缺失补齐', (key) => {
|
||||
const w = baseWorld('norm-' + key)
|
||||
const state = JSON.parse(JSON.stringify(w.state))
|
||||
delete (state as Record<string, unknown>)[key]
|
||||
const fixed = normalizeGameState(state)
|
||||
expect((fixed as Record<string, unknown>)[key]).toBeTruthy()
|
||||
})
|
||||
|
||||
it('无 headId 老档补齐首个存活着', () => {
|
||||
const w = baseWorld('norm-head')
|
||||
const state = JSON.parse(JSON.stringify(w.state))
|
||||
state.family.headId = 'x-not-exist'
|
||||
const fixed = normalizeGameState(state)
|
||||
expect(fixed.family.headId).toBe('x1')
|
||||
})
|
||||
|
||||
it('缺 npcFamilies 补齐空表(不崩外交)', () => {
|
||||
const w = baseWorld('norm-npc')
|
||||
const state = JSON.parse(JSON.stringify(w.state))
|
||||
delete state.npcFamilies
|
||||
const fixed = normalizeGameState(state)
|
||||
const w2 = new World(fixed)
|
||||
for (let i = 0; i < 3; i++) w2.advanceMonth() // 不抛
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('大比寄读渡劫动态事件矩阵', () => {
|
||||
it.each([
|
||||
['ev-tournament-10', '称病不出'],
|
||||
['ev-recruit', '婉拒'],
|
||||
['ev-centennial', '阖家简庆']
|
||||
] as const)('%s 选「%s」无副作用', (id, _label) => {
|
||||
const w = baseWorld('dyn-' + id.slice(0, 6))
|
||||
const optIdx = 0 // 第一选项最简
|
||||
applyEventChoice(w, id, optIdx)
|
||||
expect(w.state.pendingEvent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Rng, seedToRng, RngHub } from '../src/renderer/game/core/rng'
|
||||
|
||||
describe('RNG 多种子矩阵', () => {
|
||||
it.each([
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'
|
||||
].map((s) => [s] as const))('种子 %s 前 5 值互异不重复零帧', (seed) => {
|
||||
const rng = new Rng(seedToRng(seed))
|
||||
const vals = [rng.next(), rng.next(), rng.next(), rng.next(), rng.next()]
|
||||
expect(new Set(vals.map((v) => v.toFixed(6))).size).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it.each([10, 50, 100, 500, 1000].map((n) => [n] as const))('输出序列 %i 个全在 [0,1)', (n) => {
|
||||
const rng = new Rng(seedToRng('range-' + n))
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = rng.next()
|
||||
expect(v).toBeGreaterThanOrEqual(0)
|
||||
expect(v).toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([3, 7, 20, 99, 1000].map((n) => [n] as const))('int(0,%i) 端点可达', (n) => {
|
||||
const rng = new Rng(seedToRng('int-' + n))
|
||||
let lo = Number.MAX_SAFE_INTEGER
|
||||
let hi = -1
|
||||
for (let i = 0; i < n * 40; i++) {
|
||||
const v = rng.int(0, n - 1)
|
||||
lo = Math.min(lo, v)
|
||||
hi = Math.max(hi, v)
|
||||
}
|
||||
expect(lo).toBe(0)
|
||||
expect(hi).toBe(n - 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RngHub 矩阵', () => {
|
||||
it.each([1, 2, 3].map((n) => [n] as const))('rollSeed 第%i 次格式稳定', () => {
|
||||
expect(RngHub.rollSeed().startsWith('seed-')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([5, 20, 50].map((n) => [n] as const))('audioNoise01 %i 次均为 [0,1)', (n) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = RngHub.audioNoise01()
|
||||
expect(v).toBeGreaterThanOrEqual(0)
|
||||
expect(v).toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('音频流可回放(同序)', () => {
|
||||
const a = RngHub.audioNoise01()
|
||||
const b = RngHub.audioNoise01()
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RigRng state 拷贝矩阵', () => {
|
||||
it.each(['s1', 's2', 's3'].map((s) => [s] as const))('种子 %s 状态快照后继续一致', (s) => {
|
||||
const rng = new Rng(seedToRng(s))
|
||||
for (let i = 0; i < 10; i++) rng.next()
|
||||
const snap = rng.getState()
|
||||
const con = new Rng(snap)
|
||||
const r2 = new Rng(seedToRng(s))
|
||||
for (let i = 0; i < 10; i++) r2.next()
|
||||
expect(con.next()).toBe(r2.next())
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick/shuffle 矩阵', () => {
|
||||
it.each([['a'], ['a', 'b'], ['a', 'b', 'c'], Array.from({ length: 7 }, (_, i) => 'e' + i)])(
|
||||
'pick 从 %j 不丢元素',
|
||||
(arr) => {
|
||||
const rng = new Rng(seedToRng('pick-' + arr.length))
|
||||
for (let i = 0; i < 30; i++) {
|
||||
expect(arr).toContain(rng.pick(arr))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each([1, 2, 5, 12].map((n) => [n] as const))('shuffle %i 保集不变', (n) => {
|
||||
const rng = new Rng(seedToRng('sh-' + n))
|
||||
const src = Array.from({ length: n }, (_, i) => i)
|
||||
const out = rng.shuffle(src)
|
||||
expect(out.length).toBe(n)
|
||||
expect([...out].sort((a, b) => a - b)).toEqual(src)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { stateFingerprint, longRun } from './fingerprint.helper'
|
||||
import { computeGenealogy } from '../src/renderer/game/core/genealogy'
|
||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||
|
||||
describe('多 seed 长跑矩阵', () => {
|
||||
it.each([1, 2, 3, 4, 5].map((n) => [`seed-matrix-${n}`] as const))('%s 240 月可存活推进', (seed) => {
|
||||
const w = World.create({ seed, surname: '官', familyName: '官家', motto: 'm', difficulty: 'normal' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
for (let i = 0; i < 240; i++) {
|
||||
if (w.state.gameOver) break
|
||||
w.advanceMonth()
|
||||
}
|
||||
expect(w.state.year).toBeGreaterThan(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('各代际矩阵', () => {
|
||||
it.each([1, 10, 30, 50, 100].map((g) => [g] as const))('第%i 代后谱系与成员状态合法', (g) => {
|
||||
const w = World.create({ seed: 'gen-' + g, surname: '关', familyName: '关家', motto: 'm', difficulty: 'normal' })
|
||||
for (const c of Object.values(w.state.members)) c.generation = g
|
||||
w.state.family.generation = g
|
||||
const rows = computeGenealogy(w)
|
||||
expect(rows.every((r: { gen: number }) => r.gen >= 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('继承链矩阵(多路径)', () => {
|
||||
it('出生顺序长子继位', () => {
|
||||
const w = World.create({ seed: 'heir-a', surname: '韩', familyName: '韩家', motto: 'm', difficulty: 'normal' })
|
||||
w.state.members['x1'].alive = false
|
||||
w.advanceMonth()
|
||||
expect(w.state.family.headId).toBe('x3') // first-born
|
||||
})
|
||||
|
||||
it('同代男子无后时取高境界', () => {
|
||||
const w = World.create({ seed: 'heir-b', surname: '韩', familyName: '韩家', motto: 'm', difficulty: 'normal' })
|
||||
w.state.members['x1'].alive = false
|
||||
w.state.members['x3'].alive = false
|
||||
w.state.members['x5'].alive = false
|
||||
w.state.members['x4'].realm = { major: 'foundation', minor: 0 }
|
||||
w.state.members['x2'].realm = { major: 'qi', minor: 2 }
|
||||
w.advanceMonth()
|
||||
expect(w.state.family.headId).toBe('x4') // 高境界优先
|
||||
})
|
||||
})
|
||||
|
||||
describe('gameOver 路径守卫', () => {
|
||||
it.each([
|
||||
['全员死亡', (w: World) => Object.values(w.state.members).forEach((c) => (c.alive = false))],
|
||||
['家主死亡且无后人', (w: World) => {
|
||||
w.state.members['x1'].alive = false
|
||||
w.state.members['x3'].alive = false
|
||||
}]
|
||||
] as const)('%s → 世界终止标记', (_name, fn) => {
|
||||
const w = World.create({ seed: 'go-' + _name.length, surname: '欧阳', familyName: '欧阳家', motto: 'm', difficulty: 'normal' })
|
||||
fn(w)
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
if (_name.startsWith('全员')) {
|
||||
// 全员死亡:无活人即 gameover
|
||||
w.advanceMonth()
|
||||
expect(w.state.gameOver).toBeTruthy()
|
||||
} else {
|
||||
// 有活人(x2 寡)则继承;x2 就是继承人
|
||||
w.advanceMonth()
|
||||
expect(w.state.gameOver || w.state.family.headId).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('系统级固定周期事件', () => {
|
||||
it('每年必有岁簿(paper 触发统计)', () => {
|
||||
const w = World.create({ seed: 'paper-a', surname: '吴', familyName: '吴家', motto: 'm', difficulty: 'normal' })
|
||||
const yearsSeen: number[] = []
|
||||
w.out.push({
|
||||
onLog: () => undefined,
|
||||
onChronicle: () => undefined,
|
||||
onBattle: () => undefined,
|
||||
onPendingEvent: () => undefined,
|
||||
onGameOver: () => undefined,
|
||||
onYearPaper: (r) => yearsSeen.push(r.year)
|
||||
})
|
||||
for (let i = 0; i < 26; i++) w.advanceMonth()
|
||||
expect(yearsSeen).toContain(1)
|
||||
expect(yearsSeen).toContain(2)
|
||||
})
|
||||
|
||||
it('每五年大比征召(第10/15/20年)', () => {
|
||||
const w = World.create({ seed: 'tourn-b', surname: '邵', familyName: '邵家', motto: 'm', difficulty: 'normal' })
|
||||
const seen: string[] = []
|
||||
w.out.push({
|
||||
onLog: () => undefined,
|
||||
onChronicle: () => undefined,
|
||||
onBattle: () => undefined,
|
||||
onPendingEvent: (id) => { if (id.startsWith('ev-tournament-')) seen.push(id) },
|
||||
onGameOver: () => undefined
|
||||
})
|
||||
for (let i = 0; i < 20 * 12; i++) {
|
||||
w.advanceMonth()
|
||||
if (w.state.pendingEvent) w.state.pendingEvent = undefined
|
||||
}
|
||||
expect(seen).toContain('ev-tournament-10')
|
||||
expect(seen).toContain('ev-tournament-15')
|
||||
expect(seen).toContain('ev-tournament-20')
|
||||
})
|
||||
|
||||
it('偶数年四邻回声整年单发', () => {
|
||||
const w = World.create({ seed: 'echo-b', surname: '邱', familyName: '邱家', motto: 'm', difficulty: 'normal' })
|
||||
const sounds: number[] = []
|
||||
w.out.push({
|
||||
onLog: () => undefined,
|
||||
onChronicle: () => undefined,
|
||||
onBattle: () => undefined,
|
||||
onPendingEvent: (id) => { if (id.startsWith('ev-echo-')) sounds.push(Date.now() % 1000) },
|
||||
onGameOver: () => undefined
|
||||
})
|
||||
for (const y of [12, 14, 16]) {
|
||||
w.state.year = y
|
||||
w.state.month = 1
|
||||
w.state.family.flag = {}
|
||||
w.state.pendingEvent = undefined
|
||||
// 直接调用 eventRoll 检验年内单发
|
||||
w.advanceMonth()
|
||||
if (w.state.pendingEvent) {
|
||||
sounds.push(1)
|
||||
w.state.pendingEvent = undefined
|
||||
}
|
||||
w.advanceMonth()
|
||||
if (w.state.pendingEvent) sounds.push(2)
|
||||
}
|
||||
// 每偶数年均至少一打一(flag 封缄)
|
||||
expect(sounds.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('确定性复跑矩阵', () => {
|
||||
it.each([1, 2, 3].map((n) => [`det-replay-${n}`] as const))('%s 两次 120 月轨迹一致', (seed) => {
|
||||
const a = World.create({ seed, surname: '洪', familyName: '洪家', motto: 'm', difficulty: 'normal' })
|
||||
const b = World.create({ seed, surname: '洪', familyName: '洪家', motto: 'm', difficulty: 'normal' })
|
||||
resetSaveBus()
|
||||
attachLogSink(a)
|
||||
resetSaveBus()
|
||||
attachLogSink(b)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
a.advanceMonth()
|
||||
b.advanceMonth()
|
||||
}
|
||||
a.state.pendingEvent = undefined
|
||||
b.state.pendingEvent = undefined
|
||||
const fa = JSON.parse(JSON.stringify({ ...a.state, pendingEvent: undefined }))
|
||||
const fb = JSON.parse(JSON.stringify({ ...b.state, pendingEvent: undefined }))
|
||||
expect(JSON.stringify(fa)).toBe(JSON.stringify(fb))
|
||||
})
|
||||
})
|
||||
|
||||
describe('fingerprint 跨版本稳定性锚点', () => {
|
||||
it('保存后重放同世界(存档点回复)', () => {
|
||||
const w = longRun('det-anchor')
|
||||
expect(stateFingerprint(w.state)).toBe(stateFingerprint(w.state))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user