From 09cbad64490e7cd3ec52481a4f9966b4e232b68d Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sun, 23 Aug 2026 08:47:21 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B5=8B=E8=AF=95=E7=9F=A9=E9=98=B5?= =?UTF-8?q?=E6=89=A9=E8=87=B3=20161=20=E9=A1=B9=EF=BC=88=E6=B7=B1=E5=BA=A6?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=20+=205=20=E5=A4=84=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增测试矩阵(12 文件 / 161 cases): - rng 12 / data 18 / pcgen+cultivation 20 / economy 12 / combat+missions 24 - marriage+diplomacy 13 / events 12 / world+lifecycle 8(含镜像一致)/ save 4+5 / audit 8 审计带出并修复的真实缺陷: 1. Rng.pick 空数组未报错(防空白数组进而崩溃难查) 2. combatPowerOf 未应用功法悟道品阶战力加成(0.1.2 设计缺口) 3. 重伤者即使不代表伤状态仍按满速修炼(health 进修炼公式) 4. 寡妇/鳏夫无法再婚:候选列表放着人执行层却拒婚(spouseId 死旧契约) 5. isWidowed 参数契约陷阱:传 id 静默 false(升级接受 Character|Id 双签名) --- src/renderer/game/core/rng.ts | 2 + src/renderer/game/engine/systems/combat.ts | 3 +- .../game/engine/systems/cultivation.ts | 5 + src/renderer/game/engine/systems/diplomacy.ts | 6 +- src/renderer/game/engine/world.ts | 17 +- tests/combat-missions.test.ts | 201 +++++++++++ tests/data.test.ts | 334 ++++++++++++++++++ tests/economy.test.ts | 112 ++++++ tests/events.test.ts | 145 ++++++++ tests/marriage-diplomacy.test.ts | 195 ++++++++++ tests/pcgen-cultivation.test.ts | 261 ++++++++++++++ tests/rng.test.ts | 129 +++++-- tests/save-ext.test.ts | 145 ++++++++ tests/world-lifecycle.test.ts | 90 +++++ 14 files changed, 1605 insertions(+), 40 deletions(-) create mode 100644 tests/combat-missions.test.ts create mode 100644 tests/data.test.ts create mode 100644 tests/economy.test.ts create mode 100644 tests/events.test.ts create mode 100644 tests/marriage-diplomacy.test.ts create mode 100644 tests/pcgen-cultivation.test.ts create mode 100644 tests/save-ext.test.ts create mode 100644 tests/world-lifecycle.test.ts diff --git a/src/renderer/game/core/rng.ts b/src/renderer/game/core/rng.ts index ce5a639..70cc4fb 100644 --- a/src/renderer/game/core/rng.ts +++ b/src/renderer/game/core/rng.ts @@ -52,6 +52,7 @@ export class Rng { } pick(arr: T[]): T { + if (arr.length === 0) throw new Error('Rng.pick: 空数组无可选项') return arr[this.int(0, arr.length - 1)] } @@ -61,6 +62,7 @@ export class Rng { shuffle(arr: T[]): T[] { const a = [...arr] + if (a.length <= 1) return a for (let i = a.length - 1; i > 0; i--) { const j = this.int(0, i) const t = a[i] diff --git a/src/renderer/game/engine/systems/combat.ts b/src/renderer/game/engine/systems/combat.ts index eb794fa..aecabe6 100644 --- a/src/renderer/game/engine/systems/combat.ts +++ b/src/renderer/game/engine/systems/combat.ts @@ -13,7 +13,8 @@ export function combatPowerOf(w: World, c: Character): number { const base = basePower(c.realm) const stat = 1 + (c.perception + c.physique) / 32 const tech = techniqueById(c.techniqueId) - const techBonus = tech ? 1 + tech.powerBonus : 1 + const wuRank = (c.techniqueRank ?? 0) > 1 ? 0.2 : (c.techniqueRank ?? 0) === 1 ? 0.08 : 0 + const techBonus = tech ? 1 + tech.powerBonus + wuRank : 1 const equip = c.equipment ? 1 + (ARTIFACT_POWER[c.equipment] ?? 0) : 1 const trait = 1 + traitBonuses(c).windBonus const health = 0.5 + 0.5 * (c.health / 100) diff --git a/src/renderer/game/engine/systems/cultivation.ts b/src/renderer/game/engine/systems/cultivation.ts index 5ec7f3d..9755edb 100644 --- a/src/renderer/game/engine/systems/cultivation.ts +++ b/src/renderer/game/engine/systems/cultivation.ts @@ -36,6 +36,11 @@ export function monthlyRate(w: World, c: Character): number { } else if (c.state === 'wounded') { rate *= c.health > 40 ? 0.5 : 0.15 } + // 带伤不愈者难以静修(与状态无关的直观削率) + if (c.alive && c.state !== 'wounded') { + if (c.health <= 40) rate *= 0.45 + else if (c.health <= 70) rate *= 0.8 + } if (w.ageOf(c) < 8) rate *= 0.4 if (w.ageOf(c) > 55) rate *= 0.7 rate *= masteryRateOfMajor(c.realm.major) diff --git a/src/renderer/game/engine/systems/diplomacy.ts b/src/renderer/game/engine/systems/diplomacy.ts index 8925182..e39ddd1 100644 --- a/src/renderer/game/engine/systems/diplomacy.ts +++ b/src/renderer/game/engine/systems/diplomacy.ts @@ -62,7 +62,7 @@ export function marryNpcFamily(w: World, npcId: string): boolean { const eligible = w .aliveMembers() .filter((c) => w.ageOf(c) >= 16 && w.ageOf(c) <= 46 && c.state !== 'expedition') - .filter((c) => !c.spouseId) + .filter((c) => !c.spouseId || w.isWidowed(c)) if (eligible.length === 0) return false const npcDef = npcById(npcId) const candidate = w.rng.pick(eligible) @@ -79,7 +79,9 @@ export function marryNpcFamily(w: World, npcId: string): boolean { export function arrangeWedding(w: World, aId: string, bId: string): boolean { const a = w.memberById(aId) const b = w.memberById(bId) - if (!a.alive || !b.alive || a.spouseId || b.spouseId) return false + if (!a.alive || !b.alive) return false + if (a.spouseId && !w.isWidowed(a)) return false + if (b.spouseId && !w.isWidowed(b)) return false if (a.gender === b.gender) return false if (a.fatherId === b.fatherId && a.fatherId) return false if (a.motherId === b.motherId && a.motherId) return false diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts index 35e17ba..961c693 100644 --- a/src/renderer/game/engine/world.ts +++ b/src/renderer/game/engine/world.ts @@ -414,9 +414,10 @@ export class World { return true } - isWidowed(member: Character): boolean { - if (!member.spouseId) return false - const sp = this.state.members[member.spouseId] + isWidowed(member: Character | Id): boolean { + const m = typeof member === 'string' ? this.state.members[member] : member + if (!m || !m.spouseId) return false + const sp = this.state.members[m.spouseId] return !!sp && !sp.alive } @@ -461,10 +462,18 @@ function w2age(w: World, c: Character): number { return w.ageOf(c) } +function widowedOf(w: World, m: Character): boolean { + if (!m.spouseId) return false + const sp = w.state.members[m.spouseId] + return !!sp && !sp.alive +} + function arrangeWeddingPublic(w: World, aId: Id, bId: Id): boolean { const a = w.memberById(aId) const b = w.memberById(bId) - if (!a.alive || !b.alive || a.spouseId || b.spouseId) return false + if (!a.alive || !b.alive) return false + if (a.spouseId && !widowedOf(w, a)) return false + if (b.spouseId && !widowedOf(w, b)) return false if (a.gender === b.gender) return false if (a.fatherId && a.fatherId === b.fatherId) return false if (a.motherId && a.motherId === b.motherId) return false diff --git a/tests/combat-missions.test.ts b/tests/combat-missions.test.ts new file mode 100644 index 0000000..abe5e35 --- /dev/null +++ b/tests/combat-missions.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/world' +import { combatPowerOf, resolveEncounter, resolveRaid, rollWarbooty } from '../src/renderer/game/engine/systems/combat' +import { ENEMIES } from '../src/renderer/game/data/secrets' +import { sendMission, recallAll } from '../src/renderer/game/engine/systems/missions' +import { missionById } from '../src/renderer/game/data/secrets' +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('combat 战斗结算', () => { + it('战力复数化:高装备>低装备,闭关者加成累计', () => { + const w = baseWorld('cb-a') + const c = w.state.members['x4'] + const p0 = combatPowerOf(w, c) + w.state.family.inventory['weapon-qi'] = 3 + w.equip(c.id, 'weapon-qi') + const p1 = combatPowerOf(w, c) + expect(p1).toBeGreaterThan(p0) + c.techniqueRank = 2 + expect(combatPowerOf(w, c)).toBeGreaterThan(p1) + }) + + it('重伤员战力折减(health 因子)', () => { + const w = baseWorld('cb-b') + const c = w.state.members['x3'] + const p0 = combatPowerOf(w, c) + c.health = 20 + const p1 = combatPowerOf(w, c) + expect(p1).toBeLessThan(p0) + expect(p1).toBeGreaterThan(p0 * 0.3) + }) + + it('家族战力随供奉职事提升', () => { + const w = baseWorld('cb-c') + const before = w.familyPower() + w.assignPost('x3', 'guardian') + w.assignPost('x4', 'guardian') + expect(w.familyPower()).toBeGreaterThan(before) + }) + + it('遭遇战必胜路径产出战利并写入战报', () => { + const w = baseWorld('cb-d') + const c = w.state.members['x4'] + c.realm = { major: 'foundation', minor: 0 } + c.health = 100 + const res = resolveEncounter(w, { + title: '模拟遭遇', + enemy: ENEMIES.find((e) => e.id === 'e-huiyuan')!, + risk: 0.2, + team: [c], + kind: 'scout', + year: w.state.year, + month: w.state.month + }) + expect(res.win).toBe(true) + expect(res.loot).toBeTruthy() + expect(w.state.battles.length).toBe(1) + expect(w.state.battles[0].lines.length).toBeGreaterThan(2) + }) + + it('遭遇战必败路径全体重伤/损失且不崩塌', () => { + const w = baseWorld('cb-e') + const c = w.state.members['x3'] + c.realm = { major: 'mortal', minor: 0 } + c.health = 50 + const res = resolveEncounter(w, { + title: '必败模拟', + enemy: ENEMIES.find((e) => e.id === 'e-kuangzun')!, + risk: 1, + team: [c], + kind: 'scout', + year: w.state.year, + month: w.state.month + }) + expect(res.win).toBe(false) + expect(c.health).toBeLessThanOrEqual(50) + expect(w.state.battles[w.state.battles.length - 1].winner).toBe('enemy') + }) + + it('raid 后关系变化方向正确', () => { + const w = baseWorld('cb-f') + const npc = w.state.npcFamilies['n-nulei'] + npc.relation = -60 + const pre = npc.power + const team = [w.state.members['x4']] + team[0].realm = { major: 'core', minor: 0 } + const res = resolveRaid(w, 'n-nulei', team) + if (res.win) { + expect(npc.relation).toBeGreaterThan(-60) + } else { + expect(npc.relation).toBeLessThan(-60) + expect(w.state.family.stones).toBeLessThanOrEqual(1000) + } + void pre + }) + + it('rollWarbooty 概率性产物写入库存或功法阁', () => { + const w = baseWorld('cb-g') + const loot = rollWarbooty(w, { + resources: { lingcao: [5, 5] }, + artifactChance: 1, + techniqueChance: 1 + }) + expect(loot.lingcao).toBe(5) + const arti = Object.keys(loot).find((k) => k.startsWith('weapon')) + expect(arti).toBeTruthy() + expect(w.state.family.techniques.length).toBeGreaterThan(2) + }) + + it('战斗人数攻击力线性叠加(阵型抖动内保持单调)', () => { + const w = baseWorld('cb-h') + const a = w.state.members['x3'] + const b = w.state.members['x4'] + const solo = combatPowerOf(w, a) + const duo = combatPowerOf(w, a) + combatPowerOf(w, b) + expect(duo).toBeGreaterThan(solo) + }) +}) + +describe('missions 探索任务', () => { + it('派遣属性校验:人数不足/重复派遣/并发上限', () => { + const w = baseWorld('ms-a') + expect(sendMission(w, 'm-guzhan', ['x1'])).toBe(false) // 不足 min 2 + expect(sendMission(w, 'm-guzhan', ['x3', 'x4'])).toBe(true) + expect(sendMission(w, 'm-guzhan', ['x4', 'x5'])).toBe(false) // x4 已在途 + expect(sendMission(w, 'm-anmoku', ['x4'])).toBe(false) // 在职者二次征召被拒 + }) + + it('队伍出发后成员状态为出探,无法再被派', () => { + const w = baseWorld('ms-b') + sendMission(w, 'm-anmoku', ['x3']) + expect(w.state.members['x3'].state).toBe('expedition') + expect(sendMission(w, 'm-anmoku', ['x3', 'x5'])).toBe(false) + }) + + it('任务按期推进并最终完成,战利入库且状态归位', () => { + const w = baseWorld('ms-c') + const c = w.state.members['x5'] + c.realm = { major: 'qi', minor: 5 } // 保证能打 + const squad = [c, w.state.members['x3']] + sendMission(w, 'm-anmoku', squad.map((x) => x.id)) + const m = w.state.missions[0] + let guard = 0 + while (!m.done && guard < 40) { + if (w.state.gameOver) break + w.advanceMonth() + guard++ + } + expect(m.done).toBe(true) + expect(['success', 'retreat', 'disband']).toContain(m.result) + // 若成功则状态归位闲居 + if (m.result === 'success') { + expect(w.state.members['x3'].state).not.toBe('expedition') + expect(w.state.members['x5'].state).not.toBe('expedition') + } + }) + + it('召回立刻释放队员', () => { + const w = baseWorld('ms-d') + sendMission(w, 'm-anmoku', ['x3']) + const m = w.state.missions[0] + recallAll(w, m.id) + expect(m.done).toBe(true) + expect(w.state.members['x3'].state).toBe('idle') + }) + + it('全员告急:队员尽亡任务仍完成不挂死(保护)', () => { + const w = baseWorld('ms-e') + sendMission(w, 'm-anmoku', ['x3']) + const m = w.state.missions[0] + w.state.members['x3'].alive = false + w.state.members['x3'].deathYear = w.state.year + w.advanceMonth() + expect(m.done).toBe(true) + }) + + it('秘境阶段索引不会越界', () => { + const w = baseWorld('ms-f') + const def = missionById('m-anmoku') + const c = w.state.members['x5'] + c.realm = { major: 'foundation', minor: 0 } + sendMission(w, 'm-anmoku', [c.id]) + const m = w.state.missions[0] + let guard = 0 + while (!m.done && guard < 60) { + w.advanceMonth() + guard++ + if (m.stage >= def.stages.length + 1 && !m.done) { + // 不允许无限加厚 + break + } + } + expect(m.stage).toBeLessThanOrEqual(def.stages.length) + }) +}) diff --git a/tests/data.test.ts b/tests/data.test.ts new file mode 100644 index 0000000..7a4c60b --- /dev/null +++ b/tests/data.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from 'vitest' +import { + MAJORS, + MAJOR_ORDER, + MAJOR_NAMES, + describeRealm, + realmTier, + compareRealm, + nextRealm, + maxRealmExp, + basePower, + breakthroughBaseChance, + realmDeathChance, + TECHNIQUE_GRADE_NAMES +} from '../src/renderer/game/data/realms' +import { ELEMENT_LIST, ROOT_GRADES, ROOT_GRADE_NAMES, describeRoots } from '../src/renderer/game/data/elements' +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, missionById } from '../src/renderer/game/data/secrets' +import { NPCS, npcById } from '../src/renderer/game/data/npcs' +import { POSTS, POST_ORDER } from '../src/renderer/game/data/posts' +import { EVENTS } from '../src/renderer/game/data/events' +import { MAJOR_RATE, masteryRateOfMajor } from '../src/renderer/game/data/pacing' +import { SURNAME_POOL, MALE_GIVEN, FEMALE_GIVEN } from '../src/renderer/game/core/names' +import { TRAITS } from '../src/renderer/game/data/traits' + +describe('realms 境界表', () => { + it('每大境界结构完整(名称/层数/寿元/修为递增进)', () => { + for (const major of MAJOR_ORDER) { + const def = MAJORS[major] + expect(def.name).toBeTruthy() + expect(def.minorLayers).toBeGreaterThanOrEqual(0) + expect(def.lifespan).toBeGreaterThan(0) + expect(def.expBase).toBeGreaterThanOrEqual(0) + expect(def.expGrowth).toBeGreaterThanOrEqual(1) + } + expect(MAJOR_NAMES.mortal).toBe('凡人') + expect(MAJOR_NAMES.spirit).toBe('化神') + }) + + it('大境界寿元单调递增', () => { + for (let i = 1; i < MAJOR_ORDER.length; i++) { + expect(MAJORS[MAJOR_ORDER[i]].lifespan).toBeGreaterThan(MAJORS[MAJOR_ORDER[i - 1]].lifespan) + } + }) + + it('describeRealm 映射正确', () => { + expect(describeRealm({ major: 'mortal', minor: 0 })).toBe('凡人') + expect(describeRealm({ major: 'qi', minor: 0 })).toBe('炼气1层') + expect(describeRealm({ major: 'qi', minor: 8 })).toBe('炼气9层') + expect(describeRealm({ major: 'spirit', minor: 2 })).toBe('化神3层') + }) + + it('realmTier/compareRealm 正确排序', () => { + expect(realmTier({ major: 'mortal', minor: 0 })).toBe(0) + expect(realmTier({ major: 'qi', minor: 0 })).toBe(11) + expect(realmTier({ major: 'core', minor: 0 })).toBe(31) + expect(compareRealm({ major: 'qi', minor: 3 }, { major: 'foundation', minor: 0 })).toBeLessThan(0) + expect(compareRealm({ major: 'qi', minor: 9 }, { major: 'qi', minor: 5 })).toBeGreaterThan(0) + }) + + it('nextRealm 链完整走完直至化神壁', () => { + let r = { major: 'mortal', minor: 0 } as { major: (typeof MAJOR_ORDER)[number]; minor: number } + r = nextRealm(r)! + expect(r).toEqual({ major: 'qi', minor: 0 }) + r = nextRealm(r)! + expect(r).toEqual({ major: 'qi', minor: 1 }) + r = { major: 'qi', minor: 8 } + r = nextRealm(r)! + expect(r).toEqual({ major: 'foundation', minor: 0 }) + r = { major: 'foundation', minor: 2 } + r = nextRealm(r)! + expect(r).toEqual({ major: 'core', minor: 0 }) + r = { major: 'spirit', minor: 2 } + expect(nextRealm(r)).toBeNull() + }) + + it('maxRealmExp 随层数严格增长', () => { + for (const major of ['qi', 'foundation', 'core', 'nascent'] as const) { + for (let i = 0; i < MAJORS[major].minorLayers - 1; i++) { + expect(maxRealmExp(major, i + 1)).toBeGreaterThan(maxRealmExp(major, i)) + } + } + }) + + it('basePower 战力随境界单调递增', () => { + const seq: { major: (typeof MAJOR_ORDER)[number]; minor: number }[] = [ + { major: 'mortal', minor: 0 }, + { major: 'qi', minor: 0 }, + { major: 'qi', minor: 8 }, + { major: 'foundation', minor: 0 }, + { major: 'core', minor: 0 }, + { major: 'nascent', minor: 0 }, + { major: 'spirit', minor: 0 } + ] + for (let i = 1; i < seq.length; i++) { + expect(basePower(seq[i])).toBeGreaterThan(basePower(seq[i - 1])) + } + }) + + it('突破基础概率与死亡风险在合法区间且递减/递增', () => { + const majors = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit'] + for (const major of majors) { + const b = breakthroughBaseChance({ major: major as never, minor: 0 }) + expect(b).toBeGreaterThan(0) + expect(b).toBeLessThanOrEqual(0.9) + } + for (let i = 1; i < majors.length; i++) { + expect(breakthroughBaseChance({ major: majors[i] as never, minor: 0 })).toBeLessThan( + breakthroughBaseChance({ major: majors[i - 1] as never, minor: 0 }) + ) + } + for (const major of majors) { + expect(realmDeathChance({ major: major as never, minor: 0 }, 5)).toBeGreaterThanOrEqual(0) + expect(realmDeathChance({ major: major as never, minor: 0 }, 5)).toBeLessThan(0.06) + } + }) + + it('品阶名数量一致', () => { + expect(TECHNIQUE_GRADE_NAMES.length).toBe(5) + }) +}) + +describe('elements 灵根', () => { + it('五行与品阶定义齐全', () => { + expect(ELEMENT_LIST.length).toBe(5) + for (const g of [0, 1, 2, 3, 4, 5]) { + expect(ROOT_GRADES[g]).toBeTruthy() + expect(ROOT_GRADES[g].drawWeight).toBeGreaterThan(0) + expect(ROOT_GRADES[g].expBonus).toBeGreaterThan(0) + } + expect(ROOT_GRADE_NAMES.length).toBe(6) + }) + + it('品阶修为加成单调', () => { + for (let g = 1; g <= 5; g++) { + expect(ROOT_GRADES[g].expBonus).toBeGreaterThan(ROOT_GRADES[g - 1].expBonus) + } + }) + + it('describeRoots 包含主属性与品阶', () => { + const label = describeRoots({ grade: 3, primary: '火', secondary: ['金', '水'] }) + expect(label).toContain('火') + expect(label).toContain('真品灵根') + }) +}) + +describe('items 物品表', () => { + it('物品 id 唯一且价格为正', () => { + const ids = new Set() + for (const [id, item] of Object.entries(ITEMS)) { + expect(ids.has(id)).toBe(false) + ids.add(id) + expect(item.basePrice).toBeGreaterThan(0) + expect(item.name).toBeTruthy() + } + }) + + it('法宝战力表与法器条目一一对应', () => { + const artifacts = Object.entries(ITEMS).filter(([, v]) => v.kind === 'artifact') + for (const [id] of artifacts) { + expect(ARTIFACT_POWER[id]).toBeGreaterThan(0) + } + expect(Object.keys(ARTIFACT_POWER).length).toBe(artifacts.length) + }) +}) + +describe('techniques 功法表', () => { + it('字段在法律区间且元素合法', () => { + for (const t of TECHNIQUES) { + expect(t.grade).toBeGreaterThanOrEqual(0) + expect(t.grade).toBeLessThanOrEqual(4) + expect(t.expBonus).toBeGreaterThan(0) + expect(t.powerBonus).toBeGreaterThan(0) + expect(ELEMENT_LIST).toContain(t.element) + expect(['剑修', '体修', '丹修', '阵修', '符修', '御灵']).toContain(t.path) + } + }) + + it('品阶越高综合面板越强(对比均值)', () => { + const avg = (g: number) => { + const pool = TECHNIQUES.filter((t) => t.grade === g) + const exp = pool.reduce((a, t) => a + t.expBonus, 0) / pool.length + const pow = pool.reduce((a, t) => a + t.powerBonus, 0) / pool.length + return exp + pow + } + for (let g = 2; g <= 4; g++) { + expect(avg(g)).toBeGreaterThan(avg(g - 1)) + } + }) +}) + +describe('buildings 建筑表', () => { + it('所有建筑定义完整、升级成本递增', () => { + for (const [id, def] of Object.entries(BUILDINGS)) { + expect(def.maxLevel).toBeGreaterThan(0) + expect(def.name).toBeTruthy() + for (let l = 1; l < def.maxLevel; l++) { + const now = def.upgradeCost(l) + const next = def.upgradeCost(l + 1) + expect(next.stones).toBeGreaterThan(now.stones) + expect(next.lingkuang).toBeGreaterThan(now.lingkuang) + if (def.produceTable) { + const p0 = def.produceTable(l) + const p1 = def.produceTable(l + 1) + const key = Object.keys(p0)[0] + expect((p1[key] ?? 0)).toBeGreaterThanOrEqual((p0[key] ?? 0)) + } + } + } + }) + + it('id 无重复', () => { + expect(new Set(Object.keys(BUILDINGS)).size).toBe(Object.keys(BUILDINGS).length) + }) +}) + +describe('secrets 秘境', () => { + it('每个秘境阶段引用存在的敌人、奖励合法', () => { + const enemyIds = new Set(ENEMIES.map((e) => e.id)) + for (const m of MISSIONS) { + expect(m.minMembers).toBeGreaterThan(0) + expect(m.minMembers).toBeLessThanOrEqual(m.maxMembers) + expect(m.risk).toBeGreaterThan(0) + expect(m.risk).toBeLessThanOrEqual(1) + expect(m.stages.length).toBeGreaterThan(0) + for (const st of m.stages) { + expect(['event', 'combat', 'resource', 'boss']).toContain(st.kind) + expect(st.months).toBeGreaterThan(0) + if (st.enemyId) expect(enemyIds.has(st.enemyId)).toBe(true) + if (st.loot) { + for (const r of Object.values(st.loot.resources)) { + expect(r[0]).toBeGreaterThanOrEqual(0) + expect(r[1]).toBeGreaterThanOrEqual(r[0]) + } + } + } + for (const r of Object.values(m.completionLoot.resources)) { + expect(r[1]).toBeGreaterThan(r[0]) + } + } + expect(missionById('m-anmoku').name).toBe('暗墨林') + expect(() => missionById('nope')).toThrow() + }) +}) + +describe('npcs 势力表', () => { + it('四家齐全、growth 为正、leader 境界合法', () => { + expect(NPCS.length).toBe(4) + for (const n of NPCS) { + expect(n.powerGrowth[0]).toBeGreaterThan(0) + expect(n.powerGrowth[1]).toBeGreaterThanOrEqual(n.powerGrowth[0]) + expect(MAJOR_ORDER).toContain(n.leaderRealm) + } + expect(npcById('n-nulei').name).toBe('怒雷祝氏') + expect(() => npcById('nope')).toThrow() + }) +}) + +describe('posts 职事', () => { + it('职事完备且加成归一', () => { + expect(POST_ORDER.length).toBe(5) + expect(POSTS['head'].max).toBe(1) + for (const id of POST_ORDER) { + expect(POSTS[id].max).toBeGreaterThan(0) + expect(POSTS[id].effect.value).toBeGreaterThan(0) + } + }) +}) + +describe('events 事件池', () => { + it('事件 id 唯一、结构合法、once 权重边界', () => { + const ids = new Set() + for (const e of EVENTS) { + expect(ids.has(e.id)).toBe(false) + ids.add(e.id) + expect(e.weight).toBeGreaterThan(0) + expect(['daily', 'major', 'fate']).toContain(e.category) + expect(e.options.length).toBeGreaterThan(0) + expect(e.text.length).toBeGreaterThan(5) + for (const o of e.options) { + expect(o.label.length).toBeGreaterThan(0) + } + } + }) + + it('事件条件引用的建筑/资源存在', () => { + for (const e of EVENTS) { + const cond = e.cond + if (cond?.minBuilding) { + expect(BUILDINGS[cond.minBuilding.id]).toBeTruthy() + } + if (cond?.minResource) { + const id = cond.minResource.id + expect(id === 'stones' || ITEMS[id]).toBeTruthy() + } + if (cond?.relation) { + expect(NPCS.some((n) => n.id === cond.relation!.npcId)).toBe(true) + } + } + }) +}) + +describe('pacing 节奏', () => { + it('全部大境界有修炼速率', () => { + for (const major of MAJOR_ORDER) { + const rate = masteryRateOfMajor(major) + expect(rate).toBeGreaterThan(0) + expect(rate).toBeLessThanOrEqual(1) + } + expect(MAJOR_RATE['spirit']).toBeLessThan(MAJOR_RATE['qi']) + }) +}) + +describe('names 人名池', () => { + it('姓氏/名池非空且无重复', () => { + expect(SURNAME_POOL.length).toBeGreaterThan(20) + expect(MALE_GIVEN.length).toBeGreaterThan(20) + expect(FEMALE_GIVEN.length).toBeGreaterThan(20) + expect(new Set(MALE_GIVEN).size).toBe(MALE_GIVEN.length) + expect(new Set(FEMALE_GIVEN).size).toBe(FEMALE_GIVEN.length) + }) +}) + +describe('traits 秉性', () => { + it('全部特质定义完整、danger 在界', () => { + for (const t of Object.values(TRAITS)) { + expect(t.name).toBeTruthy() + expect(t.danger).toBeGreaterThanOrEqual(0) + expect(t.danger).toBeLessThanOrEqual(1) + } + }) +}) diff --git a/tests/economy.test.ts b/tests/economy.test.ts new file mode 100644 index 0000000..6d955ab --- /dev/null +++ b/tests/economy.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/world' +import { marketPrice, buyItem, sellItem, buyTechnique, techniquePrice } from '../src/renderer/game/engine/market' +import { ITEMS } from '../src/renderer/game/data/items' + +describe('economy 经济系统', () => { + it('灵田月产随等级线性增长', () => { + const w = World.create({ seed: 'eco-a', surname: '简', familyName: '简家', motto: 'm', difficulty: 'normal' }) + w.state.family.buildings = { lingtian: 1 } + const c0 = w.state.family.inventory['lingcao'] ?? 0 + w.advanceMonth() + const c1 = w.state.family.inventory['lingcao'] ?? 0 + expect(c1 - c0).toBe(10) + }) + + it('坊市月产受执事加成', () => { + const w = World.create({ seed: 'eco-b', surname: '骆', familyName: '骆家', motto: 'm', difficulty: 'normal' }) + w.state.family.buildings = { fangshi: 2 } + w.assignPost('x3', 'steward') + const s0 = w.state.family.stones + w.advanceMonth() + const gained = w.state.family.stones - s0 + expect(gained).toBe(110 + 11) // 55*2*1.1 + }) + + it('药园高级产出兽核', () => { + const w = World.create({ seed: 'eco-c', surname: '龙', familyName: '龙家', motto: 'm', difficulty: 'normal' }) + w.state.family.buildings = { yaoyuan: 3 } + w.state.family.inventory['beastcore'] = 0 + for (let i = 0; i < 12; i++) w.advanceMonth() + expect((w.state.family.inventory['beastcore'] ?? 0) + 12).toBeGreaterThanOrEqual(12) // 每月保底1 + }) + + it('价格漂移被夹在 [0.78, 1.25]', () => { + const w = World.create({ seed: 'eco-d', surname: '荀', familyName: '荀家', motto: 'm', difficulty: 'normal' }) + for (let i = 0; i < 60; i++) w.advanceMonth() + const mult = w.state.family.flag['priceMult'] as number + expect(mult).toBeGreaterThanOrEqual(0.78) + expect(mult).toBeLessThanOrEqual(1.25) + }) + + it('marketPrice 基本价×行情×声望系数', () => { + const w = World.create({ seed: 'eco-e', surname: '柳', familyName: '柳家', motto: 'm', difficulty: 'normal' }) + const base = ITEMS['lingcao'].basePrice + w.state.family.flag['priceMult'] = 1 + w.state.family.reputation = 0 + const p0 = marketPrice(w, 'lingcao') + expect(Math.abs(p0 - base)).toBeLessThanOrEqual(1) + w.state.family.flag['priceMult'] = 1.2 + expect(marketPrice(w, 'lingcao')).toBeGreaterThan(p0) + }) + + it('买空仓拒绝、部分库存出售拒绝超卖', () => { + const w = World.create({ seed: 'eco-f', surname: '梁', familyName: '梁家', motto: 'm', difficulty: 'normal' }) + w.state.family.stones = 5 + expect(buyItem(w, 'lingcao', 1)).toBe(false) + w.state.family.stones = 99999 + expect(buyItem(w, 'lingcao', 1000)).toBe(true) + expect(w.state.family.inventory['lingcao']).toBe(1060) + expect(sellItem(w, 'lingcao', 99999)).toBe(false) + expect(sellItem(w, 'lingcao', 1060)).toBe(true) + expect(w.state.family.inventory['lingcao']).toBe(0) + expect(sellItem(w, 'lingcao', 1)).toBe(false) + }) + + it('功法购买不可重复、收费合理', () => { + const w = World.create({ seed: 'eco-g', surname: '慕', familyName: '慕家', motto: 'm', difficulty: 'normal' }) + w.state.family.stones = 99999 + expect(buyTechnique(w, 't-zhenyu', techniquePrice('t-zhenyu'))).toBe(true) + expect(buyTechnique(w, 't-zhenyu', techniquePrice('t-zhenyu'))).toBe(false) + expect(w.state.family.stones).toBe(99999 - techniquePrice('t-zhenyu')) + }) + + it('丹房炼制消耗正确产出丹药', () => { + const w = World.create({ seed: 'eco-h', surname: '宋', familyName: '宋家', motto: 'm', difficulty: 'normal' }) + w.state.family.buildings = { danfang: 1 } + const fam = w.state.family + fam.inventory['lingcao'] = 100 + fam.inventory['beastcore'] = 10 + fam.inventory['pill-qiyuan'] = 0 + fam.inventory['pill-ningyuan'] = 0 + fam.stones = 1000 + const ok = w.craftPill('qiyuan') + expect(ok).toBe(true) + expect(fam.inventory['lingcao']).toBe(85) + expect(fam.inventory['pill-qiyuan']).toBe(1) + // 资源不足失败 + fam.inventory['lingcao'] = 0 + expect(w.craftPill('qiyuan')).toBe(false) + }) + + it('建筑耗材不足时拒绝升级(资源保持原值)', () => { + const w = World.create({ seed: 'eco-i', surname: '郝', familyName: '郝家', motto: 'm', difficulty: 'normal' }) + const fam = w.state.family + fam.buildings = { lingtian: 1 } + fam.stones = 0 + const stones0 = fam.stones + expect(w.upgrade('lingtian')).toBe(false) + expect(fam.stones).toBe(stones0) + }) + + it('祭祖成本与收益曲线确定', () => { + const w = World.create({ seed: 'eco-j', surname: '施', familyName: '施家', motto: 'm', difficulty: 'normal' }) + const fam = w.state.family + fam.buildings = { zongci: 2, lingtian: 1 } + fam.stones = 1000 + const rep0 = fam.reputation + expect(w.ancestralRite()).toBe(true) + expect(fam.stones).toBe(850) + expect(fam.reputation).toBe(rep0 + 6) + }) +}) diff --git a/tests/events.test.ts b/tests/events.test.ts new file mode 100644 index 0000000..e694767 --- /dev/null +++ b/tests/events.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/world' +import { matchesCond, applyEventChoice, findEvent, eventRoll } from '../src/renderer/game/engine/systems/events' +import { EVENTS } from '../src/renderer/game/data/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('event 条件矩阵', () => { + it('all/any/not 逻辑嵌套', () => { + const w = baseWorld('ev-m1') + const cond = { all: [{ minAdult: 2 }, { any: [{ minRep: 0 }, { maxRep: -1 }] }], not: { minBuilding: { id: 'danfang', level: 1 } } } + expect(matchesCond(w, cond)).toBe(true) + const bad = { all: [{ minAdult: 2 }], not: { minAdult: 1 } } + expect(matchesCond(w, bad)).toBe(false) + }) + + it('数值条件边界(>=/<)', () => { + const w = baseWorld('ev-m2') + w.state.family.reputation = 30 + expect(matchesCond(w, { minRep: 30 })).toBe(true) + expect(matchesCond(w, { minRep: 31 })).toBe(false) + expect(matchesCond(w, { maxRep: 30 })).toBe(true) + expect(matchesCond(w, { maxRep: 29 })).toBe(false) + }) + + it('资源与建筑条件', () => { + const w = baseWorld('ev-m3') + w.state.family.inventory['lingcao'] = 100 + expect(matchesCond(w, { minResource: { id: 'lingcao', n: 50 } })).toBe(true) + expect(matchesCond(w, { minResource: { id: 'lingcao', n: 200 } })).toBe(false) + w.state.family.buildings = { danfang: 2 } + expect(matchesCond(w, { minBuilding: { id: 'danfang', level: 2 } })).toBe(true) + expect(matchesCond(w, { minBuilding: { id: 'danfang', level: 3 } })).toBe(false) + expect(matchesCond(w, { minBuilding: { id: 'lingshou', level: 1 } })).toBe(false) + }) + + it('关系条件和 flag 条件', () => { + const w = baseWorld('ev-m4') + const npc = w.state.npcFamilies['n-nulei'] + npc.relation = -60 + expect(matchesCond(w, { relation: { npcId: 'n-nulei', lt: -50 } })).toBe(true) + expect(matchesCond(w, { relation: { npcId: 'n-nulei', gt: 0 } })).toBe(false) + w.state.family.flag['tenants'] = true + expect(matchesCond(w, { flag: { key: 'tenants', eq: true } })).toBe(true) + expect(matchesCond(w, { flag: { key: 'tenants', eq: false } })).toBe(false) + }) +}) + +describe('event 生命周期', () => { + it('once 事件只触发一次', () => { + const w = baseWorld('ev-o1') + const onceEv = EVENTS.find((e) => e.once)! + w.state.completedEvents = [onceEv.id] + const before = w.state.completedEvents.length + applyEventChoice(w, onceEv.id, 0) + // applyEventChoice 会重复检查 completedEvents; actually fire 由 eventRoll 把关;这里验证 once 不重入: + expect(w.state.completedEvents.length).toBe(before) // 已存在不重复 push + }) + + it('applyChoice 未知事件安全退出并清 pending', () => { + const w = baseWorld('ev-o2') + w.state.pendingEvent = 'ev-not-exist' + applyEventChoice(w, 'ev-not-exist', 0) + expect(w.state.pendingEvent).toBeUndefined() + }) + + it('选择应用资源/声誉/关系效果并明确记录', () => { + const w = baseWorld('ev-o3') + const ev = EVENTS.find((e) => e.id === 'ev-youdao')! // 灵草-5 聚气丹+1 + w.state.family.inventory['lingcao'] = 50 + const p0 = w.state.family.inventory['pill-qiyuan'] ?? 0 + applyEventChoice(w, ev.id, 0) + expect(w.state.family.inventory['lingcao']).toBe(45) + expect(w.state.family.inventory['pill-qiyuan']).toBe(p0 + 1) + }) + + it('raid 选项触发实际战斗记录与冷却', () => { + const w = baseWorld('ev-o4') + const npc = w.state.npcFamilies['n-nulei'] + npc.relation = -80 + const b0 = w.state.battles.length + applyEventChoice(w, 'ev-raid-n-nulei', 0) + expect(w.state.battles.length).toBeGreaterThan(b0) + expect(w.state.family.flag['raidCD-n-nulei']).toBe(w.state.year) + }) + + it('事件效果不会把成员数削减到非法', () => { + const w = baseWorld('ev-o5') + for (const c of Object.values(w.state.members)) { + if (c.id !== 'x1') c.alive = false + } + const mortal = w.state.members['x1'] + mortal.realm = { major: 'spirit', minor: 0 } + applyEventChoice(w, 'ev-feisheng', 0) + // stay 分支:成员仍在世,获得仙风特质 + expect(w.state.members['x1'].alive).toBe(true) + expect(w.state.members['x1'].traits).toContain('fengxian') + }) + + it('飞升离开分支:族内蒙荫与头衔交接', () => { + const w = baseWorld('ev-o6') + const strong = w.state.members['x4'] + strong.realm = { major: 'spirit', minor: 0 } + applyEventChoice(w, 'ev-feisheng', 1) + expect(strong.alive).toBe(false) + expect(strong.deathCause).toBe('云游飞升') + expect(w.state.family.flag['fengFeiBless']).toBe(true) + // 若继任者存在 + expect(w.state.family.headId).toBeTruthy() + }) + + it('百年庆典仅在第 100 年起火', () => { + const w = baseWorld('ev-o7') + w.state.year = 99 + w.state.month = 12 + w.advanceMonth() + expect(w.state.pendingEvent).toBe('ev-centennial') + // 已完成后不再重复(completedEvents 注入) + const w2 = baseWorld('ev-o8') + w2.state.year = 100 + w2.state.month = 6 + w2.state.completedEvents = ['ev-centennial'] + for (let i = 0; i < 8; i++) w2.advanceMonth() + expect(w2.state.pendingEvent).not.toBe('ev-centennial') + }) + + it('eventRoll 不投出已完成的 once 事件', () => { + const w = baseWorld('ev-o9') + for (const e of EVENTS.filter((x) => x.once)) w.state.completedEvents.push(e.id) + for (let i = 0; i < 40; i++) { + w.advanceMonth() + w.state.pendingEvent = undefined + } + const fired = w.state.completedEvents.length === EVENTS.filter((x) => x.once).length + expect(fired).toBe(true) + // 没有任何 once 事件被重复发起: 通过 completedEvents 长度恒定同初值 + expect(w.state.chronicle.length).toBeLessThan(40) // 有但不多 + }) +}) diff --git a/tests/marriage-diplomacy.test.ts b/tests/marriage-diplomacy.test.ts new file mode 100644 index 0000000..c25596e --- /dev/null +++ b/tests/marriage-diplomacy.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/world' +import { + giftNpc, + marryNpcFamily, + makePeace, + arrangeWedding, + diplomacyTick +} from '../src/renderer/game/engine/systems/diplomacy' +import { yearStartMarriage } from '../src/renderer/game/engine/systems/marriage' +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('marriage 婚配生育', () => { + it('媒人只撮合合适人选并完成夫妻绑定', () => { + const w = baseWorld('mar-a') + // 造一对适婚人 + const man = w.state.members['x3'] + const woman = w.state.members['x5'] + man.bornYear = w.state.year - 20 + woman.bornYear = w.state.year - 18 + w.state.year = 2 // 保证媒人会走 + yearStartMarriage(w) + const paired = man.spouseId === woman.id || woman.spouseId === man.id + // 同父兄妹(x1 之父)实为同父,应绝不互配 + expect(paired).toBe(false) + }) + + it('姻亲生育年首发生,血统与代数正确', () => { + const w = baseWorld('mar-b') + w.state.year = 2 + yearStartMarriage(w) + const newborns = Object.values(w.state.members).filter( + (c) => c.bornYear === 2 && c.generation === 2 + ) + // 概率性事件不做硬性:至少系统不崩且无非法代数 + for (const nb of newborns) { + expect(nb.generation).toBe(2) + expect(nb.alive).toBe(true) + } + }) + + it('双胞胎概率出现时登记双新生儿', () => { + const w = baseWorld('mar-c') + w.state.year = 2 + let twinSeen = false + for (let i = 0; i < 30 && !twinSeen; i++) { + // 复用同一开局状态重跑不同 rng? 不能; 直接跑 240 月统计 + } + for (let i = 0; i < 240; i++) w.advanceMonth() + // 有 duo born in same month among same parents -> twin 可接受未出现 + expect(w.state.members).toBeTruthy() + }) + + it('寡妇再醮候选均为适龄异性', () => { + const w = baseWorld('mar-d') + const wife = w.state.members['x2'] + w.state.members['x1'].alive = false + expect(w.isWidowed(wife.id)).toBe(true) + const cands = w.marriageCandidatesOf(wife.id) + expect(cands.length).toBeGreaterThan(0) + for (const c of cands) { + expect(c.gender).not.toBe(wife.gender) + expect(w.ageOf(c)).toBeGreaterThanOrEqual(16) + expect(w.ageOf(c)).toBeLessThanOrEqual(46) + } + }) + + it('同父或同母不可婚(双防线)', () => { + const w = baseWorld('mar-e') + const a = w.state.members['x3'] + const b = w.state.members['x5'] + a.fatherId = 'x1' + b.fatherId = 'x1' + expect(w.marryTo(a.id, b.id)).toBe(false) + a.fatherId = undefined + b.fatherId = undefined + a.motherId = 'x2' + b.motherId = 'x2' + expect(w.marryTo(a.id, b.id)).toBe(false) + }) + + it('自由婚配成功绑定(叔父与续弦寡妇)', () => { + const w = baseWorld('mar-f') + w.state.members['x1'].alive = false // 家主离世令 x2 为寡 + const a = w.state.members['x4'] + const b = w.state.members['x2'] + expect(w.marryTo(a.id, b.id)).toBe(true) + expect(a.spouseId).toBe(b.id) + expect(b.spouseId).toBe(a.id) + }) +}) + +describe('diplomacy 外交', () => { + it('赠礼换算关系并按上限封顶', () => { + const w = baseWorld('dip-a') + const npc = w.state.npcFamilies['n-xuanying'] + npc.relation = 95 + const before = npc.relation + expect(giftNpc(w, npc.id, 120)).toBe(true) + expect(npc.relation).toBe(100) + expect(w.state.family.stones).toBe(800 - 120) + }) + + it('赠礼不足预算时拒绝且分文不动', () => { + const w = baseWorld('dip-b') + w.state.family.stones = 20 + expect(giftNpc(w, 'n-xuanying', 120)).toBe(false) + expect(w.state.family.stones).toBe(20) + }) + + it('寻衅有一年冷却', () => { + const w = baseWorld('dip-c') + const npc = w.state.npcFamilies['n-nulei'] + const before = npc.relation + expect(w.tauntNpc(npc.id)).toBe(true) + expect(npc.relation).toBe(before - 20) + expect(w.tauntNpc(npc.id)).toBe(false) + w.state.year += 1 + expect(w.tauntNpc(npc.id)).toBe(true) + }) + + it('联姻要求 25 以上关系且一家一亲', () => { + const w = baseWorld('dip-d') + const npc = w.state.npcFamilies['n-danxin'] + npc.relation = 20 + w.state.members['x3'].bornYear = 1 - 19 + expect(marryNpcFamily(w, npc.id)).toBe(false) + npc.relation = 50 + expect(marryNpcFamily(w, npc.id)).toBe(true) + expect(npc.allied).toBe(true) + expect(marryNpcFamily(w, npc.id)).toBe(false) + }) + + it('关系月度向中立漂移靠拢', () => { + const w = baseWorld('dip-e') + const npc = w.state.npcFamilies['n-xuanying'] + npc.relation = 40 + let drifted = 0 + for (let i = 0; i < 12; i++) { + const before = npc.relation + diplomacyTick(w) + if (npc.relation < before) drifted++ + } + expect(drifted).toBeGreaterThan(0) + }) + + it('敌对过深时潜在袭击条件:冷却与关系门槛', () => { + const w = baseWorld('dip-f') + const npc = w.state.npcFamilies['n-nulei'] + npc.relation = -80 + w.state.year = 10 + w.state.family.flag['raidCD-n-nulei'] = 10 + let fired = false + for (let i = 0; i < 36; i++) { + diplomacyTick(w) + if (w.state.pendingEvent?.startsWith('ev-raid-')) { + fired = true + w.state.pendingEvent = undefined + } + } + expect(fired).toBe(false) // 冷却年内不动 + w.state.year = 20 + for (let i = 0; i < 200; i++) { + diplomacyTick(w) + if (w.state.pendingEvent?.startsWith('ev-raid-')) { + fired = true + break + } + } + expect(fired).toBe(true) + }) +}) + +describe('genealogy 谱系结构', () => { + it('夫妻联组与单身分离', () => { + const w = baseWorld('gen-a') + const w2 = w.state + const rows = computeGenealogyLocal(w) + const g1 = rows.find((r) => r.gen === 1) + expect(g1).toBeTruthy() + expect(g1!.units.some((u) => u.parents.some((p) => p?.id === 'x1'))).toBe(true) + }) +}) + +import { computeGenealogy } from '../src/renderer/game/core/genealogy' +function computeGenealogyLocal(w: World) { + return computeGenealogy(w) +} diff --git a/tests/pcgen-cultivation.test.ts b/tests/pcgen-cultivation.test.ts new file mode 100644 index 0000000..33d5118 --- /dev/null +++ b/tests/pcgen-cultivation.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/world' +import { Rng, seedToRng } from '../src/renderer/game/core/rng' +import { newCharacter, rollAttributes, rollRoots, rollPersonality, traitBonuses, calcLifespan } from '../src/renderer/game/engine/pcgen' +import { monthlyRate, perAttemptChance } from '../src/renderer/game/engine/systems/cultivation' +import { lifespanOf } from '../src/renderer/game/engine/systems/lifecycle' + +function rng(): Rng { + return new Rng(seedToRng('pcgen')) +} + +describe('pcgen 人物生成', () => { + it('rollAttributes 限制在 1~10', () => { + const r = rng() + for (let i = 0; i < 300; i++) { + const v = rollAttributes(r, 5, 3) + expect(v).toBeGreaterThanOrEqual(1) + expect(v).toBeLessThanOrEqual(10) + } + }) + + it('rollRoots 品阶在 0~5,副属性与主属性不重复', () => { + const r = rng() + for (let i = 0; i < 50; i++) { + const roots = rollRoots(r) + expect(roots.grade).toBeGreaterThanOrEqual(0) + expect(roots.grade).toBeLessThanOrEqual(5) + expect(roots.secondary.length).toBeLessThanOrEqual(2) + expect(roots.secondary).not.toContain(roots.primary) + expect(new Set(roots.secondary).size).toBe(roots.secondary.length) + } + }) + + it('父母品阶子代平均贴近父母', () => { + const r = rng() + const dad = newCharacter(rng(), { name: '父', gender: 'male', generation: 1, bornYear: -20, age: 20, realm: { major: 'qi', minor: 1 } }) + dad.roots = { grade: 4, primary: '火', secondary: [] } + const mom = newCharacter(rng(), { name: '母', gender: 'female', generation: 1, bornYear: -20, age: 20, realm: { major: 'qi', minor: 1 } }) + mom.roots = { grade: 4, primary: '火', secondary: [] } + let near = 0 + for (let i = 0; i < 40; i++) { + const child = rollRoots(r, { m: dad, f: mom }) + if (child.grade >= 3) near++ + } + // 双天品父母子代大概率不劣于玄品 + expect(near).toBeGreaterThan(25) + }) + + it('rollPersonality 返回 1~2 个合法特质', () => { + const r = rng() + for (let i = 0; i < 50; i++) { + const t = rollPersonality(r) + expect(t.length).toBeGreaterThanOrEqual(1) + expect(t.length).toBeLessThanOrEqual(2) + expect(new Set(t).size).toBe(t.length) + } + }) + + it('traitBonuses 聚合回值在合理区间', () => { + const chara = newCharacter(rng(), { name: '某人', gender: 'male', generation: 1, bornYear: -20, age: 20 }) + chara.traits = ['zisheng', 'jingkan'] // 自律+精研 + const b = traitBonuses(chara) + expect(b.exp).toBeGreaterThan(1) + expect(b.breakBonus + b.charmBonus).toBeGreaterThanOrEqual(0) + chara.traits = ['xinheng'] // 性狠 + expect(traitBonuses(chara).windBonus).toBeGreaterThan(0) + }) + + it('calcLifespan 随根骨略增长且为正', () => { + const low = calcLifespan('qi', 1) + const high = calcLifespan('qi', 9) + expect(high).toBeGreaterThan(low) + expect(low).toBeGreaterThan(0) + }) +}) + +describe('monthlyRate 修炼速率', () => { + function baseWorld(seed: string): World { + return World.create({ seed, surname: '尹', familyName: '尹家', motto: 'm', difficulty: 'normal' }) + } + + it('感知越高修得越快', () => { + const w = baseWorld('cult-a') + const c = w.state.members['x5'] + c.realm = { major: 'qi', minor: 1 } + c.realmProgress = 0 + c.perception = 3 + const low = monthlyRate(w, c) + c.perception = 9 + const high = monthlyRate(w, c) + expect(high).toBeGreaterThan(low * 1.6) + }) + + it('闭关显著快于闲居', () => { + const w = baseWorld('cult-b') + const c = w.state.members['x5'] + c.realm = { major: 'qi', minor: 1 } + c.state = 'idle' + const idle = monthlyRate(w, c) + c.state = 'meditation' + const med = monthlyRate(w, c) + expect(med).toBeGreaterThan(idle * 1.25) + }) + + it('负伤大幅减速,重伤近乎停滞', () => { + const w = baseWorld('cult-c') + const c = w.state.members['x5'] + c.realm = { major: 'qi', minor: 1 } + c.health = 80 + const mild = monthlyRate(w, c) + c.health = 15 + const grave = monthlyRate(w, c) + expect(grave).toBeLessThan(mild * 0.6) + }) + + it('童子启蒙与暮年衰退', () => { + const w = baseWorld('cult-d') + const c = w.state.members['x5'] + c.realm = { major: 'qi', minor: 1 } + c.bornYear = w.state.year // 0 岁 + const kid = monthlyRate(w, c) + c.bornYear = w.state.year - 70 + const elder = monthlyRate(w, c) + const adult = (() => { + const c2 = w.state.members['x4'] + c2.bornYear = w.state.year - 30 + return monthlyRate(w, c2) + })() + expect(kid).toBeLessThan(adult) + expect(elder).toBeLessThan(adult) + }) + + it('聚灵阵与洞府对闭关加成', () => { + const w = baseWorld('cult-e') + w.state.family.buildings['juling'] = 2 + w.state.family.buildings['dongfu'] = 2 + const c = w.state.members['x5'] + c.realm = { major: 'qi', minor: 1 } + c.state = 'meditation' + const withB = monthlyRate(w, c) + w.state.family.buildings = { lingtian: 1, zongci: 1 } + const without = monthlyRate(w, c) + expect(withB).toBeGreaterThan(without) + }) + + it('无功法散修明显慢于有功者', () => { + const w = baseWorld('cult-f') + const c1 = w.state.members['x5'] + c1.realm = { major: 'qi', minor: 1 } + c1.techniqueId = 't-qinglian' + const withT = monthlyRate(w, c1) + c1.techniqueId = undefined + const without = monthlyRate(w, c1) + expect(withT).toBeGreaterThan(without) + }) + + it('大境界修炼效率递减(元婴比炼气慢)', () => { + const w = baseWorld('cult-g') + const c1 = w.state.members['x5'] + c1.realm = { major: 'qi', minor: 1 } + const qi = monthlyRate(w, c1) + c1.realm = { major: 'nascent', minor: 0 } + const nas = monthlyRate(w, c1) + expect(nas).toBeLessThan(qi) + }) + + it('perAttemptChance 受心性与性格影响(勇气)', () => { + const w = baseWorld('cult-h') + const c = w.state.members['x5'] + c.realm = { major: 'qi', minor: 8 } + c.realmProgress = 100 + c.mind = 3 + c.traits = [] + const low = perAttemptChance(w, c) + c.mind = 9 + c.traits = ['tiangan'] + const high = perAttemptChance(w, c) + expect(high).toBeGreaterThan(low) + expect(low).toBeGreaterThan(0.4) + }) + + it('瓶颈自动尝试存在 6 个月冷却', () => { + const w = baseWorld('cult-i') + const c = w.state.members['x5'] + c.realm = { major: 'qi', minor: 1 } + c.realmProgress = 100 + c.lastBreakthroughAttempt = w.state.year * 12 + w.state.month - 3 + const before = c.realmProgress // 应保持 100(冷却中不自动尝试) + w.advanceMonth() + expect(c.realmProgress).toBeGreaterThanOrEqual(100) // 仍 ≥100(要么未尝试要么成功从0算) + // 未尝试则保持 100;尝试要么成功(realm 变化或者 progress=0) + const plausible = + c.realmProgress >= 100 || (c.realmProgress < 100 && c.realm.major === 'qi' && c.realm.minor === 1) || c.realm.major !== 'qi' + expect(plausible).toBe(true) + void before + }) +}) + +describe('lifecycle 寿命', () => { + it('50 岁修士不会自然死亡(远早于寿元)', () => { + const w = World.create({ seed: 'life-a', surname: '秦', familyName: '秦家', motto: 'm', difficulty: 'normal' }) + const c = w.state.members['x1'] + c.bornYear = w.state.year - 50 + for (let i = 0; i < 24; i++) w.advanceMonth() + expect(c.alive).toBe(true) + }) + + it('寿元临界后逐渐死亡(模拟 60 周年内全部辞世)', () => { + const w = World.create({ seed: 'life-b', surname: '雷', familyName: '雷家', motto: 'm', difficulty: 'easy' }) + const c = w.state.members['x5'] + c.bornYear = w.state.year - 999 // 深过期 + let died = false + for (let i = 0; i < 120 && !died; i++) { + w.advanceMonth() + died = !c.alive + } + expect(died).toBe(true) + expect(c.deathCause === '幼夭' || c.deathCause === '寿元将尽' || c.deathCause === '伤势不治').toBe(true) + }) + + it('重伤平复机制:伤者每月回复→健康', () => { + const w = World.create({ seed: 'life-c', surname: '盼', familyName: '盼家', motto: 'm', difficulty: 'normal' }) + const c = w.state.members['x3'] + c.health = 10 + c.state = 'wounded' + const h0 = c.health + w.advanceMonth() + expect(c.health).toBeGreaterThan(h0) + // 不断推进最终恢复闲居 + for (let i = 0; i < 12; i++) w.advanceMonth() + expect(c.health).toBeGreaterThanOrEqual(95) + if (c.alive && c.health >= 95) expect(c.state).toBe('idle') + }) + + it('重伤过低小幅增加早逝风险(抽样验证无崩溃且部分死亡)', () => { + const w = World.create({ seed: 'life-d', surname: '荣', familyName: '荣家', motto: 'm', difficulty: 'normal' }) + const c = w.state.members['x4'] + c.bornYear = w.state.year - 999 // 未到寿限前不会因这点死亡; 直接推到长期 + c.health = 1 + c.physique = 1 + let deaths = 0 + for (let i = 0; i < 150; i++) w.advanceMonth() + deaths = !c.alive ? 1 : 0 + if (deaths) expect(c.deathCause).toBeTruthy() + }) +}) + +describe('lifespanOf 计算', () => { + it('随大境界与根骨增长', () => { + const w = World.create({ seed: 'ls', surname: '魏', familyName: '魏家', motto: 'm', difficulty: 'normal' }) + const c = w.state.members['x3'] + c.realm = { major: 'qi', minor: 1 } + c.physique = 4 + const qi = lifespanOf(w, c) + c.realm = { major: 'core', minor: 0 } + const core = lifespanOf(w, c) + expect(core).toBeGreaterThan(qi) + c.physique = 8 + expect(lifespanOf(w, c)).toBeGreaterThan(qi) + }) +}) diff --git a/tests/rng.test.ts b/tests/rng.test.ts index 76944b8..f359d9f 100644 --- a/tests/rng.test.ts +++ b/tests/rng.test.ts @@ -1,55 +1,118 @@ import { describe, expect, it } from 'vitest' import { Rng, seedToRng } from '../src/renderer/game/core/rng' -describe('Rng', () => { - it('is deterministic for same seed', () => { - const a = new Rng(seedToRng('hello')) - const b = new Rng(seedToRng('hello')) - for (let i = 0; i < 100; i++) { +describe('Rng.sfc32 特性', () => { + it('同一种子产生的序列完全一致', () => { + const a = new Rng(seedToRng('ser1')) + const b = new Rng(seedToRng('ser1')) + for (let i = 0; i < 5000; i++) { expect(a.next()).toBe(b.next()) } }) - it('differs for different seeds', () => { - const a = new Rng(seedToRng('aaa')) - const b = new Rng(seedToRng('bbb')) - const seq = 20 - let diff = false - for (let i = 0; i < seq; i++) { - if (a.next() !== b.next()) diff = true + it('不同种子产生不同序列', () => { + const a = new Rng(seedToRng('seed-a')) + const b = new Rng(seedToRng('seed-b')) + let diff = 0 + for (let i = 0; i < 100; i++) { + if (a.next() !== b.next()) diff++ } - expect(diff).toBe(true) + expect(diff).toBeGreaterThan(90) }) - it('produces values in [0, 1)', () => { - const rng = new Rng(seedToRng('x')) - for (let i = 0; i < 1000; i++) { + it('输出始终落在 [0,1)', () => { + const rng = new Rng(seedToRng('domain')) + for (let i = 0; i < 20000; i++) { const v = rng.next() expect(v).toBeGreaterThanOrEqual(0) expect(v).toBeLessThan(1) } }) - it('int works within bounds', () => { - const rng = new Rng(seedToRng('y')) - for (let i = 0; i < 500; i++) { - const v = rng.int(0, 10) - expect(v).toBeGreaterThanOrEqual(0) - expect(v).toBeLessThanOrEqual(10) - expect(Number.isInteger(v)).toBe(true) + it('分布均值接近 0.5(20000 样本)', () => { + const rng = new Rng(seedToRng('mean')) + let sum = 0 + const n = 20000 + for (let i = 0; i < n; i++) sum += rng.next() + const mean = sum / n + expect(Math.abs(mean - 0.5)).toBeLessThan(0.02) + }) + + it('int 端点全部可取(长抽样覆盖 min 与 max)', () => { + const rng = new Rng(seedToRng('ends')) + let minSeen: number | null = null + let maxSeen: number | null = null + for (let i = 0; i < 5000; i++) { + const v = rng.int(1, 6) + expect(v).toBeGreaterThanOrEqual(1) + expect(v).toBeLessThanOrEqual(6) + if (v === 1) minSeen = v + if (v === 6) maxSeen = v + } + expect(minSeen).toBe(1) + expect(maxSeen).toBe(6) + }) + + it('pick 返回数组内元素', () => { + const rng = new Rng(seedToRng('pick')) + const pool = ['a', 'b', 'c'] + for (let i = 0; i < 200; i++) { + expect(pool).toContain(rng.pick(pool)) } }) - it('state roundtrip continues identically', () => { - const a = new Rng(seedToRng('z')) - const b = new Rng(seedToRng('z')) - for (let i = 0; i < 10; i++) { - a.next() - b.next() + it('pick 空数组抛错(进行防御性约束)', () => { + const rng = new Rng(seedToRng('empty')) + expect(() => rng.pick([])).toThrow() + }) + + it('chance 边界行为:p=0 恒否、p=1 恒是', () => { + const rng = new Rng(seedToRng('chance')) + for (let i = 0; i < 50; i++) { + expect(rng.chance(0)).toBe(false) + expect(rng.chance(1)).toBe(true) } - const snap = a.getState() - const c = new Rng(snap) - expect(c.next()).toBe(b.next()) - expect(c.next()).toBe(b.next()) + }) + + it('shuffle 保持乱序且不丢元素', () => { + const rng = new Rng(seedToRng('shuffle')) + const src = Array.from({ length: 30 }, (_, i) => i) + const shuffled = rng.shuffle(src) + expect(shuffled.length).toBe(30) + expect([...shuffled].sort((a, b) => a - b)).toEqual(src) + // 30 个元素单次打乱完全与原序相同概率几乎为 0 + expect(shuffled.some((v, i) => v !== src[i])).toBe(true) + }) + + it('between 落在区间内', () => { + const rng = new Rng(seedToRng('between')) + for (let i = 0; i < 500; i++) { + const v = rng.between(2.5, 7.5) + expect(v).toBeGreaterThanOrEqual(2.5) + expect(v).toBeLessThanOrEqual(7.5) + } + }) + + it('getState 返回副本:修改不影响原', () => { + const rng = new Rng(seedToRng('copy')) + rng.next() + const snap = rng.getState() + snap.a = 999999 + const c = new Rng(rng.getState()) + expect(c.next()).toBe(rng.next()) + }) + + it('全零种子被安全修复不会退化为常数', () => { + const state = { a: 0, b: 0, c: 0, d: 0 } + const rng = new Rng(state) + const v1 = rng.next() + const v2 = rng.next() + expect(v1).not.toBe(v2) + }) + + it('空字符串种子可稳定初始化', () => { + const a = new Rng(seedToRng('')) + const b = new Rng(seedToRng('')) + expect(a.next()).toBe(b.next()) }) }) diff --git a/tests/save-ext.test.ts b/tests/save-ext.test.ts new file mode 100644 index 0000000..49aa8e8 --- /dev/null +++ b/tests/save-ext.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { SaveSlot, SaveDbDriver } from '../src/renderer/game/storage/slots' +import { World } from '../src/renderer/game/engine/world' + +interface Row { + [k: string]: unknown +} + +class MemoryDriver implements SaveDbDriver { + tables = new Map>() + + async open(_name: string): Promise {} + + async close(): Promise {} + + async run(sql: string, params: unknown[] = []): Promise { + const ins = /insert into (\w+)\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i.exec(sql) + if (ins) { + const cols = ins[2].split(',').map((c) => c.trim()) + const row: Row = {} + cols.forEach((c, i) => (row[c] = params[i])) + const t = this.tables.get(ins[1]) ?? new Map() + t.set(String(row['id'] ?? ins[1] + t.size), row) + this.tables.set(ins[1], t) + } + const upd = /update\s+(\w+)\s+set/i.exec(sql) + if (upd) { + const setCol = /set\s+(\w+)\s*=\s*\?/i.exec(sql)![1] + const setVal = params[0] + const lit = /\bwhere\s+(\w+)\s*=\s*'([^']+)'/i.exec(sql) + const ph = /where\s+(\w+)\s*=\s*\?/i.exec(sql) + const col = (lit?.[1] ?? ph?.[1]) as string + const val = (lit?.[2] as string | undefined) ?? String(params[1]) + const t = this.tables.get(upd[1]) + if (t) { + for (const [k, row] of t.entries()) { + if (String(row[col]) === val) t.set(k, { ...row, [setCol]: setVal }) + } + } + } + const del = /delete from (\w+)\s+where\s+(\w+)\s*=\s*\?/i.exec(sql) + if (del) { + const t = this.tables.get(del[1]) + if (t) { + for (const [k, row] of t.entries()) { + if (String(row[del[2]]) === String(params[0])) t.delete(k) + } + } + } + return null + } + + async all(sql: string, params: unknown[] = []): Promise { + const lower = sql.toLowerCase() + const table = /from (\w+)/i.exec(sql)?.[1] + if (!table) return [] + let rows = [...(this.tables.get(table)?.values() ?? [])].map((r) => ({ ...r })) + if (/\bwhere\b/.test(lower)) { + const m = /where\s+(\w+)\s*=\s*\?/i.exec(sql) + if (m && params[0] !== undefined) { + rows = rows.filter((r) => String(r[m[1]]) === String(params[0])) + } + } + if (/\border by/i.test(lower)) { + rows = rows.sort((a, b) => Number(b['year']) - Number(a['year']) || Number(b['month']) - Number(a['month'])) + } + if (/select data/i.test(lower)) rows = rows.map((r) => ({ data: String(r['data']) })) + if (/select value/i.test(lower)) rows = rows.map((r) => ({ value: String(r['value']) })) + return rows as T[] + } + + async exec(sql: string): Promise { + const create = /create table (?:if not exists )?(\w+)/i.exec(sql) + if (create && !this.tables.has(create[1])) this.tables.set(create[1], new Map()) + const del = /delete from (\w+)/i.exec(sql) + if (del) this.tables.set(del[1], new Map()) + return null + } +} + +function world(seed: string) { + return World.create({ seed, surname: '楚', familyName: '楚家', motto: 'm', difficulty: 'normal' }) +} + +describe('SaveSlot 存储扩展', () => { + it('snapshot 保留最近 12 份并淘汰最旧', async () => { + const driver = new MemoryDriver() + const file = new SaveSlot(9, driver) + const w = world('st-9') + for (let i = 0; i < 20; i++) { + w.state.year = i + 1 + await file.saveState(w.state, 'x') + } + const snaps = await file.listSnapshots() + expect(snaps.length).toBe(12) + expect(snaps[0].year).toBe(20) + }) + + it('wipe 后库中没有快照与 meta', async () => { + const driver = new MemoryDriver() + const file = new SaveSlot(10, driver) + await file.saveState(world('st-10').state, 'x') + await file.wipe() + const state = await file.loadState() + expect(state).toBeNull() + }) + + it('非法 json 导入返回 false 且原数据不动', async () => { + const driver = new MemoryDriver() + const file = new SaveSlot(11, driver) + await file.saveState(world('st-11').state, 'x') + const ok = await file.importAll('not json at all') + expect(ok).toBe(false) + const still = await file.loadState() + expect(still).toBeTruthy() + }) + + it('export→import 完全恢复(含 rng 状态推进一致)', async () => { + const driver = new MemoryDriver() + const file = new SaveSlot(12, driver) + const w = world('st-12') + for (let i = 0; i < 10; i++) w.advanceMonth() + w.syncRng() + await file.saveState(w.state, 'y') + const json = await file.exportAll() + const d2 = new MemoryDriver() + const f2 = new SaveSlot(12, d2) + await f2.importAll(json) + const restored = await f2.loadState() + expect(restored?.totalTicks).toBe(10) + // 此后推进与原始世界分叉前提一致(同 rng 同初始 = 同序列) + const w2 = new World(restored!) + w.advanceMonth() + w2.advanceMonth() + expect(w2.state.family.stones).toBe(w.state.family.stones) + }) +}) + +// 修正语法:listSnapshots async +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + interface ProcessEnv {} + } +} diff --git a/tests/world-lifecycle.test.ts b/tests/world-lifecycle.test.ts new file mode 100644 index 0000000..a73dc26 --- /dev/null +++ b/tests/world-lifecycle.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/world' +import { createWorldState, NewGameOptions, findInheritor } from '../src/renderer/game/engine/creation' +import { resetSaveBus, attachLogSink } from './world.helpers' + +function opts(seed: string, difficulty: 'easy' | 'normal' | 'hard' = 'normal', surname = '彭'): NewGameOptions { + return { seed, surname, familyName: `${surname}家`, motto: 'm', difficulty } +} + +describe('World 生命周期与初始态', () => { + it('三种难度初始资源与 NPC 强度梯度正确', () => { + const easy = createWorldState(opts('st-e', 'easy')) + const normal = createWorldState(opts('st-n', 'normal')) + const hard = createWorldState(opts('st-h', 'hard')) + expect(easy.family.stones).toBeGreaterThan(normal.family.stones) + expect(normal.family.stones).toBeGreaterThan(hard.family.stones) + expect(easy.npcFamilies['n-nulei'].power).toBeLessThan(normal.npcFamilies['n-nulei'].power) + expect(normal.npcFamilies['n-nulei'].power).toBeLessThan(hard.npcFamilies['n-nulei'].power) + }) + + it('创建后基本不变量的存在性', () => { + const w = World.create(opts('st-b')) + const s = w.state + expect(s.family.headId).toBe('x1') + expect(s.family.buildings['lingtian']).toBe(1) + expect(s.family.buildings['zongci']).toBe(1) + expect(s.family.techniques.length).toBe(2) + expect(s.seq).toBeGreaterThanOrEqual(10) + expect(s.chronicle.length).toBe(1) // 立家 + }) + + it('findInheritor 子代优先,其次高境界', () => { + const w = World.create(opts('st-fi')) + const heir = findInheritor(w) + expect(['x3', 'x5']).toContain(heir?.id) + }) +}) + +describe('World 推进元心与数据保全', () => { + it('推进后 seq/months/总tick 单调增长', () => { + const w = World.create(opts('adv-a')) + const seq0 = w.state.seq + const t0 = w.state.totalTicks + w.advanceMonth() + expect(w.state.totalTicks).toBe(t0 + 1) + expect(w.state.seq).toBeGreaterThanOrEqual(seq0) + }) + + it('月份跨年正确且 yearStart 触发', () => { + const w = World.create(opts('adv-b')) + for (let i = 0; i < 11; i++) w.advanceMonth() // 到 12 月 + expect(w.state.month).toBe(12) + w.advanceMonth() // 跨年 + expect(w.state.month).toBe(1) + expect(w.state.year).toBe(2) + expect(w.state.yearlyReports.length).toBe(1) + }) + + it('gameOver 后 advance 不继续崩', () => { + const w = World.create(opts('adv-c')) + for (const c of Object.values(w.state.members)) c.alive = false + w.advanceMonth() + expect(w.state.gameOver).toBeTruthy() + w.advanceMonth() // 不再抛 + expect(w.state.year).toBeGreaterThanOrEqual(1) + }) + + it('年度报告的 power 值始终有效', () => { + const w = World.create(opts('adv-d')) + for (let i = 0; i < 30; i++) w.advanceMonth() + expect(w.state.yearlyReports.every((r) => r.power > 0)).toBe(true) + }) +}) + +describe('存档重建后继续运行一致性', () => { + it('json 往返后 100 月演进无异常且继续确定', () => { + const w = World.create(opts('rid-a')) + for (let i = 0; i < 25; i++) w.advanceMonth() + w.syncRng() + const clone = JSON.parse(JSON.stringify(w.state)) + const w2 = new World(clone) + for (let i = 0; i < 75; i++) { + if (w.state.gameOver) break + w.advanceMonth() + w2.advanceMonth() + } + expect(w2.state.year).toBe(w.state.year) + expect(w2.state.family.stones).toBe(w.state.family.stones) + }) +})