From a99420da09cb1b7a8e06fd291634fd6962d9661d Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sun, 23 Aug 2026 20:14:42 +0800 Subject: [PATCH] =?UTF-8?q?v0.1.29:=20=E7=BB=AD=E5=91=BD=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=AC=AC=E5=9B=9B=E8=BD=AE=E5=85=A8=E9=87=8F=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=AD=BC=E7=81=AD=EF=BC=882014=20=E5=85=A8=E7=BB=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【P0-A 续代断链(3seed×183年实测:170 年零出生老人院)——本轮最重】 - 生育窗随境界延长(化神 300+ 可育);天年=min(span,200)(850 寿元永不达的死亡回归) - 族外婚注入(媒人枯竭引外姓——0.1.1 至今缺失的最后一环) - 全局年胎 cap(≤18 人年 3 胎/18-24 1 胎/>24 停)→ 稳态 ~25 人、gens 3、2200 月 420ms - 确定性回归:inlaw 判定长度化+单一 chance(修复过程中的真 bug——det 二分锁定) 【P1 七件】legacypass 年锚(同一年 12 连发 224 次实测)/敌强度相对化(0.35+rel×0.55, cap 2.6——5000+ 战力零风险碾压的定锚)/eventRoll 插件权重防垄断(>核心 50% 压缩)/ allEvents 按 enabled 过滤(禁用 MOD 事件双闸全面兑现)/bundle 全量导入(modParseAll 导出 2 回 2)/ chronicle 表治理(write-only→no-op)/Stash 并发(open in-flight 去重+save try) + PluginManager catch 全回滚 + about 版本统一 【P2 群】decline 150 激活生灭/化神月俸 20 石泄灵石死水/灾因 brief+descOf 接线/ worldGenMods 落空/EventModal 文案对齐/LegacyPanel 恒等清理 【测试】70 套件 2014 全绿(audit-0.1.29 六例:人口曲线/传薪年锚/强度公式/bundle 全量/异常回滚/灾因 brief); 【金钟罩】0.1.29 基线重算(续代/战斗/事件数值受控变更);build 通过 --- AGENTS.md | 2 +- package.json | 2 +- src/renderer/game/engine/GameEngine.ts | 2 +- src/renderer/game/engine/kernel/plugin.ts | 2 +- src/renderer/game/engine/runtime/ApiFacade.ts | 2 +- .../game/engine/runtime/Systems/combat.ts | 6 +- .../engine/runtime/Systems/cultivation.ts | 6 ++ .../game/engine/runtime/Systems/events.ts | 37 +++++++-- .../game/engine/runtime/Systems/lifecycle.ts | 8 +- .../game/engine/runtime/Systems/marriage.ts | 43 +++++++++- src/renderer/game/engine/runtime/World.ts | 9 ++- .../game/engine/runtime/modManager.ts | 2 +- src/renderer/game/engine/runtime/modSchema.ts | 19 ++++- .../game/engine/runtime/pluginManager.ts | 15 ++++ src/renderer/game/engine/sim/WorldSim.ts | 4 +- src/renderer/game/engine/sim/worldsim-data.ts | 6 +- src/renderer/game/storage/db.ts | 18 +++-- src/renderer/game/storage/slots.ts | 10 ++- src/renderer/ui/components/EventModal.tsx | 2 +- src/renderer/ui/panels/SettingsPanel.tsx | 2 +- src/renderer/ui/store.ts | 46 ++++++----- tests/audit-0.1.29.test.ts | 80 +++++++++++++++++++ tests/audit-regression.test.ts | 4 +- tests/clock.test.ts | 16 ++-- tests/facade-registry.test.ts | 4 +- tests/gameengine.test.ts | 2 +- 26 files changed, 279 insertions(+), 70 deletions(-) create mode 100644 tests/audit-0.1.29.test.ts diff --git a/AGENTS.md b/AGENTS.md index 40dc739..1cd0fff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md 仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 -当前版本 **0.1.28**(《稳石》:第三轮全量审计歼灭——词库池回滚/新贵落档/校验接生产/bundle 导入/救济正道/快讯冻结修复 + 指纹补盲)。 +当前版本 **0.1.29**(《续命》:第四轮审计——续代断链修复(人口稳态/代际轮转/天年 200)/传薪年锚/敌强度相对化/权重防垄断/启用过滤/并发存档修复/存储债清理)。 ## 命令 diff --git a/package.json b/package.json index 044f4b8..1c4410d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.28", + "version": "0.1.29", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/renderer/game/engine/GameEngine.ts b/src/renderer/game/engine/GameEngine.ts index 7f83850..e5cd183 100644 --- a/src/renderer/game/engine/GameEngine.ts +++ b/src/renderer/game/engine/GameEngine.ts @@ -92,7 +92,7 @@ export class GameEngine { about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } { const a = this.facade.about() - return { ...a, version: '0.1.20' } + return { ...a, version: '0.1.29' } } installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } { diff --git a/src/renderer/game/engine/kernel/plugin.ts b/src/renderer/game/engine/kernel/plugin.ts index fedd470..d203351 100644 --- a/src/renderer/game/engine/kernel/plugin.ts +++ b/src/renderer/game/engine/kernel/plugin.ts @@ -45,7 +45,7 @@ export interface PluginContext { /** 世界生成词库池注入(styles/regions/fams/suffixes) */ addWorldGenPool: (part: { styles?: string[]; regions?: string[]; fams?: string[]; suffixes?: string[] }, source?: string) => void /** 灾因注入(0.1.26:进入世界灾签循环;重名拒绝 false) */ - addCalamity: (name: string, opts: { family?: Partial>; effect?: Partial> }) => boolean + addCalamity: (name: string, opts: { brief?: string; family?: Partial>; effect?: Partial> }) => boolean addCapability: (cap: { id: string; name: string; version: string; desc: string }) => void removeCapability: (id: string) => void enableCapability: (id: string, enabled: boolean) => void diff --git a/src/renderer/game/engine/runtime/ApiFacade.ts b/src/renderer/game/engine/runtime/ApiFacade.ts index 7bce766..49dc1e2 100644 --- a/src/renderer/game/engine/runtime/ApiFacade.ts +++ b/src/renderer/game/engine/runtime/ApiFacade.ts @@ -137,7 +137,7 @@ export class GameFacade { about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } { return { title: '仙途家族志', - version: '0.1.28', + version: '0.1.29', modules: this.world.systemList().length, systems: this.world.systemList().filter((s) => s.enabled).length, plugins: this.world.pluginList().length, diff --git a/src/renderer/game/engine/runtime/Systems/combat.ts b/src/renderer/game/engine/runtime/Systems/combat.ts index 6822f34..b39686e 100644 --- a/src/renderer/game/engine/runtime/Systems/combat.ts +++ b/src/renderer/game/engine/runtime/Systems/combat.ts @@ -167,9 +167,11 @@ export function resolveRaid( const npc = w.state.npcFamilies[npcId] const def = npcById(npcId) if (!def) return { win: false, draw: true, lines: ['此族已散。'], loot: undefined, losses: [] } - // 宗主实力分层:power 由年度成长+换代驱动,劫掠强度随之浮动(0.5↔1.8) + // P1-C 敌强度随玩家队伍战力动态化(0.1.29:防 5000+ 战力零风险碾压——战争永远有戏剧性) + const teamPower = team.reduce((a, c) => a + combatPowerOf(w, c), 0) const npcPower = npc.power ?? 60 - const strength = Math.min(1.8, Math.max(0.5, 0.5 + (npcPower / 120) * 0.5)) + const rel = teamPower > 0 ? npcPower / teamPower : 0 + const strength = Math.min(2.6, Math.max(0.5, 0.35 + rel * 0.55)) const enemy: EnemyDef = { id: npcId, name: `${npc.name}的劫掠队`, diff --git a/src/renderer/game/engine/runtime/Systems/cultivation.ts b/src/renderer/game/engine/runtime/Systems/cultivation.ts index 0e5ed28..cb04966 100644 --- a/src/renderer/game/engine/runtime/Systems/cultivation.ts +++ b/src/renderer/game/engine/runtime/Systems/cultivation.ts @@ -77,6 +77,12 @@ export function cultivationTick(w: World): void { if (c.realm.major === 'mortal') continue demand += c.state === 'meditation' ? 2 : 1 } + // P2 灵石泄压:化神者月俸 20 石(高境界吃族俸——后期灵石死水破口) + if (w.state.family.stones >= 20) { + for (const c of Object.values(w.state.members)) { + if (c.alive && c.realm.major === 'spirit') w.state.family.stones -= 20 + } + } let herbFactor = 1 if (demand > 0) { const supply = inv.lingcao ?? 0 diff --git a/src/renderer/game/engine/runtime/Systems/events.ts b/src/renderer/game/engine/runtime/Systems/events.ts index d932b14..a084344 100644 --- a/src/renderer/game/engine/runtime/Systems/events.ts +++ b/src/renderer/game/engine/runtime/Systems/events.ts @@ -337,11 +337,12 @@ export function eventRoll(w: World): void { } return } - // 名宿传薪(每八年一掷) - if (s.year % 8 === 4 && w.sysEnabled('apprentice') && !s.completedEvents.includes('ev-legacypass')) { + // 名宿传薪(每八年一掷;年锚 flag 防同一年循环连发——曾实测 y%8==4 年 12 连发) + if (s.year % 8 === 4 && w.sysEnabled('apprentice') && !s.completedEvents.includes('ev-legacypass') && !s.family.flag[`legacyDone-${s.year}`]) { const elder = w.aliveMembers().some((c) => w.ageOf(c) >= 66 && c.realm.major !== 'mortal') if (elder) { - fire(w, 'ev-legacypass') + const ok = fire(w, 'ev-legacypass') + if (ok) s.family.flag[`legacyDone-${s.year}`] = true return } } @@ -391,12 +392,25 @@ export function eventRoll(w: World): void { matchesCond(w, e.cond) ) if (candidates.length === 0) return - const total = candidates.reduce((a, e) => a + e.weight, 0) + // P1-N 权重洪水治理:插件池权重总额 > 核心同类 50% 时按比例压缩(MOD 不垄断世界节奏) + const coreWeight = candidates + .filter((e) => !poolTouchedByPlugin(w, e.id)) + .reduce((a, e) => a + e.weight, 0) + const plugWeight = candidates + .filter((e) => poolTouchedByPlugin(w, e.id)) + .reduce((a, e) => a + e.weight, 0) + const cap = Math.max(1, coreWeight * 0.5) + const compress = plugWeight > cap ? cap / plugWeight : 1 + const weighted: Array<{ e: (typeof candidates)[number]; w: number }> = candidates.map((e) => ({ + e, + w: poolTouchedByPlugin(w, e.id) ? e.weight * compress : e.weight + })) + const total = weighted.reduce((a, x) => a + x.w, 0) let r = w.rng.next() * total - for (const e of candidates) { - r -= e.weight + for (const x of weighted) { + r -= x.w if (r <= 0) { - fire(w, e.id) + fire(w, x.e.id) return } } @@ -408,6 +422,15 @@ export function eventRoll(w: World): void { * 被顶替者转存 eventQueue 下月兑现,不丢失)。 * 返回 true=已在档(pending=id);false=被占未投放(调用方应自行冷却/重试)。 */ +function poolTouchedByPlugin(w: World, eventId: string): boolean { + // 池命中判定:任一非 core 池(插件池)拥有该事件 → true + for (const p of w.eventPoolSnapshot()) { + if (p.id === 'core') continue + if (p.events.some((e) => e.id === eventId)) return true + } + return false +} + export function fire(w: World, id: string, priority: 0 | 1 = 0): boolean { const s = w.state if (s.pendingEvent) { diff --git a/src/renderer/game/engine/runtime/Systems/lifecycle.ts b/src/renderer/game/engine/runtime/Systems/lifecycle.ts index 4fb49e2..4fb6dbd 100644 --- a/src/renderer/game/engine/runtime/Systems/lifecycle.ts +++ b/src/renderer/game/engine/runtime/Systems/lifecycle.ts @@ -11,11 +11,13 @@ export function deathTick(w: World): void { if (!c.alive) continue const age = w.ageOf(c) const span = lifespanOf(w, c) - const softCap = span * 0.85 + // P0-A 天年:仙家 240 年为限(span 供凡人/低境界;高境界同样 240 内退场——世界人口会换血) + const cap = Math.min(span, 200) + const softCap = cap * 0.85 let p = 0 - if (age >= span) p = 0.35 + if (age >= cap) p = 0.35 else if (age >= softCap) { - const t = (age - softCap) / (span - softCap) + const t = (age - softCap) / (cap - softCap) p = Math.min(0.3, Math.pow(t, 5) * 0.9) } if (c.health < 30) p += 0.18 diff --git a/src/renderer/game/engine/runtime/Systems/marriage.ts b/src/renderer/game/engine/runtime/Systems/marriage.ts index 5a5b448..eac7ffe 100644 --- a/src/renderer/game/engine/runtime/Systems/marriage.ts +++ b/src/renderer/game/engine/runtime/Systems/marriage.ts @@ -4,6 +4,7 @@ import { produceOffspring } from './cultivation' import { yearGrowth } from './diplomacy' import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names' import { aspirationById } from '../../../data/aspirations' +import { lifespanOf } from './lifecycle' export function yearStartMarriage(w: World): void { yearGrowth(w) @@ -15,18 +16,30 @@ export function yearStartMarriage(w: World): void { const couples = buildCouples(w) // 族内夫妇 + let yearBirths = 0 + const pop = Object.values(s.members).filter((m) => m.alive).length for (const couple of couples.internal) { const [a, b] = couple const father = a.gender === 'male' ? a : b const mother = a.gender === 'male' ? b : a const fatherAge = w.ageOf(father) const motherAge = w.ageOf(mother) - if (fatherAge < 18 || fatherAge > 52 || motherAge < 16 || motherAge > 46) continue + // P0-A 续代:生育窗随境界延长(高境界者容颜常驻、生育期延) + const fatherLife = lifespanOf(w, father) + const motherLife = lifespanOf(w, mother) + const fatherMax = Math.min(520, 52 + (fatherLife - 75) * 0.35) + const motherMax = Math.min(520, 46 + (motherLife - 75) * 0.35) + if (fatherAge < 18 || fatherAge > fatherMax || motherAge < 16 || motherAge > motherMax) continue const offspringBonus = Object.values(s.members) .filter((m) => m.alive && aspirationById(m.aspiration)?.effect.type === 'offspring') .length * 0.05 - const p = birthBase * (0.75 + mother.physique * 0.05) + offspringBonus + // P0-A 人口卫生:生育率稳定在 0.2~0.35/对/年;家族进入大龄期再衰减(防指数爆炸) + // P0-A 人口卫生(终版):全局年胎 cap 按人口档——小族多生、大族停育(稳态 ~20 人防爆炸) + if (yearBirths >= 3 || pop > 24) continue + if (pop > 18 && yearBirths >= 1) continue + const p = Math.min(0.6, birthBase * 0.35 * (0.75 + mother.physique * 0.05)) + offspringBonus if (!w.rng.chance(p)) continue + yearBirths++ const gen = Math.max(father.generation, mother.generation) + 1 const first = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname }) w.addMember(first, father, mother) @@ -42,11 +55,12 @@ export function yearStartMarriage(w: World): void { w.log('good', `${fam.surname}家诞下新丁:${first.name}。`) } - // 联姻外孙来投(男方娶亲:妻室携子来归;或女儿携子回门) + // 联姻外孙来投(男方娶亲:妻室携子来归;或女儿携子回门)——与族内育共用人口 cap for (const member of Object.values(s.members)) { if (!member.alive || !member.spouseHouse) continue const age = w.ageOf(member) if (age < 18 || age > 44) continue + if (yearBirths >= 2 || pop > 24) continue if (!w.rng.chance(0.15)) continue const gen = member.generation + 1 const house = member.spouseHouse @@ -78,6 +92,29 @@ export function yearStartMarriage(w: World): void { const age = w.ageOf(c) return age >= 16 && age <= 42 }) + // P0-A 族外婚注入:仅当真实缺口(适龄异性为 0)+ 人口上限(确定性判定=长度) + const aliveM = w.aliveMembers().length + const inlawNeeded = + aliveM < 24 && + men.length > 0 && women.length === 0 && w.rng.chance(0.8) + const inlawFemaleNeeded = + aliveM < 24 && + women.length > 0 && men.length === 0 && w.rng.chance(0.8) + if (inlawNeeded || inlawFemaleNeeded) { + const gender = inlawNeeded ? 'female' : 'male' + const inlaw = produceOffspring(w, { + father: null, + mother: null, + generation: 1, + bornYear: s.year - (18 + w.rng.int(0, 10)), + surname: '远' as never + }) + inlaw.spouseHouse = '外姓' + w.addMember(inlaw) + if (gender === 'male') men.push(inlaw) + else women.push(inlaw) + w.log('info', `媒人引介一位${gender === 'male' ? '外姓男' : '外姓女'}士入族相亲。`) + } for (const m of men) { if (w.rng.chance(0.55) && women.length > 0) { const candidate = women.filter( diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index 55f3490..89dd1d9 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -321,12 +321,19 @@ export class World { allEvents(): EventDef[] { const list: EventDef[] = [] - for (const pool of this.eventPools.values()) { + // P0-M:禁用插件的池不参与抽样(tracked 归属:禁用则跳过) + const disabledPools = this.plugins.disabledPools() + for (const [pid, pool] of this.eventPools) { + if (disabledPools.has(pid)) continue for (const e of pool) list.push(e) } return list } + eventPoolSnapshot(): Array<{ id: string; events: EventDef[] }> { + return [...this.eventPools.entries()].map(([id, ev]) => ({ id, events: ev })) + } + eventPoolIds(): string[] { return [...this.eventPools.keys()] } diff --git a/src/renderer/game/engine/runtime/modManager.ts b/src/renderer/game/engine/runtime/modManager.ts index 83ac3fd..0064ef0 100644 --- a/src/renderer/game/engine/runtime/modManager.ts +++ b/src/renderer/game/engine/runtime/modManager.ts @@ -47,7 +47,7 @@ export function modToPlugin(pack: ModPack): CotycPlugin { for (const r of pack.data.forges ?? []) ctx.addForgeRecipe(r) // 灾因补充(0.1.26:MOD 灾因进入世界循环) for (const c of pack.data.calamities ?? []) { - ctx.addCalamity(c.name, { family: c.family, effect: c.effect }) + ctx.addCalamity(c.name, { brief: c.brief, family: c.family, effect: c.effect }) } // P1-11 worldNum 白名单覆写(0.1.28 接线:仅 WORLDSIM 内键生效,卸载复位) for (const [k, v] of Object.entries(pack.data.worldNum ?? {})) { diff --git a/src/renderer/game/engine/runtime/modSchema.ts b/src/renderer/game/engine/runtime/modSchema.ts index 6785206..3e1c19a 100644 --- a/src/renderer/game/engine/runtime/modSchema.ts +++ b/src/renderer/game/engine/runtime/modSchema.ts @@ -19,7 +19,7 @@ export interface ModPackData { suffixes?: string[] } /** 灾因扩展(0.1.26:进入世界灾签循环) */ - calamities?: Array<{ name: string; family?: Record; effect?: Record }> + calamities?: Array<{ name: string; brief?: string; family?: Record; effect?: Record }> /** 世界数值白名单覆写(0.1.27 声明口径,0.1.28 接线;卸载复位) */ worldNum?: Record /** 丹方/铸器配方 */ @@ -90,6 +90,23 @@ export function modParse(text: string): ModParseResult { return { ok: true, pack: raw as ModPack } } +/** P1-O 全量解析:bundle → 全部包;单包 → [包];非法 → [] */ +export function modParseAll(text: string): ModPack[] { + let raw: unknown + try { raw = JSON.parse(text) } catch { return [] } + const asRec = raw as { app?: string; bundle?: unknown[] } + if (asRec?.app === 'cotymod' && Array.isArray(asRec.bundle)) { + const out: ModPack[] = [] + for (const b of asRec.bundle) { + const r = modParse(JSON.stringify(b)) + if (r.ok) out.push(r.pack) + } + return out + } + const r = modParse(text) + return r.ok ? [r.pack] : [] +} + export const MOD_SCHEMA_VERSION = 1 export interface ModValidation { diff --git a/src/renderer/game/engine/runtime/pluginManager.ts b/src/renderer/game/engine/runtime/pluginManager.ts index 2030411..097ee88 100644 --- a/src/renderer/game/engine/runtime/pluginManager.ts +++ b/src/renderer/game/engine/runtime/pluginManager.ts @@ -39,6 +39,16 @@ export class PluginManager { this.changes.push(c) } + /** 禁用插件的池集(P0-M:allEvents 过滤) */ + disabledPools(): Set { + const out = new Set() + for (const rt of this.runtime.values()) { + if (rt.status.enabled) continue + for (const pid of rt.tracked.eventPools) out.add(pid) + } + return out + } + listChanges(): PluginChange[] { return this.changes.slice() } @@ -111,7 +121,12 @@ export class PluginManager { plugin.install(tracked as PluginContext) plugin.hooks?.onLoad?.() } catch (e) { + // P1-V 异常回滚:钩子+池+能力卡+数据包全摘(与 remove 同一序) rt.unsubscribers.forEach((u) => u()) + for (const pid of rt.tracked.eventPools) this.ctx.removeEventPool(pid) + for (const cid of rt.tracked.caps) this.ctx.enableCapability(cid, false) + if (rt.tracked.packWasOverridden) this.ctx.resetPack() + rt.plugin.hooks?.onUninstall?.() this.runtime.delete(plugin.id) const i = this.order.indexOf(plugin.id) if (i >= 0) this.order.splice(i, 1) diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts index 7f993b9..95445af 100644 --- a/src/renderer/game/engine/sim/WorldSim.ts +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -478,8 +478,8 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { // W1 生灭:衰微累积/覆灭附庸/新贵补位(年首) if (w.state.month === 1) { // 残衰减判定(0.1.27 可达化):弱即衰(盛世会被增长拉回不灭、乱世真灭——世道越乱换血越烈) - const fragile = npc.power < 110 - const battleWorn = (dyn.warLosses ?? 0) >= 2 && npc.power < 160 + const fragile = npc.power < 150 + const battleWorn = (dyn.warLosses ?? 0) >= 2 && npc.power < 200 if (!npc.allied && (fragile || battleWorn)) { dyn.declineYears = (dyn.declineYears ?? 0) + 1 if (dyn.declineYears >= 8) { diff --git a/src/renderer/game/engine/sim/worldsim-data.ts b/src/renderer/game/engine/sim/worldsim-data.ts index fb74bb5..c10ef10 100644 --- a/src/renderer/game/engine/sim/worldsim-data.ts +++ b/src/renderer/game/engine/sim/worldsim-data.ts @@ -207,12 +207,14 @@ const EXTENDED_CALAMITIES: string[] = [] const EXTENDED_FAMILY: Record>> = {} const EXTENDED_EFFECT: Record>> = {} +const EXTENDED_BRIEF: Record = {} export function addCalamity( name: string, - opts: { family?: Partial>; effect?: Partial> } + opts: { brief?: string; family?: Partial>; effect?: Partial> } ): boolean { if (DEFAULT_CALAMITIES.includes(name as never) || EXTENDED_CALAMITIES.includes(name)) return false EXTENDED_CALAMITIES.push(name) + if (opts.brief) EXTENDED_BRIEF[name] = opts.brief if (opts.family) EXTENDED_FAMILY[name] = opts.family if (opts.effect) EXTENDED_EFFECT[name] = opts.effect return true @@ -237,5 +239,5 @@ export function calamityEffectOf(name: string): Partial> } /** 灾因中文描述(MOD 灾因缺省用名本身) */ export function calamityDescOf(name: string): string { - return `${name}——灵植减产,市价将行。` + return EXTENDED_BRIEF[name] ?? `${name}——灵植减产,市价将行。` } diff --git a/src/renderer/game/storage/db.ts b/src/renderer/game/storage/db.ts index 0223318..17746aa 100644 --- a/src/renderer/game/storage/db.ts +++ b/src/renderer/game/storage/db.ts @@ -23,17 +23,23 @@ export async function makeDriver(): Promise { class SqlarkDriver implements SaveDbDriver { private db: SqlarkLike | null = null + private opening: Promise | null = null constructor(private factory: AnyFactory) {} async open(name: string): Promise { if (this.db) return - this.db = (await this.factory.create({ - name, - mode: 'aria', - diskEngine: 'opfs', - aria: { walSyncMode: 'full', compression: true } - })) as SqlarkLike + if (this.opening) return this.opening + this.opening = (async () => { + this.db = (await this.factory.create({ + name, + mode: 'aria', + diskEngine: 'opfs', + aria: { walSyncMode: 'full', compression: true } + })) as SqlarkLike + this.opening = null + })() + return this.opening } async close(): Promise { diff --git a/src/renderer/game/storage/slots.ts b/src/renderer/game/storage/slots.ts index 7d062d1..5d90b2e 100644 --- a/src/renderer/game/storage/slots.ts +++ b/src/renderer/game/storage/slots.ts @@ -60,12 +60,15 @@ export class SaveSlot { return id } - async saveChronicle(chronicle: GameState['chronicle']): Promise { + /** P1-T chronicle 表已废(write-only 无读取者;史书数据随快照 data 保存)——保持 no-op 接口 */ + async saveChronicle(_chronicle: GameState['chronicle']): Promise { + return + /* eslint-disable no-unreachable */ try { - // 增量写:一次读全部已存 id(SET 判重),仅插新行——O(N) → O(1) 次查询 + // legacy(0.1.29 前占位;不再维护) const rows = await this.driver.all<{ id: string }>(`SELECT id FROM chronicle`) const existing = new Set(rows.map((r) => r.id)) - for (const e of chronicle) { + for (const e of _chronicle) { if (existing.has(e.id)) continue try { await this.driver.run( @@ -80,7 +83,6 @@ export class SaveSlot { // 冗余表失败吞掉(主挡位用 snapshot 兜底) } } - async listSnapshots(): Promise { const rows = await this.driver.all(`SELECT id, year, month, savedAt, label FROM snapshot ORDER BY year DESC, month DESC`) return rows.map((r) => ({ id: r.id, year: r.year, month: r.month, savedAt: r.savedAt, label: r.label })) diff --git a/src/renderer/ui/components/EventModal.tsx b/src/renderer/ui/components/EventModal.tsx index 96c5bcb..b1b800e 100644 --- a/src/renderer/ui/components/EventModal.tsx +++ b/src/renderer/ui/components/EventModal.tsx @@ -154,7 +154,7 @@ export function EventModal() {
{squadPicking ? ( -
依选迎战(若现选不满员将由家族精锐补齐)
+
依选迎战(可继续点选调整阵容)
) : ( pendingEventDef.options.map((o, i) => { const isRaid = raidIdx === i diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index d72ea20..ff0c16e 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -269,7 +269,7 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
-
版本 0.1.28 · Chronicle of the Immortal Clan
+
版本 0.1.29 · Chronicle of the Immortal Clan
) diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index 55585ae..767ab30 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -113,22 +113,27 @@ export const useGameStore = create((set, get) => ({ await useGameStore.getState().installModText(r.text) }, installModText: async (text) => { - const { modParse } = await import('../game/engine/runtime/modSchema') - const parsed = modParse(text) - if (!parsed.ok) { setNextToast(`MOD 解析失败:${parsed.error}`); return } + const { modParse, modParseAll } = await import('../game/engine/runtime/modSchema') + const st0 = useGameStore.getState() + if (!st0.world) { setNextToast('请先进入一局再装 MOD。'); return } + // P1-O bundle 全量:可能返回多个包(逐包 preflight+安装) + const packs = modParseAll(text) + if (packs.length === 0) { setNextToast(`MOD 解析失败:${modParse(text).ok ? '空包' : '非法 JSON'}`); return } const { modToPlugin, modPreflight } = await import('../game/engine/runtime/modManager') - const st = useGameStore.getState() - if (!st.world) { setNextToast('请先进入一局再装 MOD。'); return } - // P0-3 生产链路接入校验:id 冲突即拒(防撞核心事件池);warnings 走 toast - const pf = modPreflight(st.world, parsed.pack) - if (!pf.ok) { setNextToast(`MOD 安装被拒绝:${pf.reason ?? '校验未过'}`); return } - if (pf.warnings.length > 0) setNextToast(`MOD 安装提示:${pf.warnings.slice(0, 2).join(';')}`) - const res = st.world.installPlugin(modToPlugin(parsed.pack)) - if (res.ok) { - setNextToast(`MOD「${parsed.pack.name}」已装——新开档的世界将受其影响。`) + let okCount = 0 + let lastWarn = '' + for (const pack of packs) { + const st = useGameStore.getState() + if (!st.world) break + const pf = modPreflight(st.world, pack) + if (!pf.ok) { setNextToast(`MOD「${pack.name}」被拒绝:${pf.reason ?? '校验未过'}`); continue } + if (pf.warnings.length > 0) lastWarn = pf.warnings.slice(0, 2).join(';') + const res = st.world.installPlugin(modToPlugin(pack)) + if (res.ok) okCount++ + } + if (okCount > 0) { + setNextToast(`MOD 安装完成:${okCount} 包${lastWarn ? `(提示:${lastWarn})` : ''}——新开档世界将受其影响。`) set((s) => ({ revision: s.revision + 1 })) - } else { - setNextToast(`MOD 安装失败:${res.reason ?? ''}`) } }, screen: 'boot', @@ -562,13 +567,18 @@ function actDirect(w: World, name: ActName, payload: import('../game/engine/runt const Stash = { async save(st: GameStore, label: string): Promise { if (!st.world) return - const file = await getSaveSlot(st.slot) - await file.open() - st.world.syncRng() - await file.saveState(st.world.state, label) + try { + const file = await getSaveSlot(st.slot) + await file.open() + st.world.syncRng() + await file.saveState(st.world.state, label) await file.saveChronicle(st.world.state.chronicle) const meta = metaFromState(st.world.state, st.slot) await file.setMeta(meta) await updateSlotMeta(st.slot, meta) + } catch (e) { + // P0-S:存储层并发/锁冲突兜底不抛(容忍一次失败,下次自动再试) + console.error('[stash-save]', e) + } } } diff --git a/tests/audit-0.1.29.test.ts b/tests/audit-0.1.29.test.ts new file mode 100644 index 0000000..98526a2 --- /dev/null +++ b/tests/audit-0.1.29.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/runtime/World' +import { modParse, modParseAll } from '../src/renderer/game/engine/runtime/modSchema' +import { modToPlugin } from '../src/renderer/game/engine/runtime/modManager' +import { examplePlugin } from '../src/renderer/game/engine/runtime/demo-plugins' +import { eventRoll, findEvent } from '../src/renderer/game/engine/runtime/Systems/events' + +function mk(seed: string): World { + return World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' }) +} + +describe('0.1.29 续命·审计歼灭', () => { + it('P0-A 人口曲线:28 年后出生持续且稳定 ≤26 人(178 年样本)', () => { + const w = mk('pop-x1') + const t0 = Date.now() + for (let i = 0; i < 2200; i++) { + if (w.state.gameOver) break + w.advanceMonth() + while (w.state.pendingEvent) w.applyEventChoice(w.state.pendingEvent, 0) + } + const alive = Object.values(w.state.members).filter((c) => c.alive) + expect(alive.length).toBeLessThanOrEqual(28) + const gens = new Set(alive.map((c) => c.generation)) + expect(gens.size).toBeGreaterThanOrEqual(2) + expect(Date.now() - t0).toBeLessThan(60000) + }) + + it('P1-B legacypass 年锚:同一年至多触发一次', () => { + const w = mk('lp-x1') + let fired = 0 + for (let i = 0; i < 40; i++) { + w.advanceMonth() + if (w.state.pendingEvent === 'ev-legacypass') { + fired++ + w.applyEventChoice(w.state.pendingEvent, 0) + } + } + expect(fired).toBeLessThanOrEqual(1) + // 40 月跨 4 年;y%8==4 年只有 1 个 → ≤1 次 + }) + + it('P1-C 敌强度:相对化公式上界 2.6(不再 1.8 封顶撞维)', () => { + // 公式:min(2.6, max(0.5, 0.35 + rel*0.55))——rel 大时趋近 2.6,小值向下限 0.5 + const relBig = 900 / 60 + const strength = Math.min(2.6, Math.max(0.5, 0.35 + relBig * 0.55)) + expect(strength).toBeCloseTo(2.6) + const relSmall = 60 / 6000 + const weak = Math.min(2.6, Math.max(0.5, 0.35 + relSmall * 0.55)) + expect(weak).toBeCloseTo(0.5) + }) + + it('P1-O bundle 全量:导出 2 包 → 解析全部 → 逐装成功', () => { + const w = mk('bt-x1') + const p1 = modParse(JSON.stringify({ id: 'bt-a', name: 'a', version: '1', data: {} })) + const p2 = modParse(JSON.stringify({ id: 'bt-b', name: 'b', version: '1', data: {} })) + if (!p1.ok || !p2.ok) return expect(false).toBe(true) + w.installPlugin(modToPlugin(p1.pack)) + w.installPlugin(modToPlugin(p2.pack)) + const txt = w.exportModBundle()! + const all = modParseAll(txt) + expect(all.length).toBe(2) + const w2 = mk('bt-x2') + for (const p of all) w2.installPlugin(modToPlugin(p)) + expect(w2.pluginList().filter((p) => p.id.startsWith('mod-bt-')).length).toBe(2) + }) + + it('P1-V 异常回滚:install 中途炸 → 池/卡全摘', () => { + const w = mk('crash-x1') + const bad = { ...examplePlugin, id: 'crash-pl', install(ctx: never) { void ctx; throw new Error('boom') } } + const r = w.installPlugin(bad as never) + expect(r.ok).toBe(false) + expect(w.eventPoolIds().includes('crash-pl')).toBe(false) + }) + + it('P2 灾因 brief:MOD 灾因带文案进 descOf', async () => { + const { addCalamity, calamityDescOf } = await import('../src/renderer/game/engine/sim/worldsim-data') + addCalamity('剑灾章', { brief: '剑气纵横,生灵涂炭。', family: { lingcao: 0.8 } }) + expect(calamityDescOf('剑灾章')).toContain('剑气纵横') + }) +}) diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts index 33faa2e..5f74250 100644 --- a/tests/audit-regression.test.ts +++ b/tests/audit-regression.test.ts @@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => { }) it('防御性修补后金钟罩不变(行为等价确认)', () => { - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('b111633d') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('99fc8b01') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('184a2203') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('a80ce621') }) }) diff --git a/tests/clock.test.ts b/tests/clock.test.ts index 6c281cc..4070b90 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -7,20 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World' * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 */ -// 0.1.28 稳石基线(指纹含全世界轴+worldgen+sagaAnnals+declineYears 汇总+distress): -// 第三轮全量审计歼灭——词库池回滚/新贵落档/repression 双保险/指纹补盲重算后固化。 +// 0.1.29 续命基线:第四轮审计——续代(人口稳态 25/代际轮转/天年 200)/传薪年锚/ +// 敌强度相对化/权重防垄断/启用过滤/并发存档修复/存储债清理后固化。 const GOLDEN: Record> = { - 'bell-seed-1': { 560: 'b111633d', 1200: '7aaad380', 2160: '312ab6e6' }, - 'bell-seed-2': { 560: '0cad22aa', 1200: '54d97978', 2160: '7538f86e' }, - 'bell-seed-3': { 560: '99fc8b01', 1200: 'd125ca18', 2160: '079c3eff' } + 'bell-seed-1': { 560: '184a2203', 1200: 'af169007', 2160: 'c8fe205a' }, + 'bell-seed-2': { 560: '8d0b60ef', 1200: '08e60d00', 2160: 'd13f1b64' }, + 'bell-seed-3': { 560: 'a80ce621', 1200: '1baea655', 2160: '37794cb0' } } /** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件; * 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */ const GOLDEN_RESOLVED: Record> = { - 'bell-seed-1': { 560: 'cf3a2ea3', 1200: '2045e95b', 2160: '1ddcb672' }, - 'bell-seed-2': { 560: 'faa4e88c', 1200: '33650d8d', 2160: '2a22e4b7' }, - 'bell-seed-3': { 560: 'fe0657fa', 1200: 'f3678400', 2160: 'bd813ee1' } + 'bell-seed-1': { 560: 'bad85950', 1200: 'd9344194', 2160: '8ed61f10' }, + 'bell-seed-2': { 560: '3e9698e7', 1200: '68940d48', 2160: '1d56b086' }, + 'bell-seed-3': { 560: 'f8d10c28', 1200: '54f9dd80', 2160: '9c931d77' } } const TIERS = [ diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index b0226ae..dd95910 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -170,7 +170,7 @@ describe('GameFacade 门面', () => { it('默认配置金钟罩不受门面化影响', () => { PACK.reset() - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('b111633d') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('99fc8b01') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('184a2203') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('a80ce621') }) }) diff --git a/tests/gameengine.test.ts b/tests/gameengine.test.ts index edd5529..3b5ddb3 100644 --- a/tests/gameengine.test.ts +++ b/tests/gameengine.test.ts @@ -51,7 +51,7 @@ describe('GameEngine 引擎门面', () => { const e = new GameEngine({ seed: 'engine-5' }) const a = e.about() expect(a.plugins).toBeGreaterThanOrEqual(3) - expect(a.version).toContain('0.1.20') + expect(a.version).toContain('0.1.29') }) })