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
-10
View File
@@ -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}`)
+1 -1
View File
@@ -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'
+2 -6
View File
@@ -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
}
@@ -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)
+1 -1
View File
@@ -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)
}
}
@@ -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
+9 -5
View File
@@ -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)
+41 -6
View File
@@ -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}人手不足,队伍提前收队。`)
}
}
+21
View File
@@ -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)
+27 -9
View File
@@ -79,16 +79,34 @@ export function EventModal() {
)}
<div className="modal-opts">
{pendingEventDef.options.map((o, i) => {
const isRaid = raidIdx === i
return (
<button key={i} className="opt-btn" onClick={() => choose(i)}>
{isRaid && squadPicking ? '出战!(点将已毕)' : o.label}
{isRaid && !squadPicking && <span className="hint"> · </span>}
{!isRaid && o.hint && <span className="hint">{o.hint}</span>}
{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
return (
<button key={i} className="opt-btn" onClick={() => choose(i)}>
{o.label}
{isRaid && <span className="hint"> · </span>}
{!isRaid && o.hint && <span className="hint">{o.hint}</span>}
</button>
)
})
)}
</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 { 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 }) {
<>
<button
className="btn btn-sm"
disabled={pillCount(s, 'pill-qiyuan') <= 0}
title={`聚气丹×${pillCount(s, 'pill-qiyuan')}`}
onClick={() => {
w.takePill(member.id, 'pill-qiyuan')
bump()
}}
>
({pillCount(s, 'pill-qiyuan')})
</button>
{member.realm.major !== 'mortal' && (
<button
className="btn btn-sm"
disabled={pillCount(s, 'pill-ningyuan') <= 0}
title={`凝元丹×${pillCount(s, 'pill-ningyuan')}`}
onClick={() => {
w.takePill(member.id, 'pill-ningyuan')
bump()
}}
>
({pillCount(s, 'pill-ningyuan')})
</button>
)}
{member.realmProgress >= 100 && member.realm.major !== 'mortal' && (
{member.realmProgress >= 70 && member.realm.major !== 'mortal' && (
<button
className="btn btn-sm"
disabled={pillCount(s, 'pill-pojing') <= 0}
title={`破境丹×${pillCount(s, 'pill-pojing')}`}
onClick={() => {
w.takePill(member.id, 'pill-pojing')
bump()
}}
>
({pillCount(s, 'pill-pojing')})
</button>
)}
</>
-5
View File
@@ -89,11 +89,6 @@ export default function SettingsPanel() {
</button>
</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="help-text">
· <br />