From f38669fa4a3dfa141869f9fdacd17ac0d5f601a1 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sun, 23 Aug 2026 08:34:13 +0800 Subject: [PATCH] =?UTF-8?q?v0.1.3:=20=E6=B7=B1=E5=BA=A6=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88=E5=85=BC=E5=AE=B9/=E8=A1=80?= =?UTF-8?q?=E7=BC=98/=E8=81=94=E5=A7=BB/=E8=BF=9C=E5=BE=81/=E6=95=B0?= =?UTF-8?q?=E5=80=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 旧档兼容:<0.1.1 存档缺 finance/yearStats/yearlyReports 字段加载归一化(防运行期崩溃) - 血缘防护:同母异父兄妹也禁止婚配(指婚/媒人/联姻三路齐封) - 联姻修正:一家一姻亲(allied 锁),再娶再嫁不再无限刷;男儿娶亲也子孙来归 - 和约语义:议和强推关系至 +30(真停战),不再打完一场还是仇雠 - 远征收队:任务完成/召回自动释放队员闲居;重伤者当月离队疗伤、不足定员提前撤队(修复人员永久困远征/卡状态) - 数值:婴儿夭折率 2%→0.8%;劫掠队强度 0.9→0.78;事件负资源钳制不为负灵石 - UI:丹药按钮显示持有并禁点;点将模式支持取消重选;设置页移除摆设项 - 新增 audit 套件:8 项(含 600 月长跑耐力)总测试 38→46 全绿 --- package.json | 2 +- src/main/index.ts | 10 -- src/renderer/game/engine/creation.ts | 2 +- src/renderer/game/engine/systems/combat.ts | 8 +- src/renderer/game/engine/systems/diplomacy.ts | 9 +- src/renderer/game/engine/systems/events.ts | 2 +- src/renderer/game/engine/systems/lifecycle.ts | 2 +- src/renderer/game/engine/systems/marriage.ts | 14 ++- src/renderer/game/engine/systems/missions.ts | 47 ++++++- src/renderer/game/engine/world.ts | 21 ++++ src/renderer/ui/components/EventModal.tsx | 36 ++++-- src/renderer/ui/components/MemberModal.tsx | 18 ++- src/renderer/ui/panels/SettingsPanel.tsx | 5 - tests/audit.test.ts | 118 ++++++++++++++++++ 14 files changed, 242 insertions(+), 52 deletions(-) create mode 100644 tests/audit.test.ts diff --git a/package.json b/package.json index 57ed141..88ba801 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.2", + "version": "0.1.3", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/main/index.ts b/src/main/index.ts index 7dc65c9..951222b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -54,16 +54,6 @@ function createWindow(): void { wc.once('did-finish-load', () => { 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 wc.on('console-message', (_e, level, message) => { console.log(`[renderer:${level}] ${message}`) diff --git a/src/renderer/game/engine/creation.ts b/src/renderer/game/engine/creation.ts index 5f52ae1..1ca9aba 100644 --- a/src/renderer/game/engine/creation.ts +++ b/src/renderer/game/engine/creation.ts @@ -153,7 +153,7 @@ export function createWorldState(opts: NewGameOptions): GameState { uncle.techniqueId = 't-houtu' uncle.traits = ['xinheng', 'shensui'] uncle.id = 'x4' - uncle.spouseHouse = 'sihai王氏' + uncle.spouseHouse = '四海王氏' uncle.children = [] head.id = 'x1' diff --git a/src/renderer/game/engine/systems/combat.ts b/src/renderer/game/engine/systems/combat.ts index 23e5636..eb794fa 100644 --- a/src/renderer/game/engine/systems/combat.ts +++ b/src/renderer/game/engine/systems/combat.ts @@ -1,5 +1,5 @@ import { World } from '../world' -import { Character, BattleLog, NpcFamilyState } from '../../types/domain' +import { Character, BattleLog } from '../../types/domain' import { basePower, describeRealm } from '../../data/realms' import { ARTIFACT_POWER } from '../../data/items' 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)) } -export function npcPowerOf(npc: NpcFamilyState): number { - return Math.round(npc.power) -} - export interface EncounterResult { win: boolean draw: boolean @@ -163,7 +159,7 @@ export function resolveRaid( id: npcId, name: `${npc.name}的劫掠队`, realm: def.leaderRealm, - strength: 0.9, + strength: 0.78, icon: '袭', desc: def.desc } diff --git a/src/renderer/game/engine/systems/diplomacy.ts b/src/renderer/game/engine/systems/diplomacy.ts index e72c242..8925182 100644 --- a/src/renderer/game/engine/systems/diplomacy.ts +++ b/src/renderer/game/engine/systems/diplomacy.ts @@ -48,7 +48,7 @@ export function makePeace(w: World, npcId: string): boolean { const npc = w.state.npcFamilies[npcId] if (fam.stones < 200) return false 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.log('good', `与${npc.name}言和。`) return true @@ -58,16 +58,18 @@ export function marryNpcFamily(w: World, npcId: string): boolean { const s = w.state const fam = s.family const npc = s.npcFamilies[npcId] - if (!npc || npc.relation < 25) return false + if (!npc || npc.relation < 25 || npc.allied) return false const eligible = w .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) if (eligible.length === 0) return false const npcDef = npcById(npcId) const candidate = w.rng.pick(eligible) candidate.spouseHouse = npc.name npc.relation += 20 + npc.allied = true + npc.alliedSinceYear = s.year fam.reputation += 4 w.chronicle('marriage', `${candidate.name}与${npc.name}联姻,两家绸缪通好。`, candidate.id, true) 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.gender === b.gender) return false if (a.fatherId === b.fatherId && a.fatherId) return false + if (a.motherId === b.motherId && a.motherId) return false a.spouseId = b.id b.spouseId = a.id const aAge = w.ageOf(a) diff --git a/src/renderer/game/engine/systems/events.ts b/src/renderer/game/engine/systems/events.ts index e41ef4b..31ea4be 100644 --- a/src/renderer/game/engine/systems/events.ts +++ b/src/renderer/game/engine/systems/events.ts @@ -210,7 +210,7 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { if (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) } } diff --git a/src/renderer/game/engine/systems/lifecycle.ts b/src/renderer/game/engine/systems/lifecycle.ts index 0d05164..63ca7f3 100644 --- a/src/renderer/game/engine/systems/lifecycle.ts +++ b/src/renderer/game/engine/systems/lifecycle.ts @@ -19,7 +19,7 @@ export function deathTick(w: World): void { p = Math.min(0.3, Math.pow(t, 5) * 0.9) } 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)) { c.alive = false c.deathYear = w.state.year diff --git a/src/renderer/game/engine/systems/marriage.ts b/src/renderer/game/engine/systems/marriage.ts index d7d3f73..030387e 100644 --- a/src/renderer/game/engine/systems/marriage.ts +++ b/src/renderer/game/engine/systems/marriage.ts @@ -38,19 +38,18 @@ export function yearStartMarriage(w: World): void { w.log('good', `${fam.surname}家诞下新丁:${first.name}。`) } - // 联姻外孙来投 + // 联姻外孙来投(男方娶亲:妻室携子来归;或女儿携子回门) for (const member of Object.values(s.members)) { if (!member.alive || !member.spouseHouse) continue - if (member.gender !== 'female') continue const age = w.ageOf(member) 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 house = member.spouseHouse const child = produceOffspring(w, { father: null, mother: null, generation: gen, bornYear: s.year, surname: fam.surname }) child.spouseHouse = house 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.log('info', `${member.name} 领着小辈回门投亲。`) } @@ -78,7 +77,12 @@ export function yearStartMarriage(w: World): void { for (const m of men) { if (w.rng.chance(0.28) && women.length > 0) { 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 const bride = w.rng.pick(candidate) diff --git a/src/renderer/game/engine/systems/missions.ts b/src/renderer/game/engine/systems/missions.ts index 05100ad..eba1526 100644 --- a/src/renderer/game/engine/systems/missions.ts +++ b/src/renderer/game/engine/systems/missions.ts @@ -6,8 +6,10 @@ import { techniqueById } from '../../data/techniques' import { describeRealm } from '../../data/realms' export function missionTick(w: World): void { - const alive = w.state.missions.filter((m) => !m.done) - for (const m of alive) { + const active = w.state.missions.filter((m) => !m.done) + for (const m of active) { + settleSquad(w, m) + if (m.done) continue m.stageMonth++ if (m.stageMonth < 2) continue @@ -63,6 +65,7 @@ export function missionTick(w: World): void { if (m.stage >= def.stages.length && !m.done) { m.done = true m.result = 'success' + releaseSquad(w, m) const total = rollWarbooty(w, def.completionLoot) m.log.push(`凯旋而归,清点战利:${lootText(total)}。`) 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 m.done = true m.result = 'recall' - m.memberIds.forEach((id) => { - const c = w.memberById(id) - if (c.alive) c.state = 'idle' - }) + releaseSquad(w, m) 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}人手不足,队伍提前收队。`) + } +} diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts index 47acf65..35e17ba 100644 --- a/src/renderer/game/engine/world.ts +++ b/src/renderer/game/engine/world.ts @@ -32,12 +32,31 @@ export interface WorldEventBus { 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 { state: GameState rng: Rng out: WorldEventBus[] constructor(state: GameState, out: WorldEventBus[] = []) { + normalizeGameState(state) this.state = state this.rng = new Rng(state.rng) this.out = out @@ -411,6 +430,7 @@ export class World { .filter( (c) => !(me.fatherId && me.fatherId === c.fatherId) && + !(me.motherId && me.motherId === c.motherId) && !me.children.includes(c.id) && !c.children.includes(me.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.gender === b.gender) return false if (a.fatherId && a.fatherId === b.fatherId) return false + if (a.motherId && a.motherId === b.motherId) return false a.spouseId = b.id b.spouseId = a.id const aAge = w.ageOf(a) diff --git a/src/renderer/ui/components/EventModal.tsx b/src/renderer/ui/components/EventModal.tsx index 8036e76..51132d6 100644 --- a/src/renderer/ui/components/EventModal.tsx +++ b/src/renderer/ui/components/EventModal.tsx @@ -79,16 +79,34 @@ export function EventModal() { )}
- {pendingEventDef.options.map((o, i) => { - const isRaid = raidIdx === i - return ( - - ) - })} + + + ) : ( + pendingEventDef.options.map((o, i) => { + const isRaid = raidIdx === i + return ( + + ) + }) + )}
diff --git a/src/renderer/ui/components/MemberModal.tsx b/src/renderer/ui/components/MemberModal.tsx index 0224262..cf98a02 100644 --- a/src/renderer/ui/components/MemberModal.tsx +++ b/src/renderer/ui/components/MemberModal.tsx @@ -9,6 +9,10 @@ import { combatPowerOf } from '../../game/engine/systems/combat' import { lifespanOf } from '../../game/engine/systems/lifecycle' 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[] { 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) @@ -104,33 +108,39 @@ export function MemberModal({ member }: { member: Character }) { <> {member.realm.major !== 'mortal' && ( )} - {member.realmProgress >= 100 && member.realm.major !== 'mortal' && ( + {member.realmProgress >= 70 && member.realm.major !== 'mortal' && ( )} diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index 7c1aec7..6a58280 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -89,11 +89,6 @@ export default function SettingsPanel() { 主菜单 -
- 立即推送修复(保存完成提示) - -
-
玩法说明
· 日常以「月」流转:成员修炼、建筑产出、往事兴替、事件沓来。
diff --git a/tests/audit.test.ts b/tests/audit.test.ts new file mode 100644 index 0000000..2c0a4d5 --- /dev/null +++ b/tests/audit.test.ts @@ -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)['finance'] + delete (legacy as Record)['yearStats'] + delete (legacy as Record)['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) + } + }) +})