v0.1.3: 深度审计修复(兼容/血缘/联姻/远征/数值)

- 旧档兼容:<0.1.1 存档缺 finance/yearStats/yearlyReports 字段加载归一化(防运行期崩溃)
- 血缘防护:同母异父兄妹也禁止婚配(指婚/媒人/联姻三路齐封)
- 联姻修正:一家一姻亲(allied 锁),再娶再嫁不再无限刷;男儿娶亲也子孙来归
- 和约语义:议和强推关系至 +30(真停战),不再打完一场还是仇雠
- 远征收队:任务完成/召回自动释放队员闲居;重伤者当月离队疗伤、不足定员提前撤队(修复人员永久困远征/卡状态)
- 数值:婴儿夭折率 2%→0.8%;劫掠队强度 0.9→0.78;事件负资源钳制不为负灵石
- UI:丹药按钮显示持有并禁点;点将模式支持取消重选;设置页移除摆设项
- 新增 audit 套件:8 项(含 600 月长跑耐力)总测试 38→46 全绿
This commit is contained in:
2026-08-23 08:34:13 +08:00
parent fefbe1f1d7
commit f38669fa4a
14 changed files with 242 additions and 52 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "chronicle-of-the-immortal-clan", "name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志", "productName": "仙途家族志",
"version": "0.1.2", "version": "0.1.3",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器", "description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "MetonaTeam", "author": "MetonaTeam",
-10
View File
@@ -54,16 +54,6 @@ function createWindow(): void {
wc.once('did-finish-load', () => { wc.once('did-finish-load', () => {
clearTimeout(smokeWatchdog) clearTimeout(smokeWatchdog)
}) })
void smokeWatchdog
setTimeout(async () => {
try {
const url = wc.getURL()
const title = await wc.executeJavaScript('document.title').catch((e: Error) => 'ERR:' + e.message)
console.log(`[PROBE] url=${url} title=${title}`)
} catch (e) {
console.log('[PROBE] failed', String(e))
}
}, 8000)
const shotsDir = process.env.SMOKE_SHOTS_DIR const shotsDir = process.env.SMOKE_SHOTS_DIR
wc.on('console-message', (_e, level, message) => { wc.on('console-message', (_e, level, message) => {
console.log(`[renderer:${level}] ${message}`) console.log(`[renderer:${level}] ${message}`)
+1 -1
View File
@@ -153,7 +153,7 @@ export function createWorldState(opts: NewGameOptions): GameState {
uncle.techniqueId = 't-houtu' uncle.techniqueId = 't-houtu'
uncle.traits = ['xinheng', 'shensui'] uncle.traits = ['xinheng', 'shensui']
uncle.id = 'x4' uncle.id = 'x4'
uncle.spouseHouse = 'sihai王氏' uncle.spouseHouse = '四海王氏'
uncle.children = [] uncle.children = []
head.id = 'x1' head.id = 'x1'
+2 -6
View File
@@ -1,5 +1,5 @@
import { World } from '../world' import { World } from '../world'
import { Character, BattleLog, NpcFamilyState } from '../../types/domain' import { Character, BattleLog } from '../../types/domain'
import { basePower, describeRealm } from '../../data/realms' import { basePower, describeRealm } from '../../data/realms'
import { ARTIFACT_POWER } from '../../data/items' import { ARTIFACT_POWER } from '../../data/items'
import { techniqueById } from '../../data/techniques' import { techniqueById } from '../../data/techniques'
@@ -29,10 +29,6 @@ export function enemyPowerOf(enemy: EnemyDef, risk: number): number {
return Math.round(base * enemy.strength * (1.05 + risk * 0.55)) return Math.round(base * enemy.strength * (1.05 + risk * 0.55))
} }
export function npcPowerOf(npc: NpcFamilyState): number {
return Math.round(npc.power)
}
export interface EncounterResult { export interface EncounterResult {
win: boolean win: boolean
draw: boolean draw: boolean
@@ -163,7 +159,7 @@ export function resolveRaid(
id: npcId, id: npcId,
name: `${npc.name}的劫掠队`, name: `${npc.name}的劫掠队`,
realm: def.leaderRealm, realm: def.leaderRealm,
strength: 0.9, strength: 0.78,
icon: '袭', icon: '袭',
desc: def.desc desc: def.desc
} }
@@ -48,7 +48,7 @@ export function makePeace(w: World, npcId: string): boolean {
const npc = w.state.npcFamilies[npcId] const npc = w.state.npcFamilies[npcId]
if (fam.stones < 200) return false if (fam.stones < 200) return false
fam.stones -= 200 fam.stones -= 200
npc.relation = Math.max(npc.relation + 35, 0) npc.relation = Math.max(npc.relation + 35, 30)
w.chronicle('diplomacy', `${npc.name}立下和约,两家罢兵互市。`, undefined, true) w.chronicle('diplomacy', `${npc.name}立下和约,两家罢兵互市。`, undefined, true)
w.log('good', `${npc.name}言和。`) w.log('good', `${npc.name}言和。`)
return true return true
@@ -58,16 +58,18 @@ export function marryNpcFamily(w: World, npcId: string): boolean {
const s = w.state const s = w.state
const fam = s.family const fam = s.family
const npc = s.npcFamilies[npcId] const npc = s.npcFamilies[npcId]
if (!npc || npc.relation < 25) return false if (!npc || npc.relation < 25 || npc.allied) return false
const eligible = w const eligible = w
.aliveMembers() .aliveMembers()
.filter((c) => w.ageOf(c) >= 18 && w.ageOf(c) <= 42 && c.state !== 'expedition') .filter((c) => w.ageOf(c) >= 16 && w.ageOf(c) <= 46 && c.state !== 'expedition')
.filter((c) => !c.spouseId) .filter((c) => !c.spouseId)
if (eligible.length === 0) return false if (eligible.length === 0) return false
const npcDef = npcById(npcId) const npcDef = npcById(npcId)
const candidate = w.rng.pick(eligible) const candidate = w.rng.pick(eligible)
candidate.spouseHouse = npc.name candidate.spouseHouse = npc.name
npc.relation += 20 npc.relation += 20
npc.allied = true
npc.alliedSinceYear = s.year
fam.reputation += 4 fam.reputation += 4
w.chronicle('marriage', `${candidate.name}${npc.name}联姻,两家绸缪通好。`, candidate.id, true) w.chronicle('marriage', `${candidate.name}${npc.name}联姻,两家绸缪通好。`, candidate.id, true)
w.log('good', `${candidate.name}${npc.name}联姻成功!每年或降麟儿。`) w.log('good', `${candidate.name}${npc.name}联姻成功!每年或降麟儿。`)
@@ -80,6 +82,7 @@ export function arrangeWedding(w: World, aId: string, bId: string): boolean {
if (!a.alive || !b.alive || a.spouseId || b.spouseId) return false if (!a.alive || !b.alive || a.spouseId || b.spouseId) return false
if (a.gender === b.gender) return false if (a.gender === b.gender) return false
if (a.fatherId === b.fatherId && a.fatherId) return false if (a.fatherId === b.fatherId && a.fatherId) return false
if (a.motherId === b.motherId && a.motherId) return false
a.spouseId = b.id a.spouseId = b.id
b.spouseId = a.id b.spouseId = a.id
const aAge = w.ageOf(a) const aAge = w.ageOf(a)
+1 -1
View File
@@ -210,7 +210,7 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
if (eff.res) { if (eff.res) {
for (const [k, v] of Object.entries(eff.res)) { for (const [k, v] of Object.entries(eff.res)) {
if (k === 'stones') fam.stones += v if (k === 'stones') fam.stones = Math.max(0, fam.stones + v)
else fam.inventory[k] = Math.max(0, (fam.inventory[k] ?? 0) + v) else fam.inventory[k] = Math.max(0, (fam.inventory[k] ?? 0) + v)
} }
} }
@@ -19,7 +19,7 @@ export function deathTick(w: World): void {
p = Math.min(0.3, Math.pow(t, 5) * 0.9) p = Math.min(0.3, Math.pow(t, 5) * 0.9)
} }
if (c.health < 30) p += 0.18 if (c.health < 30) p += 0.18
if (age < 2) p = Math.max(p, 0.02) if (age < 2) p = Math.max(p, 0.008)
if (p > 0 && w.rng.chance(p)) { if (p > 0 && w.rng.chance(p)) {
c.alive = false c.alive = false
c.deathYear = w.state.year c.deathYear = w.state.year
+9 -5
View File
@@ -38,19 +38,18 @@ export function yearStartMarriage(w: World): void {
w.log('good', `${fam.surname}家诞下新丁:${first.name}`) w.log('good', `${fam.surname}家诞下新丁:${first.name}`)
} }
// 联姻外孙来投 // 联姻外孙来投(男方娶亲:妻室携子来归;或女儿携子回门)
for (const member of Object.values(s.members)) { for (const member of Object.values(s.members)) {
if (!member.alive || !member.spouseHouse) continue if (!member.alive || !member.spouseHouse) continue
if (member.gender !== 'female') continue
const age = w.ageOf(member) const age = w.ageOf(member)
if (age < 18 || age > 44) continue if (age < 18 || age > 44) continue
if (!w.rng.chance(0.16)) continue if (!w.rng.chance(0.15)) continue
const gen = member.generation + 1 const gen = member.generation + 1
const house = member.spouseHouse const house = member.spouseHouse
const child = produceOffspring(w, { father: null, mother: null, generation: gen, bornYear: s.year, surname: fam.surname }) const child = produceOffspring(w, { father: null, mother: null, generation: gen, bornYear: s.year, surname: fam.surname })
child.spouseHouse = house child.spouseHouse = house
w.addMember(child) w.addMember(child)
w.chronicle('birth', `${member.name}${house}携幼子归,名唤${child.name}`, child.id, true) w.chronicle('birth', `${member.name}${house}携幼子归,名唤${child.name}`, child.id, true)
w.state.yearStats.births += 1 w.state.yearStats.births += 1
w.log('info', `${member.name} 领着小辈回门投亲。`) w.log('info', `${member.name} 领着小辈回门投亲。`)
} }
@@ -78,7 +77,12 @@ export function yearStartMarriage(w: World): void {
for (const m of men) { for (const m of men) {
if (w.rng.chance(0.28) && women.length > 0) { if (w.rng.chance(0.28) && women.length > 0) {
const candidate = women.filter( const candidate = women.filter(
(x) => !(x.fatherId && x.fatherId === m.fatherId) && x !== m && !x.children.includes(m.id) && !m.children.includes(x.id) (x) =>
!(x.fatherId && x.fatherId === m.fatherId) &&
!(x.motherId && x.motherId === m.motherId) &&
x !== m &&
!x.children.includes(m.id) &&
!m.children.includes(x.id)
) )
if (candidate.length === 0) continue if (candidate.length === 0) continue
const bride = w.rng.pick(candidate) const bride = w.rng.pick(candidate)
+41 -6
View File
@@ -6,8 +6,10 @@ import { techniqueById } from '../../data/techniques'
import { describeRealm } from '../../data/realms' import { describeRealm } from '../../data/realms'
export function missionTick(w: World): void { export function missionTick(w: World): void {
const alive = w.state.missions.filter((m) => !m.done) const active = w.state.missions.filter((m) => !m.done)
for (const m of alive) { for (const m of active) {
settleSquad(w, m)
if (m.done) continue
m.stageMonth++ m.stageMonth++
if (m.stageMonth < 2) continue if (m.stageMonth < 2) continue
@@ -63,6 +65,7 @@ export function missionTick(w: World): void {
if (m.stage >= def.stages.length && !m.done) { if (m.stage >= def.stages.length && !m.done) {
m.done = true m.done = true
m.result = 'success' m.result = 'success'
releaseSquad(w, m)
const total = rollWarbooty(w, def.completionLoot) const total = rollWarbooty(w, def.completionLoot)
m.log.push(`凯旋而归,清点战利:${lootText(total)}`) m.log.push(`凯旋而归,清点战利:${lootText(total)}`)
const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、') const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、')
@@ -143,9 +146,41 @@ export function recallAll(w: World, missionId: string): void {
if (!m || m.done) return if (!m || m.done) return
m.done = true m.done = true
m.result = 'recall' m.result = 'recall'
m.memberIds.forEach((id) => { releaseSquad(w, m)
const c = w.memberById(id)
if (c.alive) c.state = 'idle'
})
w.log('info', '探索队伍奉命返家。') w.log('info', '探索队伍奉命返家。')
} }
/** 队伍成员状态归位:伤者保持养伤,其余恢复闲居 */
function releaseSquad(w: World, m: MissionState): void {
for (const id of m.memberIds) {
const c = w.memberById(id)
if (!c.alive) continue
if (c.state === 'wounded') continue
c.state = 'idle'
}
}
/** 月度巡查:亡者除名、重伤者离队疗伤;人数不足则提前收队 */
function settleSquad(w: World, m: MissionState): void {
const def = missionById(m.defId)
let removed = 0
for (let i = m.memberIds.length - 1; i >= 0; i--) {
const c = w.memberById(m.memberIds[i])
if (!c.alive) {
m.memberIds.splice(i, 1)
removed++
} else if (c.state === 'wounded' || c.health < 30) {
c.state = 'wounded'
m.memberIds.splice(i, 1)
removed++
m.log.push(`${c.name} 身负重伤,离队回庄疗养。`)
}
}
if (removed > 0) w.log('info', `${def.name}队中有人离队。`)
if (m.memberIds.length < def.minMembers) {
m.done = true
m.result = 'disband'
releaseSquad(w, m)
w.log('bad', `${def.name}人手不足,队伍提前收队。`)
}
}
+21
View File
@@ -32,12 +32,31 @@ export interface WorldEventBus {
onYearPaper?(entry: YearlyReport): void onYearPaper?(entry: YearlyReport): void
} }
export function normalizeGameState(state: GameState): GameState {
// 老版本存档(<0.1.1)缺少新增字段,加载时补齐,避免运行期 undefined 崩溃
if (!state.finance) state.finance = { accum: 0 }
if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 }
if (!state.yearlyReports) state.yearlyReports = []
if (typeof state.totalTicks !== 'number') state.totalTicks = 0
if (typeof state.seq !== 'number') state.seq = 10
if (!state.battles) state.battles = []
if (!state.eventQueue) state.eventQueue = []
if (!state.completedEvents) state.completedEvents = []
for (const c of Object.values(state.members)) {
if (typeof c.techniqueRank !== 'number') c.techniqueRank = 0
if (typeof c.techniqueProgress !== 'number') c.techniqueProgress = 0
if (typeof c.health !== 'number') c.health = 100
}
return state
}
export class World { export class World {
state: GameState state: GameState
rng: Rng rng: Rng
out: WorldEventBus[] out: WorldEventBus[]
constructor(state: GameState, out: WorldEventBus[] = []) { constructor(state: GameState, out: WorldEventBus[] = []) {
normalizeGameState(state)
this.state = state this.state = state
this.rng = new Rng(state.rng) this.rng = new Rng(state.rng)
this.out = out this.out = out
@@ -411,6 +430,7 @@ export class World {
.filter( .filter(
(c) => (c) =>
!(me.fatherId && me.fatherId === c.fatherId) && !(me.fatherId && me.fatherId === c.fatherId) &&
!(me.motherId && me.motherId === c.motherId) &&
!me.children.includes(c.id) && !me.children.includes(c.id) &&
!c.children.includes(me.id) && !c.children.includes(me.id) &&
me.id !== c.id me.id !== c.id
@@ -447,6 +467,7 @@ function arrangeWeddingPublic(w: World, aId: Id, bId: Id): boolean {
if (!a.alive || !b.alive || a.spouseId || b.spouseId) return false if (!a.alive || !b.alive || a.spouseId || b.spouseId) return false
if (a.gender === b.gender) return false if (a.gender === b.gender) return false
if (a.fatherId && a.fatherId === b.fatherId) return false if (a.fatherId && a.fatherId === b.fatherId) return false
if (a.motherId && a.motherId === b.motherId) return false
a.spouseId = b.id a.spouseId = b.id
b.spouseId = a.id b.spouseId = a.id
const aAge = w.ageOf(a) const aAge = w.ageOf(a)
+22 -4
View File
@@ -79,16 +79,34 @@ export function EventModal() {
)} )}
<div className="modal-opts"> <div className="modal-opts">
{pendingEventDef.options.map((o, i) => { {squadPicking ? (
<>
<button className="opt-btn" onClick={() => choose(raidIdx)}>
{squadSel.length}
<span className="hint"></span>
</button>
<button
className="btn"
onClick={() => {
setSquadPicking(false)
setSquadSel([])
}}
>
</button>
</>
) : (
pendingEventDef.options.map((o, i) => {
const isRaid = raidIdx === i const isRaid = raidIdx === i
return ( return (
<button key={i} className="opt-btn" onClick={() => choose(i)}> <button key={i} className="opt-btn" onClick={() => choose(i)}>
{isRaid && squadPicking ? '出战!(点将已毕)' : o.label} {o.label}
{isRaid && !squadPicking && <span className="hint"> · </span>} {isRaid && <span className="hint"> · </span>}
{!isRaid && o.hint && <span className="hint">{o.hint}</span>} {!isRaid && o.hint && <span className="hint">{o.hint}</span>}
</button> </button>
) )
})} })
)}
</div> </div>
</div> </div>
</div> </div>
+14 -4
View File
@@ -9,6 +9,10 @@ import { combatPowerOf } from '../../game/engine/systems/combat'
import { lifespanOf } from '../../game/engine/systems/lifecycle' import { lifespanOf } from '../../game/engine/systems/lifecycle'
import { POSTS, POST_ORDER } from '../../game/data/posts' import { POSTS, POST_ORDER } from '../../game/data/posts'
function pillCount(s: GameState, id: string): number {
return s.family.inventory[id] ?? 0
}
function equipableItems(member: Character, s: GameState): string[] { function equipableItems(member: Character, s: GameState): string[] {
const inv = Object.keys(ARTIFACT_POWER).filter((ai) => (s.family.inventory[ai] ?? 0) > 0) const inv = Object.keys(ARTIFACT_POWER).filter((ai) => (s.family.inventory[ai] ?? 0) > 0)
if (member.equipment && !inv.includes(member.equipment)) inv.push(member.equipment) if (member.equipment && !inv.includes(member.equipment)) inv.push(member.equipment)
@@ -104,33 +108,39 @@ export function MemberModal({ member }: { member: Character }) {
<> <>
<button <button
className="btn btn-sm" className="btn btn-sm"
disabled={pillCount(s, 'pill-qiyuan') <= 0}
title={`聚气丹×${pillCount(s, 'pill-qiyuan')}`}
onClick={() => { onClick={() => {
w.takePill(member.id, 'pill-qiyuan') w.takePill(member.id, 'pill-qiyuan')
bump() bump()
}} }}
> >
({pillCount(s, 'pill-qiyuan')})
</button> </button>
{member.realm.major !== 'mortal' && ( {member.realm.major !== 'mortal' && (
<button <button
className="btn btn-sm" className="btn btn-sm"
disabled={pillCount(s, 'pill-ningyuan') <= 0}
title={`凝元丹×${pillCount(s, 'pill-ningyuan')}`}
onClick={() => { onClick={() => {
w.takePill(member.id, 'pill-ningyuan') w.takePill(member.id, 'pill-ningyuan')
bump() bump()
}} }}
> >
({pillCount(s, 'pill-ningyuan')})
</button> </button>
)} )}
{member.realmProgress >= 100 && member.realm.major !== 'mortal' && ( {member.realmProgress >= 70 && member.realm.major !== 'mortal' && (
<button <button
className="btn btn-sm" className="btn btn-sm"
disabled={pillCount(s, 'pill-pojing') <= 0}
title={`破境丹×${pillCount(s, 'pill-pojing')}`}
onClick={() => { onClick={() => {
w.takePill(member.id, 'pill-pojing') w.takePill(member.id, 'pill-pojing')
bump() bump()
}} }}
> >
({pillCount(s, 'pill-pojing')})
</button> </button>
)} )}
</> </>
-5
View File
@@ -89,11 +89,6 @@ export default function SettingsPanel() {
</button> </button>
</div> </div>
<div className="set-row">
<span></span>
<button className="btn btn-sm" onClick={bump}></button>
</div>
<div className="card-title" style={{ marginTop: 20 }}></div> <div className="card-title" style={{ marginTop: 20 }}></div>
<div className="help-text"> <div className="help-text">
· <br /> · <br />
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { World, normalizeGameState } from '../src/renderer/game/engine/world'
import {
marryNpcFamily,
makePeace
} from '../src/renderer/game/engine/systems/diplomacy'
import { sendMission, recallAll } from '../src/renderer/game/engine/systems/missions'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { GameState } from '../src/renderer/game/types/domain'
import { resetSaveBus, attachLogSink } from './world.helpers'
describe('0.1.3 audit regression', () => {
it('loads a 0.1.0-era save (missing finance/yearStats fields)', () => {
const w = World.create({ seed: 'legacy', surname: '崔', familyName: '崔家', motto: 'm', difficulty: 'normal' })
const legacy = JSON.parse(JSON.stringify(w.state)) as GameState
delete (legacy as Record<string, unknown>)['finance']
delete (legacy as Record<string, unknown>)['yearStats']
delete (legacy as Record<string, unknown>)['yearlyReports']
const w2 = new World(legacy)
for (let i = 0; i < 40; i++) w2.advanceMonth()
expect(w2.state.yearlyReports.length).toBeGreaterThan(0)
expect(Number.isNaN(w2.state.family.stones)).toBe(false)
})
it('marries a npc family once only (allied lock)', () => {
const w = World.create({ seed: 'hy', surname: '卫', familyName: '卫家', motto: 'm', difficulty: 'normal' })
const npc = w.state.npcFamilies['n-danxin']
npc.relation = 60
// 姑且让长子成年(18岁可婚)
w.state.members['x3'].bornYear = 1 - 18
expect(marryNpcFamily(w, 'n-danxin')).toBe(true)
expect(npc.allied).toBe(true)
expect(marryNpcFamily(w, 'n-danxin')).toBe(false)
})
it('or same-mother siblings cannot marry (candidates + direct)', () => {
const w = World.create({ seed: 'kin', surname: '陆', familyName: '陆家', motto: 'm', difficulty: 'normal' })
const a = w.state.members['x3']
const b = w.state.members['x5']
// 人为制造同母异父关系
a.motherId = 'x2'
b.motherId = 'x2'
a.fatherId = undefined
b.fatherId = undefined
expect(w.marriageCandidatesOf(a.id).map((c) => c.id)).not.toContain(b.id)
expect(w.marryTo(a.id, b.id)).toBe(false)
})
it('peace cements relation to a floor above zero', () => {
const w = World.create({ seed: 'peace', surname: '华', familyName: '华家', motto: 'm', difficulty: 'normal' })
const npc = w.state.npcFamilies['n-nulei']
npc.relation = -80
w.state.family.stones = 900
expect(makePeace(w, 'n-nulei')).toBe(true)
expect(npc.relation).toBeGreaterThanOrEqual(30)
})
it('event negative resource cannot sink stones below zero', () => {
const w = World.create({ seed: 'clamp', surname: '曾', familyName: '曾家', motto: 'm', difficulty: 'normal' })
w.state.family.stones = 30
applyEventChoice(w, 'ev-raid-n-nulei', 1) // 割地求和 -250
expect(w.state.family.stones).toBe(0)
})
it('expedition squad releases members and drops wounded', () => {
const w = World.create({ seed: 'exp', surname: '纪', familyName: '纪家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
const ok = sendMission(w, 'm-anmoku', ['x1', 'x3'])
expect(ok).toBe(true)
const m = w.state.missions[0]
expect(m.memberIds.length).toBe(2)
// 制造重伤:让一人带伤退出
const c = w.state.members['x3']
c.health = 10
c.state = 'wounded'
w.advanceMonth()
expect(m.memberIds).not.toContain('x3')
expect(w.state.members['x3'].state).toBe('wounded')
// 剩余的 x1 一人 ≥ minMembers=1 可以继续;test recall releases
recallAll(w, m.id)
expect(w.state.members['x1'].state).toBe('idle')
})
it('squad disband when members run short', () => {
const w = World.create({ seed: 'exp2', surname: '窦', familyName: '窦家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
const ok = sendMission(w, 'm-xuangu', ['x1', 'x3']) // minMembers 1
expect(ok).toBe(true)
const m = w.state.missions[0]
// 两人同时重伤离队
w.state.members['x1'].health = 10
w.state.members['x1'].state = 'wounded'
w.state.members['x3'].health = 10
w.state.members['x3'].state = 'wounded'
w.advanceMonth()
expect(m.done).toBe(true)
expect(m.result).toBe('disband')
})
it('long run 600 months stays sane', () => {
const w = World.create({ seed: 'long', surname: '丁', familyName: '丁家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
for (let i = 0; i < 600; i++) {
if (w.state.gameOver) break
w.advanceMonth()
}
const s = w.state
expect(Number.isNaN(s.family.stones)).toBe(false)
expect(s.year).toBeGreaterThan(30)
for (const c of Object.values(s.members)) {
expect(Number.isNaN(c.realmProgress)).toBe(false)
expect(Number.isNaN(c.health)).toBe(false)
}
})
})