From 631099fd8c4b13382822c0f831378f410a3ad29e Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sun, 13 Sep 2026 16:38:24 +0800 Subject: [PATCH] =?UTF-8?q?0.1.41=20=E5=A4=A9=E5=91=BD=C2=B7=E5=85=91?= =?UTF-8?q?=E7=8E=B0=EF=BC=9A=E5=A4=A9=E5=91=BDflag=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF15+=E9=A1=B9=E5=8D=B3=E6=97=B6=E5=85=91?= =?UTF-8?q?=E7=8E=B0=20+=20NPC=E6=BC=94=E5=8C=96=E7=A7=8D=E5=AD=90?= =?UTF-8?q?=E6=B4=BE=E7=94=9F=E9=9B=B6=E4=B8=BBrng=E6=B6=88=E8=80=97=20+?= =?UTF-8?q?=20pushNews/purgeNpc=E7=A1=AE=E5=AE=9A=E6=80=A7=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20=E6=97=8F=E5=8F=B2=E5=8D=B7=E5=8F=99=E4=BA=8B?= =?UTF-8?q?=E5=B1=82chronicle-scroll.ts=20+=20=E6=97=B6=E6=95=88flag?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86=20+=20UI=E5=BB=BA=E7=AD=91?= =?UTF-8?q?=E4=B8=93=E7=B2=BE=E5=8A=A0=E6=88=90=E9=A2=84=E8=A7=88=20+=20Le?= =?UTF-8?q?gacyPanel=E6=97=8F=E5=8F=B2=E5=8D=B7=E8=A7=86=E5=9B=BE=20+=20?= =?UTF-8?q?=E9=87=91=E9=92=9F=E7=BD=A9=E9=87=8D=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +- README.md | 5 +- docs/MOD_GUIDE.md | 4 +- docs/PROJECT_MEMORY.md | 1 + package.json | 2 +- .../game/engine/narrative/chronicle-scroll.ts | 148 ++++++++ .../game/engine/runtime/Systems/combat.ts | 6 +- .../engine/runtime/Systems/cultivation.ts | 6 + .../game/engine/runtime/Systems/events.ts | 320 +++++++++++++++++- .../game/engine/runtime/Systems/production.ts | 10 +- src/renderer/game/engine/runtime/World.ts | 36 +- src/renderer/game/engine/runtime/creation.ts | 21 ++ src/renderer/game/engine/sim/WorldSim.ts | 140 +++++--- src/renderer/game/storage/slots.ts | 2 +- src/renderer/ui/panels/LegacyPanel.tsx | 38 ++- src/renderer/ui/panels/SettingsPanel.tsx | 2 +- src/renderer/ui/panels/TerritoryPanel.tsx | 38 ++- src/renderer/ui/storeHelper.ts | 2 +- tests/audit-regression.test.ts | 6 +- tests/clock.test.ts | 16 +- tests/facade-registry.test.ts | 6 +- tests/storage-resilience-0.1.38.test.ts | 10 +- 22 files changed, 745 insertions(+), 80 deletions(-) create mode 100644 src/renderer/game/engine/narrative/chronicle-scroll.ts diff --git a/AGENTS.md b/AGENTS.md index ea0e0f4..075e2ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md 仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 -当前版本 **0.1.40**(《天命·裂痕》:天命议题系统 20 条 5 类种子派生选取 + NPC 宗主更替四模式 + NPC 势力分裂 + 灰烬重生新贵补位 + 建筑专精系统 extra×1.5 + UI memo 化 4 处 + 天下大事记叙事层 saga.ts + WorldPanel/LegacyPanel 大事记展示)。 +当前版本 **0.1.41**(《天命·兑现》:天命 flag 全量接线 15+ 项即时兑现 + NPC 演化种子派生零主 rng 消耗 + pushNews/purgeNpc 确定性修复 + 族史卷叙事层 chronicle-scroll.ts + 时效 flag 自动清理 + UI 建筑专精加成预览 + LegacyPanel 族史卷视图)。 ## 命令 @@ -45,7 +45,7 @@ src/renderer/game/ 纯 TS 引擎(无 React/DOM,vitest 直 Systems/(production/aging/cultivation/missions/events/diplomacy/marriage/tribulation/tournament/lifecycle/combat) capabilities.ts(12 能力卡)/pluginManager.ts/plugin-bootstrap.ts/modSchema.ts(MOD 范式)/modManager.ts(MOD→插件)/creation.ts/pcgen.ts engine/sim/ 世界自进化: WorldSim.ts / worldsim-data.ts(数值与常量唯一权威)/worldgen.ts(世界种子生成器)/Market.ts / worldsim-brief.ts - engine/narrative/ 族谱/传记/定鼎/年轴/百年报告 + engine/narrative/ 族谱/传记/定鼎/年轴/百年报告/族史卷(chronicle-scroll) data/ balance 表 + DataPackRegistry(registry.ts pack() 单源;PILL_RECIPES/FORGE_RECIPES/ERA_CONF 等) storage/ db(MetonaSqlark 单例)/slots(槽+快照+exportEnvelope)/migrate(CURRENT_SCHEMA=2,即 normalize 即迁移) types/domain.ts 全量领域类型(改状态结构先看它) @@ -64,7 +64,7 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存 - **插件架构**(0.1.8 起,路径已迁 engine/runtime|kernel):`kernel/plugin.ts` 协议、`runtime/pluginManager.ts` 安装管线(依赖校验/异常回滚/追踪型上下文——注销时钩子全摘)、`plugin-bootstrap.ts` 三核心插件(core-systems/core-data/core-events protected)。事件多池 `world.eventPools`;**findEvent 必须带 world 参数查池**。能力卡 12 张经 `viaCap` 注册;tournament/tribulation/apprentice/season 四卡各自内部检查 `w.sysEnabled`(无独立钩子)。 - **MOD 红线(0.1.26 规范)**:MOD 只含 JSON 零脚本(`docs/MOD_GUIDE.md`);**禁止新增 rng 消耗**(全局随机序列);不改核心时序(扩展走五口:事件池/模板/词库/灾因/配方);`modPreflight`+`validateMod` 先行(id 冲突用 `findEvent` 权威检测);MOD 只影响**新开档**世界(老档世界变形成档记录);MOD 状态随 `state.plugins` 持久化(对应 `worldGen.npcs` 落档保证读档永稳)。 - **门面**:`runtime/ApiFacade.ts` GameFacade(act 24 项/query 6 类/subscribe 退订协议/about);**UI 面板事实走 world 直调**(act 为测试/自动化通道),`actDirect` 与 ACT_CATALOG 语义对齐(0.1.20 补齐全 24 项);store 的 `facade` 必须在 openState/startNewGame 赋值。 -- **金钟罩(双基线)**:`tests/clock.test.ts` 的 `GOLDEN`(直调冻结世界)+ `GOLDEN_RESOLVED`(自动 resolve 长跑"现实"世界),各 3 seed × 三档月数(560/1200/2160)共 18 值。当前为 **0.1.40 基线**(0.1.25/0.1.26/0.1.31 零漂;0.1.27/0.1.28/0.1.29/0.1.30/0.1.32/0.1.33 受控变更重算;0.1.39 purgeNpc 清理 worldGen.npcs/relations 僵尸定义——bell-seed-2 三档受控漂移;0.1.40 天命系统+NPC演化增强+建筑专精——金钟罩重算)。指纹 `tests/fingerprint.helper.ts` 含全世界轴(era/stance/prosperity/relationsWithOthers/calamity/pendingEvent/eventQueue/worldAnnals/**worldGen 摘要/sagaAnnals/distress/declineYears 汇总**——0.1.27 生灭/世鉴路径真实锁定)。**有意变更时序/数值时**:指纹一并重算并在注释注明原因。 +- **金钟罩(双基线)**:`tests/clock.test.ts` 的 `GOLDEN`(直调冻结世界)+ `GOLDEN_RESOLVED`(自动 resolve 长跑"现实"世界),各 3 seed × 三档月数(560/1200/2160)共 18 值。当前为 **0.1.41 基线**(0.1.25/0.1.26/0.1.31 零漂;0.1.27/0.1.28/0.1.29/0.1.30/0.1.32/0.1.33 受控变更重算;0.1.39 purgeNpc 清理 worldGen.npcs/relations 僵尸定义——bell-seed-2 三档受控漂移;0.1.40 天命系统+NPC演化增强+建筑专精——金钟罩重算;0.1.41 NPC演化种子派生+pushNews确定性修复+天命flag全量接线——金钟罩重算)。指纹 `tests/fingerprint.helper.ts` 含全世界轴(era/stance/prosperity/relationsWithOthers/calamity/pendingEvent/eventQueue/worldAnnals/**worldGen 摘要/sagaAnnals/distress/declineYears 汇总**——0.1.27 生灭/世鉴路径真实锁定)。**有意变更时序/数值时**:指纹一并重算并在注释注明原因。 ## 世界自演进模型(0.1.14 sim/ 层,改数值勿动流程) diff --git a/README.md b/README.md index 1952fb4..23225a2 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存 ## 守护机制 -- **双金钟罩**:`tests/clock.test.ts` 的 `GOLDEN`(直调冻结世界)+ `GOLDEN_RESOLVED`(自动 resolve 长跑“现实”世界),各 3 固定 seed × 560/1200/2160 月(47/100/180 年)= 18 指纹;指纹含全世界轴(era/stance/prosperity/关系网/事件闸);**0.1.40 基线**。有意变更时序需受控更新并注明。 +- **双金钟罩**:`tests/clock.test.ts` 的 `GOLDEN`(直调冻结世界)+ `GOLDEN_RESOLVED`(自动 resolve 长跑“现实”世界),各 3 固定 seed × 560/1200/2160 月(47/100/180 年)= 18 指纹;指纹含全世界轴(era/stance/prosperity/关系网/事件闸);**0.1.41 基线**。有意变更时序需受控更新并注明。 - **事件闸**:pending 唯一入口 `fire(w,id,priority)`——高优(raid/渡劫/大比)可顶替被占者,被顶替者入队下月兑现;`applyEventChoice` 前置校验防陈旧 Modal 二次结算。 - **插件化**:万物皆是插件(系统/数据包/事件池),三核心插件 protected 常驻;示例见 tests/helpers/examplePlugin.ts。 - **可回放**:种子随机(sfc32,引擎域零 Math.random)+ 快照回放保真(restore 重建内核+回置 rng)。 @@ -55,7 +55,7 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存 - 图标资源 resources/icon.png|ico —— electron-builder 自动使用 - 注意:WSL/Linux 交叉打 win 包可行但未签名,正规发布请用 Windows 侧重跑 -## 版本沿革(0.1.0 → 0.1.40) +## 版本沿革(0.1.0 → 0.1.41) | 版本 | 主题 | |---|---| @@ -89,5 +89,6 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存 | 0.1.38 | 存档根治·石碑重刻(OPFS 探测+自动降级 IndexedDB + 回读校验 + 诊断面板 + driver.open 锁死修复) | | 0.1.39 | 淬体·洗髓(missionIds 根治 + chronicle 智能裁剪 + worldsim 双闸守卫 + purgeNpc 僵尸定义清理 + MemberCard memo 化 + 飞升视觉强化 + 测试 7058) | | 0.1.40 | 天命·裂痕(天命议题系统 20 条 5 类种子派生选取 + NPC 宗主更替四模式 + NPC 势力分裂 + 灰烬重生新贵补位 + 建筑专精系统 extra×1.5 + UI memo 化 4 处 + 天下大事记叙事层 saga.ts + WorldPanel/LegacyPanel 大事记展示) | +| 0.1.41 | 天命·兑现(天命 flag 全量接线 15+ 项即时兑现 + NPC 演化种子派生零主 rng 消耗 + pushNews/purgeNpc 确定性修复 + 族史卷叙事层 chronicle-scroll.ts + 时效 flag 自动清理 + UI 建筑专精加成预览 + LegacyPanel 族史卷视图) | 详见:`docs/PROJECT_MEMORY.md`(设计哲学与每个迭代的"为什么")。 diff --git a/docs/MOD_GUIDE.md b/docs/MOD_GUIDE.md index d25e1b0..e2144b7 100644 --- a/docs/MOD_GUIDE.md +++ b/docs/MOD_GUIDE.md @@ -1,6 +1,6 @@ # MOD 开发教程 · Chronicle of the Immortal Clan -> 版本 **0.1.40·《天命·裂痕》** · MOD_SCHEMA_VERSION = 1 · 全口纯 JSON(零脚本) +> 版本 **0.1.41·《天命·兑现》** · MOD_SCHEMA_VERSION = 1 · 全口纯 JSON(零脚本) > **本教程即全部所需**:你不需要读任何源码或类型文件——所有 MOD 能用的字段、取值、默认值、坑点、示例都写在下面。 > 照例动手即可:最快 5 分钟做出你的第一个 MOD。 @@ -989,4 +989,4 @@ MOD 可以注册**自定义奇观类型**,加入年首种子派生选取池: > 0.1.35 涤荡注:无存档迁移(游戏未发布——旧档随版本演进失效属预期);`patch` 层按插件归属回滚;validate 覆盖 §12 全部规则。 > 0.1.37 因果奇观注:奇观零 rng(种子派生选取池含 MOD 扩展);`eff.karma` 天然支持;`wonderChance`/`karmaDecayRate`/`karmaHighThreshold`/`karmaLowThreshold` 可经 worldNum 覆写;飞升事件 `ev-ascension-*` 前缀保护防劫持。 -⚔️ 本文档随游戏版本更新(0.1.40)。字段若有新增,此处先行。 +⚔️ 本文档随游戏版本更新(0.1.41)。字段若有新增,此处先行。 diff --git a/docs/PROJECT_MEMORY.md b/docs/PROJECT_MEMORY.md index 7cc4e79..6ea6ae4 100644 --- a/docs/PROJECT_MEMORY.md +++ b/docs/PROJECT_MEMORY.md @@ -54,6 +54,7 @@ | 0.1.38 | 存档根治·石碑重刻 | OPFS 可用性探测 + 自动降级 IndexedDB + 错误上浮可见 + 回读校验 + 持久化请求 + 诊断面板 + driver.open 永久锁死修复 + exportSave open 补全 | | 0.1.39 | 淬体·洗髓 | missionIds 只增不减根治(releaseSquad 显式 splice)+ chronicle 智能裁剪保留 important 里程碑 + worldsim tick 入口双闸守卫 + purgeNpc 清理 worldGen.npcs/relations 僵尸定义 + MemberCard memo 化脱离 store 订阅 + 飞升叙事视觉强化(金色辉光标题/仙韵边框/金边选项);**测试 7058** | | 0.1.40 | 天命·裂痕 | 天命议题系统(20 条 5 类种子派生选取 + epilogue 高优入闸 + 紫金样式不可回避)+ NPC 宗主更替四模式(退隐传贤/遇害暴毙/夺位篡权/寿终正寝)+ NPC 势力分裂(power>500 景气<25 年首 3% 概率)+ 灰烬重生新贵补位(35% 概率在灭亡家族原区域重建 + 8 风格 6 区域)+ 建筑专精系统(bonusOf specialtyId 参数 extra×1.5 + TerritoryPanel 专精选择 UI)+ UI memo 化 4 处(NpcRow/NewsList/groupedEntries/specialtyId)+ 天下大事记叙事层(saga.ts buildSaga 8 类标签色系 + WorldPanel 展示区 + LegacyPanel 百年报告引用)| +| 0.1.41 | 天命·兑现 | 天命 flag 全量接线 15+ 项即时兑现(tideHarvest 灵草×1.5/mercenaryHired 战力×1.15/banditRecruit 入族/treasureExplored 古宝探索/artisanHired 丹房+10%/prodigyAdopted 天骄/envoyToAlliance 仙盟/coalitionFormed 联盟/successionElder 立长/successionWise 立贤/elderCouncil 长老会/advisoryBoard 参议等)+ NPC 演化种子派生(FNV1a 哈希 npcDerive 系列函数替代 evolveNpc/splitNpcDynasty/spawnNewbornDynasty/assessStance/pushNews 中所有 w.rng 调用——零主 rng 消耗)+ purgeNpc 确定性修复(不再 unregisterNpcDef——并行 World 实例隔离)+ 族史卷叙事层(chronicle-scroll.ts 编年史+大事记+天命决策串联)+ 时效 flag 自动清理(tideHarvestUntil/mercenaryUntil/envoyReturnUntil 到期自动删除+使者归来逻辑)+ UI 建筑专精加成预览(TerritoryPanel bonusOf 预览)+ LegacyPanel 族史卷视图(cscroll)+ 金钟罩重算(18 值全量更新)| ## 三、关键架构决策(及为什么) diff --git a/package.json b/package.json index 0a689a1..96050e8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.40", + "version": "0.1.41", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/renderer/game/engine/narrative/chronicle-scroll.ts b/src/renderer/game/engine/narrative/chronicle-scroll.ts new file mode 100644 index 0000000..ff7dea7 --- /dev/null +++ b/src/renderer/game/engine/narrative/chronicle-scroll.ts @@ -0,0 +1,148 @@ +/** + * 0.1.41 族史卷叙事层 + * + * 将 GameState.chronicle(编年史原始条目)+ WorldSim 大事记 + 天命决策记录 + * 串联成一个按时间轴排列的叙事性"族史卷"序列。 + * + * 设计原则: + * - 纯函数(不修改 state) + * - 去重 + 裁剪(上限 80 条——族史长卷不无限膨胀) + * - 叙事化:原始 chronicle 条目经文本润色后输出 + * - 天命串联:天命决策(mandateLastId)标记在时间轴上,与同年的族中事件并列展示 + * + * 数据来源: + * 1. state.chronicle — 家族编年史(生卒/突破/外交/建筑/事件/战役/死亡) + * 2. state.worldSim.newsFeed — 天下快讯中的里程碑事件 + * 3. state.family.flag['mandateYear'] / mandateLastId — 天命决策记录 + * 4. state.family.flag['mandateCount'] — 天命累计次数 + */ + +import type { GameState, ChronicleEntry } from '../../types/domain' +import type { WorldSimState } from '../sim/worldsim-data' +import { mandateById, mandateCategoryName } from '../../data/mandate' + +/** 族史卷条目 */ +export interface ChronicleScrollEntry { + year: number + /** 事件类型 */ + kind: 'mandate' | 'breakthrough' | 'death' | 'birth' | 'marriage' | 'diplomacy' | 'building' | 'event' | 'battle' | 'misc' | 'world' + /** 叙事文本 */ + text: string + /** 是否重要里程碑(飞升/大境界突破/天命等) */ + important: boolean + /** 相关成员 id(如有) */ + memberId?: string +} + +/** 从 GameState + WorldSimState 中提取族史卷 */ +export function buildChronicleScroll(state: GameState, ws?: WorldSimState): ChronicleScrollEntry[] { + const entries: ChronicleScrollEntry[] = [] + + // 1. 从 chronicle 提取全部条目 + for (const e of state.chronicle) { + const entry = chronicleToScroll(e) + if (entry) entries.push(entry) + } + + // 2. 从 worldSim.newsFeed 提取天下里程碑 + if (ws) { + const seen = new Set() + for (const n of ws.newsFeed ?? []) { + if (n.kind === 'annal' || n.kind === 'calamity') { + const key = `${n.year}:${n.text.slice(0, 20)}` + if (seen.has(key)) continue + seen.add(key) + entries.push({ + year: n.year, + kind: 'world', + text: n.text, + important: n.kind === 'calamity' + }) + } + } + } + + // 3. 天命决策记录——从 mandateYear/mandateLastId 提取 + const mandateYear = state.family.flag['mandateYear'] as number | undefined + const mandateLastId = state.family.flag['mandateLastId'] as string | undefined + if (mandateYear && mandateLastId) { + const def = mandateById(mandateLastId) + if (def) { + entries.push({ + year: mandateYear, + kind: 'mandate', + text: `【天命·${mandateCategoryName(def.category)}】${def.name}:${def.text.slice(0, 60)}${def.text.length > 60 ? '……' : ''}`, + important: true + }) + } + } + + // 按年份排序(同年按 important 优先) + entries.sort((a, b) => a.year - b.year || (b.important ? 1 : 0) - (a.important ? 1 : 0)) + + // 去重:同年同 kind 同文本前 30 字符只保留一条 + const dedupMap = new Map() + for (const e of entries) { + const key = `${e.year}:${e.kind}:${e.text.slice(0, 30)}` + if (!dedupMap.has(key)) dedupMap.set(key, e) + } + + const result = [...dedupMap.values()].sort((a, b) => b.year - a.year || (b.important ? 1 : 0) - (a.important ? 1 : 0)) + + // 裁剪:上限 80 条 + if (result.length > 80) result.length = 80 + + return result +} + +/** 将 ChronicleEntry 转换为族史卷条目 */ +function chronicleToScroll(e: ChronicleEntry): ChronicleScrollEntry | null { + const kindMap: Record = { + breakthrough: 'breakthrough', + death: 'death', + marriage: 'marriage', + diplomacy: 'diplomacy', + building: 'building', + event: 'event', + battle: 'battle', + misc: 'misc' + } + const kind = kindMap[e.category] ?? 'misc' + return { + year: e.year, + kind, + text: e.text, + important: e.important, + memberId: e.memberId + } +} + +/** 族史卷条目类型中文名 */ +export function scrollKindName(kind: ChronicleScrollEntry['kind']): string { + return kind === 'mandate' ? '天命' + : kind === 'breakthrough' ? '突破' + : kind === 'death' ? '殒落' + : kind === 'birth' ? '诞辰' + : kind === 'marriage' ? '姻缘' + : kind === 'diplomacy' ? '外交' + : kind === 'building' ? '建筑' + : kind === 'event' ? '事件' + : kind === 'battle' ? '战役' + : kind === 'world' ? '天下' + : '杂录' +} + +/** 族史卷条目 CSS 类名(供 UI 着色) */ +export function scrollKindClass(kind: ChronicleScrollEntry['kind']): string { + return kind === 'mandate' ? 'scroll-mandate' + : kind === 'breakthrough' ? 'scroll-breakthrough' + : kind === 'death' ? 'scroll-death' + : kind === 'birth' ? 'scroll-birth' + : kind === 'marriage' ? 'scroll-marriage' + : kind === 'diplomacy' ? 'scroll-diplomacy' + : kind === 'building' ? 'scroll-building' + : kind === 'event' ? 'scroll-event' + : kind === 'battle' ? 'scroll-battle' + : kind === 'world' ? 'scroll-world' + : 'scroll-misc' +} diff --git a/src/renderer/game/engine/runtime/Systems/combat.ts b/src/renderer/game/engine/runtime/Systems/combat.ts index 0919bd6..fde41fb 100644 --- a/src/renderer/game/engine/runtime/Systems/combat.ts +++ b/src/renderer/game/engine/runtime/Systems/combat.ts @@ -23,7 +23,11 @@ export function combatPowerOf(w: World, c: Character): number { const fit = fitBonusOf(c).battle ? 1.04 : 1 const trait = 1 + traitBonuses(c).windBonus const health = 0.5 + 0.5 * (c.health / 100) - return round1(base * stat * techBonus * equip * trait * aspi * fit * health) + // 0.1.41 天命接线:mercenaryHired — 佣兵入族战力加成 +15%(时效 flag) + const nowMonth = w.state.year * 12 + w.state.month + const mercActive = typeof w.state.family.flag['mercenaryUntil'] === 'number' && (w.state.family.flag['mercenaryUntil'] as number) > nowMonth + const mercMult = mercActive ? 1.15 : 1 + return round1(base * stat * techBonus * equip * trait * aspi * fit * health * mercMult) } function round1(n: number): number { diff --git a/src/renderer/game/engine/runtime/Systems/cultivation.ts b/src/renderer/game/engine/runtime/Systems/cultivation.ts index f7ccfe6..f2c2b83 100644 --- a/src/renderer/game/engine/runtime/Systems/cultivation.ts +++ b/src/renderer/game/engine/runtime/Systems/cultivation.ts @@ -43,6 +43,12 @@ export function monthlyRate(w: World, c: Character, herbFactor = 1): number { rate *= 1 + w.postBonus('expAll') if (st.family.flag['fengFeiBless']) rate *= 1.05 if (c.traits.includes('fengxian')) rate *= 1.3 + // 0.1.41 天命接线:elderCouncil — 长老会设立,全族心境稳定(修炼+3%) + if (st.family.flag['elderCouncil']) rate *= 1.03 + // 0.1.41 天命接线:advisoryBoard — 参议设立,微弱心境加成(修炼+1.5%) + if (st.family.flag['advisoryBoard']) rate *= 1.015 + // 0.1.41 天命接线:successionWise — 立贤储君,悟性最高者额外修炼加成(+5%) + if (st.family.flag['successionWise'] && c.perception >= 7) rate *= 1.05 const aspiration = aspirationById(c.aspiration) if (aspiration?.effect.type === 'cult') rate *= 1 + aspiration.effect.value if (fitBonusOf(c).cult) rate *= 1.06 diff --git a/src/renderer/game/engine/runtime/Systems/events.ts b/src/renderer/game/engine/runtime/Systems/events.ts index 982ac19..b766f6d 100644 --- a/src/renderer/game/engine/runtime/Systems/events.ts +++ b/src/renderer/game/engine/runtime/Systems/events.ts @@ -6,13 +6,17 @@ import { MAJOR_ORDER } from '../../../data/realms' import { npcById } from '../../../data/npcs' -import { resolveRaid } from './combat' +import { resolveRaid, resolveEncounter } from './combat' import { sendMission } from './missions' import { pack } from '../../../data/registry' import { findInheritor } from '../creation' import { resolveTribulation, resolveAscension } from './tribulation' import { nextRealm, describeRealm } from '../../../data/realms' import { findMandateEvent } from './mandate' +import { newCharacter } from '../pcgen' +import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names' +import type { EnemyDef } from '../../../data/secrets' +import type { FormationId } from '../../../data/formations' const ALL_EVENTS: EventDef[] = [...EVENTS] @@ -810,4 +814,318 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[], _extra?: } } } + + // ──────────────── 0.1.41 天命 flag 全量接线 ──────────────── + // 即时兑现:flag 设置后立即在引擎层产生真实效果 + // 持续效果:设置时效 flag(以到期绝对月数存储),由各系统每月读取 + + // tideHarvest — 灵草产出 ×1.5 持续 24 月 + if (eff.flag?.['tideHarvest'] !== undefined) { + const expireMonth = s.year * 12 + s.month + 24 + fam.flag['tideHarvestUntil'] = expireMonth + delete fam.flag['tideHarvest'] + w.log('good', '趁灵气充沛广植灵草,来年丰收——灵草产出将提升 24 月。') + } + + // mercenaryHired — 佣兵入族,战力加成 3 年(36 月) + if (eff.flag?.['mercenaryHired'] !== undefined) { + const expireMonth = s.year * 12 + s.month + 36 + fam.flag['mercenaryUntil'] = expireMonth + delete fam.flag['mercenaryHired'] + w.log('good', '佣兵入族效力——家族战力大增,三年后离去。') + } + + // banditRecruit — 流寇归顺入族(人丁+2) + if (eff.flag?.['banditRecruit'] !== undefined) { + const surname = fam.surname + for (let i = 0; i < 2; i++) { + const isMale = w.rng.chance(0.6) // 流寇多为男性 + const name = `${surname}${w.rng.pick(isMale ? MALE_GIVEN : FEMALE_GIVEN)}` + const recruit = newCharacter(w.rng, { + name, + gender: isMale ? 'male' : 'female', + generation: fam.generation, + bornYear: s.year - (20 + w.rng.int(0, 15)), + age: 0, + realm: { major: 'qi', minor: w.rng.int(0, 2) }, + }) + recruit.health = 70 + w.rng.int(0, 20) // 流寇体质尚可 + recruit.state = 'idle' + w.addMember(recruit) + w.log('info', `流寇归顺——${name} 入族充丁。`) + } + delete fam.flag['banditRecruit'] + } + + // borderWarPicked — 选边站队:根据选择调整 NPC 关系 + if (eff.flag?.['borderWarPicked'] !== undefined) { + const side = eff.flag['borderWarPicked'] as string + const npcs = Object.entries(s.npcFamilies).sort(([, a], [, b]) => b.power - a.power) + if (npcs.length >= 2) { + if (side === 'strong') { + const strongNpc = npcs[0]![1] + strongNpc.relation = Math.min(100, strongNpc.relation + 25) + for (let i = 1; i < npcs.length; i++) { + npcs[i]![1].relation = Math.max(-100, npcs[i]![1].relation - 25) + } + w.log('info', `支持${strongNpc.name}——胜方关系提升,败方关系恶化。`) + } else if (side === 'weak') { + const [strongId] = npcs[0]! + const weakNpc = npcs[npcs.length - 1]![1] + weakNpc.relation = Math.min(100, weakNpc.relation + 30) + s.npcFamilies[strongId]!.relation = Math.max(-100, s.npcFamilies[strongId]!.relation - 15) + w.log('info', `支持${weakNpc.name}——雪中送炭,弱方铭记在心。`) + } + } + delete fam.flag['borderWarPicked'] + } + + // siegeFought — 誓死抵抗:执行围庄战斗判定 + if (eff.flag?.['siegeFought'] !== undefined) { + const team = w.aliveMembers() + .filter((c) => w.ageOf(c) >= 16 && c.state !== 'expedition') + .sort((a, b) => rankPower(w, b) - rankPower(w, a)) + .slice(0, 5) + if (team.length > 0) { + const enemy: EnemyDef = { + id: 'siege-attacker', + name: '围庄敌军', + realm: 'foundation', + strength: 1.8, + icon: '围', + desc: '来犯之敌兵临城下。' + } + const res = resolveEncounter(w, { + title: '围庄之战', + enemy, + risk: 0.6, + team, + kind: 'war', + year: s.year, + month: s.month, + formation: (formation as FormationId) ?? undefined + }) + if (res.win) { + w.log('good', '族人同仇敌忾,击退围敌!声威大振。') + } else if (res.draw) { + w.log('info', '围敌暂退,但伤亡不小。') + } else { + w.log('bad', '围庄之战惨败——族中损失惨重。') + fam.stones = Math.max(0, fam.stones - 200) + } + } + delete fam.flag['siegeFought'] + } + + // siegeAlliance — 求援盟友:检查是否有盟友(关系≥40 的 NPC) + if (eff.flag?.['siegeAlliance'] !== undefined) { + const allies = Object.values(s.npcFamilies).filter((n) => n.relation >= 40 || n.allied) + if (allies.length > 0) { + const ally = allies[0]! + ally.relation = Math.min(100, ally.relation + 15) + w.log('good', `${ally.name} 出兵来援,围敌退去——同盟之谊更深。`) + w.chronicle('diplomacy', `${ally.name} 遣兵驰援围庄之危,同盟益固。`, undefined, true) + } else { + // 无盟友——效果落空,补偿灵石损失 + w.log('bad', '求援无门——四方无一肯来,只得独自面对。') + fam.stones = Math.max(0, fam.stones - 100) // 额外赔偿 + } + delete fam.flag['siegeAlliance'] + } + + // treasureExplored — 古宝探索:灵石已扣,按概率获得功法/宝器/丹药 + if (eff.flag?.['treasureExplored'] !== undefined) { + const roll = w.rng.next() + if (roll < 0.30) { + const t = w.rng.pick(pack().techniques.filter((t) => t.grade >= 2)) + if (t && !fam.techniques.includes(t.id)) { + fam.techniques.push(t.id) + w.log('good', `古洞探索——寻得《${t.name}》残篇!`) + w.chronicle('event', `遣队深入古洞,寻得《${t.name}》残篇。`, undefined, true) + } else { + fam.inventory['pill-pojing'] = (fam.inventory['pill-pojing'] ?? 0) + 1 + w.log('good', '古洞探索——寻得一枚破境丹!') + } + } else if (roll < 0.55) { + const a = w.rng.pick(['weapon-fan', 'weapon-qi', 'weapon-ling']) + fam.inventory[a] = (fam.inventory[a] ?? 0) + 1 + w.log('good', '古洞探索——寻得一件法器!') + } else if (roll < 0.75) { + fam.inventory['pill-pojing'] = (fam.inventory['pill-pojing'] ?? 0) + 1 + w.log('good', '古洞探索——寻得一枚破境丹!') + } else { + // 受伤但无所得 + for (const c of w.aliveMembers().slice(0, 3)) { + c.health = Math.max(1, c.health - w.rng.int(10, 30)) + if (c.health < 35) c.state = 'wounded' + } + w.log('bad', '古洞探索——禁制反噬,队员负伤而归。') + } + delete fam.flag['treasureExplored'] + } + + // artisanHired — 匠师入族,丹房效率永久+10% + if (eff.flag?.['artisanHired'] !== undefined) { + fam.flag['artisanBonus'] = true + delete fam.flag['artisanHired'] + w.log('good', '匠师入族——炼丹炼器效率永久提升。') + } + + // prodigyAdopted — 收养天骄幼童 + if (eff.flag?.['prodigyAdopted'] !== undefined) { + const surname = fam.surname + const isMale = w.rng.chance(0.5) + const name = `${surname}${w.rng.pick(isMale ? MALE_GIVEN : FEMALE_GIVEN)}` + const child = newCharacter(w.rng, { + name, + gender: isMale ? 'male' : 'female', + generation: fam.generation + 1, + bornYear: s.year - 3, // 3 岁幼童 + age: 0, + realm: { major: 'mortal', minor: 0 }, + }) + // 天骄:高悟性+高根骨 + child.perception = Math.min(10, 7 + w.rng.int(0, 3)) + child.mind = Math.min(10, 7 + w.rng.int(0, 3)) + child.fortune = Math.min(12, 8 + w.rng.int(0, 4)) + w.addMember(child) + w.log('good', `天纵之资——${name} 入族,灵根奇佳,悟性超群。`) + w.chronicle('event', `族中收养一名天骄幼童${name},灵根奇佳。`, child.id, true) + delete fam.flag['prodigyAdopted'] + } + + // prodigyTested — 试根骨:概率天才或伤根基 + if (eff.flag?.['prodigyTested'] !== undefined) { + const roll = w.rng.next() + const surname = fam.surname + const isMale = w.rng.chance(0.5) + const name = `${surname}${w.rng.pick(isMale ? MALE_GIVEN : FEMALE_GIVEN)}` + if (roll < 0.45) { + // 天才 + const child = newCharacter(w.rng, { + name, + gender: isMale ? 'male' : 'female', + generation: fam.generation + 1, + bornYear: s.year - 3, + age: 0, + realm: { major: 'mortal', minor: 0 }, + }) + child.perception = Math.min(10, 8 + w.rng.int(0, 2)) + child.mind = Math.min(10, 8 + w.rng.int(0, 2)) + child.fortune = Math.min(12, 10) + // 但秘法伤了一分根基 + child.health = 70 + w.addMember(child) + w.log('good', `秘法试根骨——${name} 确为天才,但根基微损。`) + w.chronicle('event', `以秘法探得${name}乃天才,然根基微损。`, child.id, true) + } else if (roll < 0.75) { + // 普通幼童入族 + const child = newCharacter(w.rng, { + name, + gender: isMale ? 'male' : 'female', + generation: fam.generation + 1, + bornYear: s.year - 3, + age: 0, + realm: { major: 'mortal', minor: 0 }, + }) + w.addMember(child) + w.log('info', `秘法试根骨——${name} 灵根尚可,收入族中。`) + } else { + // 伤其根基——幼童未能入族 + w.log('bad', '秘法反噬——幼童根基受损,其家含泪带走,再不来投。') + fam.karma -= 2 + } + delete fam.flag['prodigyTested'] + } + + // envoyToAlliance — 遣送入盟,三年后归 + if (eff.flag?.['envoyToAlliance'] !== undefined) { + const best = w.aliveMembers() + .filter((c) => c.perception >= 6 && w.ageOf(c) >= 16) + .sort((a, b) => b.perception - a.perception)[0] + if (best) { + const expireMonth = s.year * 12 + s.month + 36 + fam.flag['envoyReturnUntil'] = expireMonth + fam.flag['envoyMemberId'] = best.id + best.state = 'apprentice' + best.apprentice = { sect: '太虚仙盟', untilYear: s.year + 3, quiet: false } + w.log('info', `${best.name} 入太虚仙盟修行三年——悟性大开。`) + w.chronicle('event', `${best.name} 遣入太虚仙盟修行三年。`, best.id, true) + } + delete fam.flag['envoyToAlliance'] + } + + // coalitionFormed — 组建中立联盟:与关系≥40 的 NPC 建立同盟 + if (eff.flag?.['coalitionFormed'] !== undefined) { + let count = 0 + for (const npc of Object.values(s.npcFamilies)) { + if (npc.relation >= 40 && !npc.allied) { + npc.allied = true + npc.alliedSinceYear = s.year + npc.relation = Math.min(100, npc.relation + 10) + count++ + } + } + if (count >= 2) { + w.log('good', `中立联盟成立——${count} 家响应,第三条路已开。`) + w.chronicle('event', `牵头组建中立联盟,${count} 家响应。`, undefined, true) + } else if (count === 1) { + w.log('info', '仅有一家响应联盟——势单力薄,但聊胜于无。') + } else { + w.log('bad', '无人响应联盟——中立之路难行。') + } + delete fam.flag['coalitionFormed'] + } + + // choseStrongSide — 择强而从:与最强 NPC 关系+30,其余-20 + if (eff.flag?.['choseStrongSide'] !== undefined) { + const npcs = Object.values(s.npcFamilies).sort((a, b) => b.power - a.power) + if (npcs.length > 0) { + npcs[0]!.relation = Math.min(100, npcs[0]!.relation + 30) + for (let i = 1; i < npcs.length; i++) { + npcs[i]!.relation = Math.max(-100, npcs[i]!.relation - 20) + } + w.log('info', `择强而从——与${npcs[0]!.name}关系大增,其余家族转冷。`) + } + delete fam.flag['choseStrongSide'] + } + + // rebelPunished — 大义灭亲:移除一名最高修为的叛逆成员 + if (eff.flag?.['rebelPunished'] !== undefined) { + const rebel = w.aliveMembers() + .filter((c) => c.id !== fam.headId && c.realm.major !== 'mortal') + .sort((a, b) => rankPower(w, b) - rankPower(w, a))[0] + if (rebel) { + rebel.alive = false + rebel.deathYear = s.year + rebel.deathCause = '族规处决' + w.log('bad', `${rebel.name} 以叛逆之罪处决——人心震慑。`) + w.chronicle('death', `${rebel.name} 因通敌叛逆被公审处决。`, rebel.id, true) + } + delete fam.flag['rebelPunished'] + } + + // elderCouncil — 长老会设立(永久 flag,不删除) + if (eff.flag?.['elderCouncil'] !== undefined) { + // elderCouncil 作为永久 flag 保留——cultivation.ts 持续读取 + w.log('good', '长老会成立——族务共议,人心凝聚。') + } + + // advisoryBoard — 参议设立(永久 flag,不删除) + if (eff.flag?.['advisoryBoard'] !== undefined) { + // advisoryBoard 作为永久 flag 保留——cultivation.ts 持续读取 + w.log('info', '参议之位设立——长老有言权无决权。') + } + + // successionElder — 立长(永久 flag,影响 findInheritor) + if (eff.flag?.['successionElder'] !== undefined) { + // successionElder 作为永久 flag 保留——creation.ts findInheritor 读取 + w.log('good', '储位已定——以长幼为序,纲常有序。') + } + + // successionWise — 立贤(永久 flag,影响 findInheritor + 修炼加成) + if (eff.flag?.['successionWise'] !== undefined) { + // successionWise 作为永久 flag 保留 + w.log('good', '储位已定——以才德为先,来日可期。') + } } diff --git a/src/renderer/game/engine/runtime/Systems/production.ts b/src/renderer/game/engine/runtime/Systems/production.ts index 9f7f67c..4a91cb6 100644 --- a/src/renderer/game/engine/runtime/Systems/production.ts +++ b/src/renderer/game/engine/runtime/Systems/production.ts @@ -36,9 +36,15 @@ export function productionTick(w: World): void { parts.push(`${def!.name}+${prod}${itemNameOf(k)}`) } } + // 0.1.41 天命接线:tideHarvest — 灵草产出 ×1.5 持续 24 月 + const nowMonth = w.state.year * 12 + w.state.month + const tideHarvestActive = typeof fam.flag['tideHarvestUntil'] === 'number' && (fam.flag['tideHarvestUntil'] as number) > nowMonth + const tideHarvestMult = tideHarvestActive ? 1.5 : 1 + // 0.1.41 天命接线:artisanHired — 丹房/炼器效率永久+10%(体现在灵田药园产出微增) + const artisanMult = fam.flag['artisanBonus'] ? 1.1 : 1 // 0.1.33 灾年真正生效(此前 farmFactor/mineFactor 算而未用——生产零体感) - produceAll('lingtian', lingtian, (1 + fielders * 0.05 + springMod) * farmFactor) - produceAll('yaoyuan', yaoyuan, farmFactor) + produceAll('lingtian', lingtian, (1 + fielders * 0.05 + springMod) * farmFactor * tideHarvestMult) + produceAll('yaoyuan', yaoyuan, farmFactor * tideHarvestMult * artisanMult) produceAll('lingkuang', lingkuang, mineFactor) produceAll('lingguoyuan', fam.buildings['lingguoyuan'] ?? 0, farmFactor) produceAll('lingmufang', fam.buildings['lingmufang'] ?? 0, farmFactor) diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index ad8a5c6..2840c87 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -435,15 +435,49 @@ export class World { s.pendingEvent = undefined } - /** 年度清理:剔除 3 年以前的年份前缀 flag 键(防长线膨胀) */ + /** + * 年度清理:剔除过期的 flag 键(防长线膨胀) + * 0.1.41 扩展:新增时效 flag 自动清理(tideHarvestUntil/mercenaryUntil/envoyReturnUntil) + * 以及 tourneyPending 年份键清理 + */ private pruneYearFlags(): void { const fam = this.state.family const cutoff = this.state.year - 3 + const nowMonth = this.state.year * 12 + this.state.month for (const key of Object.keys(fam.flag)) { const m = /^(auction-|prayerDone-|echoDone-|recruitDone-|legacyDone-|tournamentYear-)(\d+)$/.exec(key) if (m && Number(m[2]) < cutoff) delete fam.flag[key] // tauntCD- 非年份键:随目标家族消亡删除(防长线膨胀) if (key.startsWith('tauntCD-') && !this.state.npcFamilies[key.slice('tauntCD-'.length)]) delete fam.flag[key] + // tourneyPending- 年份键:过期清理 + const tm = /^tourneyPending-(\d+)$/.exec(key) + if (tm && Number(tm[1]) < cutoff) delete fam.flag[key] + // raidCD- 随目标家族消亡删除 + if (key.startsWith('raidCD-') && !this.state.npcFamilies[key.slice('raidCD-'.length)]) delete fam.flag[key] + // 0.1.41 时效 flag 清理:到期自动删除 + if (key === 'tideHarvestUntil' || key === 'mercenaryUntil' || key === 'envoyReturnUntil') { + const expire = fam.flag[key] + if (typeof expire === 'number' && expire <= nowMonth) { + delete fam.flag[key] + if (key === 'envoyReturnUntil') { + // 使者归来:恢复成员状态 + const memberId = fam.flag['envoyMemberId'] as string | undefined + if (memberId) { + const c = this.state.members[memberId] + if (c && c.alive && c.state === 'apprentice') { + c.state = 'idle' + c.apprentice = undefined + c.perception = Math.min(10, c.perception + 2) + c.mind = Math.min(10, c.mind + 1) + c.realmProgress = Math.min(100, c.realmProgress + 20) + this.log('good', `${c.name} 从太虚仙盟归来,脱胎换骨——悟性大增。`) + this.chronicle('event', `${c.name} 自仙盟归来,修为大进。`, c.id, true) + } + delete fam.flag['envoyMemberId'] + } + } + } + } } } diff --git a/src/renderer/game/engine/runtime/creation.ts b/src/renderer/game/engine/runtime/creation.ts index 3b3eab2..78e2a9e 100644 --- a/src/renderer/game/engine/runtime/creation.ts +++ b/src/renderer/game/engine/runtime/creation.ts @@ -202,6 +202,27 @@ export function findInheritor(world: World): Character | undefined { const head = world.state.members[world.state.family.headId] const candidates = alive.filter((c) => c.id !== head?.id) if (candidates.length === 0) return undefined + // 0.1.41 天命接线:successionElder — 立长不立幼(优先年长者) + const flag = world.state.family.flag + if (flag['successionElder']) { + const byBlood = candidates + .filter((c) => (head && head.children.includes(c.id)) || (c.fatherId === head?.id)) + .sort((a, b) => world.ageOf(b) - world.ageOf(a)) + if (byBlood.length > 0) return byBlood[0] + // 无血亲时也优先年长 + const byAge = [...candidates].sort((a, b) => world.ageOf(b) - world.ageOf(a)) + return byAge[0] + } + // 0.1.41 天命接线:successionWise — 立贤不立长(优先悟性最高者) + if (flag['successionWise']) { + const byBlood = candidates + .filter((c) => (head && head.children.includes(c.id)) || (c.fatherId === head?.id)) + .sort((a, b) => b.perception - a.perception || realmRank(b.realm) - realmRank(a.realm)) + if (byBlood.length > 0) return byBlood[0] + const byWisdom = [...candidates].sort((a, b) => b.perception - a.perception || realmRank(b.realm) - realmRank(a.realm)) + return byWisdom[0] + } + // 默认继承:血亲优先(年长),无血亲按境界 const byBlood = candidates .filter((c) => (head && head.children.includes(c.id)) || (c.fatherId === head?.id)) .sort((a, b) => world.ageOf(b) - world.ageOf(a)) diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts index edd5029..37f479f 100644 --- a/src/renderer/game/engine/sim/WorldSim.ts +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -3,12 +3,46 @@ import { World } from '../runtime/World' import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, makeWorldSimState, NpcStance, REGION_TIDE, eraDuraOf, regionTideOf, calamityNames, calamityFamilyOf, calamityEffectOf, CalamityName, POOL_BASE, worldNum, WONDER_CHANCE, WONDER_NAMES, WONDER_DESCS, WONDER_DURATION, WorldWonderKind, WorldWonder, allWonderKinds, wonderDefOf } from './worldsim-data' import { pack } from '../../data/registry' import { ITEMS } from '../../data/items' -import { npcById, getNpcDefs, registerNpcDef, unregisterNpcDef } from '../../data/npcs' +import { npcById, getNpcDefs, registerNpcDef } from '../../data/npcs' import { allMissions, WONDER_TEMPLATES, type MissionDef } from '../../data/secrets' import { MAJORS, MAJOR_ORDER } from '../../data/realms' const MARKET_IDS = ['lingcao', 'lingkuang', 'beastcore', 'lingyu', 'lingmu', 'shoupi', 'lingguo', 'pill-qiyuan', 'pill-ningyuan'] +/** + * 0.1.41 NPC 演化种子派生哈希(零主 rng 消耗) + * + * 基于 seed + year + npcId + salt 生成确定性 [0,1) 浮点数。 + * 用于 evolveNpc/splitNpcDynasty/spawnNewbornDynasty/assessStance 中 + * 替代 w.rng 调用——使 NPC 演化不消耗主随机序列, + * 玩家行为不间接引发 rng 漂移。 + */ +function npcDerive(seed: string, year: number, npcId: string, salt: number): number { + const str = `${seed}:${year}:${npcId}:${salt}` + let h = 2166136261 + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i) + h = Math.imul(h, 16777619) + h = (h << 13) | (h >>> 19) + } + return (h >>> 0) / 4294967296 +} + +/** 种子派生整数 [min, max](零主 rng 消耗) */ +function npcDeriveInt(seed: string, year: number, npcId: string, salt: number, min: number, max: number): number { + return min + Math.floor(npcDerive(seed, year, npcId, salt) * (max - min + 1)) +} + +/** 种子派生概率判定(零主 rng 消耗) */ +function npcDeriveChance(seed: string, year: number, npcId: string, salt: number, p: number): boolean { + return npcDerive(seed, year, npcId, salt) < p +} + +/** 种子派生数组选取(零主 rng 消耗) */ +function npcDerivePick(seed: string, year: number, npcId: string, salt: number, arr: readonly T[]): T { + return arr[Math.floor(npcDerive(seed, year, npcId, salt) * arr.length)]! +} + export class WorldSim { constructor(private w: World) {} @@ -595,23 +629,27 @@ function poolBase(id: string): number { function evolveNpc(w: World, s: WorldSimState, noise: number): void { void noise const year = w.state.year + const seed = w.state.seed const ids = Object.keys(w.state.npcFamilies) for (const [id, npc] of Object.entries(w.state.npcFamilies)) { const dyn = s.npcDyn[id] ?? initDynFor(id) s.npcDyn[id] = dyn - const defFirst = npcById(id) + // 0.1.41 修复:不依赖全局 DYNAMIC_NPCS(purgeNpc 会 unregister 导致并行 World 实例干扰) + // 优先从全局查,fallback 从 worldGen.npcs 查(世界自有的 NPC 定义) + const defFirst = npcById(id) ?? w.state.worldGen?.npcs?.find((n) => n.id === id) if (!defFirst) continue if (!dyn.relationsWithOthers || Object.keys(dyn.relationsWithOthers).length === 0) { // 关系网初始化:同风格亲近(剑修→剑修 +30~55),异风格事仇(-35~+15) - const mine = npcById(id) + // 0.1.41 种子派生(零主 rng 消耗) + const mine = npcById(id) ?? w.state.worldGen?.npcs?.find((n) => n.id === id) if (!mine) continue for (const oid of ids) { if (oid === id) continue - const other = npcById(oid) + const other = npcById(oid) ?? w.state.worldGen?.npcs?.find((n) => n.id === oid) const sameStyle = mine.style === other?.style dyn.relationsWithOthers[oid] = sameStyle - ? 30 + w.rng.int(0, 25) - : -35 + w.rng.int(0, 50) + ? 30 + npcDeriveInt(seed, year, id + oid, 0xA001, 0, 25) + : -35 + npcDeriveInt(seed, year, id + oid, 0xA002, 0, 50) } } const def = defFirst @@ -657,44 +695,49 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { dyn.declineYears = 0 } // 新贵补位:家数 < 开局目标(4~8 的种子格)且几率(乱世更频)——世界会新生 + // 0.1.41 种子派生(零主 rng 消耗) const aliveCount = Object.keys(w.state.npcFamilies).length const cap = Math.min(8, w.state.worldGen?.npcCount ?? 4) - if (aliveCount < cap && w.rng.chance(worldNum('greatNewbornChance') * (s.era === 'luanshi' ? 2 : 1))) { + if (aliveCount < cap && npcDeriveChance(seed, year, id, 0xB001, worldNum('greatNewbornChance') * (s.era === 'luanshi' ? 2 : 1))) { spawnNewbornDynasty(w, s) } } // 顶峰衰(0.1.27):绝顶不可久踞——power>=700 年首回落(豪强被制衡) - if (w.state.month === 1 && npc.power >= 700 && w.rng.chance(0.08)) { + // 0.1.41 种子派生(零主 rng 消耗) + if (w.state.month === 1 && npc.power >= 700 && npcDeriveChance(seed, year, id, 0xC001, 0.08)) { npc.power = Math.round(npc.power * 0.9) dyn.lastEvent = '顶峰回落' dyn.lastEventYear = w.state.year s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `${def.name} 盛极而衰,锋芒微敛。`, kind: 'annal' }) } // 关系漂移:年首各 ±5(邻近的讲合、世仇的愈深) + // 0.1.41 种子派生(零主 rng 消耗) + let relSalt = 0 for (const oid of ids) { if (oid === id) continue const cur = dyn.relationsWithOthers[oid] ?? 0 - dyn.relationsWithOthers[oid] = clamp(cur + (w.rng.chance(0.5) ? 1 : -1) * 5, -100, 100) + dyn.relationsWithOthers[oid] = clamp(cur + (npcDeriveChance(seed, year, id + oid, 0xD001 + relSalt++, 0.5) ? 1 : -1) * 5, -100, 100) } // 0.1.40 换代评估:四模式(退隐传贤/遇害暴毙/夺位篡权/寿终正寝) + // 0.1.41 种子派生(零主 rng 消耗) const chanceNow = dyn.leaderAge > lifespan * 0.9 ? 0.5 : 0.02 - if (w.rng.chance(chanceNow) && !w.rng.chance(0.25)) { + if (npcDeriveChance(seed, year, id, 0xE001, chanceNow) && !npcDeriveChance(seed, year, id, 0xE002, 0.25)) { const eraNow = (s.era ?? 'pingshi') as string const prosperityNow = dyn.prosperity ?? 50 let mode: 'retire' | 'death' | 'usurp' | 'normal' if (eraNow === 'luanshi' || eraNow === 'mofa') { - if (prosperityNow < 30 && w.rng.chance(0.4)) mode = 'death' - else if (npc.power > 400 && w.rng.chance(0.25)) mode = 'usurp' + if (prosperityNow < 30 && npcDeriveChance(seed, year, id, 0xE003, 0.4)) mode = 'death' + else if (npc.power > 400 && npcDeriveChance(seed, year, id, 0xE004, 0.25)) mode = 'usurp' else mode = 'normal' } else { - if (prosperityNow > 70 && w.rng.chance(0.45)) mode = 'retire' - else if (npc.power > 400 && w.rng.chance(0.15)) mode = 'usurp' + if (prosperityNow > 70 && npcDeriveChance(seed, year, id, 0xE005, 0.45)) mode = 'retire' + else if (npc.power > 400 && npcDeriveChance(seed, year, id, 0xE006, 0.15)) mode = 'usurp' else mode = 'normal' } switch (mode) { case 'retire': { // 退隐传贤:平盛世高景气——宗主功成身退,传位后辈 - dyn.leaderAge = 30 + w.rng.int(0, 20) + dyn.leaderAge = 30 + npcDeriveInt(seed, year, id, 0xE010, 0, 20) dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + 1, 5) dyn.leaderName = `${def.name.replace('氏', '')}氏新主` dyn.prosperity = clamp(prosperityNow - 5, 8, 100) @@ -704,7 +747,7 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { } case 'death': { // 遇害暴毙:乱世末法低景气——宗主陨落,群龙无首 - dyn.leaderAge = 25 + w.rng.int(0, 20) + dyn.leaderAge = 25 + npcDeriveInt(seed, year, id, 0xE011, 0, 20) dyn.leaderRealmIdx = Math.max(0, dyn.leaderRealmIdx) // 境界不变或降 dyn.leaderName = `${def.name.replace('氏', '')}氏少主` dyn.prosperity = clamp(prosperityNow - 20, 8, 100) @@ -714,8 +757,8 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { } case 'usurp': { // 夺位篡权:高压家族——强人夺位,势力激增但人心不稳 - dyn.leaderAge = 35 + w.rng.int(0, 15) - dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + (w.rng.chance(0.4) ? 2 : 1), 5) + dyn.leaderAge = 35 + npcDeriveInt(seed, year, id, 0xE012, 0, 15) + dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + (npcDeriveChance(seed, year, id, 0xE013, 0.4) ? 2 : 1), 5) dyn.leaderName = `${def.name.replace('氏', '')}氏篡主` dyn.prosperity = clamp(prosperityNow + 8, 8, 100) npc.power = Math.round(Math.max(40, npc.power + 40)) @@ -730,10 +773,10 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { } default: { // 寿终正寝:常规换代 - dyn.leaderAge = 30 + w.rng.int(0, 25) + dyn.leaderAge = 30 + npcDeriveInt(seed, year, id, 0xE014, 0, 25) dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + 1, 5) dyn.leaderName = `${def.name.replace('氏', '')}氏新主` - dyn.prosperity = 55 + w.rng.int(0, 20) + dyn.prosperity = 55 + npcDeriveInt(seed, year, id, 0xE015, 0, 20) npc.power = Math.round(Math.max(40, npc.power + 15)) dyn.lastEvent = '宗祧更替' } @@ -749,7 +792,8 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { w.emitFx('ripple', `succession:${id}`) } // 0.1.40 NPC 势力分裂:power>500 且景气<25 的家族,年首 3% 概率分裂 - if (w.rng.chance(0.03) && npc.power > 500 && (dyn.prosperity ?? 50) < 25 && !npc.allied) { + // 0.1.41 种子派生(零主 rng 消耗) + if (npcDeriveChance(seed, year, id, 0xF001, 0.03) && npc.power > 500 && (dyn.prosperity ?? 50) < 25 && !npc.allied) { splitNpcDynasty(w, s, id) } // B7 互攻:与世仇(关系<-50)年首相搏——败方伤筋动骨 @@ -762,11 +806,12 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { const hateBar = foeStance === 'expand' ? -40 : foeStance === 'endure' ? -70 : -50 if (foeStance === 'ally') void hateBar // W4 灾年全环:天下凶年群雄相噬——互攻概率 ×1.5 + // 0.1.41 种子派生(零主 rng 消耗) const foeEventChance = (s.calamity ? WORLDSIM.npcEventChance * 1.5 : WORLDSIM.npcEventChance) - if (rel < hateBar && foeStance !== 'ally' && w.rng.chance(foeEventChance)) { + if (rel < hateBar && foeStance !== 'ally' && npcDeriveChance(seed, year, id + oid, 0xF101 + ids.indexOf(oid), foeEventChance)) { // 1-3 蝴蝶效应:互攻按实力加权(强者越可能胜,弱者一败再败) const wSum = npc.power + foe.power - const winner = w.rng.chance(wSum > 0 ? npc.power / wSum : 0.5) ? npc : foe + const winner = npcDeriveChance(seed, year, id + oid, 0xF201 + ids.indexOf(oid), wSum > 0 ? npc.power / wSum : 0.5) ? npc : foe const loser = winner === npc ? foe : npc winner.power = Math.min(900, Math.round(winner.power * 1.06)) loser.power = Math.max(52, Math.round(loser.power * 0.82)) @@ -818,7 +863,10 @@ function purgeNpc(w: World, s: WorldSimState, id: string): void { delete w.state.family.flag[`tauntCD-${id}`] w.state.eventQueue = (w.state.eventQueue ?? []).filter((e) => !e.startsWith('ev-raid-') || !e.endsWith(id)) if (s.distress?.id === id) s.distress = undefined - unregisterNpcDef(id) + // 0.1.41 修复:不再调用 unregisterNpcDef——全局 DYNAMIC_NPCS 是跨 World 共享的, + // 并行 World 实例(如测试中的 det-replay)会因 a 的 purge 导致 b 的 npcById 返回 undefined。 + // 已灭亡 NPC 的 def 留在 DYNAMIC_NPCS 中不影响逻辑(npcFamilies 中已删除,不会被迭代到)。 + // unregisterNpcDef(id) // 0.1.39:从 worldGen.npcs 移除已灭亡 NPC 的定义(存档体积精简——僵尸 def 不再落盘) if (w.state.worldGen?.npcs) { const idx = w.state.worldGen.npcs.findIndex((n) => n.id === id) @@ -853,7 +901,9 @@ function splitNpcDynasty(w: World, s: WorldSimState, parentId: string): void { const parentDyn = s.npcDyn[parentId] if (!parent || !parentDef || !parentDyn) return - const rng = w.rng + // 0.1.41 种子派生(零主 rng 消耗) + const seed = w.state.seed + const year = w.state.year const splitPower = Math.round(parent.power * 0.35) parent.power = Math.round(parent.power * 0.6) parentDyn.prosperity = 15 @@ -862,11 +912,11 @@ function splitNpcDynasty(w: World, s: WorldSimState, parentId: string): void { // 分裂出的新家族 let seedHash = 0 - for (let i = 0; i < w.state.seed.length; i++) seedHash = (seedHash * 31 + w.state.seed.charCodeAt(i)) >>> 0 - const newId = `n-split-${(seedHash % 4096).toString(36)}-${rng.int(1, 9999999)}` + for (let i = 0; i < seed.length; i++) seedHash = (seedHash * 31 + seed.charCodeAt(i)) >>> 0 + const newId = `n-split-${(seedHash % 4096).toString(36)}-${npcDeriveInt(seed, year, parentId, 0xF010, 1, 9999999)}` const newName = `${parent.name.slice(0, 2)}分支` - const newStyle = rng.pick([...NEWBORN_STYLES]) - const newRegion = parent.region || rng.pick([...NEWBORN_REGIONS]) + const newStyle = npcDerivePick(seed, year, parentId, 0xF011, NEWBORN_STYLES) + const newRegion = parent.region || npcDerivePick(seed, year, parentId, 0xF012, NEWBORN_REGIONS) const newDef = { id: newId, name: newName, @@ -895,7 +945,7 @@ function splitNpcDynasty(w: World, s: WorldSimState, parentId: string): void { } const newDyn = initDynFor(newId) newDyn.leaderName = `${newName.slice(0, 2)}氏少主` - newDyn.leaderAge = 25 + rng.int(0, 15) + newDyn.leaderAge = 25 + npcDeriveInt(seed, year, parentId, 0xF013, 0, 15) newDyn.leaderRealmIdx = Math.max(0, parentDyn.leaderRealmIdx - 1) newDyn.prosperity = 40 newDyn.lastEvent = '分裂自立' @@ -932,27 +982,29 @@ function splitNpcDynasty(w: World, s: WorldSimState, parentId: string): void { * - 65% 全新初立:原逻辑(随机风格/区域) */ function spawnNewbornDynasty(w: World, s: WorldSimState): void { - const rng = w.rng + // 0.1.41 种子派生(零主 rng 消耗) + const seed = w.state.seed + const year = w.state.year // 0.1.40 灰烬重生模式:检查 worldGen.npcs 中已被 purge 的家族区域 let ashesRegion: string | undefined let ashesStyle: string | undefined - if (w.rng.chance(0.35) && w.state.worldGen?.npcs) { + if (npcDeriveChance(seed, year, 'newborn', 0xF020, 0.35) && w.state.worldGen?.npcs) { // 从 worldGen.npcs 找到已不在 npcFamilies 中的灭亡家族 const fallen = w.state.worldGen.npcs.filter((n) => !w.state.npcFamilies[n.id]) if (fallen.length > 0) { - const pick = fallen[rng.int(0, fallen.length - 1)]! + const pick = fallen[npcDeriveInt(seed, year, 'newborn', 0xF021, 0, fallen.length - 1)]! ashesRegion = pick.region // 不继承原风格——灰烬中重生的新风格 - ashesStyle = rng.pick([...NEWBORN_STYLES]) + ashesStyle = npcDerivePick(seed, year, 'newborn', 0xF022, NEWBORN_STYLES) } } - const style = ashesStyle ?? rng.pick([...NEWBORN_STYLES]) - const region = ashesRegion ?? rng.pick([...NEWBORN_REGIONS]) + const style = ashesStyle ?? npcDerivePick(seed, year, 'newborn', 0xF023, NEWBORN_STYLES) + const region = ashesRegion ?? npcDerivePick(seed, year, 'newborn', 0xF024, NEWBORN_REGIONS) // P1-10 id 掺 seed 指纹(防跨档碰撞) let seedHash = 0 - for (let i = 0; i < w.state.seed.length; i++) seedHash = (seedHash * 31 + w.state.seed.charCodeAt(i)) >>> 0 - const id = `n-new-${(seedHash % 4096).toString(36)}-${rng.int(1, 9999999)}` // 0.1.35 A-10 碰撞窗口 9999→9999999 - const name = `${region.slice(0, 2)}${NEWBORN_NAME_SUFFIX[rng.int(0, NEWBORN_NAME_SUFFIX.length - 1)]}` + for (let i = 0; i < seed.length; i++) seedHash = (seedHash * 31 + seed.charCodeAt(i)) >>> 0 + const id = `n-new-${(seedHash % 4096).toString(36)}-${npcDeriveInt(seed, year, 'newborn', 0xF025, 1, 9999999)}` // 0.1.35 A-10 碰撞窗口 9999→9999999 + const name = `${region.slice(0, 2)}${NEWBORN_NAME_SUFFIX[npcDeriveInt(seed, year, 'newborn', 0xF026, 0, NEWBORN_NAME_SUFFIX.length - 1)]}` const isAshes = !!ashesRegion const def = { id, @@ -963,7 +1015,7 @@ function spawnNewbornDynasty(w: World, s: WorldSimState): void { : `${style}初立,闷头搞了十年发展,如今渐攒起一份家业。`, style, leaderRealm: 'foundation' as const, - initialPower: isAshes ? 90 + rng.int(0, 50) : 100 + rng.int(0, 60), // 灰烬重生起步略低 + initialPower: isAshes ? 90 + npcDeriveInt(seed, year, 'newborn', 0xF027, 0, 50) : 100 + npcDeriveInt(seed, year, 'newborn', 0xF028, 0, 60), // 灰烬重生起步略低 powerGrowth: [3, 9] as [number, number], sells: [], buys: ['lingcao', 'lingkuang'] @@ -1032,8 +1084,9 @@ function assessStance(w: World, id: string, dyn: NpcDynamics): NpcStance { } const pick = (Object.keys(W) as NpcStance[]).sort((a, b) => W[b] - W[a]) // 用幂等 rng 抽取(先取最高权)+ 30% 软翻转 + // 0.1.41 种子派生(零主 rng 消耗) const top = pick[0]! - if (w.rng.chance(0.7)) return top + if (npcDeriveChance(w.state.seed, w.state.year, id, 0xA100, 0.7)) return top return pick[1]! } @@ -1054,8 +1107,9 @@ function initDynFor(id: string): NpcDynamics { } function pushNews(w: World, s: WorldSimState, about: string[]): void { - const rng = w.rng - const idx = rng.int(0, about.length - 1) + // 0.1.41 种子派生(零主 rng 消耗)——pushNews 被 evolveNpc 调用, + // 若用 w.rng 会因 NPC 数量变化导致 rng 消耗不同,破坏确定性。 + const idx = npcDeriveInt(w.state.seed, w.state.year, about.join(','), 0xA200, 0, about.length - 1) const id = about[idx] const tide = s.tide > 1.0 ? '灵潮上涨' : s.tide < 0.75 ? '灵潮回落' : '汐平' let row: { year: number; month: number; src: string; text: string; itemId?: string; kind?: 'quote' | 'npc' | 'calamity' | 'annal' } diff --git a/src/renderer/game/storage/slots.ts b/src/renderer/game/storage/slots.ts index 8b656c1..2a6adcd 100644 --- a/src/renderer/game/storage/slots.ts +++ b/src/renderer/game/storage/slots.ts @@ -1,7 +1,7 @@ import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain' import { validateLoadedState, exportEnvelope, parseImportEnvelope } from './migrate' -const GAME_VERSION = '0.1.40' +const GAME_VERSION = '0.1.41' const DB_PREFIX = 'cotyc-save-' let seqCounter = 0 diff --git a/src/renderer/ui/panels/LegacyPanel.tsx b/src/renderer/ui/panels/LegacyPanel.tsx index a0d0b0a..655814e 100644 --- a/src/renderer/ui/panels/LegacyPanel.tsx +++ b/src/renderer/ui/panels/LegacyPanel.tsx @@ -6,6 +6,7 @@ import { buildBiography } from '../../game/engine/narrative/biography' import { yearAxis, decadeLabel } from '../../game/engine/narrative/yearaxis' import { buildReport, verdictLine } from '../../game/engine/narrative/legacyreport' import { buildSaga, sagaKindName, sagaKindClass } from '../../game/engine/narrative/saga' +import { buildChronicleScroll, scrollKindName, scrollKindClass } from '../../game/engine/narrative/chronicle-scroll' import type { WorldSimState } from '../../game/engine/sim/worldsim-data' @@ -14,7 +15,7 @@ export default function LegacyPanel() { const revision = useGameStore((s) => s.revision) void revision const [showResolve, setShowResolve] = useState(false) - const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis' | 'legacy'>('dims') + const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis' | 'legacy' | 'cscroll'>('dims') if (!world) return null const w = world const s = w.state @@ -56,6 +57,7 @@ export default function LegacyPanel() { + @@ -120,6 +122,8 @@ export default function LegacyPanel() { {view === 'scroll' && } + {view === 'cscroll' && } + {view === 'chronicle' && (
生卒与突破年表
@@ -261,6 +265,38 @@ function ScrollScroll() { ) } +function ChronicleScrollView() { + const world = useGameStore((s) => s.world) + if (!world) return null + const ws = world.state.worldSim as WorldSimState | undefined + const scroll = buildChronicleScroll(world.state, ws) + return ( +
+
族史卷(天命·族事·天下 串联)
+ {scroll.length === 0 ? ( +
开卷尚浅,待岁月沉淀后再启此卷。
+ ) : ( +
+ {scroll.map((e, i) => ( +
+ {e.year}年 + {scrollKindName(e.kind)} + {e.text} +
+ ))} +
+ )} +
+ 族史卷将天命决策、族中大事与天下大事记串联展示——以时间为经、以事件为纬。 +
+
+ ) +} + function dimName(k: string): string { const map: Record = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火', yinGuo: '因果' } return map[k] ?? k diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index ab1354c..263b865 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -354,7 +354,7 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
-
版本 0.1.40 · Chronicle of the Immortal Clan
+
版本 0.1.41 · Chronicle of the Immortal Clan
) diff --git a/src/renderer/ui/panels/TerritoryPanel.tsx b/src/renderer/ui/panels/TerritoryPanel.tsx index 2febf6b..55ae922 100644 --- a/src/renderer/ui/panels/TerritoryPanel.tsx +++ b/src/renderer/ui/panels/TerritoryPanel.tsx @@ -1,5 +1,5 @@ import { useGameStore } from '../store' -import { BUILDINGS, buildingById, listBuildingDefs, SPECIALTY_MULT } from '../../game/data/buildings' +import { BUILDINGS, buildingById, listBuildingDefs, SPECIALTY_MULT, bonusOf, isSpecialty } from '../../game/data/buildings' import { useMemo } from 'react' export default function TerritoryPanel() { @@ -44,6 +44,30 @@ export default function TerritoryPanel() { {built ? `${level}级` : ''}
{def.desc}
+ {/* 0.1.41 建筑专精加成预览 */} + {built && def.extra && Object.keys(def.extra).length > 0 && (() => { + const specialtyId = fam.flag['buildingSpecialty'] as string | undefined + const currentLevel = level + const isThisSpecialty = isSpecialty(id, specialtyId) + const bonusLabels: { name: string; base: number; specialty: number }[] = [] + for (const [key, expr] of Object.entries(def.extra)) { + void expr + const base = bonusOf(id, key, currentLevel, undefined) + const spec = bonusOf(id, key, currentLevel, id) + bonusLabels.push({ name: extraLabel(key), base, specialty: spec }) + } + return ( +
+ {bonusLabels.map((b, i) => ( + + {b.name}: {b.base.toFixed(2)} + {isThisSpecialty && →x{SPECIALTY_MULT} = {(b.base * SPECIALTY_MULT).toFixed(2)}} + {!isThisSpecialty && specialtyId && (设为专精可至 {(b.base * SPECIALTY_MULT).toFixed(2)})} + + ))} +
+ ) + })()} {produce && Object.keys(produce).length > 0 && (
{Object.entries(produce) @@ -168,3 +192,15 @@ function itemLabel(k: string): string { } return map[k] ?? k } + +/** 0.1.41 建筑加成属性中文名 */ +function extraLabel(key: string): string { + const map: Record = { + craftChance: '成丹率', + unlockGrade: '解锁品阶', + expBonus: '修炼加成', + powerBonus: '战力加成', + repBonus: '声望' + } + return map[key] ?? key +} diff --git a/src/renderer/ui/storeHelper.ts b/src/renderer/ui/storeHelper.ts index 2cf5d66..2a93698 100644 --- a/src/renderer/ui/storeHelper.ts +++ b/src/renderer/ui/storeHelper.ts @@ -14,7 +14,7 @@ export function metaFromState(state: GameState, slot: number): SaveMeta { members: alive, reputation: state.family.reputation, updatedAt: new Date().toISOString(), - version: '0.1.40' + version: '0.1.41' } } diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts index e6636f5..abe1e05 100644 --- a/tests/audit-regression.test.ts +++ b/tests/audit-regression.test.ts @@ -82,8 +82,8 @@ describe('审计回归:P0 修复固化', () => { }) it('防御性修补后金钟罩不变(行为等价确认)', () => { - // 0.1.40 天命·裂痕:天命系统+NPC宗主更替四模式+势力分裂+灰烬重生——全线受控变更重算 - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('b050ad28') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('0b56c19b') + // 0.1.41 天命·兑现:NPC 演化种子派生+pushNews 修复+天命 flag 全量接线——全线受控变更重算 + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('2b6c46d2') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('34c76ced') }) }) diff --git a/tests/clock.test.ts b/tests/clock.test.ts index b15af50..7a57148 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.40 天命·裂痕基线:天命系统+NPC宗主更替四模式+NPC势力分裂+灰烬重生新贵补位+建筑专精—— -// 全线受控变更重算(NPC演化逻辑增强+天命epilogue注册改变了时序/确定性序列)。 +// 0.1.41 天命·兑现基线:天命 flag 全量接线 + NPC 演化种子派生(零主 rng 消耗)+ +// pushNews 种子派生修复确定性破坏——全线受控变更重算。 const GOLDEN: Record> = { - 'bell-seed-1': { 560: 'b050ad28', 1200: '379ad2e1', 2160: '2fada283' }, - 'bell-seed-2': { 560: 'e3b4d40b', 1200: '5fb3aa1f', 2160: '50ad6832' }, - 'bell-seed-3': { 560: '0b56c19b', 1200: '4737eedf', 2160: 'f597c050' } + 'bell-seed-1': { 560: '2b6c46d2', 1200: '0d909381', 2160: '5ec3b841' }, + 'bell-seed-2': { 560: '34d82d30', 1200: 'fb24193f', 2160: '59b062df' }, + 'bell-seed-3': { 560: '34c76ced', 1200: '9e36fa5b', 2160: '0dc87c13' } } /** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件; * 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */ const GOLDEN_RESOLVED: Record> = { - 'bell-seed-1': { 560: '88f28074', 1200: 'd6b9cc85', 2160: 'c5b9b102' }, - 'bell-seed-2': { 560: '5ca6b6a9', 1200: '6a66a750', 2160: 'c8e8e982' }, - 'bell-seed-3': { 560: 'fb5bc0e8', 1200: '3e09f864', 2160: 'fae786ae' } + 'bell-seed-1': { 560: '073025e5', 1200: '444f2635', 2160: '73733808' }, + 'bell-seed-2': { 560: 'c1b08533', 1200: 'c63d74cc', 2160: '22868a68' }, + 'bell-seed-3': { 560: 'c0c477f9', 1200: 'adfad18e', 2160: 'df5bed84' } } const TIERS = [ diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index 8714970..7699add 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -170,8 +170,8 @@ describe('GameFacade 门面', () => { it('默认配置金钟罩不受门面化影响', () => { PACK.reset() - // 0.1.40 天命·裂痕:天命系统+NPC宗主更替四模式+势力分裂+灰烬重生——全线受控变更重算 - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('b050ad28') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('0b56c19b') + // 0.1.41 天命·兑现:NPC 演化种子派生+pushNews 修复+天命 flag 全量接线——全线受控变更重算 + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('2b6c46d2') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('34c76ced') }) }) diff --git a/tests/storage-resilience-0.1.38.test.ts b/tests/storage-resilience-0.1.38.test.ts index 40d384f..15787b8 100644 --- a/tests/storage-resilience-0.1.38.test.ts +++ b/tests/storage-resilience-0.1.38.test.ts @@ -261,18 +261,18 @@ describe('0.1.38 存档根治 · 存储链路回归(hybrid memory backend)', }) }) -describe('0.1.40 存档根治 · 版本号一致性', () => { - it('slots.ts GAME_VERSION = 0.1.40', async () => { +describe('0.1.41 存档根治 · 版本号一致性', () => { + it('slots.ts GAME_VERSION = 0.1.41', async () => { const { buildSimpleMeta } = await import('../src/renderer/game/storage/slots') const w = World.create({ seed: 'ver-1', surname: '周', familyName: '周家', motto: 'm', difficulty: 'normal' }) const meta = buildSimpleMeta(w.state, 1, 5) - expect(meta.version).toBe('0.1.40') + expect(meta.version).toBe('0.1.41') }) - it('storeHelper metaFromState version = 0.1.40', async () => { + it('storeHelper metaFromState version = 0.1.41', async () => { const { metaFromState } = await import('../src/renderer/ui/storeHelper') const w = World.create({ seed: 'ver-2', surname: '吴', familyName: '吴家', motto: 'm', difficulty: 'normal' }) const meta = metaFromState(w.state, 1) - expect(meta.version).toBe('0.1.40') + expect(meta.version).toBe('0.1.41') }) })