diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts index 020b730..4127fdf 100644 --- a/src/renderer/game/engine/world.ts +++ b/src/renderer/game/engine/world.ts @@ -294,6 +294,21 @@ export class World { const stonesEnd = s.family.stones s.finance.accum += stonesEnd - stonesStart this.trackStats() + this.clampState() + } + + /** 月底统一数值钳制:修为/气血/库存/金钱永不越界 */ + private clampState(): void { + for (const c of Object.values(this.state.members)) { + if (c.realmProgress < 0) c.realmProgress = 0 + if (c.realmProgress > 100) c.realmProgress = 100 + if (c.health < 0) c.health = 0 + if (c.health > 100) c.health = 100 + } + if (this.state.family.stones < 0) this.state.family.stones = 0 + for (const [k, v] of Object.entries(this.state.family.inventory)) { + if (typeof v === 'number' && v < 0) this.state.family.inventory[k] = 0 + } } private trackStats(): void { diff --git a/tests/api-matrix.test.ts b/tests/api-matrix.test.ts new file mode 100644 index 0000000..5186808 --- /dev/null +++ b/tests/api-matrix.test.ts @@ -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]> = [ + ['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 + } + ) +}) diff --git a/tests/balance-edge.test.ts b/tests/balance-edge.test.ts new file mode 100644 index 0000000..d1ed960 --- /dev/null +++ b/tests/balance-edge.test.ts @@ -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 { + 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)[k]).toBeGreaterThan((d0 as never as Record)[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) + }) +}) diff --git a/tests/data-matrix.test.ts b/tests/data-matrix.test.ts new file mode 100644 index 0000000..714d784 --- /dev/null +++ b/tests/data-matrix.test.ts @@ -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) + }) +}) diff --git a/tests/event-state-matrix.test.ts b/tests/event-state-matrix.test.ts new file mode 100644 index 0000000..bdb8b2d --- /dev/null +++ b/tests/event-state-matrix.test.ts @@ -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)[key] + const fixed = normalizeGameState(state) + expect((fixed as Record)[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() + }) +}) diff --git a/tests/rng-matrix.test.ts b/tests/rng-matrix.test.ts new file mode 100644 index 0000000..f3c2fde --- /dev/null +++ b/tests/rng-matrix.test.ts @@ -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) + }) +}) diff --git a/tests/world-matrix.test.ts b/tests/world-matrix.test.ts new file mode 100644 index 0000000..bdcec70 --- /dev/null +++ b/tests/world-matrix.test.ts @@ -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)) + }) +})