Compare commits
16
Commits
8f1f8d48ce
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
631099fd8c | ||
|
|
beeda036f6 | ||
|
|
54face8275 | ||
|
|
989bc3f444 | ||
|
|
2c7596cec7 | ||
|
|
dc970ff13b | ||
|
|
dcd2345192 | ||
|
|
07c0a218eb | ||
|
|
7ba64750bc | ||
|
|
818c6025f4 | ||
|
|
fdc9d750f2 | ||
|
|
e2070c8fcb | ||
|
|
62da87a4e4 | ||
|
|
fb23afa322 | ||
|
|
c0efc63434 | ||
|
|
5030eb04d2 |
@@ -1,7 +1,7 @@
|
||||
# AGENTS.md
|
||||
|
||||
仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。
|
||||
当前版本 **0.1.37**(《天人感应·因果织锦》:功德因果系统 + 飞升三阶段试炼 + 种子派生世界奇观 + MOD 扩展口(奇观类型注册/worldNum 白名单/因果事件/飞升前缀保护)+ 测试 7040)。
|
||||
当前版本 **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.33 基线**(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.33 符箓战力乘量/灾年因子入产/tribBoost 清零均为时序内更改)。指纹 `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/ 层,改数值勿动流程)
|
||||
|
||||
@@ -109,6 +109,6 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
|
||||
5. **高危文件白名单**(world.ts / events.ts / store.ts / styles.css / cultivation.ts / **WorldSim.ts / worldsim-data.ts**)改动后必须 `git diff --stat` 核对改动面与预期一致。
|
||||
6. 每次迭代收尾:`git status` 应干净 + `git log --oneline` 确认每步都有提交记录;凡 "git stash/checkout/rm" 类命令,执行前后都 `git status` 留痕。
|
||||
|
||||
- 提交前:`npm test`(当前 89 套件 5230 例全绿)+ `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。
|
||||
- 提交前:`npm test`(当前 108 套件 7058 例全绿)+ `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。
|
||||
- 平衡数值集中在 `game/data/` 与 `engine/sim/worldsim-data.ts`(世界循环参数唯一权威),调平衡不改引擎流程。
|
||||
- 发布产物 `release/`、`out/` 均已 gitignore,勿入库。
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
npm install # 依赖(含 git.metona.cn 私有 registry,token 在用户级 ~/.npmrc)
|
||||
npm install-scripts approve electron esbuild # npm12 首装时必须(Electron 二进制)
|
||||
npm run dev # 开发模式(热更新)
|
||||
npm test # 引擎测试(89 套件 / 5230 用例,纯 Node 无 GUI)
|
||||
npm test # 引擎测试(108 套件 / 7058 用例,纯 Node 无 GUI)
|
||||
npm run build # 产物到 out/
|
||||
npm run package # 打包(Windows 需在 Windows 侧执行)
|
||||
```
|
||||
@@ -42,12 +42,12 @@ 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.21 基线**。有意变更时序需受控更新并注明。
|
||||
- **双金钟罩**:`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)。
|
||||
- **审计四轮**:0.1.15(引擎 P0 歼灭)/ 0.1.20(事件闸·市场黑洞·双金钟罩)/ 0.1.28(稳石:词库回滚·新贵落档·校验接生产·bundle 闭环·指纹补盲)/ **0.1.29(续命:续代断链·敌强度相对化·权重防洪)**。
|
||||
- **测试规模**:**89 套件 / 5230 例**(41 个矩阵域:随机序列/世界生成/市场实体化/事件闸/数据表全量/插件 MOD 组合/省级矩阵等——语义真实,无刷水)。
|
||||
- **审计五轮**:0.1.15(引擎 P0 歼灭)/ 0.1.20(事件闸·市场黑洞·双金钟罩)/ 0.1.28(稳石:词库回滚·新贵落档·校验接生产·bundle 闭环·指纹补盲)/ 0.1.29(续命:续代断链·敌强度相对化·权重防洪)/ **0.1.35(荡涤:17 缺陷歼灭·兼容层铲除·validateLoadedState 替代迁移)**。
|
||||
- **测试规模**:**108 套件 / 7058 例**(矩阵域:随机序列/世界生成/市场实体化/事件闸/数据表全量/插件 MOD 组合/省级矩阵等——语义真实,无刷水)。
|
||||
|
||||
## 打包产物
|
||||
|
||||
@@ -55,7 +55,7 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
|
||||
- 图标资源 resources/icon.png|ico —— electron-builder 自动使用
|
||||
- 注意:WSL/Linux 交叉打 win 包可行但未签名,正规发布请用 Windows 侧重跑
|
||||
|
||||
## 版本沿革(0.1.0 → 0.1.21)
|
||||
## 版本沿革(0.1.0 → 0.1.41)
|
||||
|
||||
| 版本 | 主题 |
|
||||
|---|---|
|
||||
@@ -82,5 +82,13 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
|
||||
| 0.1.31 | 万物皆可 MOD(内容面七口:物品/功法/建筑(表达式 DSL)/职事/志向/阵型/性格 + kind 分类 + ERA_DURA/REGION_TIDE 聚合 + 测试 4006) |
|
||||
| 0.1.32 | 万宝琳琅(物品六类化 + 新材料七种入市 + 符箓武备/灵酿回气 + 生产泛化 + 测试 5009) |
|
||||
| 0.1.33 | 闭环·流光(物品生态闭环(符箓生效/僵尸物品归位/灾年入产)+ 引擎止血 15 项 + UI 动效打磨 + 测试 5230) |
|
||||
| 0.1.34 | 万象归元(功法流派化×元素时节共鸣 + 世界自演化二层 + 插件卡 12→14 + MOD 功法效果口+patch 数值层 + 测试 6000) |
|
||||
| 0.1.35 | 荡涤·向新(17 缺陷歼灭 + 兼容层整体铲除 + validateLoadedState 替代迁移 + 测试 7000) |
|
||||
| 0.1.36 | CSP 根因(arith.ts 纯解析器全量替换 Function() + ErrorBoundary 防白屏 + dev:web 浏览器通道) |
|
||||
| 0.1.37 | 天人感应(因果系统 karma + 飞升试炼三阶段 + 世界奇观种子派生 + 测试 7030) |
|
||||
| 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`(设计哲学与每个迭代的"为什么")。
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# MOD 开发教程 · Chronicle of the Immortal Clan
|
||||
|
||||
> 版本 **0.1.36·《CSP 根因》** · 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-*` 前缀保护防劫持。
|
||||
|
||||
<sub>⚔️ 本文档随游戏版本更新(0.1.37)。字段若有新增,此处先行。</sub>
|
||||
<sub>⚔️ 本文档随游戏版本更新(0.1.41)。字段若有新增,此处先行。</sub>
|
||||
|
||||
@@ -50,6 +50,11 @@
|
||||
| 0.1.34 | 万象归元 | 功法流派化(crit/guard/trib/secret 四效果 ×6 新功 ×元素时节共鸣)+ 世界自演化二层(warFatigue 温度冷降/换代恩义半衰/断供应急×1.5/新秘境种子派生);插件卡 12→14(worldsim 观察/tech 功法);MOD 功法效果口+patch 数值层(0.5~2 mult 校验/卸载复原);**测试 5230→6000** |
|
||||
| 0.1.35 | 荡涤·向新 | 全量审计 17 缺陷歼灭(wonder 全链/boss 释放/patch 归属/拒单门/衰减/transition);**兼容层整体铲除**:migrate 迁移链 55 行+版本戳 8 处+chronicle 死表+__state/seed 三元+normalize 兼容批 55 行(未发布不向下兼容——validateLoadedState 结构校验替代)+domain 5 兼容 optional 收紧;**测试 6000→7000** |
|
||||
| 0.1.36 | CSP 根因 | 三问题处置:成员弹框双排名(壳/内容双渲染已修);**推进崩溃+定制白屏同一根因——打包 renderer CSP 禁 unsafe-eval,bonusOf/produceExpr/MOD 表达式三处 Function() 求值全被拦**→ 新建 arith.ts 纯解析器(令牌化+递归下降)全量替换(零 eval);toast 根错可见+出险双保险;ErrorBoundary 防白屏;dev:web 浏览器通道;存储真链测试(MetonaSqlark memory 方言);**测试 7007→7011** |
|
||||
| 0.1.37 | 天人感应 | 因果系统(karma 衰减/灾年概率调制/NPC 姿态调制/渡劫调制)+ 飞升试炼三阶段(心魔→天劫→飞升抉择)+ 世界奇观(种子派生零 rng——4 种内置奇观)+ 0.1.37 金钟罩基线重算;**测试 7011→7030** |
|
||||
| 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 值全量更新)|
|
||||
|
||||
## 三、关键架构决策(及为什么)
|
||||
|
||||
@@ -143,7 +148,7 @@
|
||||
2. GameFacade act 通道与面板直调并存(**仲裁结论**:直调是事实通道、act 为测试/自动化通道——actDirect 已补齐 24 项等价映射)。
|
||||
3. `guide.ts`(引导任务)只做 UI 条,钩子目标点不完("提示"非"强制")。
|
||||
4. `family.flag` 长线键已有 3 年剪枝、但 `state.missions/missionIds` 只增不减(未剪枝)。
|
||||
5. NPC 四家是固定成分(无"覆灭/新贵补位"——0.1.19 留作 0.1.22+ 候选);附庸/吞并仅 UI"衰"标记。
|
||||
5. ~~NPC 四家是固定成分(无"覆灭/新贵补位"——0.1.19 留作 0.1.22+ 候选)~~ **已解决**(0.1.23 覆灭/新贵补位 + 0.1.40 宗主更替四模式/势力分裂/灰烬重生)。附庸/吞并仅 UI"衰"标记。
|
||||
6. `worldsim` 的能力卡开关无独立钩子(viaCap 恒真已注册,实际不可关)——如需可关需重构。
|
||||
|
||||
## 七、已知技术债补充(0.1.26)
|
||||
@@ -151,7 +156,7 @@
|
||||
7. MOD 目录形态仅支持单文件(目录/分包/依赖多个 .cotymod 自动解析留待)。
|
||||
8. CALAMITY 与 WORLDSTIM 数值表的大规模注入(多 MOD 叠加平衡)未做。
|
||||
9. NPC"中期势力轮替"曲线(newborn 频度/覆灭线)仍单参数,可寻参数化。
|
||||
10. **测试规模 7011**(102 套件矩阵域),后续新增机制优先在对应矩阵域追加参(seed×组合),避免重开大文件。
|
||||
10. **测试规模 7058**(108 套件矩阵域),后续新增机制优先在对应矩阵域追加参(seed×组合),避免重开大文件。
|
||||
11. **缓做项(0.1.28 审计定案)**:declineYears 盛世误触/闪烁——重平衡归 0.1.29 独立调参(避免二次基线漂移);CALAMITY_FAMILY 独立口收益低(灾因口已覆盖);ERA_DURA/REGION_TIDE 松弛版数值口 0.1.29+(必须走"默认优先+聚合"模式)。
|
||||
|
||||
## 八、如何开始修改(给下一个智能体的路径)
|
||||
@@ -162,7 +167,7 @@
|
||||
改结构:注册进 clocks/plugins;世界循环参数唯一权威 = worldsim-data.ts
|
||||
→ 5. 改 UI:先改 store(revision)再改组件;纯逻辑放 game/engine 可测
|
||||
→ 6. 加测试:双金钟罩不动,新增用例挂 tests/xxx-0.1.x.test.ts(按主题命名)
|
||||
→ 7. 提交前:npm test(102 套件 7011+)+ typecheck + (GUI 冒烟需 WSLg 在线)+ 更新三份文档 + MOD_GUIDE(触 MOD 时)
|
||||
→ 7. 提交前:npm test(108 套件 7058+)+ typecheck + (GUI 冒烟需 WSLg 在线)+ 更新三份文档 + MOD_GUIDE(触 MOD 时)
|
||||
```
|
||||
|
||||
**一句话原则**:先复现、再小改、后金钟罩。改坏确定性比改错玩法更危险;改数值去 worldsim-data,改流程去 clocks,改事件去 fire()。
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chronicle-of-the-immortal-clan",
|
||||
"productName": "仙途家族志",
|
||||
"version": "0.1.37",
|
||||
"version": "0.1.41",
|
||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "MetonaTeam",
|
||||
|
||||
@@ -152,13 +152,24 @@ export function buildingYieldOf(id: string, level: number): Record<string, numbe
|
||||
return JSON.parse(expr) as Record<string, number>
|
||||
}
|
||||
|
||||
/** 0.1.40 建筑专精乘数:专精建筑 extra 加成 ×1.5 */
|
||||
export const SPECIALTY_MULT = 1.5
|
||||
|
||||
/** 0.1.40 检查建筑是否为当前专精建筑(flag 路径:family.flag['buildingSpecialty']) */
|
||||
export function isSpecialty(id: string, specialtyId?: string): boolean {
|
||||
return !!specialtyId && id === specialtyId
|
||||
}
|
||||
|
||||
/** 解析 extra 表达式(如 '0.5 + 0.1*L'):仅允许 数字/括号/四则/空格 与变量 L。
|
||||
* 白名单校验后求值——所有数字加成以 extra 为单一权威。 */
|
||||
export function bonusOf(id: string, key: string, level: number): number {
|
||||
* 白名单校验后求值——所有数字加成以 extra 为单一权威。
|
||||
* 0.1.40:专精建筑 extra 加成 ×1.5(specialtyId 传入时自动判定) */
|
||||
export function bonusOf(id: string, key: string, level: number, specialtyId?: string): number {
|
||||
const def = BUILDINGS[id]
|
||||
const expr = def?.extra?.[key]
|
||||
if (!expr) return 0
|
||||
// 0.1.36 CSP safe:纯解析器——不再 Function()(unsafe-eval 被 Electron 打包版拦截)
|
||||
const v = arith(expr, level)
|
||||
return Number.isFinite(v) ? v : 0
|
||||
if (!Number.isFinite(v)) return 0
|
||||
// 0.1.40 专精加成
|
||||
return isSpecialty(id, specialtyId) ? v * SPECIALTY_MULT : v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* 0.1.40 天命系统 · 数据层
|
||||
*
|
||||
* 天命议题池:每 3~5 年(种子派生选取,零 rng 消耗),天下大势产生一个"天命议题"。
|
||||
* 玩家必须做出影响家族与天下走向的重大决策——"天命不可违"。
|
||||
*
|
||||
* 天命经 fire(w, id, 1) 高优入事件闸(可顶替被占日常事件)。
|
||||
* 天命不可"稍后再议"(requeue)——不可回避。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 效果复用 EffectDef 结构(零新类型)
|
||||
* - 选取走种子派生(seed ^ year ^ mandateCount 哈希),零 rng 消耗
|
||||
* - 每条议题含 cond 过滤(不满足条件的议题不入候选池)
|
||||
*/
|
||||
|
||||
import type { Cond, EffectDef } from './events'
|
||||
|
||||
/** 天命类别 */
|
||||
export type MandateCategory = 'calamity' | 'war' | 'fortune' | 'visitor' | 'turbulence'
|
||||
|
||||
/** 天命选项 */
|
||||
export interface MandateOption {
|
||||
label: string
|
||||
hint?: string
|
||||
eff: EffectDef
|
||||
note?: string
|
||||
}
|
||||
|
||||
/** 天命议题定义 */
|
||||
export interface MandateDef {
|
||||
id: string
|
||||
name: string
|
||||
category: MandateCategory
|
||||
text: string
|
||||
options: MandateOption[]
|
||||
/** 触发条件(不满足的议题不出现在候选池中) */
|
||||
cond?: Cond
|
||||
/** 天命权重(在同类中抽签的权重,默认 10) */
|
||||
weight?: number
|
||||
}
|
||||
|
||||
/** 天命类别中文名 */
|
||||
export function mandateCategoryName(cat: MandateCategory): string {
|
||||
return cat === 'calamity' ? '天灾篇'
|
||||
: cat === 'war' ? '人祸篇'
|
||||
: cat === 'fortune' ? '天时篇'
|
||||
: cat === 'visitor' ? '人事篇'
|
||||
: '乱世篇'
|
||||
}
|
||||
|
||||
/**
|
||||
* 天命议题池(20 条,分 5 类,每类 4 条)
|
||||
*
|
||||
* 设计要点:
|
||||
* - calamity(天灾):考验玩家的资源调度与道德抉择
|
||||
* - war(人祸):考验玩家的军事与外交平衡
|
||||
* - fortune(天时):考验玩家的战略取舍(短期 vs 长期)
|
||||
* - visitor(人事):考验玩家的人才经营
|
||||
* - turbulence(乱世):考验玩家的天下站位
|
||||
*/
|
||||
export const MANDATES: MandateDef[] = [
|
||||
// ──────────────── 天灾篇 ────────────────
|
||||
{
|
||||
id: 'mandate-drought',
|
||||
name: '大旱连年',
|
||||
category: 'calamity',
|
||||
weight: 12,
|
||||
text: '天象异变,赤地千里——方圆数百里灵田干裂,灵泉枯竭。周遭散修聚于族门求一口灵水。天灾面前,族中存粮与道义如何权衡?',
|
||||
options: [
|
||||
{
|
||||
label: '开仓赈灾',
|
||||
hint: '灵石-300,声望+15,功德+8',
|
||||
eff: { res: { stones: -300 }, rep: 15, karma: 8 },
|
||||
note: '四方散修感念恩德,族名远扬。'
|
||||
},
|
||||
{
|
||||
label: '筑坛祈雨',
|
||||
hint: '灵草-50,全员修为微升,功德+3',
|
||||
eff: { res: { lingcao: -50 }, karma: 3, memberBy: { by: 'exp', target: 'all', n: 3 } },
|
||||
note: '族中修士联手做法,天际隐隐有甘霖之意。'
|
||||
},
|
||||
{
|
||||
label: '坐视不理',
|
||||
hint: '功德-12,声望-8,可能招致难民冲击',
|
||||
eff: { karma: -12, rep: -8 },
|
||||
note: '散修愤而散去,日后或成祸患。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-plague',
|
||||
name: '疫病横行',
|
||||
category: 'calamity',
|
||||
weight: 10,
|
||||
text: '一场诡异疫病在周边坊市蔓延——染者灵力逆乱,三日不治则经脉尽毁。族中医者请命:是封锁山门隔绝疫源,还是以丹药济世?',
|
||||
options: [
|
||||
{
|
||||
label: '封锁山门',
|
||||
hint: '声望-5,全族安全,功德-2',
|
||||
eff: { rep: -5, karma: -2 },
|
||||
note: '紧闭门户虽保族中平安,却失了人心。'
|
||||
},
|
||||
{
|
||||
label: '炼丹济世',
|
||||
hint: '灵草-80,兽核-10,声望+12,功德+10',
|
||||
eff: { res: { lingcao: -80, beastcore: -10 }, rep: 12, karma: 10 },
|
||||
note: '丹药送出,疫病渐息,四方称颂。'
|
||||
},
|
||||
{
|
||||
label: '借疫敛财',
|
||||
hint: '灵石+400,功德-20,声望-15',
|
||||
eff: { res: { stones: 400 }, karma: -20, rep: -15 },
|
||||
note: '高价售药虽得暴利,却种下恶因。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-flood',
|
||||
name: '洪涝滔天',
|
||||
category: 'calamity',
|
||||
weight: 10,
|
||||
text: '连月暴雨如注,灵河决堤——下游数处坊市被淹,灵田被冲毁者十之六七。水患当前,治水还是自救?',
|
||||
options: [
|
||||
{
|
||||
label: '组织治水',
|
||||
hint: '灵石-200,灵矿-20,声望+10,功德+6',
|
||||
eff: { res: { stones: -200, lingkuang: -20 }, rep: 10, karma: 6 },
|
||||
note: '族中修士以术法疏导洪流,保住数处坊市。'
|
||||
},
|
||||
{
|
||||
label: '加固本族',
|
||||
hint: '灵矿-30,仅保本族灵田',
|
||||
eff: { res: { lingkuang: -30 } },
|
||||
note: '护住了自家,却眼看邻家田园尽没。'
|
||||
},
|
||||
{
|
||||
label: '趁灾捞利',
|
||||
hint: '灵石+250,功德-15,声望-10',
|
||||
eff: { res: { stones: 250 }, karma: -15, rep: -10 },
|
||||
note: '灾中哄抬物价,财是进了,道义却亏了。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-beasttide',
|
||||
name: '兽潮汹涌',
|
||||
category: 'calamity',
|
||||
weight: 10,
|
||||
cond: { minYear: 5 },
|
||||
text: '深山老林中妖兽暴动,成群结队涌向人烟——灵兽园首当其冲,周边散修纷纷逃向族门求庇护。兽潮将至,如何应对?',
|
||||
options: [
|
||||
{
|
||||
label: '出兵抵御',
|
||||
hint: '全员参战,胜则兽核+20,声望+8',
|
||||
eff: { memberBy: { by: 'exp', target: 'all', n: 2 }, res: { beastcore: 20 }, rep: 8, karma: 4 },
|
||||
note: '击退兽潮,满载而归。'
|
||||
},
|
||||
{
|
||||
label: '诱兽离境',
|
||||
hint: '灵草-30,灵石-100,无损退敌',
|
||||
eff: { res: { lingcao: -30, stones: -100 }, karma: 2 },
|
||||
note: '以灵草为饵将兽群引向荒山,不伤一兵一卒。'
|
||||
},
|
||||
{
|
||||
label: '献地求安',
|
||||
hint: '灵石-350,声望-6',
|
||||
eff: { res: { stones: -350 }, rep: -6, karma: -3 },
|
||||
note: '割让灵地赔款,暂保平安——但地盘缩了。'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// ──────────────── 人祸篇 ────────────────
|
||||
{
|
||||
id: 'mandate-bandits',
|
||||
name: '流寇袭扰',
|
||||
category: 'war',
|
||||
weight: 12,
|
||||
text: '乱世流寇盘踞山头,劫掠过往商旅——近日竟犯族门灵道,截去灵石若干。族中弟子请战:是出兵围剿,还是破财消灾?',
|
||||
options: [
|
||||
{
|
||||
label: '出兵围剿',
|
||||
hint: '战力判定——胜则声望+10,灵石+150',
|
||||
eff: { memberBy: { by: 'exp', target: 'highestPower', n: 5 }, rep: 10, res: { stones: 150 }, karma: 3 },
|
||||
note: '遣精锐扫荡匪巢,缴获颇丰。'
|
||||
},
|
||||
{
|
||||
label: '招安纳降',
|
||||
hint: '灵石-100,人丁+2,声望-3',
|
||||
eff: { res: { stones: -100 }, rep: -3, karma: 4, flag: { banditRecruit: true } },
|
||||
note: '流寇归顺入族,虽非良材,胜在充丁。'
|
||||
},
|
||||
{
|
||||
label: '贿赂驱离',
|
||||
hint: '灵石-200',
|
||||
eff: { res: { stones: -200 }, karma: -2 },
|
||||
note: '花钱买平安——但流寇去了又来,何时是头?'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-borderwar',
|
||||
name: '边境烽烟',
|
||||
category: 'war',
|
||||
weight: 10,
|
||||
cond: { minYear: 8 },
|
||||
text: '两方天下家开战,战场就在族门百里之外——灵脉被战火波及,坊市断商。是选边站队,还是保持中立?',
|
||||
options: [
|
||||
{
|
||||
label: '支持强者',
|
||||
hint: '灵石-150,与胜方关系+25,败方-25',
|
||||
eff: { res: { stones: -150 }, flag: { borderWarPicked: 'strong' } },
|
||||
note: '押注强者——赢了得利,输了遭殃。'
|
||||
},
|
||||
{
|
||||
label: '支持弱者',
|
||||
hint: '灵石-100,与弱方关系+30,强方-15,功德+5',
|
||||
eff: { res: { stones: -100 }, karma: 5, flag: { borderWarPicked: 'weak' } },
|
||||
note: '雪中送炭,弱者或铭记在心。'
|
||||
},
|
||||
{
|
||||
label: '中立观望',
|
||||
hint: '声望-3,两方关系各-5',
|
||||
eff: { rep: -3 },
|
||||
note: '两边不得罪——也两边不讨好。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-siege',
|
||||
name: '围庄之危',
|
||||
category: 'war',
|
||||
weight: 8,
|
||||
cond: { minRep: 30, minYear: 10 },
|
||||
text: '一个崛起中的 NPC 家族觊觎族门灵脉,兵临庄下——围而不攻,限三日交出灵石一千否则破庄。兵临城下,何以处之?',
|
||||
options: [
|
||||
{
|
||||
label: '誓死抵抗',
|
||||
hint: '全员出战——胜则声望+20,败则大伤',
|
||||
eff: { memberBy: { by: 'exp', target: 'all', n: 4 }, rep: 20, karma: 5, flag: { siegeFought: true } },
|
||||
note: '族人同仇敌忾,破釜沉舟!'
|
||||
},
|
||||
{
|
||||
label: '献财求和',
|
||||
hint: '灵石-800,声望-10',
|
||||
eff: { res: { stones: -800 }, rep: -10, karma: -3 },
|
||||
note: '忍辱献财,暂保门户——但被人看了笑话。'
|
||||
},
|
||||
{
|
||||
label: '求援盟友',
|
||||
hint: '需有盟友(关系≥40的NPC),灵石-100',
|
||||
eff: { res: { stones: -100 }, rep: 5, karma: 2, flag: { siegeAlliance: true } },
|
||||
note: '盟友出兵来援,围敌退去,同盟之谊更深。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-mercenary',
|
||||
name: '佣兵招募',
|
||||
category: 'war',
|
||||
weight: 8,
|
||||
cond: { minYear: 6 },
|
||||
text: '一支游历散修队伍慕族名而来——为首者修为不俗,愿以灵石三百为酬,为族效力三年。收还是不收?',
|
||||
options: [
|
||||
{
|
||||
label: '重金聘用',
|
||||
hint: '灵石-300,战力大增,三年后离开',
|
||||
eff: { res: { stones: -300 }, rep: 3, flag: { mercenaryHired: true } },
|
||||
note: '佣兵入族,战力大增——但三年后便走。'
|
||||
},
|
||||
{
|
||||
label: '婉言谢绝',
|
||||
hint: '声望+2',
|
||||
eff: { rep: 2 },
|
||||
note: '不收外人——族中自有良材。'
|
||||
},
|
||||
{
|
||||
label: '以礼相待送行',
|
||||
hint: '灵石-50,功德+3',
|
||||
eff: { res: { stones: -50 }, karma: 3 },
|
||||
note: '赠以盘缠送走——江湖路远,或再相逢。'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// ──────────────── 天时篇 ────────────────
|
||||
{
|
||||
id: 'mandate-tidesurge',
|
||||
name: '灵气大涌',
|
||||
category: 'fortune',
|
||||
weight: 12,
|
||||
text: '天象异常——灵气潮汐骤然高涨,方圆百里灵雾弥漫。此等天赐良机,采气修炼还是广修灵田?',
|
||||
options: [
|
||||
{
|
||||
label: '闭门采气',
|
||||
hint: '全员修为+8',
|
||||
eff: { memberBy: { by: 'exp', target: 'all', n: 8 } },
|
||||
note: '族中修士闭关采气,修为精进。'
|
||||
},
|
||||
{
|
||||
label: '广修灵田',
|
||||
hint: '灵石-150,灵草产出 ×1.5 持续 24 月',
|
||||
eff: { res: { stones: -150 }, flag: { tideHarvest: true } },
|
||||
note: '趁灵气充沛广植灵草,来年丰收。'
|
||||
},
|
||||
{
|
||||
label: '抛售灵草',
|
||||
hint: '灵草+100(可出售换取灵石)',
|
||||
eff: { res: { lingcao: 100 } },
|
||||
note: '灵气催生灵草疯长——趁高价出手!'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-spiritvein',
|
||||
name: '灵脉显踪',
|
||||
category: 'fortune',
|
||||
weight: 10,
|
||||
cond: { minYear: 5 },
|
||||
text: '族中探子在深山发现一条隐没灵脉——尚未被任何家族占据。是立刻开采,还是报与天下共享?',
|
||||
options: [
|
||||
{
|
||||
label: '独占开采',
|
||||
hint: '灵矿+80,灵石+200,功德-3',
|
||||
eff: { res: { lingkuang: 80, stones: 200 }, karma: -3 },
|
||||
note: '灵脉归我——但消息走漏或引来觊觎。'
|
||||
},
|
||||
{
|
||||
label: '报与仙盟',
|
||||
hint: '声望+15,灵石+100,功德+6',
|
||||
eff: { res: { stones: 100 }, rep: 15, karma: 6 },
|
||||
note: '仙盟嘉奖我族高义,分润一条灵脉。'
|
||||
},
|
||||
{
|
||||
label: '暗中出售',
|
||||
hint: '灵石+350,功德-8',
|
||||
eff: { res: { stones: 350 }, karma: -8 },
|
||||
note: '将灵脉位置卖给了一家——无本买卖。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-treasure',
|
||||
name: '古宝出世',
|
||||
category: 'fortune',
|
||||
weight: 8,
|
||||
cond: { minYear: 8 },
|
||||
text: '一座远古洞府在地震中显露——禁制松动,宝光冲天。四方修士云集争夺。是深入探索,还是坐收渔利?',
|
||||
options: [
|
||||
{
|
||||
label: '遣队探索',
|
||||
hint: '灵石-100,可能得功法/宝器/丹药',
|
||||
eff: { res: { stones: -100 }, techniqueChance: 0.35, artifactChance: 0.25, flag: { treasureExplored: true } },
|
||||
note: '遣精锐深入古洞——机缘与风险并存。'
|
||||
},
|
||||
{
|
||||
label: '设卡收税',
|
||||
hint: '灵石+250,声望-5',
|
||||
eff: { res: { stones: 250 }, rep: -5 },
|
||||
note: '在洞府入口设卡——凡入者交灵石百枚。'
|
||||
},
|
||||
{
|
||||
label: '通报仙盟',
|
||||
hint: '声望+8,灵石+50,功德+4',
|
||||
eff: { res: { stones: 50 }, rep: 8, karma: 4 },
|
||||
note: '古宝归公,仙盟论功行赏。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-heavenbless',
|
||||
name: '天降祥瑞',
|
||||
category: 'fortune',
|
||||
weight: 8,
|
||||
text: '天降异象——五彩祥云盘旋族门上空七日不散。祥瑞之下,万物滋长,族中人心振奋。这是天道的嘉许,还是考验的前奏?',
|
||||
options: [
|
||||
{
|
||||
label: '设坛祭天',
|
||||
hint: '灵石-100,声望+12,功德+8,全员气血+10',
|
||||
eff: { res: { stones: -100 }, rep: 12, karma: 8, memberBy: { by: 'heal', target: 'all', n: 10 } },
|
||||
note: '祭天谢恩——天道降福,族运昌隆。'
|
||||
},
|
||||
{
|
||||
label: '趁势修炼',
|
||||
hint: '全员修为+12',
|
||||
eff: { memberBy: { by: 'exp', target: 'all', n: 12 } },
|
||||
note: '祥瑞灵气入体——族中修士齐齐精进。'
|
||||
},
|
||||
{
|
||||
label: '广邀宾客',
|
||||
hint: '灵石-200,声望+18,关系全面提升',
|
||||
eff: { res: { stones: -200 }, rep: 18 },
|
||||
note: '趁祥瑞之名广邀天下——一时风头无两。'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// ──────────────── 人事篇 ────────────────
|
||||
{
|
||||
id: 'mandate-scholar',
|
||||
name: '名士来访',
|
||||
category: 'visitor',
|
||||
weight: 12,
|
||||
cond: { minRep: 15 },
|
||||
text: '一位游历天下的名士造访族门——此人参悟三教百家,论道之能名闻四海。他愿在族中盘桓数月,但需以灵石供养。是礼聘还是婉拒?',
|
||||
options: [
|
||||
{
|
||||
label: '以礼相待',
|
||||
hint: '灵石-150,功法机率+30%,全员悟性微升',
|
||||
eff: { res: { stones: -150 }, techniqueChance: 0.3, memberBy: { by: 'exp', target: 'all', n: 2 } },
|
||||
note: '名士居族数月,或传功法一部,族中子弟眼界大开。'
|
||||
},
|
||||
{
|
||||
label: '切磋论道',
|
||||
hint: '灵石-50,全员悟性+1(高悟性者突破概率提升)',
|
||||
eff: { res: { stones: -50 }, memberBy: { by: 'inspire', target: 'all', n: 3 } },
|
||||
note: '不聘为客——只做论道之交。'
|
||||
},
|
||||
{
|
||||
label: '婉言谢绝',
|
||||
hint: '声望-2',
|
||||
eff: { rep: -2 },
|
||||
note: '名士拂袖而去——或传我不敬贤才。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-artisan',
|
||||
name: '匠师投奔',
|
||||
category: 'visitor',
|
||||
weight: 10,
|
||||
cond: { minYear: 4, minBuilding: { id: 'danfang', level: 1 } },
|
||||
text: '一名游方匠师携炼器图谱来投——自称曾为仙盟名工,因故流落。他愿为族中效力,但需以灵矿灵石建炉开炉。收是不收?',
|
||||
options: [
|
||||
{
|
||||
label: '厚礼收留',
|
||||
hint: '灵石-200,灵矿-30,丹房效率永久+10%',
|
||||
eff: { res: { stones: -200, lingkuang: -30 }, flag: { artisanHired: true }, karma: 2 },
|
||||
note: '匠师入族,炼丹炼器皆有所长。'
|
||||
},
|
||||
{
|
||||
label: '试用三月',
|
||||
hint: '灵石-80,得一件随机兵器',
|
||||
eff: { res: { stones: -80 }, artifactChance: 0.5 },
|
||||
note: '先试后用——匠师卖力铸了一件兵器。'
|
||||
},
|
||||
{
|
||||
label: '荐与他人',
|
||||
hint: '声望+3,灵石+50',
|
||||
eff: { res: { stones: 50 }, rep: 3, karma: 1 },
|
||||
note: '将匠师荐给盟友——做了一桩人情。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-disciple',
|
||||
name: '天纵之资',
|
||||
category: 'visitor',
|
||||
weight: 8,
|
||||
text: '一幼童在族门附近迷路——经探查,此童灵根奇佳,悟性超群,乃百年难遇之才。是收为族中子弟,还是送还寻亲?',
|
||||
options: [
|
||||
{
|
||||
label: '收为族中',
|
||||
hint: '人丁+1(天骄幼童),功德+3',
|
||||
eff: { karma: 3, flag: { prodigyAdopted: true } },
|
||||
note: '幼童入族——或许将成为族中下一代栋梁。'
|
||||
},
|
||||
{
|
||||
label: '送还寻亲',
|
||||
hint: '声望+5,功德+6',
|
||||
eff: { rep: 5, karma: 6 },
|
||||
note: '幼童归家——其家族或日后登门致谢。'
|
||||
},
|
||||
{
|
||||
label: '试其根骨',
|
||||
hint: '灵石-30,有概率得天才(也有概率伤其根基)',
|
||||
eff: { res: { stones: -30 }, flag: { prodigyTested: true } },
|
||||
note: '以秘法探其根骨——有得有失。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-envoy',
|
||||
name: '仙盟来使',
|
||||
category: 'visitor',
|
||||
weight: 10,
|
||||
cond: { minRep: 25, minYear: 6 },
|
||||
text: '太虚仙盟遣使来访——提请族中派一名弟子入仙盟修行三年。仙盟资源丰厚但规矩森严,弟子或大有长进,或因过被逐。是否应允?',
|
||||
options: [
|
||||
{
|
||||
label: '遣送入盟',
|
||||
hint: '灵石-50,成员悟性+2,三年后归',
|
||||
eff: { res: { stones: -50 }, flag: { envoyToAlliance: true }, memberBy: { by: 'exp', target: 'highestPerception', n: 5 } },
|
||||
note: '遣最聪慧的弟子入盟——三年后归来,脱胎换骨。'
|
||||
},
|
||||
{
|
||||
label: '婉拒好意',
|
||||
hint: '声望-5',
|
||||
eff: { rep: -5 },
|
||||
note: '婉拒仙盟——或被视为不识抬举。'
|
||||
},
|
||||
{
|
||||
label: '献银代替',
|
||||
hint: '灵石-300,声望+3',
|
||||
eff: { res: { stones: -300 }, rep: 3 },
|
||||
note: '以灵石代遣——仙盟收了钱,但暗记一笔。'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// ──────────────── 乱世篇 ────────────────
|
||||
{
|
||||
id: 'mandate-choose-side',
|
||||
name: '群雄逐鹿',
|
||||
category: 'turbulence',
|
||||
weight: 10,
|
||||
cond: { minYear: 10 },
|
||||
text: '天下大势已至分水岭——两大阵营对峙,战云密布。中立者两面受压,选边者成王败寇。家族何去何从?',
|
||||
options: [
|
||||
{
|
||||
label: '组建联盟',
|
||||
hint: '需关系≥40的两家以上NPC,声望+10',
|
||||
eff: { rep: 10, karma: 3, flag: { coalitionFormed: true } },
|
||||
note: '牵头组建中立联盟——第三条路。'
|
||||
},
|
||||
{
|
||||
label: '保持中立',
|
||||
hint: '声望-5,两方关系各-8',
|
||||
eff: { rep: -5 },
|
||||
note: '两边不沾——但也两边受气。'
|
||||
},
|
||||
{
|
||||
label: '择强而从',
|
||||
hint: '与最强NPC关系+30,其余-20',
|
||||
eff: { flag: { choseStrongSide: true }, karma: -2 },
|
||||
note: '押注强者——赢了分蛋糕,输了上餐桌。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-rebellion',
|
||||
name: '宗门叛逆',
|
||||
category: 'turbulence',
|
||||
weight: 8,
|
||||
cond: { minYear: 8, minMembers: 12 },
|
||||
text: '族中一名高修为弟子心生异志——暗中联络外族,意图自立门户。事发东窗,如何处置?',
|
||||
options: [
|
||||
{
|
||||
label: '大义灭亲',
|
||||
hint: '声望+5,功德+4,人丁-1',
|
||||
eff: { rep: 5, karma: 4, flag: { rebelPunished: true } },
|
||||
note: '公审叛逆,以正族规——人心震慑。'
|
||||
},
|
||||
{
|
||||
label: '宽恕放逐',
|
||||
hint: '功德+6,声望-3',
|
||||
eff: { rep: -3, karma: 6 },
|
||||
note: '念其旧功,放逐出族——或日后报恩。'
|
||||
},
|
||||
{
|
||||
label: '安抚留用',
|
||||
hint: '灵石-100,声望-8',
|
||||
eff: { res: { stones: -100 }, rep: -8, karma: -2 },
|
||||
note: '好言安抚赐以灵石——但人心已散。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-elder-council',
|
||||
name: '长老议政',
|
||||
category: 'turbulence',
|
||||
weight: 10,
|
||||
cond: { minGeneration: 3, minAdult: 5 },
|
||||
text: '族中已传数代,人丁渐旺——长老们提议设立长老会,分权制衡。家主独尊还是从善如流?',
|
||||
options: [
|
||||
{
|
||||
label: '设立长老会',
|
||||
hint: '声望+8,全员心境+3,家主权力分散',
|
||||
eff: { rep: 8, karma: 2, memberBy: { by: 'inspire', target: 'all', n: 3 }, flag: { elderCouncil: true } },
|
||||
note: '长老会成立——族务共议,人心凝聚。'
|
||||
},
|
||||
{
|
||||
label: '独断专行',
|
||||
hint: '声望-5,家主修为+5',
|
||||
eff: { rep: -5, karma: -3, memberBy: { by: 'exp', target: 'head', n: 5 } },
|
||||
note: '家主一言堂——权力集中,但人心微妙。'
|
||||
},
|
||||
{
|
||||
label: '折中设参议',
|
||||
hint: '声望+3,灵石-50',
|
||||
eff: { rep: 3, res: { stones: -50 }, flag: { advisoryBoard: true } },
|
||||
note: '设参议之位——长老有言权无决权。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mandate-succession',
|
||||
name: '储位之争',
|
||||
category: 'turbulence',
|
||||
weight: 10,
|
||||
cond: { minGeneration: 3, minYear: 15 },
|
||||
text: '家主年事渐高,继承人未定——族中分作两派,各推人选。纷争暗涌,若不决断,恐生内乱。储位归属何人?',
|
||||
options: [
|
||||
{
|
||||
label: '立长不立幼',
|
||||
hint: '声望+5,族中安定',
|
||||
eff: { rep: 5, karma: 2, flag: { successionElder: true } },
|
||||
note: '以长幼为序——纲常有序,人心服帖。'
|
||||
},
|
||||
{
|
||||
label: '立贤不立长',
|
||||
hint: '声望+3,悟性最高者修为+8',
|
||||
eff: { rep: 3, memberBy: { by: 'exp', target: 'highestPerception', n: 8 }, flag: { successionWise: true } },
|
||||
note: '以才德为先——族中或有不甘,但来日可期。'
|
||||
},
|
||||
{
|
||||
label: '不立储位',
|
||||
hint: '声望-8,功德-4',
|
||||
eff: { rep: -8, karma: -4 },
|
||||
note: '拖延不决——暗流涌动,人心浮动。'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
/** 按类别分组的天命池索引 */
|
||||
export const MANDATE_BY_CATEGORY: Record<MandateCategory, MandateDef[]> = {
|
||||
calamity: MANDATES.filter((m) => m.category === 'calamity'),
|
||||
war: MANDATES.filter((m) => m.category === 'war'),
|
||||
fortune: MANDATES.filter((m) => m.category === 'fortune'),
|
||||
visitor: MANDATES.filter((m) => m.category === 'visitor'),
|
||||
turbulence: MANDATES.filter((m) => m.category === 'turbulence')
|
||||
}
|
||||
|
||||
/** 天命 id 列表(用于冲突检测) */
|
||||
export const MANDATE_IDS: string[] = MANDATES.map((m) => m.id)
|
||||
|
||||
/** 根据 id 查找天命定义 */
|
||||
export function mandateById(id: string): MandateDef | undefined {
|
||||
return MANDATES.find((m) => m.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 种子派生哈希(零 rng 消耗)——选取天命议题
|
||||
* hash = FNV1a(seed_str + ':' + year + ':' + mandateCount) % candidate_pool.length
|
||||
*/
|
||||
export function mandateHash(seed: string, year: number, mandateCount: number, salt: number): number {
|
||||
const str = `${seed}:${year}:${mandateCount}:${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
|
||||
}
|
||||
|
||||
/** 天命间隔年数(3~5 年,种子派生) */
|
||||
export function mandateInterval(seed: string, mandateCount: number): number {
|
||||
const h = mandateHash(seed, mandateCount, 0, 0x4d41)
|
||||
return 3 + (h % 3) // 3, 4, or 5
|
||||
}
|
||||
|
||||
/** 天命类别选取(种子派生,零 rng 消耗) */
|
||||
export function mandateCategoryFor(seed: string, year: number, mandateCount: number): MandateCategory {
|
||||
const categories: MandateCategory[] = ['calamity', 'war', 'fortune', 'visitor', 'turbulence']
|
||||
const h = mandateHash(seed, year, mandateCount, 0x4d43)
|
||||
return categories[h % categories.length]
|
||||
}
|
||||
@@ -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<string>()
|
||||
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<string, ChronicleScrollEntry>()
|
||||
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<string, ChronicleScrollEntry['kind']> = {
|
||||
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'
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 0.1.40 天下大事记叙事层
|
||||
*
|
||||
* 聚合 WorldSim 的 sagaAnnals(世鉴 50/100 年)+ worldAnnals(十年一鉴)+
|
||||
* newsFeed 中的里程碑事件(宗祧更替/势力分裂/新贵崛起/灾年/世道更替),
|
||||
* 输出一个按年份排序的"天下大事记"序列,供 WorldPanel 展示区和 LegacyPanel 百年报告引用。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 纯函数(不修改 state)
|
||||
* - 去重(同一年同类事件只保留最新一条)
|
||||
* - 裁剪(上限 60 条——百年内的重要事件)
|
||||
*/
|
||||
|
||||
import type { WorldSimState } from '../sim/worldsim-data'
|
||||
|
||||
/** 大事记条目 */
|
||||
export interface SagaEntry {
|
||||
year: number
|
||||
/** 事件类型标签 */
|
||||
kind: 'era' | 'calamity' | 'succession' | 'split' | 'newborn' | 'annal' | 'war' | 'wonder'
|
||||
/** 叙事文本 */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** 从 WorldSimState 中提取天下大事记
|
||||
*
|
||||
* 聚合来源:
|
||||
* 1. sagaAnnals — 50/100 年世鉴(宏观视角)
|
||||
* 2. worldAnnals — 十年一鉴(史官综述)
|
||||
* 3. newsFeed 中 kind=annal/npc/calamity 的里程碑事件
|
||||
*/
|
||||
export function buildSaga(ws: WorldSimState | undefined): SagaEntry[] {
|
||||
if (!ws) return []
|
||||
const entries: SagaEntry[] = []
|
||||
|
||||
// 1. 世鉴
|
||||
for (const s of ws.sagaAnnals ?? []) {
|
||||
entries.push({ year: s.year, kind: 'annal', text: s.text })
|
||||
}
|
||||
|
||||
// 2. 十年一鉴
|
||||
for (const a of ws.worldAnnals ?? []) {
|
||||
entries.push({ year: a.year, kind: 'annal', text: a.text })
|
||||
}
|
||||
|
||||
// 3. newsFeed 里程碑事件(只取 annal/npc/calamity 类,去重)
|
||||
const seen = new Set<string>()
|
||||
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)
|
||||
let kind: SagaEntry['kind'] = 'annal'
|
||||
if (n.kind === 'calamity') kind = 'calamity'
|
||||
else if (n.text.includes('宗主') || n.text.includes('更替') || n.text.includes('退隐') || n.text.includes('遇害') || n.text.includes('篡权')) kind = 'succession'
|
||||
else if (n.text.includes('分裂')) kind = 'split'
|
||||
else if (n.text.includes('新贵') || n.text.includes('崛起') || n.text.includes('灰烬重生')) kind = 'newborn'
|
||||
else if (n.text.includes('世道更替')) kind = 'era'
|
||||
else if (n.text.includes('击破') || n.text.includes('起衅')) kind = 'war'
|
||||
else if (n.text.includes('异变') || n.text.includes('出世') || n.text.includes('祥瑞')) kind = 'wonder'
|
||||
entries.push({ year: n.year, kind, text: n.text })
|
||||
}
|
||||
}
|
||||
|
||||
// 按年份排序,同年按 kind 分组
|
||||
entries.sort((a, b) => a.year - b.year)
|
||||
|
||||
// 去重:同年同 kind 只保留最新一条
|
||||
const dedupMap = new Map<string, SagaEntry>()
|
||||
for (const e of entries) {
|
||||
const key = `${e.year}:${e.kind}`
|
||||
dedupMap.set(key, e) // 后来的覆盖前面的(但已排序,同年同 kind 只有一条)
|
||||
}
|
||||
|
||||
const result = [...dedupMap.values()].sort((a, b) => b.year - a.year)
|
||||
|
||||
// 裁剪:上限 60 条
|
||||
if (result.length > 60) result.length = 60
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** 大事记条目类型中文名 */
|
||||
export function sagaKindName(kind: SagaEntry['kind']): string {
|
||||
return kind === 'era' ? '世道'
|
||||
: kind === 'calamity' ? '灾年'
|
||||
: kind === 'succession' ? '宗祧'
|
||||
: kind === 'split' ? '分裂'
|
||||
: kind === 'newborn' ? '新贵'
|
||||
: kind === 'war' ? '战事'
|
||||
: kind === 'wonder' ? '奇观'
|
||||
: '史鉴'
|
||||
}
|
||||
|
||||
/** 大事记条目 CSS 类名(供 UI 着色) */
|
||||
export function sagaKindClass(kind: SagaEntry['kind']): string {
|
||||
return kind === 'era' ? 'saga-era'
|
||||
: kind === 'calamity' ? 'saga-calamity'
|
||||
: kind === 'succession' ? 'saga-succession'
|
||||
: kind === 'split' ? 'saga-split'
|
||||
: kind === 'newborn' ? 'saga-newborn'
|
||||
: kind === 'war' ? 'saga-war'
|
||||
: kind === 'wonder' ? 'saga-wonder'
|
||||
: 'saga-annal'
|
||||
}
|
||||
@@ -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.37',
|
||||
version: '0.1.38',
|
||||
modules: this.world.systemList().length,
|
||||
systems: this.world.systemList().filter((s) => s.enabled).length,
|
||||
plugins: this.world.pluginList().length,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -38,18 +38,24 @@ export function monthlyRate(w: World, c: Character, herbFactor = 1): number {
|
||||
rate *= 0.65
|
||||
}
|
||||
const buildings = st.family.buildings
|
||||
rate *= 1 + bonusOf('juling', 'expBonus', buildings['juling'] ?? 0)
|
||||
rate *= 1 + bonusOf('juling', 'expBonus', buildings['juling'] ?? 0, st.family.flag['buildingSpecialty'] as string | undefined)
|
||||
if (w.sysEnabled('season')) rate *= 1 + seasonMod(st.month, 'cult')
|
||||
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
|
||||
if (c.state === 'meditation') {
|
||||
rate *= 1.35
|
||||
rate *= 1 + w.postBonus('meditation') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0)
|
||||
rate *= 1 + bonusOf('dongfu', 'expBonus', buildings['dongfu'] ?? 0)
|
||||
rate *= 1 + bonusOf('dongfu', 'expBonus', buildings['dongfu'] ?? 0, st.family.flag['buildingSpecialty'] as string | undefined)
|
||||
} else if (c.state === 'expedition') {
|
||||
rate *= 0.25
|
||||
} else if (c.state === 'wounded') {
|
||||
|
||||
@@ -6,12 +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]
|
||||
|
||||
@@ -270,6 +275,10 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
|
||||
]
|
||||
}
|
||||
}
|
||||
// 0.1.40 天命事件查找
|
||||
if (id.startsWith('mandate-')) {
|
||||
return findMandateEvent(id)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -805,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', '储位已定——以才德为先,来日可期。')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 0.1.40 天命系统 · 引擎层
|
||||
*
|
||||
* 天命系统核心逻辑:
|
||||
* - checkMandate(w): 检查是否到期 + 选取议题 + fire 高优入闸
|
||||
* - resolveMandate(w, idx): 玩家选择结算(复用 applyEventChoice 路径)
|
||||
* - 动态事件生成:天命 id = `mandate-<defId>`,经 dynamicEventFor 路径返回 EventDef
|
||||
*
|
||||
* 确定性保证:
|
||||
* - 选取走种子派生(mandateHash),零 rng 消耗
|
||||
* - 效果结算复用 applyEventChoice 路径(已验证的确定性通道)
|
||||
* - 天命间隔 3~5 年(种子派生)
|
||||
* - 天命经 fire(w, id, 1) 高优入闸——可顶替被占日常事件
|
||||
* - 天命不可"稍后再议"——不可回避(UI 层隐藏 dorm 按钮)
|
||||
*/
|
||||
|
||||
import type { World } from '../World'
|
||||
import { fire } from './events'
|
||||
import { matchesCond } from './events'
|
||||
import type { EventDef } from '../../../data/events'
|
||||
import {
|
||||
MANDATES,
|
||||
MANDATE_BY_CATEGORY,
|
||||
mandateById,
|
||||
mandateCategoryFor,
|
||||
mandateHash,
|
||||
mandateInterval,
|
||||
mandateCategoryName,
|
||||
type MandateDef,
|
||||
type MandateCategory
|
||||
} from '../../../data/mandate'
|
||||
|
||||
/** 天命事件 id 前缀 */
|
||||
export const MANDATE_PREFIX = 'mandate-'
|
||||
|
||||
/** 天命事件 id 构造 */
|
||||
export function mandateEventId(defId: string): string {
|
||||
return `${MANDATE_PREFIX}${defId}`
|
||||
}
|
||||
|
||||
/** 判断事件 id 是否为天命 */
|
||||
export function isMandateEvent(eventId: string): boolean {
|
||||
return eventId.startsWith(MANDATE_PREFIX)
|
||||
}
|
||||
|
||||
/** 从天命事件 id 提取天命 def id */
|
||||
export function mandateDefIdFromEvent(eventId: string): string {
|
||||
return eventId.slice(MANDATE_PREFIX.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查天命是否到期——如到期则选取议题并 fire 高优入闸
|
||||
*
|
||||
* 在 epilogue 相位末调用(clocks.ts 注册)。
|
||||
* 检查逻辑:
|
||||
* 1. 首次天命:开局第 5 年(给玩家前期适应时间)
|
||||
* 2. 后续天命:上次天命年 + mandateInterval(seed, count)(3~5 年)
|
||||
* 3. 如果有待决事件——不触发(避免覆盖渡劫/飞升等关键事件)
|
||||
* 4. 如果 game over——不触发
|
||||
*
|
||||
* 返回 true 表示已触发天命(调用了 fire)
|
||||
*/
|
||||
export function checkMandate(w: World): boolean {
|
||||
const s = w.state
|
||||
if (s.gameOver) return false
|
||||
if (s.pendingEvent) return false // 有待决事件——不覆盖
|
||||
|
||||
const seed = s.seed
|
||||
const mandateYear = (s.family.flag['mandateYear'] as number | undefined) ?? 0
|
||||
const mandateCount = (s.family.flag['mandateCount'] as number | undefined) ?? 0
|
||||
|
||||
// 首次天命:第 5 年
|
||||
if (mandateYear === 0) {
|
||||
if (s.year < 5) return false
|
||||
} else {
|
||||
// 后续天命:上次天命年 + 间隔
|
||||
const interval = mandateInterval(seed, mandateCount)
|
||||
if (s.year < mandateYear + interval) return false
|
||||
}
|
||||
|
||||
// 选取天命议题
|
||||
const def = selectMandate(w)
|
||||
if (!def) return false
|
||||
|
||||
// 记录天命年/次数
|
||||
s.family.flag['mandateYear'] = s.year
|
||||
s.family.flag['mandateCount'] = mandateCount + 1
|
||||
s.family.flag['mandateLastId'] = def.id
|
||||
|
||||
// 高优入闸
|
||||
const eventId = mandateEventId(def.id)
|
||||
return fire(w, eventId, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 选取天命议题(种子派生,零 rng 消耗)
|
||||
*
|
||||
* 流程:
|
||||
* 1. 种子派生选取类别
|
||||
* 2. 在该类别的议题池中过滤 cond
|
||||
* 3. 在符合条件的议题中按权重+种子派生选取
|
||||
* 4. 如果该类别无符合条件议题——回退到全池(按 cond 过滤后种子派生)
|
||||
*/
|
||||
function selectMandate(w: World): MandateDef | undefined {
|
||||
const s = w.state
|
||||
const seed = s.seed
|
||||
const mandateCount = (s.family.flag['mandateCount'] as number | undefined) ?? 0
|
||||
|
||||
// 种子派生选取类别
|
||||
const cat = mandateCategoryFor(seed, s.year, mandateCount)
|
||||
|
||||
// 在该类别中过滤 cond
|
||||
const pool = MANDATE_BY_CATEGORY[cat].filter((m) => matchesCond(w, m.cond))
|
||||
|
||||
if (pool.length > 0) {
|
||||
return weightedPick(pool, seed, s.year, mandateCount)
|
||||
}
|
||||
|
||||
// 该类别无符合条件议题——回退到全池
|
||||
const fullPool = MANDATES.filter((m) => matchesCond(w, m.cond))
|
||||
if (fullPool.length === 0) return undefined
|
||||
return weightedPick(fullPool, seed, s.year, mandateCount)
|
||||
}
|
||||
|
||||
/** 种子派生加权选取(零 rng 消耗) */
|
||||
function weightedPick(pool: MandateDef[], seed: string, year: number, mandateCount: number): MandateDef {
|
||||
const weights = pool.map((m) => m.weight ?? 10)
|
||||
const total = weights.reduce((a, b) => a + b, 0)
|
||||
const h = mandateHash(seed, year, mandateCount, 0x4d50)
|
||||
const r = h % total
|
||||
let acc = 0
|
||||
for (let i = 0; i < pool.length; i++) {
|
||||
acc += weights[i]
|
||||
if (r < acc) return pool[i]
|
||||
}
|
||||
return pool[pool.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 天命事件动态生成——将 MandateDef 转为 EventDef
|
||||
* 经 events.ts 的 dynamicEventFor 路径调用
|
||||
*/
|
||||
export function mandateAsEvent(def: MandateDef): EventDef {
|
||||
return {
|
||||
id: mandateEventId(def.id),
|
||||
name: `【天命】${def.name}`,
|
||||
category: 'fate',
|
||||
weight: 0,
|
||||
once: false, // 天命可重复(不同年可选到不同议题)
|
||||
text: def.text,
|
||||
options: def.options.map((o) => ({
|
||||
label: o.label,
|
||||
hint: o.hint,
|
||||
eff: o.eff,
|
||||
note: o.note
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 events.ts 的 dynamicEventFor 中注册天命事件查找
|
||||
*
|
||||
* 当事件 id 以 `mandate-` 开头时,查找天命定义并转为 EventDef
|
||||
*/
|
||||
export function findMandateEvent(eventId: string): EventDef | undefined {
|
||||
if (!isMandateEvent(eventId)) return undefined
|
||||
const defId = mandateDefIdFromEvent(eventId)
|
||||
const def = mandateById(defId)
|
||||
if (!def) return undefined
|
||||
return mandateAsEvent(def)
|
||||
}
|
||||
|
||||
/** 天命类别名(供 UI 展示) */
|
||||
export function mandateEventCategoryName(eventId: string): string | undefined {
|
||||
if (!isMandateEvent(eventId)) return undefined
|
||||
const defId = mandateDefIdFromEvent(eventId)
|
||||
const def = mandateById(defId)
|
||||
if (!def) return undefined
|
||||
return mandateCategoryName(def.category)
|
||||
}
|
||||
@@ -191,7 +191,9 @@ export function recallAll(w: World, missionId: string): void {
|
||||
w.log('info', '探索队伍奉命返家。')
|
||||
}
|
||||
|
||||
/** 队伍成员状态归位:伤者保持养伤,其余恢复闲居 */
|
||||
/** 队伍成员状态归位:伤者保持养伤,其余恢复闲居。
|
||||
* 0.1.39 根治:完成后从 missionIds 移除——missionIds 只跟踪活跃任务 id,
|
||||
* 避免长跑 180 年后死引用堆积百条。state.missions 全量保留用于史书回看。 */
|
||||
function releaseSquad(w: World, m: MissionState): void {
|
||||
for (const id of m.memberIds) {
|
||||
const c = w.memberById(id)
|
||||
@@ -199,6 +201,9 @@ function releaseSquad(w: World, m: MissionState): void {
|
||||
if (c.state === 'wounded') continue
|
||||
c.state = 'idle'
|
||||
}
|
||||
// 0.1.39:从 family.missionIds 移除已完成任务的 id
|
||||
const idx = w.state.family.missionIds.indexOf(m.id)
|
||||
if (idx >= 0) w.state.family.missionIds.splice(idx, 1)
|
||||
}
|
||||
|
||||
/** 月度巡查:亡者除名、重伤者离队疗伤;人数不足则提前收队 */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -334,6 +334,13 @@ export class World {
|
||||
this.out.forEach((o) => o.onLog(kind, text))
|
||||
}
|
||||
|
||||
/**
|
||||
* 0.1.39 根治:chronicle 智能裁剪——保留全部 important=true 里程碑 +
|
||||
* 最近 MAX_NON_IMPORTANT 条非重要记录。旧版无差别 splice 会丢失关键里程碑。
|
||||
* 裁剪在 push 后触发,对调用方透明(不改变确定性序列——裁剪不消耗 rng)。
|
||||
*/
|
||||
private static readonly CHRONICLE_MAX_NON_IMPORTANT = 400
|
||||
|
||||
chronicle(cat: ChronicleEntry['category'], text: string, memberId?: Id, important = false): void {
|
||||
const entry: ChronicleEntry = {
|
||||
id: this.seq(),
|
||||
@@ -345,7 +352,16 @@ export class World {
|
||||
important
|
||||
}
|
||||
this.state.chronicle.push(entry)
|
||||
if (this.state.chronicle.length > 520) this.state.chronicle.splice(0, this.state.chronicle.length - 520)
|
||||
// 0.1.39 智能裁剪:仅在总数超限时触发,保留全部 important +最近非 important
|
||||
const max = World.CHRONICLE_MAX_NON_IMPORTANT
|
||||
if (this.state.chronicle.length > max + 200) {
|
||||
const important_ones = this.state.chronicle.filter((e) => e.important)
|
||||
const non_important = this.state.chronicle.filter((e) => !e.important)
|
||||
const trimmed_non = non_important.slice(-max)
|
||||
this.state.chronicle = [...important_ones, ...trimmed_non].sort(
|
||||
(a, b) => (a.year - b.year) || (a.month - b.month) || (a.id < b.id ? -1 : 1)
|
||||
)
|
||||
}
|
||||
this.out.forEach((o) => o.onChronicle(entry, important))
|
||||
}
|
||||
|
||||
@@ -419,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-<npcId> 非年份键:随目标家族消亡删除(防长线膨胀)
|
||||
if (key.startsWith('tauntCD-') && !this.state.npcFamilies[key.slice('tauntCD-'.length)]) delete fam.flag[key]
|
||||
// tourneyPending-<year> 年份键:过期清理
|
||||
const tm = /^tourneyPending-(\d+)$/.exec(key)
|
||||
if (tm && Number(tm[1]) < cutoff) delete fam.flag[key]
|
||||
// raidCD-<npcId> 随目标家族消亡删除
|
||||
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']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,7 +812,7 @@ export class World {
|
||||
inv['lingcao'] -= r.lingcao
|
||||
inv['beastcore'] -= r.beastcore
|
||||
fam.stones -= r.stones
|
||||
const chance = bonusOf('danfang', 'craftChance', lvl)
|
||||
const chance = bonusOf('danfang', 'craftChance', lvl, fam.flag['buildingSpecialty'] as string | undefined)
|
||||
if (this.rng.chance(chance)) {
|
||||
inv[`pill-${kind}`] = (inv[`pill-${kind}`] ?? 0) + 1
|
||||
this.log('good', `丹房炼成一枚${r.name}。`)
|
||||
@@ -839,8 +889,8 @@ export class World {
|
||||
const fam = this.state.family
|
||||
const bonus =
|
||||
1 +
|
||||
bonusOf('yanwu', 'powerBonus', fam.buildings['yanwu'] ?? 0) +
|
||||
bonusOf('lingshou', 'powerBonus', fam.buildings['lingshou'] ?? 0) +
|
||||
bonusOf('yanwu', 'powerBonus', fam.buildings['yanwu'] ?? 0, fam.flag['buildingSpecialty'] as string | undefined) +
|
||||
bonusOf('lingshou', 'powerBonus', fam.buildings['lingshou'] ?? 0, fam.flag['buildingSpecialty'] as string | undefined) +
|
||||
this.postBonus('battlePower')
|
||||
const top = this.aliveMembers()
|
||||
.map((c) => combatPowerOf(this, c))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { eventRoll } from './Systems/events'
|
||||
import { diplomacyTick } from './Systems/diplomacy'
|
||||
import { yearStartMarriage } from './Systems/marriage'
|
||||
import { WorldSim } from '../sim/WorldSim'
|
||||
import { checkMandate } from './Systems/mandate'
|
||||
|
||||
/** 原语义保留:满门凋零后仅存生产/寿元与收尾 */
|
||||
function ifAlive(fn: (w: World) => void): (w: World) => void {
|
||||
@@ -62,6 +63,8 @@ export function installCoreSystems(ctx: PluginContext): Array<() => void> {
|
||||
}
|
||||
}))
|
||||
unsubs.push(clock.register('epilogue', (w: World) => w.epilogueTick()))
|
||||
// 0.1.40 天命系统:epilogue 末检查天命是否到期——种子派生选取,零 rng 消耗
|
||||
unsubs.push(clock.register('epilogue', viaCap('events', ifAlive((w: World) => { void checkMandate(w) }))))
|
||||
|
||||
return unsubs
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<T>(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) {}
|
||||
|
||||
@@ -25,6 +59,10 @@ export class WorldSim {
|
||||
}
|
||||
|
||||
tick(): void {
|
||||
// 0.1.39 根治双闸:clocks.ts 外部守卫 + 此处入口二次守卫。
|
||||
// 与 tournament/apprentice 等系统卡同模式(sysEnabled ?? true 兜底,
|
||||
// 注册后默认开、关停后 false)。防止未来新增直接调 tick() 的路径绕过守卫。
|
||||
if (!this.w.sysEnabled('worldsim')) return
|
||||
const s = this.s()
|
||||
const rng = this.w.rng
|
||||
// ---- C. 灵气潮汐(大周期) ----
|
||||
@@ -591,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
|
||||
@@ -653,40 +695,107 @@ 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)
|
||||
}
|
||||
// 换代评估:寿终阈值 或 年首小概率(退隐/遇害)——约 50 年一代
|
||||
// 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)) {
|
||||
dyn.leaderAge = 30 + w.rng.int(0, 25)
|
||||
dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + 1, 5)
|
||||
dyn.leaderName = `${def.name.replace('氏', '')}氏新主`
|
||||
dyn.prosperity = 55 + w.rng.int(0, 20)
|
||||
npc.power = Math.round(Math.max(40, npc.power + 15))
|
||||
dyn.lastEvent = '宗祧更替'
|
||||
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 && 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 && 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 + 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)
|
||||
npc.power = Math.round(Math.max(40, npc.power - 5))
|
||||
dyn.lastEvent = '退隐传贤'
|
||||
break
|
||||
}
|
||||
case 'death': {
|
||||
// 遇害暴毙:乱世末法低景气——宗主陨落,群龙无首
|
||||
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)
|
||||
npc.power = Math.round(Math.max(40, npc.power - 30))
|
||||
dyn.lastEvent = '宗主遇害'
|
||||
break
|
||||
}
|
||||
case 'usurp': {
|
||||
// 夺位篡权:高压家族——强人夺位,势力激增但人心不稳
|
||||
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))
|
||||
dyn.lastEvent = '夺位篡权'
|
||||
// 篡权者树敌:与邻家关系恶化
|
||||
for (const oid of ids) {
|
||||
if (oid === id) continue
|
||||
const cur = dyn.relationsWithOthers[oid] ?? 0
|
||||
dyn.relationsWithOthers[oid] = clamp(cur - 12, -100, 100)
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// 寿终正寝:常规换代
|
||||
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 + npcDeriveInt(seed, year, id, 0xE015, 0, 20)
|
||||
npc.power = Math.round(Math.max(40, npc.power + 15))
|
||||
dyn.lastEvent = '宗祧更替'
|
||||
}
|
||||
}
|
||||
dyn.lastEventYear = year
|
||||
s.npcSuccessions++
|
||||
pushNews(w, s, [id])
|
||||
w.log('info', `【天下】${def.name} 更易宗主,气象一新。`)
|
||||
const eventDesc = dyn.lastEvent === '退隐传贤' ? '宗主功成身退,后辈继位'
|
||||
: dyn.lastEvent === '宗主遇害' ? '宗主遇害身亡,少主仓促继位'
|
||||
: dyn.lastEvent === '夺位篡权' ? '强人夺位,人心浮动'
|
||||
: '更易宗主,气象一新'
|
||||
w.log('info', `【天下】${def.name} ${eventDesc}。`)
|
||||
w.emitFx('ripple', `succession:${id}`)
|
||||
}
|
||||
// 0.1.40 NPC 势力分裂:power>500 且景气<25 的家族,年首 3% 概率分裂
|
||||
// 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)年首相搏——败方伤筋动骨
|
||||
for (const oid of ids) {
|
||||
if (oid === id) continue
|
||||
@@ -697,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))
|
||||
@@ -739,7 +849,10 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 覆灭清理:npcFamilies/npcDyn/关系网/raidCD/排队事件/distress/dynamic def 全摘 */
|
||||
/** 覆灭清理:npcFamilies/npcDyn/关系网/raidCD/排队事件/distress/dynamic def/worldGen.npcs 全摘
|
||||
* 0.1.39 根治:补全 worldGen.npcs + worldGen.relations 中已灭亡 NPC 的定义清理——
|
||||
* 旧版 purgeNpc 遗漏了存档层数据,长跑 180 年后 worldGen.npcs 堆积 20+ 条僵尸定义,
|
||||
* 直接膨胀存档体积且读档时重注册已灭亡 NPC 的 def(虽然无运行时副作用但不洁)。 */
|
||||
function purgeNpc(w: World, s: WorldSimState, id: string): void {
|
||||
delete w.state.npcFamilies[id]
|
||||
delete s.npcDyn[id]
|
||||
@@ -750,31 +863,159 @@ 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)
|
||||
if (idx >= 0) w.state.worldGen.npcs.splice(idx, 1)
|
||||
}
|
||||
// 0.1.39:从 worldGen.relations 清理已灭亡 NPC 的关系条目(存档体积精简)
|
||||
if (w.state.worldGen?.relations) {
|
||||
delete w.state.worldGen.relations[id]
|
||||
for (const rid of Object.keys(w.state.worldGen.relations)) {
|
||||
delete w.state.worldGen.relations[rid]?.[id]
|
||||
}
|
||||
}
|
||||
w.emitFx('ripple', `purge:${id}`)
|
||||
}
|
||||
|
||||
const NEWBORN_STYLES = ['新锐剑宗', '灵植世家', '商盟豪族', '兵修门阀', '符箓仙门'] as const
|
||||
const NEWBORN_REGIONS = ['南麓青泽', '西山雾谷', '东溪云汉', '北原古井'] as const
|
||||
const NEWBORN_STYLES = ['新锐剑宗', '灵植世家', '商盟豪族', '兵修门阀', '符箓仙门', '丹道世家', '阵法传承', '御兽门阀'] as const
|
||||
const NEWBORN_REGIONS = ['南麓青泽', '西山雾谷', '东溪云汉', '北原古井', '中州云台', '东海浮屿'] as const
|
||||
|
||||
/** 新贵补位:随机风格/区域/初始 power 的新家族(乱世更频) */
|
||||
/**
|
||||
* 0.1.40 NPC 势力分裂
|
||||
*
|
||||
* 触发条件:power>500 且景气<25 且非附庸,年首 3% 概率
|
||||
* 效果:
|
||||
* - 原家族 power 降 40%,景气降至 15
|
||||
* - 分裂出的新家族继承 35% power + 部分关系网
|
||||
* - 新家族注册为独立 NPC(registerNpcDef + npcFamilies + npcDyn)
|
||||
* - 落档 worldGen.npcs 保证读档幂等
|
||||
*/
|
||||
function splitNpcDynasty(w: World, s: WorldSimState, parentId: string): void {
|
||||
const parent = w.state.npcFamilies[parentId]
|
||||
const parentDef = npcById(parentId)
|
||||
const parentDyn = s.npcDyn[parentId]
|
||||
if (!parent || !parentDef || !parentDyn) return
|
||||
|
||||
// 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
|
||||
parentDyn.lastEvent = '势力分裂'
|
||||
parentDyn.lastEventYear = w.state.year
|
||||
|
||||
// 分裂出的新家族
|
||||
let seedHash = 0
|
||||
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 = npcDerivePick(seed, year, parentId, 0xF011, NEWBORN_STYLES)
|
||||
const newRegion = parent.region || npcDerivePick(seed, year, parentId, 0xF012, NEWBORN_REGIONS)
|
||||
const newDef = {
|
||||
id: newId,
|
||||
name: newName,
|
||||
region: newRegion,
|
||||
desc: `${parentDef.name}内乱分裂而出,另立门户。`,
|
||||
style: newStyle,
|
||||
leaderRealm: 'foundation' as const,
|
||||
initialPower: splitPower,
|
||||
powerGrowth: [3, 9] as [number, number],
|
||||
sells: parentDef.sells ? [...parentDef.sells] : [],
|
||||
buys: parentDef.buys ? [...parentDef.buys] : ['lingcao', 'lingkuang']
|
||||
}
|
||||
registerNpcDef(newDef)
|
||||
// 落档 worldGen.npcs
|
||||
if (w.state.worldGen) {
|
||||
if (!w.state.worldGen.npcs) w.state.worldGen.npcs = []
|
||||
w.state.worldGen.npcs.push({
|
||||
id: newDef.id, name: newDef.name, region: newDef.region, style: newDef.style,
|
||||
desc: newDef.desc, leaderRealm: newDef.leaderRealm, initialPower: newDef.initialPower,
|
||||
powerGrowth: newDef.powerGrowth, sells: newDef.sells, buys: newDef.buys
|
||||
})
|
||||
}
|
||||
w.state.npcFamilies[newId] = {
|
||||
id: newId, name: newName, region: newRegion,
|
||||
power: splitPower, relation: 0, allied: false, raidCount: 0, declineYears: 0
|
||||
}
|
||||
const newDyn = initDynFor(newId)
|
||||
newDyn.leaderName = `${newName.slice(0, 2)}氏少主`
|
||||
newDyn.leaderAge = 25 + npcDeriveInt(seed, year, parentId, 0xF013, 0, 15)
|
||||
newDyn.leaderRealmIdx = Math.max(0, parentDyn.leaderRealmIdx - 1)
|
||||
newDyn.prosperity = 40
|
||||
newDyn.lastEvent = '分裂自立'
|
||||
newDyn.lastEventYear = w.state.year
|
||||
// 继承部分关系网(与原家族的盟友关系减半、仇敌转为中立)
|
||||
for (const [oid, rel] of Object.entries(parentDyn.relationsWithOthers)) {
|
||||
if (oid === parentId) continue
|
||||
newDyn.relationsWithOthers[oid] = rel > 0 ? Math.round(rel * 0.5) : Math.round(rel * 0.3)
|
||||
// 分裂消息传至邻家:对原家族关系微降、对新家族态度不明
|
||||
const otherDyn = s.npcDyn[oid]
|
||||
if (otherDyn) {
|
||||
otherDyn.relationsWithOthers[parentId] = clamp((otherDyn.relationsWithOthers[parentId] ?? 0) - 5, -100, 100)
|
||||
otherDyn.relationsWithOthers[newId] = 0 // 中立起步
|
||||
}
|
||||
}
|
||||
// 父子反目
|
||||
newDyn.relationsWithOthers[parentId] = -40
|
||||
parentDyn.relationsWithOthers[newId] = -40
|
||||
s.npcDyn[newId] = newDyn
|
||||
|
||||
s.newsFeed.push({
|
||||
year: w.state.year, month: 1, src: '史官',
|
||||
text: `${parent.name} 内乱分裂——${newName} 另立门户,天下格局再变。`, kind: 'annal'
|
||||
})
|
||||
w.log('info', `【天下】${parent.name} 内乱分裂,${newName} 崛起自立。`)
|
||||
w.emitFx('ripple', `split:${newId}`)
|
||||
}
|
||||
|
||||
/** 新贵补位:随机风格/区域/初始 power 的新家族(乱世更频)
|
||||
* 0.1.40 增强:灰烬重生模式——灭亡家族的位置有概率被新势力填补
|
||||
*
|
||||
* 模式选取(rng 派生):
|
||||
* - 35% 灰烬重生:在已灭亡家族的原区域,以新风格重生(继承部分地缘)
|
||||
* - 65% 全新初立:原逻辑(随机风格/区域)
|
||||
*/
|
||||
function spawnNewbornDynasty(w: World, s: WorldSimState): void {
|
||||
const rng = w.rng
|
||||
const style = rng.pick([...NEWBORN_STYLES])
|
||||
const region = rng.pick([...NEWBORN_REGIONS])
|
||||
// 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 (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[npcDeriveInt(seed, year, 'newborn', 0xF021, 0, fallen.length - 1)]!
|
||||
ashesRegion = pick.region
|
||||
// 不继承原风格——灰烬中重生的新风格
|
||||
ashesStyle = npcDerivePick(seed, year, 'newborn', 0xF022, NEWBORN_STYLES)
|
||||
}
|
||||
}
|
||||
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,
|
||||
name,
|
||||
region,
|
||||
desc: `${style}初立,闷头搞了十年发展,如今渐攒起一份家业。`,
|
||||
desc: isAshes
|
||||
? `${style}于故地废墟上重建——前人虽去,灵脉犹存,新主收拾残局再起。`
|
||||
: `${style}初立,闷头搞了十年发展,如今渐攒起一份家业。`,
|
||||
style,
|
||||
leaderRealm: 'foundation' as const,
|
||||
initialPower: 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']
|
||||
@@ -801,8 +1042,13 @@ function spawnNewbornDynasty(w: World, s: WorldSimState): void {
|
||||
dyn.leaderName = `${name.slice(0, 2)}氏新主`
|
||||
s.npcDyn[id] = dyn
|
||||
const asr = s.era ?? 'pingshi'
|
||||
w.log('good', `【天下】新贵${name}在${region}崛起(${rgStyleName(def.style)}),天下格局松动。`)
|
||||
s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `新贵${name}崛起于${region}——天下格局松动。`, kind: 'annal' })
|
||||
if (isAshes) {
|
||||
w.log('good', `【天下】灰烬重生——${name}于${region}故地重建(${rgStyleName(def.style)}),旧地新主。`)
|
||||
s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `灰烬重生——${name}于${region}故地重建,旧地新主。`, kind: 'annal' })
|
||||
} else {
|
||||
w.log('good', `【天下】新贵${name}在${region}崛起(${rgStyleName(def.style)}),天下格局松动。`)
|
||||
s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `新贵${name}崛起于${region}——天下格局松动。`, kind: 'annal' })
|
||||
}
|
||||
void asr
|
||||
}
|
||||
|
||||
@@ -838,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]!
|
||||
}
|
||||
|
||||
@@ -860,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' }
|
||||
|
||||
+301
-19
@@ -1,6 +1,24 @@
|
||||
import { SaveSlot } from './slots'
|
||||
import { GameState, SaveMeta, SnapshotMeta } from '../types/domain'
|
||||
import type { SaveDbDriver } from './slots'
|
||||
import { IndexedDBBackend } from './idb-backend'
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 0.1.38 存档根治(最终版):IndexedDB 持久化后端注入
|
||||
//
|
||||
// 根因分析(上一版修复遗留问题):
|
||||
// 0.1.38 初版探测 OPFS → 不可用时降级 diskEngine='kv' → AriaEngine 内部
|
||||
// new KVStoreBackend() 不传 medium → KVStore.defaultMedium() 在 OPFS 不可
|
||||
// 用时返回 SharedMemoryBackend(纯内存!)→ 数据写入内存 → 进程关闭全丢。
|
||||
// 即使 OPFS "假可用"(getDirectory 不抛错但数据不持久化),也有同样问题。
|
||||
//
|
||||
// 最终方案:
|
||||
// · 自建 IndexedDBBackend(idb-backend.ts),用原生 IndexedDB API 持久化
|
||||
// · MetonaSqlark.create 成功后,通过反射链替换底层 KVStore.medium
|
||||
// · 替换后 reload() 重新从 IndexedDB 加载数据
|
||||
// · 无论 OPFS 是否可用,数据始终写入 IndexedDB(跨进程持久化)
|
||||
// · 保留 OPFS 探测和诊断能力供 UI 展示
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Adapter for @metona-team/metona-sqlark
|
||||
// The driver interface keeps the engine swappable (testable in node too).
|
||||
@@ -14,36 +32,205 @@ interface SqlarkLike {
|
||||
|
||||
export function setSqlarkBackend(impl: AnyFactory): void {
|
||||
MetonaSqlark = impl
|
||||
_backendType = 'mock'
|
||||
}
|
||||
|
||||
export async function makeDriver(): Promise<SaveDbDriver> {
|
||||
const factory = await getSqlarkFactory()
|
||||
return new SqlarkDriver(factory)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// OPFS 可用性探测(一次性,进程级缓存)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type StorageBackendType = 'opfs' | 'kv' | 'memory' | 'mock' | 'unknown'
|
||||
|
||||
let _backendType: StorageBackendType = 'unknown'
|
||||
let _opfsProbed = false
|
||||
let _opfsAvailable = false
|
||||
|
||||
/** 探测 OPFS 是否可用(navigator.storage.getDirectory 不抛错即视为可用) */
|
||||
export async function probeOpfs(): Promise<boolean> {
|
||||
if (_opfsProbed) return _opfsAvailable
|
||||
_opfsProbed = true
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.storage && typeof navigator.storage.getDirectory === 'function') {
|
||||
const root = await navigator.storage.getDirectory()
|
||||
_opfsAvailable = !!root
|
||||
}
|
||||
} catch {
|
||||
_opfsAvailable = false
|
||||
}
|
||||
return _opfsAvailable
|
||||
}
|
||||
|
||||
class SqlarkDriver implements SaveDbDriver {
|
||||
/** 获取当前存储后端类型(供 UI 诊断面板) */
|
||||
export function getStorageBackendType(): StorageBackendType {
|
||||
return _backendType
|
||||
}
|
||||
|
||||
/** 重置探测状态(测试用) */
|
||||
export function _resetStorageProbe(): void {
|
||||
_opfsProbed = false
|
||||
_opfsAvailable = false
|
||||
_backendType = 'unknown'
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 数据库创建配置——根据 OPFS 可用性自动选择 diskEngine
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 构建数据库配置——始终用 hybrid 模式(KVStore 后端),后续注入 IndexedDB medium */
|
||||
async function buildDbConfig(name: string, _withCompression: boolean): Promise<{
|
||||
name: string
|
||||
mode: string
|
||||
diskEngine: string
|
||||
}> {
|
||||
// 探测 OPFS 供诊断面板展示(不影响实际后端选择——始终注入 IndexedDB)
|
||||
await probeOpfs()
|
||||
_backendType = 'kv'
|
||||
return {
|
||||
name,
|
||||
mode: 'hybrid',
|
||||
diskEngine: 'kv',
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// IndexedDB 后端注入——通过反射链替换底层 KVStore.medium
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Reflective = Record<string, any>
|
||||
|
||||
/**
|
||||
* 通过反射链找到并替换 MetonaSqlark 实例底层 KVStore 的 medium。
|
||||
*
|
||||
* 反射路径(两种模式):
|
||||
* · mode='aria': instance.engine.backend → KVStoreBackend.kv.medium
|
||||
* instance.engine.backend → OPFSBackend (直接替换 this.backend)
|
||||
* · mode='hybrid': instance.engine.diskEngine.kv.medium
|
||||
*
|
||||
* 替换后重新 open KVStore 并 reload HybridEngine 内存缓存。
|
||||
*/
|
||||
async function injectIndexedDBBackend(instance: SqlarkLike & { engine?: unknown }, dbName: string): Promise<void> {
|
||||
// 测试环境用 mock backend(setSqlarkBackend),没有 engine 属性——跳过
|
||||
const engine = (instance as Reflective).engine as Reflective | undefined
|
||||
if (!engine) return
|
||||
|
||||
try {
|
||||
// 尝试 mode='hybrid' 路径:engine.diskEngine.kv.medium
|
||||
const diskEngine = engine.diskEngine as Reflective | undefined
|
||||
if (diskEngine?.kv) {
|
||||
const kv = diskEngine.kv as Reflective
|
||||
const oldMedium = kv.medium as Reflective | undefined
|
||||
const oldName = oldMedium?.constructor?.name ?? ''
|
||||
|
||||
// 无论 OPFS 还是 SharedMemory,都替换为 IndexedDB
|
||||
// OPFS 在 Electron app:// 下可能"假可用"(不抛错但不持久化)
|
||||
if (oldName !== 'IndexedDBBackend') {
|
||||
const idbBackend = new IndexedDBBackend()
|
||||
// 先打开新的 IndexedDB backend(KVStore.reload 会再次调 open,幂等)
|
||||
await idbBackend.open(dbName)
|
||||
// 直接替换 medium(不先关闭旧的——kv.reload 内部会先排空 opQueue 再 open 新 medium)
|
||||
kv.medium = idbBackend
|
||||
// 重新加载 KVStore 从新 medium 读取数据
|
||||
if (typeof kv.reload === 'function') {
|
||||
await kv.reload()
|
||||
}
|
||||
// 让 HybridEngine 从磁盘重新加载到内存
|
||||
if (typeof engine.reloadMemoryFromDisk === 'function') {
|
||||
await engine.reloadMemoryFromDisk()
|
||||
}
|
||||
_backendType = 'kv'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试 mode='aria' 路径:engine.backend
|
||||
const ariaBackend = engine.backend as Reflective | undefined
|
||||
if (ariaBackend) {
|
||||
const backendName = ariaBackend.constructor?.name ?? ''
|
||||
// 如果是 KVStoreBackend,走 kv.medium 路径
|
||||
if (backendName === 'KVStoreBackend' && ariaBackend.kv) {
|
||||
const kv = ariaBackend.kv as Reflective
|
||||
const oldMedium = kv.medium as Reflective | undefined
|
||||
const oldName = oldMedium?.constructor?.name ?? ''
|
||||
if (oldName !== 'IndexedDBBackend') {
|
||||
const idbBackend = new IndexedDBBackend()
|
||||
await idbBackend.open(dbName)
|
||||
try { await oldMedium?.close?.() } catch { /* ignore */ }
|
||||
kv.medium = idbBackend
|
||||
if (typeof kv.reload === 'function') {
|
||||
await kv.reload()
|
||||
}
|
||||
_backendType = 'kv'
|
||||
}
|
||||
return
|
||||
}
|
||||
// 如果是 OPFSBackend,直接替换 engine.backend
|
||||
// 但 AriaEngine 内部有大量组件引用 backend(FileManager 等),直接替换不安全
|
||||
// → 跳过,让 OPFS 自己工作(如果 OPFS 确实持久化就没问题)
|
||||
// 如果 OPFS 不持久化,应该走 buildDbConfig 的 kv 降级路径
|
||||
}
|
||||
} catch {
|
||||
// 反射失败(可能是 mock/test 环境)——静默跳过
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// SqlarkDriver——根治 open 失败后永久锁死问题
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class SqlarkDriver implements SaveDbDriver {
|
||||
private db: SqlarkLike | null = null
|
||||
private opening: Promise<void> | null = null
|
||||
private lastError: string | null = null
|
||||
|
||||
constructor(private factory: AnyFactory) {}
|
||||
|
||||
async open(name: string): Promise<void> {
|
||||
if (this.db) return
|
||||
if (this.opening) return this.opening
|
||||
// 0.1.38 修复:如果上次 opening 失败(rejected),清除它允许重试
|
||||
if (this.opening) {
|
||||
try {
|
||||
return await this.opening
|
||||
} catch {
|
||||
// 上次失败,清除后走下方重试逻辑
|
||||
this.opening = null
|
||||
}
|
||||
}
|
||||
this.opening = (async () => {
|
||||
this.db = (await this.factory.create({
|
||||
name,
|
||||
mode: 'aria',
|
||||
diskEngine: 'opfs',
|
||||
aria: { walSyncMode: 'full', compression: true }
|
||||
})) as SqlarkLike
|
||||
this.opening = null
|
||||
try {
|
||||
const config = await buildDbConfig(name, true)
|
||||
const instance = (await this.factory.create(config)) as SqlarkLike & {
|
||||
engine?: unknown
|
||||
}
|
||||
// 0.1.38 最终方案:注入 IndexedDB 持久化后端
|
||||
// 无论 OPFS 是否可用,都替换为 IndexedDBBackend 确保数据跨进程持久化
|
||||
await injectIndexedDBBackend(instance, name)
|
||||
this.db = instance
|
||||
this.lastError = null
|
||||
} catch (e) {
|
||||
this.lastError = String(e)
|
||||
throw e
|
||||
} finally {
|
||||
// 无论成功失败都清除 opening,允许后续重试
|
||||
this.opening = null
|
||||
}
|
||||
})()
|
||||
return this.opening
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.db = null
|
||||
this.opening = null
|
||||
}
|
||||
|
||||
/** 获取上次错误信息(供诊断) */
|
||||
getLastError(): string | null {
|
||||
return this.lastError
|
||||
}
|
||||
|
||||
/** 判断数据库是否已成功打开 */
|
||||
isOpen(): boolean {
|
||||
return this.db !== null
|
||||
}
|
||||
|
||||
async run(sql: string, params: unknown[] = []): Promise<unknown> {
|
||||
@@ -65,6 +252,11 @@ class SqlarkDriver implements SaveDbDriver {
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeDriver(): Promise<SaveDbDriver> {
|
||||
const factory = await getSqlarkFactory()
|
||||
return new SqlarkDriver(factory)
|
||||
}
|
||||
|
||||
const META_DB = 'cotyc-appmeta'
|
||||
|
||||
let singletonManager: SlotManager | null = null
|
||||
@@ -83,6 +275,12 @@ export async function getSqlarkFactory(): Promise<AnyFactory> {
|
||||
}
|
||||
|
||||
const slotCache = new Map<number, SaveSlot>()
|
||||
|
||||
/** 0.1.38:重置 slot 缓存(测试用——避免旧 driver 残留) */
|
||||
export function _resetSlotCache(): void {
|
||||
slotCache.clear()
|
||||
}
|
||||
|
||||
export async function getSaveSlot(slot: number): Promise<SaveSlot> {
|
||||
let s = slotCache.get(slot)
|
||||
if (!s) {
|
||||
@@ -93,18 +291,48 @@ export async function getSaveSlot(slot: number): Promise<SaveSlot> {
|
||||
return s
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// SlotManager——0.1.38 同样走降级策略 + 错误恢复
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class SlotManager {
|
||||
private metaDb: SqlarkLike | null = null
|
||||
private metaOpening: Promise<void> | null = null
|
||||
private metaError: string | null = null
|
||||
|
||||
async ensureMeta(): Promise<void> {
|
||||
if (this.metaDb) return
|
||||
this.metaDb = (await (await getSqlarkFactory()).create({
|
||||
name: META_DB,
|
||||
mode: 'aria',
|
||||
diskEngine: 'opfs',
|
||||
aria: { walSyncMode: 'full' }
|
||||
})) as SqlarkLike
|
||||
await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`)
|
||||
if (this.metaOpening) {
|
||||
try {
|
||||
return await this.metaOpening
|
||||
} catch {
|
||||
this.metaOpening = null
|
||||
}
|
||||
}
|
||||
this.metaOpening = (async () => {
|
||||
try {
|
||||
const config = await buildDbConfig(META_DB, false)
|
||||
const instance = (await (await getSqlarkFactory()).create(config)) as SqlarkLike & {
|
||||
engine?: unknown
|
||||
}
|
||||
// 注入 IndexedDB 持久化后端
|
||||
await injectIndexedDBBackend(instance, META_DB)
|
||||
this.metaDb = instance
|
||||
await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`)
|
||||
this.metaError = null
|
||||
} catch (e) {
|
||||
this.metaError = String(e)
|
||||
throw e
|
||||
} finally {
|
||||
this.metaOpening = null
|
||||
}
|
||||
})()
|
||||
return this.metaOpening
|
||||
}
|
||||
|
||||
/** 获取 meta DB 的最后错误(供诊断) */
|
||||
getMetaError(): string | null {
|
||||
return this.metaError
|
||||
}
|
||||
|
||||
async listSlotMetas(): Promise<(SaveMeta | null)[]> {
|
||||
@@ -142,5 +370,59 @@ export class SlotManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 存储健康诊断——供 UI 诊断面板调用
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StorageHealthReport {
|
||||
backendType: StorageBackendType
|
||||
opfsAvailable: boolean
|
||||
persistGranted: boolean | null
|
||||
driverOpen: boolean
|
||||
driverError: string | null
|
||||
metaError: string | null
|
||||
slotCount: number
|
||||
}
|
||||
|
||||
/** 全面诊断存储状态——供 SettingsPanel 健康面板调用 */
|
||||
export async function diagnoseStorage(): Promise<StorageHealthReport> {
|
||||
const opfsAvailable = await probeOpfs()
|
||||
const persistGranted = typeof navigator !== 'undefined' && navigator.storage
|
||||
? (await navigator.storage.persisted?.().catch(() => null) ?? null)
|
||||
: null
|
||||
|
||||
let driverOpen = false
|
||||
let driverError: string | null = null
|
||||
|
||||
// 尝试打开 slot 1 做探测
|
||||
try {
|
||||
const slot = await getSaveSlot(1)
|
||||
await slot.open()
|
||||
driverOpen = true
|
||||
} catch (e) {
|
||||
driverError = String(e).slice(0, 200)
|
||||
}
|
||||
|
||||
const manager = getSlotManager()
|
||||
const metaError = manager.getMetaError()
|
||||
|
||||
let slotCount = 0
|
||||
try {
|
||||
const metas = await manager.listSlotMetas()
|
||||
slotCount = metas.filter((m) => m !== null).length
|
||||
} catch {
|
||||
// meta DB 不可用
|
||||
}
|
||||
|
||||
return {
|
||||
backendType: _backendType,
|
||||
opfsAvailable,
|
||||
persistGranted,
|
||||
driverOpen,
|
||||
driverError,
|
||||
metaError,
|
||||
slotCount
|
||||
}
|
||||
}
|
||||
|
||||
export type { GameState, SaveMeta, SnapshotMeta }
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// IndexedDB Storage Backend — 自建 IStorageBackend 实现
|
||||
//
|
||||
// 0.1.38 根治方案:
|
||||
// MetonaSqlark 的 KVStore 在 OPFS 不可用时 fallback 到 SharedMemoryBackend(纯内存),
|
||||
// 进程关闭后数据全部丢失。此模块用原生 IndexedDB API 实现 IStorageBackend 接口,
|
||||
// 确保数据真正持久化到浏览器/Electron 的 IndexedDB 中。
|
||||
//
|
||||
// 设计要点:
|
||||
// · 每个 key 对应一条 IndexedDB 记录(value 为 ArrayBuffer)
|
||||
// · open 时创建 object store(如不存在)
|
||||
// · 所有写操作通过单条 IDB 事务保证原子性
|
||||
// · writeMany 用单事务多 put 保证原子批量写入
|
||||
// · append 用读-改-写(单事务内 read+write 保证原子性)
|
||||
// · close 不删数据(持久化语义——重开同名库可读到上次数据)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** IStorageBackend 最小接口(与 metona-sqlark 的 IStorageBackend 对齐) */
|
||||
export interface IStorageBackendLike {
|
||||
open(name: string): Promise<void>
|
||||
close(): Promise<void>
|
||||
isOpen(): boolean
|
||||
read(key: string): Promise<ArrayBuffer | null>
|
||||
write(key: string, data: ArrayBuffer): Promise<void>
|
||||
append?(key: string, data: ArrayBuffer): Promise<void>
|
||||
writeMany(entries: Record<string, ArrayBuffer>): Promise<void>
|
||||
delete(key: string): Promise<void>
|
||||
deleteMany(keys: string[]): Promise<void>
|
||||
listKeys(): Promise<string[]>
|
||||
exists(key: string): Promise<boolean>
|
||||
clear(): Promise<void>
|
||||
}
|
||||
|
||||
const STORE_NAME = 'kv'
|
||||
const DB_PREFIX = 'cotyc-idb-'
|
||||
|
||||
/**
|
||||
* 基于 IndexedDB 的持久化存储后端。
|
||||
*
|
||||
* 用法:传入 KVStoreBackend 或 KVStore 构造函数作为 medium 参数,
|
||||
* 替代 OPFSBackend / SharedMemoryBackend。
|
||||
*/
|
||||
export class IndexedDBBackend implements IStorageBackendLike {
|
||||
private dbName = ''
|
||||
private db: IDBDatabase | null = null
|
||||
private writeQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
async open(name: string): Promise<void> {
|
||||
// 幂等:如果已打开同名库,直接返回(KVStore.reload 会二次调 open)
|
||||
if (this.db && this.dbName === name) return
|
||||
// 如果打开了不同名的库,先关闭旧的
|
||||
if (this.db) {
|
||||
this.db.close()
|
||||
this.db = null
|
||||
}
|
||||
this.dbName = name
|
||||
const fullDbName = DB_PREFIX + name
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(fullDbName, 1)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => {
|
||||
this.db = req.result
|
||||
resolve()
|
||||
}
|
||||
req.onerror = () => {
|
||||
reject(new Error(`IndexedDB open failed: ${req.error?.message ?? 'unknown'}`))
|
||||
}
|
||||
req.onblocked = () => {
|
||||
reject(new Error('IndexedDB open blocked (another tab holding connection)'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// 等待写队列排空
|
||||
try { await this.writeQueue } catch { /* ignore */ }
|
||||
if (this.db) {
|
||||
this.db.close()
|
||||
this.db = null
|
||||
}
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.db !== null
|
||||
}
|
||||
|
||||
async read(key: string): Promise<ArrayBuffer | null> {
|
||||
if (!this.db) return null
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const req = store.get(key)
|
||||
req.onsuccess = () => {
|
||||
const result = req.result
|
||||
if (result === undefined) {
|
||||
resolve(null)
|
||||
} else if (result instanceof ArrayBuffer) {
|
||||
resolve(result)
|
||||
} else if (result instanceof Uint8Array) {
|
||||
// 某些浏览器可能返回 Uint8Array
|
||||
resolve(result.buffer as ArrayBuffer)
|
||||
} else {
|
||||
// 兜底:尝试作为 ArrayBuffer 处理
|
||||
resolve(result as ArrayBuffer)
|
||||
}
|
||||
}
|
||||
req.onerror = () => reject(req.error ?? new Error(`IDB read failed: ${key}`))
|
||||
})
|
||||
}
|
||||
|
||||
async write(key: string, data: ArrayBuffer): Promise<void> {
|
||||
const run = this.writeQueue.then(() => this._writeRaw(key, data))
|
||||
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
private _writeRaw(key: string, data: ArrayBuffer): Promise<void> {
|
||||
if (!this.db) return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
store.put(data, key)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error(`IDB write failed: ${key}`))
|
||||
tx.onabort = () => reject(tx.error ?? new Error(`IDB write aborted: ${key}`))
|
||||
})
|
||||
}
|
||||
|
||||
async append(key: string, data: ArrayBuffer): Promise<void> {
|
||||
if (!this.db) return
|
||||
const run = this.writeQueue.then(() => this._appendRaw(key, data))
|
||||
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
private _appendRaw(key: string, data: ArrayBuffer): Promise<void> {
|
||||
if (!this.db) return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const getReq = store.get(key)
|
||||
getReq.onsuccess = () => {
|
||||
const existing = getReq.result
|
||||
let combined: ArrayBuffer
|
||||
if (existing instanceof ArrayBuffer) {
|
||||
const merged = new Uint8Array(existing.byteLength + data.byteLength)
|
||||
merged.set(new Uint8Array(existing), 0)
|
||||
merged.set(new Uint8Array(data), existing.byteLength)
|
||||
combined = merged.buffer as ArrayBuffer
|
||||
} else if (existing instanceof Uint8Array) {
|
||||
const merged = new Uint8Array(existing.byteLength + data.byteLength)
|
||||
merged.set(existing, 0)
|
||||
merged.set(new Uint8Array(data), existing.byteLength)
|
||||
combined = merged.buffer as ArrayBuffer
|
||||
} else if (existing !== undefined) {
|
||||
combined = existing as ArrayBuffer
|
||||
} else {
|
||||
combined = data
|
||||
}
|
||||
store.put(combined, key)
|
||||
}
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error(`IDB append failed: ${key}`))
|
||||
tx.onabort = () => reject(tx.error ?? new Error(`IDB append aborted: ${key}`))
|
||||
})
|
||||
}
|
||||
|
||||
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
|
||||
if (!this.db || Object.keys(entries).length === 0) return
|
||||
const run = this.writeQueue.then(() => this._writeManyRaw(entries))
|
||||
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
private _writeManyRaw(entries: Record<string, ArrayBuffer>): Promise<void> {
|
||||
if (!this.db) return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
for (const [key, data] of Object.entries(entries)) {
|
||||
store.put(data, key)
|
||||
}
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error('IDB writeMany failed'))
|
||||
tx.onabort = () => reject(tx.error ?? new Error('IDB writeMany aborted'))
|
||||
})
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
if (!this.db) return
|
||||
const run = this.writeQueue.then(() => this._deleteRaw(key))
|
||||
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
private _deleteRaw(key: string): Promise<void> {
|
||||
if (!this.db) return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
store.delete(key)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error(`IDB delete failed: ${key}`))
|
||||
})
|
||||
}
|
||||
|
||||
async deleteMany(keys: string[]): Promise<void> {
|
||||
if (!this.db || keys.length === 0) return
|
||||
const run = this.writeQueue.then(() => this._deleteManyRaw(keys))
|
||||
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
private _deleteManyRaw(keys: string[]): Promise<void> {
|
||||
if (!this.db) return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
for (const key of keys) {
|
||||
store.delete(key)
|
||||
}
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error('IDB deleteMany failed'))
|
||||
})
|
||||
}
|
||||
|
||||
async listKeys(): Promise<string[]> {
|
||||
if (!this.db) return []
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const req = store.getAllKeys()
|
||||
req.onsuccess = () => {
|
||||
const keys = req.result as IDBValidKey[]
|
||||
resolve(keys.map((k) => String(k)))
|
||||
}
|
||||
req.onerror = () => reject(req.error ?? new Error('IDB listKeys failed'))
|
||||
})
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
if (!this.db) return false
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const req = store.count(key)
|
||||
req.onsuccess = () => resolve(req.result > 0)
|
||||
req.onerror = () => reject(req.error ?? new Error(`IDB exists failed: ${key}`))
|
||||
})
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
if (!this.db) return
|
||||
const run = this.writeQueue.then(() => this._clearRaw())
|
||||
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
private _clearRaw(): Promise<void> {
|
||||
if (!this.db) return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
store.clear()
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error('IDB clear failed'))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
|
||||
import { validateLoadedState, exportEnvelope, parseImportEnvelope } from './migrate'
|
||||
|
||||
const GAME_VERSION = '0.1.37'
|
||||
const GAME_VERSION = '0.1.41'
|
||||
|
||||
const DB_PREFIX = 'cotyc-save-'
|
||||
let seqCounter = 0
|
||||
@@ -47,6 +47,23 @@ export class SaveSlot {
|
||||
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[id, state.year, state.month, new Date().toISOString(), label, json]
|
||||
)
|
||||
// 0.1.38 P1-F:回读校验——确认数据真正落盘
|
||||
const verify = await this.driver.all<{ data: string }>(
|
||||
`SELECT data FROM snapshot WHERE id = ?`,
|
||||
[id]
|
||||
)
|
||||
if (!verify.length || !verify[0]!.data) {
|
||||
throw new Error(`存档写入校验失败:数据未落盘(slot=${this.slot}, id=${id})`)
|
||||
}
|
||||
// 校验 JSON 可解析且关键字段在
|
||||
try {
|
||||
const check = JSON.parse(verify[0]!.data) as GameState
|
||||
if (!check.family || !check.members || typeof check.seed !== 'string') {
|
||||
throw new Error('存档回读校验失败:关键字段缺失')
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`存档回读校验失败:${String(e).slice(0, 80)}`)
|
||||
}
|
||||
const keep = 12
|
||||
const rows = await this.driver.all<{ id: string }>(
|
||||
`SELECT id FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC`
|
||||
|
||||
@@ -8,6 +8,7 @@ import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
|
||||
import { FORMATIONS, FormationId } from '../../game/data/formations'
|
||||
import { buildingById } from '../../game/data/buildings'
|
||||
import { ModalShell } from './ModalShell'
|
||||
import { isMandateEvent, mandateEventCategoryName } from '../../game/engine/runtime/Systems/mandate'
|
||||
|
||||
const RES_NAME: Record<string, string> = {
|
||||
lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核', stones: '灵石',
|
||||
@@ -17,7 +18,7 @@ const RES_NAME: Record<string, string> = {
|
||||
|
||||
function describeEff(eff: Record<string, never> | { [k: string]: unknown }): string {
|
||||
const parts: string[] = []
|
||||
const e = eff as { res?: Record<string, number>; rep?: number; relation?: Record<string, number>; addBuilding?: string; pillGain?: Record<string, number>; mission?: string; addTech?: string; raid?: { npcId: string }; trib?: { mode: string }; feisheng?: { stay: boolean }; techniqueChance?: number; artifactChance?: number; flag?: Record<string, unknown> }
|
||||
const e = eff as { res?: Record<string, number>; rep?: number; relation?: Record<string, number>; addBuilding?: string; pillGain?: Record<string, number>; mission?: string; addTech?: string; raid?: { npcId: string }; trib?: { mode: string }; feisheng?: { stay: boolean }; ascension?: { stage: string; mode: string }; techniqueChance?: number; artifactChance?: number; flag?: Record<string, unknown> }
|
||||
if (e.res) {
|
||||
for (const [k, v] of Object.entries(e.res)) {
|
||||
const name = RES_NAME[k] ?? k
|
||||
@@ -41,6 +42,12 @@ function describeEff(eff: Record<string, never> | { [k: string]: unknown }): str
|
||||
parts.push(e.trib.mode === 'rash' ? '硬渡天劫' : e.trib.mode === 'guard' ? '护法渡劫' : '渡劫延一年')
|
||||
}
|
||||
if (e.feisheng) parts.push(e.feisheng.stay ? '留世不飞升' : '飞升离世')
|
||||
// 0.1.39:飞升试炼效果描述
|
||||
if (e.ascension) {
|
||||
const stageName = e.ascension.stage === 'heart' ? '心魔试炼' : e.ascension.stage === 'heaven' ? '天劫试炼' : '飞升抉择'
|
||||
const modeName = e.ascension.mode === 'rash' ? '硬抗' : e.ascension.mode === 'guard' ? '护持' : '延后'
|
||||
parts.push(`${stageName}·${modeName}`)
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
@@ -55,6 +62,11 @@ export function EventModal() {
|
||||
const [formation, setFormation] = useState<FormationId>('vanguard')
|
||||
if (!pendingEventId || !pendingEventDef || !world) return null
|
||||
|
||||
// 0.1.39:飞升事件视觉强化——金色辉光标题 + 仙韵边框 + 金边选项
|
||||
const isAscension = pendingEventId.startsWith('ev-ascension-')
|
||||
// 0.1.40:天命事件视觉强化——紫金辉光标题 + 天命边框 + 不可回避
|
||||
const isMandate = isMandateEvent(pendingEventId)
|
||||
|
||||
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
|
||||
const squadPool: Character[] =
|
||||
raidIdx >= 0
|
||||
@@ -90,6 +102,8 @@ export function EventModal() {
|
||||
}
|
||||
|
||||
const dorm = () => {
|
||||
// 天命不可回避——不可"稍后再议"
|
||||
if (isMandate) return
|
||||
// 「稍后再议」:把事件按回队列,不消灭、不误弃
|
||||
world.requeueEvent(pendingEventId)
|
||||
closeModal()
|
||||
@@ -103,6 +117,7 @@ export function EventModal() {
|
||||
allowOuter={false}
|
||||
onClose={dorm}
|
||||
closeLabel="稍后再议"
|
||||
className={isAscension ? 'modal-ascension' : isMandate ? 'modal-mandate' : undefined}
|
||||
footer={
|
||||
squadPicking ? (
|
||||
<>
|
||||
@@ -119,6 +134,8 @@ export function EventModal() {
|
||||
{squadSel.length === 0 ? '族中无可战之人' : `出战!(共${squadSel.length}人)`}
|
||||
</button>
|
||||
</>
|
||||
) : isMandate ? (
|
||||
<div className="dim2" style={{ fontSize: '0.82rem' }}>天命不可违——必须做出抉择</div>
|
||||
) : (
|
||||
<button className="btn" onClick={dorm}>稍后再议</button>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useGameStore } from '../store'
|
||||
import { memo } from 'react'
|
||||
import { Character } from '../../game/types/domain'
|
||||
import { describeRealm } from '../../game/data/realms'
|
||||
import { describeRoots } from '../../game/data/elements'
|
||||
@@ -13,18 +13,34 @@ const STATE_LABEL: Record<string, { label: string; cls: string }> = {
|
||||
dead: { label: '已殁', cls: 'bad' }
|
||||
}
|
||||
|
||||
export function MemberCard({ c }: { c: Character }) {
|
||||
const world = useGameStore((s) => s.world)
|
||||
const setSelected = useGameStore((s) => s.setSelected)
|
||||
if (!world) return null
|
||||
const C_STATE: Record<string, string> = {
|
||||
idle: '',
|
||||
meditation: 'good',
|
||||
expedition: 'warn',
|
||||
wounded: 'bad',
|
||||
dead: 'bad'
|
||||
}
|
||||
|
||||
/**
|
||||
* 0.1.39 渲染提效:MemberCard 改为 memo 纯展示组件——
|
||||
* 不再从 store 获取 world(之前每次 revision bump 全部卡片重渲染)。
|
||||
* 所需外部数据(surname/isHead/age)经 props 传入,只在 c 或依赖 props 变化时重渲染。
|
||||
*/
|
||||
export interface MemberCardProps {
|
||||
c: Character
|
||||
surname: string
|
||||
isHead: boolean
|
||||
age: number
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
export const MemberCard = memo(function MemberCard({ c, surname, isHead, age, onSelect }: MemberCardProps) {
|
||||
const dead = !c.alive
|
||||
const isHead = world.state.family.headId === c.id
|
||||
const state = STATE_LABEL[dead ? 'dead' : c.state]
|
||||
const blocked = !dead && c.realmProgress >= 100 && c.realm.major !== 'spirit'
|
||||
return (
|
||||
<div className={`member-card ${dead ? 'dead' : ''} ${blocked ? 'blocked' : ''}`} onClick={() => setSelected(c.id)}>
|
||||
<div className={`member-card ${dead ? 'dead' : ''} ${blocked ? 'blocked' : ''}`} onClick={() => onSelect(c.id)}>
|
||||
<div className="mc-head">
|
||||
<div className="m-avatar">{world.state.family.surname}</div>
|
||||
<div className="m-avatar">{surname}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="member-name">
|
||||
<span>{c.name}</span>
|
||||
@@ -34,7 +50,7 @@ export function MemberCard({ c }: { c: Character }) {
|
||||
<div className="member-realm">
|
||||
{dead
|
||||
? `殁于${c.deathYear}年 · ${c.deathCause ?? '寿终'}`
|
||||
: `${describeRealm(c.realm)} · ${world.ageOf(c)}岁 · 修为${Math.floor(c.realmProgress)}%`}
|
||||
: `${describeRealm(c.realm)} · ${age}岁 · 修为${Math.floor(c.realmProgress)}%`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,12 +71,4 @@ export function MemberCard({ c }: { c: Character }) {
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const C_STATE: Record<string, string> = {
|
||||
idle: '',
|
||||
meditation: 'good',
|
||||
expedition: 'warn',
|
||||
wounded: 'bad',
|
||||
dead: 'bad'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ModalShellProps {
|
||||
footerAlign?: 'right' | 'center' | 'space'
|
||||
children: ReactNode
|
||||
closeLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +30,8 @@ export function ModalShell({
|
||||
footer,
|
||||
footerAlign = 'right',
|
||||
children,
|
||||
closeLabel = '关闭'
|
||||
closeLabel = '关闭',
|
||||
className
|
||||
}: ModalShellProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -46,7 +48,7 @@ export function ModalShell({
|
||||
// 顶部菜单/主域均在其下(0.1.22 修复:弹框出现在宗族/谱系菜单下方的 z-index 越界)。
|
||||
const shell = (
|
||||
<div className="modal-outer" onClick={allowOuter ? onClose : undefined}>
|
||||
<div className="modal" style={{ width: `min(92vw, ${width}px)` }} onClick={(e) => e.stopPropagation()} data-modal-kind="shell">
|
||||
<div className={`modal ${className ?? ''}`} style={{ width: `min(92vw, ${width}px)` }} onClick={(e) => e.stopPropagation()} data-modal-kind="shell">
|
||||
<button className="modal-close" onClick={onClose} title={closeLabel} aria-label={closeLabel}>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useGameStore } from '../store'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState, memo } from 'react'
|
||||
|
||||
const CAT_NAME: Record<string, string> = {
|
||||
birth: '诞庆',
|
||||
@@ -61,6 +61,7 @@ export default function ChroniclePanel() {
|
||||
if (!world) return null
|
||||
const battles = world.state.battles.slice().reverse()
|
||||
const battle = battles.find((b) => b.id === battleId)
|
||||
const groupedEntries = useMemo(() => grouped(entries), [entries])
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -99,7 +100,7 @@ export default function ChroniclePanel() {
|
||||
))}
|
||||
</div>
|
||||
<div className="chronicle-list">
|
||||
{grouped(entries).map((g) => (
|
||||
{groupedEntries.map((g) => (
|
||||
<div key={g.year}>
|
||||
<div className="ch-yearbar">{g.year}年 <span className="dim2">{g.items.length}条 · {statOf(g)}</span></div>
|
||||
{g.items.map((e) => (
|
||||
|
||||
@@ -1,12 +1,89 @@
|
||||
import { useGameStore } from '../store'
|
||||
import { npcById } from '../../game/data/npcs'
|
||||
import { giftNpc, makePeace, marryNpcFamily, calcGiftGain, GIFT_TIERS } from '../../game/engine/runtime/Systems/diplomacy'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, memo } from 'react'
|
||||
import { MAJOR_NAMES } from '../../game/data/realms'
|
||||
import type { World } from '../../game/engine/runtime/World'
|
||||
import type { NpcFamilyState } from '../../game/types/domain'
|
||||
|
||||
const REL_TYPE = (r: number) =>
|
||||
r >= 80 ? ['死生之交', 'good'] : r >= 50 ? ['通好之势', 'good'] : r >= 25 ? ['友善', 'warn'] : r >= 10 ? ['温和', 'warn'] : r > -10 ? ['中立', ''] : r > -30 ? ['嫌隙', ''] : r > -50 ? ['敌对', 'bad'] : ['仇雠', 'bad']
|
||||
|
||||
const NpcRow = memo(function NpcRow({ npc, world, bump }: { npc: NpcFamilyState; world: World; bump: () => void }) {
|
||||
const def = npcById(npc.id)
|
||||
if (!def) return null
|
||||
const [relLabel, relCls] = REL_TYPE(npc.relation)
|
||||
const married = npc.allied
|
||||
return (
|
||||
<div className="npc-row">
|
||||
<div className="npc-name">
|
||||
<div style={{ fontSize: '1.15rem' }}>{npc.name}</div>
|
||||
<div className="dim2" style={{ fontSize: '0.78rem' }}>{def.style} · {npc.region}</div>
|
||||
</div>
|
||||
<div className="relation-bar">
|
||||
<div className="bar" style={{ borderColor: npc.relation >= 0 ? '#2e5a2e' : '#6e2f2f' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${(npc.relation + 100) / 2}%`,
|
||||
background: npc.relation >= 0 ? '#5a8a44' : '#8a4444'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`dim ${relCls}`} style={{ marginTop: 4, fontSize: '0.82rem' }}>
|
||||
关系{npc.relation} · {relLabel} · 势力{npc.power}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relation-num">
|
||||
<span className="dim2">{MAJOR_NAMES[def.leaderRealm]}宗主</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{GIFT_TIERS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className="btn btn-sm"
|
||||
disabled={world.state.family.stones < t}
|
||||
title={`关系 +${calcGiftGain(t)}`}
|
||||
onClick={() => { giftNpc(world, npc.id, t); bump() }}
|
||||
>
|
||||
赠礼{t}(+{calcGiftGain(t)})
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={npc.relation < 25 || married}
|
||||
title={npc.relation < 25 ? `需关系≥25(当前${npc.relation})` : married ? '一姓已结亲' : '关系≥25方可联姻'}
|
||||
onClick={() => { marryNpcFamily(world, npc.id); bump() }}
|
||||
>
|
||||
{married ? '已联姻' : '联姻'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
title="关系-20,可能招致劫掠;每年一次"
|
||||
onClick={() => {
|
||||
if (window.confirm(`向${npc.name}寻衅?关系将下降 20,可能招致劫掠。`)) {
|
||||
world.tauntNpc(npc.id)
|
||||
bump()
|
||||
}
|
||||
}}
|
||||
>
|
||||
寻衅
|
||||
</button>
|
||||
{npc.relation < -30 && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={world.state.family.stones < 200}
|
||||
title={world.state.family.stones < 200 ? '需灵石 200' : '与对方结下和约'}
|
||||
onClick={() => { makePeace(world, npc.id); bump() }}
|
||||
>
|
||||
议和
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default function DiplomacyPanel() {
|
||||
const world = useGameStore((s) => s.world)
|
||||
const bump = useGameStore((s) => s.bump)
|
||||
@@ -23,80 +100,9 @@ export default function DiplomacyPanel() {
|
||||
<div className="help-text">
|
||||
外交之道:送礼结好、联姻通好;仇恨过深的老对家会遣队劫掠。家族声望同样影响诸家态度。
|
||||
</div>
|
||||
{sorted.map((npc) => {
|
||||
const def = npcById(npc.id)
|
||||
if (!def) return null
|
||||
const [relLabel, relCls] = REL_TYPE(npc.relation)
|
||||
const married = npc.allied
|
||||
return (
|
||||
<div key={npc.id} className="npc-row">
|
||||
<div className="npc-name">
|
||||
<div style={{ fontSize: '1.15rem' }}>{npc.name}</div>
|
||||
<div className="dim2" style={{ fontSize: '0.78rem' }}>{def.style} · {npc.region}</div>
|
||||
</div>
|
||||
<div className="relation-bar">
|
||||
<div className="bar" style={{ borderColor: npc.relation >= 0 ? '#2e5a2e' : '#6e2f2f' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${(npc.relation + 100) / 2}%`,
|
||||
background: npc.relation >= 0 ? '#5a8a44' : '#8a4444'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`dim ${relCls}`} style={{ marginTop: 4, fontSize: '0.82rem' }}>
|
||||
关系{npc.relation} · {relLabel} · 势力{npc.power}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relation-num">
|
||||
<span className="dim2">{MAJOR_NAMES[def.leaderRealm]}宗主</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{GIFT_TIERS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className="btn btn-sm"
|
||||
disabled={w.state.family.stones < t}
|
||||
title={`关系 +${calcGiftGain(t)}`}
|
||||
onClick={() => { giftNpc(w, npc.id, t); bump() }}
|
||||
>
|
||||
赠礼{t}(+{calcGiftGain(t)})
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={npc.relation < 25 || married}
|
||||
title={npc.relation < 25 ? `需关系≥25(当前${npc.relation})` : married ? '一姓已结亲' : '关系≥25方可联姻'}
|
||||
onClick={() => { marryNpcFamily(w, npc.id); bump() }}
|
||||
>
|
||||
{married ? '已联姻' : '联姻'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
title="关系-20,可能招致劫掠;每年一次"
|
||||
onClick={() => {
|
||||
if (window.confirm(`向${npc.name}寻衅?关系将下降 20,可能招致劫掠。`)) {
|
||||
w.tauntNpc(npc.id)
|
||||
bump()
|
||||
}
|
||||
}}
|
||||
>
|
||||
寻衅
|
||||
</button>
|
||||
{npc.relation < -30 && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={w.state.family.stones < 200}
|
||||
title={w.state.family.stones < 200 ? '需灵石 200' : '与对方结下和约'}
|
||||
onClick={() => { makePeace(w, npc.id); bump() }}
|
||||
>
|
||||
议和
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{sorted.map((npc) => (
|
||||
<NpcRow key={npc.id} npc={npc} world={w} bump={bump} />
|
||||
))}
|
||||
<div className="dim2" style={{ marginTop: 12 }}>
|
||||
示好请用灵石铺路;寻衅会立刻恶化关系,且可能招致劫掠或围庄。
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useGameStore } from '../store'
|
||||
import { useMemo } from 'react'
|
||||
import { MemberCard } from '../components/MemberCard'
|
||||
import { MemberModal } from '../components/MemberModal'
|
||||
import { Character } from '../../game/types/domain'
|
||||
@@ -11,25 +12,29 @@ export default function FamilyPanel() {
|
||||
void revision
|
||||
if (!world) return null
|
||||
const s = world.state
|
||||
const alive = Object.values(s.members).filter((c) => c.alive)
|
||||
const dead = Object.values(s.members).filter((c) => !c.alive)
|
||||
const sorted = [...alive].sort((a, b) => {
|
||||
const aHead = a.id === s.family.headId ? -1 : 0
|
||||
const bHead = b.id === s.family.headId ? -1 : 0
|
||||
return aHead - bHead || world.ageOf(b) - world.ageOf(a)
|
||||
})
|
||||
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined
|
||||
|
||||
const marriageReady = (() => {
|
||||
if (!world) return []
|
||||
return alive.filter(
|
||||
const surname = s.family.surname
|
||||
const headId = s.family.headId
|
||||
const { alive, dead, sorted, marriageReady } = useMemo(() => {
|
||||
const alive = Object.values(s.members).filter((c) => c.alive)
|
||||
const dead = Object.values(s.members).filter((c) => !c.alive)
|
||||
const sorted = [...alive].sort((a, b) => {
|
||||
const aHead = a.id === headId ? -1 : 0
|
||||
const bHead = b.id === headId ? -1 : 0
|
||||
return aHead - bHead || world.ageOf(b) - world.ageOf(a)
|
||||
})
|
||||
const marriageReady = alive.filter(
|
||||
(c) =>
|
||||
world.ageOf(c) >= 16 &&
|
||||
world.ageOf(c) <= 46 &&
|
||||
c.state !== 'expedition' &&
|
||||
(!c.spouseId || world.isWidowed(c))
|
||||
)
|
||||
})()
|
||||
return { alive, dead, sorted, marriageReady }
|
||||
}, [s.members, headId, world])
|
||||
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined
|
||||
|
||||
// 0.1.39:稳定回调引用——避免每次渲染新建函数导致 MemberCard memo 失效
|
||||
const onSelect = setSelected
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -78,10 +83,10 @@ export default function FamilyPanel() {
|
||||
)}
|
||||
<div className="member-grid">
|
||||
{sorted.map((c) => (
|
||||
<MemberCard key={c.id} c={c} />
|
||||
<MemberCard key={c.id} c={c} surname={surname} isHead={c.id === headId} age={world.ageOf(c)} onSelect={onSelect} />
|
||||
))}
|
||||
{dead.map((c) => (
|
||||
<MemberCard key={c.id} c={c} />
|
||||
<MemberCard key={c.id} c={c} surname={surname} isHead={false} age={world.ageOf(c)} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
{alive.length === 0 && (
|
||||
|
||||
@@ -5,6 +5,9 @@ import { ResolveModal } from '../components/ResolveModal'
|
||||
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'
|
||||
|
||||
|
||||
export default function LegacyPanel() {
|
||||
@@ -12,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
|
||||
@@ -54,6 +57,7 @@ export default function LegacyPanel() {
|
||||
<button className="btn" onClick={() => setView('report')}>年报</button>
|
||||
<button className="btn" onClick={() => setView('chronicle')}>年表</button>
|
||||
<button className="btn" onClick={() => setView('scroll')}>长卷</button>
|
||||
<button className="btn" onClick={() => setView('cscroll')}>族史卷</button>
|
||||
<button className="btn" onClick={() => setView('axis')}>年轴</button>
|
||||
<button className="btn" onClick={() => setView('legacy')}>百年报告</button>
|
||||
</div>
|
||||
@@ -118,6 +122,8 @@ export default function LegacyPanel() {
|
||||
|
||||
{view === 'scroll' && <ScrollScroll />}
|
||||
|
||||
{view === 'cscroll' && <ChronicleScrollView />}
|
||||
|
||||
{view === 'chronicle' && (
|
||||
<div className="card">
|
||||
<div className="card-title">生卒与突破年表</div>
|
||||
@@ -155,6 +161,25 @@ function LegacyReportView() {
|
||||
<div className="dim" style={{ marginTop: 10 }}>死因榜:{Object.entries(r.deathCauses).filter(([k]) => k !== '').sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k}×${v}`).join(' · ') || '尚无记录'}</div>
|
||||
<div className="dim" style={{ marginTop: 6 }}>突破之劲:{r.breakthroughs.slice(-6).map((b) => `${b.year}年×${b.count}`).join(' · ')}</div>
|
||||
<div className="resolve-poem" style={{ marginTop: 14 }}>{verdictLine(r)}</div>
|
||||
{/* 0.1.40 天下大事记摘要 */}
|
||||
{(() => {
|
||||
const ws = world.state.worldSim as WorldSimState | undefined
|
||||
if (!ws) return null
|
||||
const saga = buildSaga(ws).slice(0, 12)
|
||||
if (saga.length === 0) return null
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div className="dim" style={{ marginBottom: 4 }}>天下大事记摘要:</div>
|
||||
{saga.map((e, i) => (
|
||||
<div key={i} className="ch-item" style={{ padding: '4px 8px', fontSize: '0.84rem' }}>
|
||||
<span className="ch-month">{e.year}年</span>
|
||||
<span className={`tag ${sagaKindClass(e.kind)}`} style={{ marginRight: 6, fontSize: '0.72rem' }}>{sagaKindName(e.kind)}</span>
|
||||
<span className="dim">{e.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -240,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 (
|
||||
<div className="card">
|
||||
<div className="card-title">族史卷(天命·族事·天下 串联)</div>
|
||||
{scroll.length === 0 ? (
|
||||
<div className="dim">开卷尚浅,待岁月沉淀后再启此卷。</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 500, overflowY: 'auto' }}>
|
||||
{scroll.map((e, i) => (
|
||||
<div key={i} className="ch-item" style={{
|
||||
padding: '5px 8px',
|
||||
borderLeft: e.important ? '3px solid #c9a227' : undefined,
|
||||
paddingLeft: e.important ? 10 : 8
|
||||
}}>
|
||||
<span className="ch-month">{e.year}年</span>
|
||||
<span className={`tag ${scrollKindClass(e.kind)}`} style={{ marginRight: 6, fontSize: '0.72rem' }}>{scrollKindName(e.kind)}</span>
|
||||
<span className={e.important ? 'gold-t' : ''}>{e.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="dim2" style={{ marginTop: 8 }}>
|
||||
族史卷将天命决策、族中大事与天下大事记串联展示——以时间为经、以事件为纬。
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function dimName(k: string): string {
|
||||
const map: Record<string, string> = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火', yinGuo: '因果' }
|
||||
return map[k] ?? k
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function MarketPanel() {
|
||||
<div className="help-text">
|
||||
丹房依「灵草+兽核+灵石」配比丹药,成数随丹房等级;破境丹需丹房三级。炼器以灵矿兽核+灵石相合,无建筑门槛,只看家底。
|
||||
</div>
|
||||
<h4 className="dim" style={{ margin: '8px 0 4px' }}>丹房炼制(当前 {fam.buildings['danfang'] ?? 0} 级 · 成率 {Math.round(bonusOf('danfang', 'craftChance', fam.buildings['danfang'] ?? 0) * 100)}%)</h4>
|
||||
<h4 className="dim" style={{ margin: '8px 0 4px' }}>丹房炼制(当前 {fam.buildings['danfang'] ?? 0} 级 · 成率 {Math.round(bonusOf('danfang', 'craftChance', fam.buildings['danfang'] ?? 0, fam.flag['buildingSpecialty'] as string | undefined) * 100)}%)</h4>
|
||||
<table className="market-table">
|
||||
<thead>
|
||||
<tr><th>丹方</th><th>药性</th><th>配方</th><th>门槛</th><th></th></tr>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { examplePlugin } from '../../game/engine/runtime/demo-plugins'
|
||||
import { ensureFx } from '../fx'
|
||||
import { SYSTEM_DEFS } from '../../game/engine/runtime/capabilities'
|
||||
import { buildBiography } from '../../game/engine/narrative/biography'
|
||||
import { diagnoseStorage, type StorageHealthReport } from '../../game/storage/db'
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const world = useGameStore((s) => s.world)
|
||||
@@ -24,6 +25,8 @@ export default function SettingsPanel() {
|
||||
const toggleSound = useGameStore((s) => s.toggleSound)
|
||||
const setNextToast = useGameStore((s) => s.setNextToast)
|
||||
const [fxMode, setFxModeState] = useState('std')
|
||||
const [healthReport, setHealthReport] = useState<StorageHealthReport | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const setFxMode = (m: 'soft' | 'std' | 'full') => {
|
||||
setFxModeState(m)
|
||||
ensureFx().setMode(m)
|
||||
@@ -191,6 +194,71 @@ export default function SettingsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 0.1.38 存档健康诊断 ===== */}
|
||||
<div className="card settings-card">
|
||||
<div className="card-title">存档健康诊断</div>
|
||||
<div className="help-text">
|
||||
0.1.38 新增:存储后端自动探测 OPFS 可用性,不可用时降级到 IndexedDB (KVStore)。
|
||||
此面板实时显示存储状态与错误信息,便于排查存档失效问题。
|
||||
</div>
|
||||
<table className="settings-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>存储后端</td>
|
||||
<td>{healthReport ? (
|
||||
<span className={healthReport.backendType === 'opfs' ? 'gold' : healthReport.backendType === 'kv' ? '' : 'bad'}>
|
||||
{healthReport.backendType === 'opfs' ? 'OPFS(原生文件系统)' : healthReport.backendType === 'kv' ? 'IndexedDB(降级)' : healthReport.backendType === 'mock' ? 'Mock(测试)' : healthReport.backendType}
|
||||
</span>
|
||||
) : '尚未检测'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OPFS 可用</td>
|
||||
<td>{healthReport ? (healthReport.opfsAvailable ? '是' : '否(已自动降级)') : '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>持久化授权</td>
|
||||
<td>{healthReport ? (healthReport.persistGranted === true ? '已授权' : healthReport.persistGranted === false ? '未授权' : '不支持') : '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>驱动状态</td>
|
||||
<td>{healthReport ? (healthReport.driverOpen ? '已打开' : '未打开') : '—'}</td>
|
||||
</tr>
|
||||
{healthReport?.driverError && (
|
||||
<tr>
|
||||
<td>驱动错误</td>
|
||||
<td className="bad">{healthReport.driverError}</td>
|
||||
</tr>
|
||||
)}
|
||||
{healthReport?.metaError && (
|
||||
<tr>
|
||||
<td>Meta 错误</td>
|
||||
<td className="bad">{healthReport.metaError}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>已有存档</td>
|
||||
<td>{healthReport ? `${healthReport.slotCount} 个槽` : '—'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={testing}
|
||||
onClick={async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
const r = await diagnoseStorage()
|
||||
setHealthReport(r)
|
||||
setNextToast(r.driverOpen ? `诊断完成:${r.backendType} 后端可用。` : `诊断失败:${r.driverError ?? '未知错误'}`)
|
||||
} catch (e) {
|
||||
setNextToast(`诊断异常:${String(e).slice(0, 80)}`)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}}
|
||||
>{testing ? '诊断中…' : '运行诊断'}</button>
|
||||
</div>
|
||||
|
||||
{/* ===== 存档与导出 ===== */}
|
||||
<div className="card settings-card">
|
||||
<div className="card-title">存档与导出</div>
|
||||
@@ -286,7 +354,7 @@ export default function SettingsPanel() {
|
||||
· 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。<br />
|
||||
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
|
||||
</div>
|
||||
<div className="dim2" style={{ marginTop: 8 }}>版本 0.1.37 · Chronicle of the Immortal Clan</div>
|
||||
<div className="dim2" style={{ marginTop: 8 }}>版本 0.1.41 · Chronicle of the Immortal Clan</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useGameStore } from '../store'
|
||||
import { BUILDINGS, buildingById, listBuildingDefs } from '../../game/data/buildings'
|
||||
import { BUILDINGS, buildingById, listBuildingDefs, SPECIALTY_MULT, bonusOf, isSpecialty } from '../../game/data/buildings'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export default function TerritoryPanel() {
|
||||
@@ -32,17 +32,42 @@ export default function TerritoryPanel() {
|
||||
!!cost &&
|
||||
fam.stones >= cost.stones &&
|
||||
fam.inventory['lingkuang'] >= cost.lingkuang
|
||||
const isSpecialtyBuilding = fam.flag['buildingSpecialty'] === id
|
||||
return (
|
||||
<div key={id} className={`bld-card ${built ? '' : 'uc'}`}>
|
||||
<div key={id} className={`bld-card ${built ? '' : 'uc'} ${isSpecialtyBuilding ? 'bld-specialty' : ''}`}>
|
||||
<div className="bld-head">
|
||||
<span className="bld-icon">{def.icon}</span>
|
||||
<div>
|
||||
<div>{def.name}</div>
|
||||
<div>{def.name}{isSpecialtyBuilding && <span className="gold" style={{ marginLeft: 4, fontSize: '0.75rem' }}>【专精】</span>}</div>
|
||||
<div className="dim2" style={{ fontSize: '0.75rem' }}>{def.kind === 'produce' ? '产出' : '功效'}</div>
|
||||
</div>
|
||||
<span className="bld-lv">{built ? `${level}级` : ''}</span>
|
||||
</div>
|
||||
<div className="bld-desc">{def.desc}</div>
|
||||
{/* 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 (
|
||||
<div className="bld-prod" style={{ fontSize: '0.72rem', color: '#a89878' }}>
|
||||
{bonusLabels.map((b, i) => (
|
||||
<span key={i} style={{ marginRight: 8 }}>
|
||||
{b.name}: {b.base.toFixed(2)}
|
||||
{isThisSpecialty && <span className="gold"> →x{SPECIALTY_MULT} = {(b.base * SPECIALTY_MULT).toFixed(2)}</span>}
|
||||
{!isThisSpecialty && specialtyId && <span className="dim2"> (设为专精可至 {(b.base * SPECIALTY_MULT).toFixed(2)})</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{produce && Object.keys(produce).length > 0 && (
|
||||
<div className="bld-prod">
|
||||
{Object.entries(produce)
|
||||
@@ -95,6 +120,26 @@ export default function TerritoryPanel() {
|
||||
祭祖{world.state.year - ((fam.flag['lastRiteYear'] as number) ?? -9) < 2 ? '(冷却)' : '150灵石'}
|
||||
</button>
|
||||
)}
|
||||
{built && def.extra && Object.keys(def.extra).length > 0 && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ marginLeft: 4, fontSize: '0.7rem' }}
|
||||
disabled={isSpecialtyBuilding ? false : fam.stones < 200}
|
||||
title={isSpecialtyBuilding ? '取消专精(免费)' : `设为专精(耗200灵石)· extra 加成 ×${SPECIALTY_MULT}`}
|
||||
onClick={() => {
|
||||
if (isSpecialtyBuilding) {
|
||||
delete fam.flag['buildingSpecialty']
|
||||
} else {
|
||||
if (fam.stones < 200) return
|
||||
fam.stones -= 200
|
||||
fam.flag['buildingSpecialty'] = id
|
||||
}
|
||||
bump()
|
||||
}}
|
||||
>
|
||||
{isSpecialtyBuilding ? '取消专精' : '设为专精'}
|
||||
</button>
|
||||
)}
|
||||
<span className="dim2" style={{ marginLeft: 'auto' }}>{levelLabel(level)}</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -147,3 +192,15 @@ function itemLabel(k: string): string {
|
||||
}
|
||||
return map[k] ?? k
|
||||
}
|
||||
|
||||
/** 0.1.41 建筑加成属性中文名 */
|
||||
function extraLabel(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
craftChance: '成丹率',
|
||||
unlockGrade: '解锁品阶',
|
||||
expBonus: '修炼加成',
|
||||
powerBonus: '战力加成',
|
||||
repBonus: '声望'
|
||||
}
|
||||
return map[key] ?? key
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useGameStore } from '../store'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, memo } from 'react'
|
||||
import { npcById } from '../../game/data/npcs'
|
||||
import { MAJOR_NAMES } from '../../game/data/realms'
|
||||
import type { WorldSimState } from '../../game/engine/sim/worldsim-data'
|
||||
@@ -7,10 +7,54 @@ import { POOL_BASE, WORLDSIM, ERA_CONF } from '../../game/engine/sim/worldsim-da
|
||||
import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
|
||||
import { sellItem, buyItem, marketPrice } from '../../game/engine/sim/Market'
|
||||
import { useState } from 'react'
|
||||
import type { World } from '../../game/engine/runtime/World'
|
||||
import { buildSaga, sagaKindName, sagaKindClass } from '../../game/engine/narrative/saga'
|
||||
|
||||
const RES_LABEL: Record<string, string> = { lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核' }
|
||||
const REALM_IDX = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit'] as const
|
||||
|
||||
/** 0.1.40 memo 化天下快讯——120 条 newsFeed 不随主面板其他状态变动重渲染 */
|
||||
const NewsList = memo(function NewsList({ news, world, bump }: { news: { year: number; month: number; src: string; text: string; itemId?: string; kind?: string }[]; world: World; bump: () => void }) {
|
||||
return (
|
||||
<>
|
||||
{news.length === 0 && <div className="dim2">风平浪静,尚无消息。</div>}
|
||||
{news.map((row, i) => {
|
||||
const canBuy = row.kind === 'quote' && row.itemId && world.state.family.stones >= marketPrice(world, row.itemId) * 5
|
||||
const canSell = row.kind === 'quote' && row.itemId && (world.state.family.inventory[row.itemId] ?? 0) >= 5
|
||||
const isCrisis = row.kind === 'calamity'
|
||||
return (
|
||||
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
|
||||
<span className="ch-month">{row.year}年{row.month}月</span>
|
||||
<span>{row.text}</span>
|
||||
{isCrisis && world.state.family.stones >= 50 && (
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
title="引粥施药,换两家之谊"
|
||||
onClick={() => {
|
||||
world.reliefCalamity(50)
|
||||
bump()
|
||||
}}
|
||||
>救济</button>
|
||||
</span>
|
||||
)}
|
||||
{canBuy && (
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<button className="btn btn-sm" title="行情见底,出手吃进(买5)" onClick={() => { buyItem(world, row.itemId!, 5); bump() }}>吃进5</button>
|
||||
</span>
|
||||
)}
|
||||
{canSell && !canBuy && (
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<button className="btn btn-sm" title="行情见顶,集市抛售(卖5)" onClick={() => { sellItem(world, row.itemId!, 5); bump() }}>抛售5</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default function WorldPanel() {
|
||||
const world = useGameStore((s) => s.world)
|
||||
const bump = useGameStore((s) => s.bump)
|
||||
@@ -123,40 +167,7 @@ export default function WorldPanel() {
|
||||
<div>
|
||||
<h4 className="dim" style={{ margin: '4px 0 6px' }}>天下快讯</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 380, overflowY: 'auto' }}>
|
||||
{news.length === 0 && <div className="dim2">风平浪静,尚无消息。</div>}
|
||||
{news.map((row, i) => {
|
||||
const canBuy = row.kind === 'quote' && row.itemId && w.state.family.stones >= marketPrice(w, row.itemId) * 5
|
||||
const canSell = row.kind === 'quote' && row.itemId && (w.state.family.inventory[row.itemId] ?? 0) >= 5
|
||||
const isCrisis = row.kind === 'calamity'
|
||||
return (
|
||||
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
|
||||
<span className="ch-month">{row.year}年{row.month}月</span>
|
||||
<span>{row.text}</span>
|
||||
{isCrisis && w.state.family.stones >= 50 && (
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
title="引粥施药,换两家之谊"
|
||||
onClick={() => {
|
||||
w.reliefCalamity(50)
|
||||
bump()
|
||||
}}
|
||||
>救济</button>
|
||||
</span>
|
||||
)}
|
||||
{canBuy && (
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<button className="btn btn-sm" title="行情见底,出手吃进(买5)" onClick={() => { buyItem(w, row.itemId!, 5); bump() }}>吃进5</button>
|
||||
</span>
|
||||
)}
|
||||
{canSell && !canBuy && (
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<button className="btn btn-sm" title="行情见顶,集市抛售(卖5)" onClick={() => { sellItem(w, row.itemId!, 5); bump() }}>抛售5</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<NewsList news={news} world={w} bump={bump} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -229,6 +240,23 @@ export default function WorldPanel() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 0.1.40 天下大事记展示区 */}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h4 className="dim" style={{ margin: '4px 0 6px' }}>天下大事记</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto' }}>
|
||||
{(() => {
|
||||
const saga = buildSaga(ws)
|
||||
if (saga.length === 0) return <div className="dim2">天下初定,尚无大事可录。</div>
|
||||
return saga.slice(0, 30).map((e, i) => (
|
||||
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
|
||||
<span className="ch-month">{e.year}年</span>
|
||||
<span className={`tag ${sagaKindClass(e.kind)}`} style={{ marginRight: 6 }}>{sagaKindName(e.kind)}</span>
|
||||
<span>{e.text}</span>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+45
-14
@@ -4,7 +4,7 @@ import { GameEngine } from '../game/engine/GameEngine'
|
||||
import { EventDef } from '../game/data/events'
|
||||
import { findEvent, applyEventChoice } from '../game/engine/runtime/Systems/events'
|
||||
import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain'
|
||||
import { getSlotManager, getSaveSlot } from '../game/storage/db'
|
||||
import { getSlotManager, getSaveSlot, probeOpfs } from '../game/storage/db'
|
||||
import { metaFromState, updateSlotMeta } from './storeHelper'
|
||||
import { GameFacade, ActName } from '../game/engine/runtime/ApiFacade'
|
||||
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sTick } from './sound'
|
||||
@@ -157,12 +157,30 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
soundOn: true,
|
||||
|
||||
init: async () => {
|
||||
// 0.1.38 P1-E:请求存储持久化(防浏览器/Electron 在存储压力下清除 OPFS 数据)
|
||||
if (typeof navigator !== 'undefined' && navigator.storage) {
|
||||
try {
|
||||
if (typeof navigator.storage.persist === 'function') {
|
||||
await navigator.storage.persist()
|
||||
}
|
||||
} catch {
|
||||
// 持久化请求失败不阻塞启动
|
||||
}
|
||||
// 0.1.38 P0-A:探测 OPFS 可用性(触发降级逻辑)
|
||||
try {
|
||||
await probeOpfs()
|
||||
} catch {
|
||||
// 探测失败不阻塞启动——降级逻辑已在 db.ts 内处理
|
||||
}
|
||||
}
|
||||
// P1-13 落盘兜底:页面隐藏/关闭时瞬时保存(防节流窗口(≤12tick)丢档)
|
||||
if (typeof window !== 'undefined') {
|
||||
const flush = () => {
|
||||
const st = useGameStore.getState()
|
||||
if (!st.world || st.screen !== 'game') return
|
||||
void Stash.save(st, '隐藏前')
|
||||
void Stash.save(st, '隐藏前').then((r) => {
|
||||
if (!r.ok) console.error('[flush-save-failed]', r.error)
|
||||
})
|
||||
}
|
||||
window.addEventListener('beforeunload', flush)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
@@ -290,15 +308,20 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
// 自动速度节流落盘:疾进 12 tick/常速 6 tick/缓行 4 tick;手动单步即时落盘
|
||||
const saveEvery = st.speed >= 3 ? 12 : st.speed === 2 ? 6 : 4
|
||||
if (st.speed === 0 || w.state.totalTicks % saveEvery === 0) {
|
||||
await Stash.save(st, '自动')
|
||||
const sr = await Stash.save(st, '自动')
|
||||
if (!sr.ok) {
|
||||
// 0.1.38:自动保存失败也要让用户知晓
|
||||
setNextToast(`自动保存失败:${sr.error}`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 出险保护:先落一档"出险前"现场再提示回档
|
||||
console.error('[advance-crash]', e)
|
||||
try {
|
||||
await Stash.save(st, '出险前')
|
||||
} catch {
|
||||
// 保存失败时不再叠加错误
|
||||
const sr = await Stash.save(st, '出险前')
|
||||
if (!sr.ok) console.error('[crash-save-failed]', sr.error)
|
||||
} catch (e2) {
|
||||
console.error('[crash-save-exception]', e2)
|
||||
}
|
||||
const detail = String(e).slice(0, 120)
|
||||
let safed = false
|
||||
@@ -398,8 +421,12 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
},
|
||||
|
||||
saveNow: async (label = '手动') => {
|
||||
await Stash.save(get(), label)
|
||||
setNextToast(`已落笔——第${get().slot}档(${label})`)
|
||||
const r = await Stash.save(get(), label)
|
||||
if (r.ok) {
|
||||
setNextToast(`已落笔——第${get().slot}档(${label})`)
|
||||
} else {
|
||||
setNextToast(`存档失败:${r.error}——请检查存储权限或使用导出功能。`)
|
||||
}
|
||||
},
|
||||
|
||||
bump: () => set((s) => ({ revision: s.revision + 1 })),
|
||||
@@ -443,6 +470,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
const st = get()
|
||||
if (!st.world || !window.api) return
|
||||
const file = await getSaveSlot(st.slot)
|
||||
await file.open()
|
||||
const meta0 = metaFromState(st.world.state, st.slot)
|
||||
st.world.syncRng()
|
||||
await file.saveState(st.world.state, '导出前')
|
||||
@@ -588,19 +616,22 @@ function actDirect(w: World, name: ActName, payload: import('../game/engine/runt
|
||||
}
|
||||
|
||||
const Stash = {
|
||||
async save(st: GameStore, label: string): Promise<void> {
|
||||
if (!st.world) return
|
||||
async save(st: GameStore, label: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (!st.world) return { ok: true }
|
||||
try {
|
||||
const file = await getSaveSlot(st.slot)
|
||||
await file.open()
|
||||
st.world.syncRng()
|
||||
await file.saveState(st.world.state, label)
|
||||
const meta = metaFromState(st.world.state, st.slot)
|
||||
await file.setMeta(meta)
|
||||
await updateSlotMeta(st.slot, meta)
|
||||
const meta = metaFromState(st.world.state, st.slot)
|
||||
await file.setMeta(meta)
|
||||
await updateSlotMeta(st.slot, meta)
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
// P0-S:存储层并发/锁冲突兜底不抛(容忍一次失败,下次自动再试)
|
||||
// 0.1.38:错误不再静默——上浮给调用方,让用户可见
|
||||
const msg = String(e).slice(0, 120)
|
||||
console.error('[stash-save]', e)
|
||||
return { ok: false, error: msg }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.37'
|
||||
version: '0.1.41'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1050,6 +1050,10 @@ html[data-blade] .battle-lines {
|
||||
color: #4a3d29;
|
||||
filter: saturate(0.6);
|
||||
}
|
||||
.bld-card.bld-specialty { /* 0.1.40 专精建筑 */
|
||||
border-color: #c9a227;
|
||||
box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.25), 0 2px 8px rgba(201, 162, 39, 0.1);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
坊市 / 外交 / 探秘
|
||||
@@ -1910,3 +1914,77 @@ html[data-blade] .battle-lines {
|
||||
font-size: 0.8rem;
|
||||
color: #a8c2da;
|
||||
}
|
||||
|
||||
/* ---------- 0.1.39 飞升叙事视觉强化 ---------- */
|
||||
.modal-ascension .modal-title {
|
||||
color: #8a6d2f;
|
||||
text-shadow: 0 0 12px rgba(201, 162, 39, 0.5), 0 1px 0 rgba(255, 250, 235, 0.8);
|
||||
letter-spacing: 8px;
|
||||
}
|
||||
.modal-ascension .modal-text {
|
||||
border-top-color: rgba(201, 162, 39, 0.4);
|
||||
border-bottom-color: rgba(201, 162, 39, 0.3);
|
||||
color: #3a2f1a;
|
||||
}
|
||||
.modal-ascension .modal-cat {
|
||||
color: #a08540;
|
||||
}
|
||||
.modal-ascension .opt-btn {
|
||||
border-color: #c9a227;
|
||||
background: linear-gradient(180deg, #fbf8ee, #f5edda);
|
||||
}
|
||||
.modal-ascension .opt-btn:hover {
|
||||
border-color: #8a6d2f;
|
||||
background: #fbf5e0;
|
||||
box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.3), inset 0 1px 0 rgba(255, 255, 250, 0.6);
|
||||
}
|
||||
.modal-ascension .opt-btn .hint {
|
||||
color: #8a6d2f;
|
||||
}
|
||||
.modal-ascension::before,
|
||||
.modal-ascension::after {
|
||||
background: linear-gradient(180deg, #3a3018, #241e0d);
|
||||
border-color: #8a6d2f;
|
||||
}
|
||||
|
||||
/* ---------- 0.1.40 天命叙事视觉强化(紫金辉光) ---------- */
|
||||
.modal-mandate .modal-title {
|
||||
color: #6b4c9a;
|
||||
text-shadow: 0 0 14px rgba(107, 76, 154, 0.45), 0 1px 0 rgba(245, 240, 255, 0.7);
|
||||
letter-spacing: 6px;
|
||||
}
|
||||
.modal-mandate .modal-text {
|
||||
border-top-color: rgba(107, 76, 154, 0.4);
|
||||
border-bottom-color: rgba(107, 76, 154, 0.3);
|
||||
color: #2a1e3d;
|
||||
}
|
||||
.modal-mandate .modal-cat {
|
||||
color: #7a5aa8;
|
||||
}
|
||||
.modal-mandate .opt-btn {
|
||||
border-color: #8b6db5;
|
||||
background: linear-gradient(180deg, #f8f5fd, #f0ebf8);
|
||||
}
|
||||
.modal-mandate .opt-btn:hover {
|
||||
border-color: #6b4c9a;
|
||||
background: #f3edfa;
|
||||
box-shadow: 0 0 0 1px rgba(107, 76, 154, 0.3), inset 0 1px 0 rgba(255, 250, 255, 0.6);
|
||||
}
|
||||
.modal-mandate .opt-btn .hint {
|
||||
color: #6b4c9a;
|
||||
}
|
||||
.modal-mandate::before,
|
||||
.modal-mandate::after {
|
||||
background: linear-gradient(180deg, #2a1e3d, #1a1228);
|
||||
border-color: #6b4c9a;
|
||||
}
|
||||
|
||||
/* ---------- 0.1.40 天下大事记标签色系 ---------- */
|
||||
.saga-era { color: #c9a227; border-color: #c9a227; }
|
||||
.saga-calamity { color: #c05a41; border-color: #a33; }
|
||||
.saga-succession { color: #6b8a9a; border-color: #4a6a7a; }
|
||||
.saga-split { color: #8a5a8a; border-color: #6a4a6a; }
|
||||
.saga-newborn { color: #5a8a44; border-color: #4a6a34; }
|
||||
.saga-war { color: #c08a4a; border-color: #a06a3a; }
|
||||
.saga-wonder { color: #7a5aa8; border-color: #6b4c9a; }
|
||||
.saga-annal { color: #8a7a5a; border-color: #6a5a3a; }
|
||||
|
||||
@@ -82,8 +82,8 @@ describe('审计回归:P0 修复固化', () => {
|
||||
})
|
||||
|
||||
it('防御性修补后金钟罩不变(行为等价确认)', () => {
|
||||
// 0.1.37 因果/奇观/飞升逻辑变更,指纹受控重算
|
||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('45b0c17a')
|
||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('f24480f4')
|
||||
// 0.1.41 天命·兑现:NPC 演化种子派生+pushNews 修复+天命 flag 全量接线——全线受控变更重算
|
||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('2b6c46d2')
|
||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('34c76ced')
|
||||
})
|
||||
})
|
||||
|
||||
+8
-8
@@ -7,20 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World'
|
||||
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
|
||||
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
|
||||
*/
|
||||
// 0.1.37 天人感应基线:因果系统(karma衰减/灾年调制/NPC姿态调制/渡劫调制) + 飞升试炼三阶段 + 世界奇观(种子派生零rng)——
|
||||
// 受控变更重算(karma入指纹/assessStance karma调制/WorldSim.tick新增D-0.8/D-0.9段)。
|
||||
// 0.1.41 天命·兑现基线:天命 flag 全量接线 + NPC 演化种子派生(零主 rng 消耗)+
|
||||
// pushNews 种子派生修复确定性破坏——全线受控变更重算。
|
||||
const GOLDEN: Record<string, Record<number, string>> = {
|
||||
'bell-seed-1': { 560: '45b0c17a', 1200: '62e037c1', 2160: 'b85dd7c7' },
|
||||
'bell-seed-2': { 560: '1279a1da', 1200: '3439bd8f', 2160: 'a29efe9f' },
|
||||
'bell-seed-3': { 560: 'f24480f4', 1200: 'e52bed85', 2160: 'abb2a5d2' }
|
||||
'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<string, Record<number, string>> = {
|
||||
'bell-seed-1': { 560: '2837ad55', 1200: 'f6798d97', 2160: 'bc713cca' },
|
||||
'bell-seed-2': { 560: '9c5eddc5', 1200: '6a3c8522', 2160: '0791881d' },
|
||||
'bell-seed-3': { 560: '79e8126e', 1200: '21d9c804', 2160: '95de644d' }
|
||||
'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 = [
|
||||
|
||||
@@ -122,14 +122,18 @@ describe('0.1.27 生灭可达化', () => {
|
||||
})
|
||||
|
||||
it('顶峰衰:power≥700 年首可回落(非贴顶硬化)', () => {
|
||||
// 0.1.40 修正:rng 序列变更后 60 月(5 年首) 8% × 5 ≈ 34% 命中率太低。
|
||||
// 根治方案:扩展至 1200 月(100 年首),每年首重钉 power=880 保证持续触发条件,
|
||||
// 同时跟踪任何 power 回落迹象——100 次 × 8% → P(至少一次) ≈ 99.97%。
|
||||
const w = worldAt('wl-2', 24)
|
||||
const ws = w.state.worldSim as WorldSimState
|
||||
const top = Object.keys(w.state.npcFamilies).sort((a, b) => w.state.npcFamilies[b].power - w.state.npcFamilies[a].power)[0]!
|
||||
w.state.npcFamilies[top].power = 880
|
||||
let fell = false
|
||||
for (let i = 0; i < 60 && !fell; i++) {
|
||||
for (let i = 0; i < 1200 && !fell; i++) {
|
||||
// 每年首前重钉 power,防止回落后又涨回 880 导致 fell 被覆盖
|
||||
if (w.state.month === 12) w.state.npcFamilies[top].power = 880
|
||||
w.advanceMonth()
|
||||
if (w.state.npcFamilies[top]!.power < 880) fell = true
|
||||
if (w.state.npcFamilies[top] && w.state.npcFamilies[top]!.power < 880) fell = true
|
||||
}
|
||||
expect(fell).toBe(true)
|
||||
})
|
||||
|
||||
@@ -162,7 +162,7 @@ describe('GameFacade 门面', () => {
|
||||
const f = new GameFacade(w, 1)
|
||||
const info = f.about()
|
||||
expect(info.title).toBe('仙途家族志')
|
||||
expect(info.version).toContain('0.1.37')
|
||||
expect(info.version).toContain('0.1.38')
|
||||
expect(info.modules).toBeGreaterThanOrEqual(11)
|
||||
expect(info.systems).toBeGreaterThan(0)
|
||||
expect(info.plugins).toBeGreaterThanOrEqual(3)
|
||||
@@ -170,8 +170,8 @@ describe('GameFacade 门面', () => {
|
||||
|
||||
it('默认配置金钟罩不受门面化影响', () => {
|
||||
PACK.reset()
|
||||
// 0.1.37 因果/奇观/飞升逻辑变更,指纹受控重算
|
||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('45b0c17a')
|
||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('f24480f4')
|
||||
// 0.1.41 天命·兑现:NPC 演化种子派生+pushNews 修复+天命 flag 全量接线——全线受控变更重算
|
||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('2b6c46d2')
|
||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('34c76ced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.36')
|
||||
expect(a.version).toContain('0.1.38')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 0.1.38 存档根治测试套件
|
||||
*
|
||||
* 覆盖点:
|
||||
* 1. SqlarkDriver.open 失败后可重试(不永久锁死)
|
||||
* 2. Stash.save 返回 { ok, error } 而非 void
|
||||
* 3. SaveSlot.saveState 回读校验(写入后 SELECT 确认数据落盘)
|
||||
* 4. OPFS 降级逻辑(probeOpfs 返回 false 时走 kv 后端配置)
|
||||
* 5. 诊断接口返回完整报告
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { SaveSlot, type SaveDbDriver } from '../src/renderer/game/storage/slots'
|
||||
import { World } from '../src/renderer/game/engine/runtime/World'
|
||||
import type { GameState } from '../src/renderer/game/types/domain'
|
||||
import {
|
||||
setSqlarkBackend,
|
||||
_resetSlotCache,
|
||||
_resetStorageProbe,
|
||||
getStorageBackendType,
|
||||
probeOpfs,
|
||||
} from '../src/renderer/game/storage/db'
|
||||
import { MeSqlark } from '@metona-team/metona-sqlark'
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Mock Driver:可控失败 + 回读校验
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Row { [k: string]: unknown }
|
||||
|
||||
class ControllableDriver implements SaveDbDriver {
|
||||
tables = new Map<string, Map<string, Row>>()
|
||||
private failNext = false
|
||||
private openCallCount = 0
|
||||
|
||||
setOpenFail(on: boolean): void { this.failNext = on }
|
||||
getOpenCount(): number { return this.openCallCount }
|
||||
|
||||
async open(_name: string): Promise<void> {
|
||||
this.openCallCount++
|
||||
if (this.failNext) {
|
||||
this.failNext = false
|
||||
throw new Error('OPFS_OPEN_ERROR: simulated failure')
|
||||
}
|
||||
}
|
||||
async close(): Promise<void> {}
|
||||
async run(sql: string, params: unknown[] = []): Promise<unknown> {
|
||||
const ins = /insert into (\w+)\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i.exec(sql)
|
||||
if (ins) {
|
||||
const cols = ins[2].split(',').map((c) => c.trim())
|
||||
const row: Row = {}
|
||||
cols.forEach((c, i) => (row[c] = params[i]))
|
||||
const t = this.tables.get(ins[1]) ?? new Map<string, Row>()
|
||||
t.set(String(row['id'] ?? ins[1] + t.size), row)
|
||||
this.tables.set(ins[1], t)
|
||||
}
|
||||
const upd = /update\s+(\w+)\s+set/i.exec(sql)
|
||||
if (upd) {
|
||||
const setCol = /set\s+(\w+)\s*=\s*\?/i.exec(sql)![1]
|
||||
const setVal = params[0]
|
||||
const lit = /\bwhere\s+(\w+)\s*=\s*'([^']+)'/i.exec(sql)
|
||||
const ph = /where\s+(\w+)\s*=\s*\?/i.exec(sql)
|
||||
const col = (lit?.[1] ?? ph?.[1]) as string
|
||||
const val = (lit?.[2] as string | undefined) ?? String(params[1])
|
||||
const t = this.tables.get(upd[1])
|
||||
if (t) {
|
||||
for (const [k, row] of t.entries()) {
|
||||
if (String(row[col]) === val) t.set(k, { ...row, [setCol]: setVal })
|
||||
}
|
||||
}
|
||||
}
|
||||
const del = /delete from (\w+)\s+where\s+(\w+)\s*=\s*\?/i.exec(sql)
|
||||
if (del) {
|
||||
const t = this.tables.get(del[1])
|
||||
if (t) {
|
||||
for (const [k, row] of t.entries()) {
|
||||
if (String(row[del[2]]) === String(params[0])) t.delete(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
async all<T>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
const lower = sql.toLowerCase()
|
||||
const table = /from (\w+)/i.exec(sql)?.[1]
|
||||
if (!table) return []
|
||||
let rows = [...(this.tables.get(table)?.values() ?? [])].map((r) => ({ ...r }))
|
||||
if (/\bwhere\b/.test(lower)) {
|
||||
const m = /where\s+(\w+)\s*=\s*\?/i.exec(sql)
|
||||
if (m && params[0] !== undefined) {
|
||||
rows = rows.filter((r) => String(r[m[1]]) === String(params[0]))
|
||||
}
|
||||
}
|
||||
if (/\border by/i.test(lower)) {
|
||||
rows = rows.sort((a, b) => Number(b['year']) - Number(a['year']) || Number(b['month']) - Number(a['month']))
|
||||
}
|
||||
if (/select data/i.test(lower)) rows = rows.map((r) => ({ data: String(r['data']) }))
|
||||
if (/select value/i.test(lower)) rows = rows.map((r) => ({ value: String(r['value']) }))
|
||||
return rows as T[]
|
||||
}
|
||||
async exec(sql: string): Promise<unknown> {
|
||||
const create = /create table (?:if not exists )?(\w+)/i.exec(sql)
|
||||
if (create && !this.tables.has(create[1])) this.tables.set(create[1], new Map())
|
||||
const del = /delete from (\w+)/i.exec(sql)
|
||||
if (del) this.tables.set(del[1], new Map())
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Mock Backend for metona-sqlark (hybrid memory mode, like real test)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function memoryBackend(): void {
|
||||
setSqlarkBackend({
|
||||
create: async (cfg: { name: string }) => {
|
||||
const db = new MeSqlark({
|
||||
name: `it-${cfg.name}-${Math.random().toString(36).slice(2)}`,
|
||||
mode: 'hybrid',
|
||||
diskEngine: 'memory',
|
||||
aria: {}
|
||||
})
|
||||
await db.init()
|
||||
return { query: (sql: string, params?: unknown[]) => db.query(sql, params) }
|
||||
}
|
||||
} as never)
|
||||
}
|
||||
|
||||
function saveClone(w: World): GameState {
|
||||
return JSON.parse(JSON.stringify(w.state)) as GameState
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('0.1.38 存档根治 · P0-C: SqlarkDriver.open 失败后可重试', () => {
|
||||
beforeEach(() => {
|
||||
_resetSlotCache()
|
||||
_resetStorageProbe()
|
||||
})
|
||||
|
||||
it('首次 open 失败后,第二次 open 不被永久锁死', async () => {
|
||||
// 用 ControllableDriver 模拟首次失败
|
||||
const driver = new ControllableDriver()
|
||||
driver.setOpenFail(true)
|
||||
const slot = new SaveSlot(1, driver)
|
||||
|
||||
// 第一次 open 应该失败
|
||||
await expect(slot.open()).rejects.toThrow('simulated failure')
|
||||
expect(driver.getOpenCount()).toBe(1)
|
||||
|
||||
// 第二次 open 应该成功(关键:旧版会返回已 rejected 的 promise,永远锁死)
|
||||
await slot.open()
|
||||
expect(driver.getOpenCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('连续多次失败后仍可恢复', async () => {
|
||||
const driver = new ControllableDriver()
|
||||
driver.setOpenFail(true)
|
||||
const slot = new SaveSlot(2, driver)
|
||||
|
||||
// 第一次失败
|
||||
await expect(slot.open()).rejects.toThrow()
|
||||
// 第二次也失败(手动再设)
|
||||
driver.setOpenFail(true)
|
||||
await expect(slot.open()).rejects.toThrow()
|
||||
// 第三次成功
|
||||
await slot.open()
|
||||
expect(driver.getOpenCount()).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('0.1.38 存档根治 · P1-F: SaveSlot.saveState 回读校验', () => {
|
||||
it('saveState 后数据确实写入并可回读', async () => {
|
||||
const driver = new ControllableDriver()
|
||||
const slot = new SaveSlot(3, driver)
|
||||
const w = World.create({ seed: 'verify-1', surname: '赵', familyName: '赵家', motto: 'm', difficulty: 'normal' })
|
||||
|
||||
const id = await slot.saveState(saveClone(w), 'test')
|
||||
expect(id).toBeTruthy()
|
||||
|
||||
// 回读校验:loadState 应返回有效数据
|
||||
const loaded = await slot.loadState()
|
||||
expect(loaded).not.toBeNull()
|
||||
expect(loaded!.family.surname).toBe('赵')
|
||||
expect(loaded!.seed).toBe('verify-1')
|
||||
})
|
||||
|
||||
it('saveState 数据完整性(关键字段 family/members/seed)', async () => {
|
||||
const driver = new ControllableDriver()
|
||||
const slot = new SaveSlot(4, driver)
|
||||
const w = World.create({ seed: 'verify-2', surname: '钱', familyName: '钱家', motto: 'm', difficulty: 'normal' })
|
||||
w.advanceMonth()
|
||||
w.advanceMonth()
|
||||
|
||||
await slot.saveState(saveClone(w), 'advance')
|
||||
const loaded = await slot.loadState()
|
||||
expect(loaded).not.toBeNull()
|
||||
expect(loaded!.family).toBeDefined()
|
||||
expect(loaded!.members).toBeDefined()
|
||||
expect(typeof loaded!.seed).toBe('string')
|
||||
expect(loaded!.year).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('0.1.38 存档根治 · P0-A: OPFS 降级配置', () => {
|
||||
beforeEach(() => {
|
||||
_resetStorageProbe()
|
||||
_resetSlotCache()
|
||||
memoryBackend()
|
||||
})
|
||||
|
||||
it('probeOpfs 在无 navigator.storage 环境返回 false', async () => {
|
||||
// node 环境无 navigator.storage
|
||||
const result = await probeOpfs()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('getStorageBackendType 在 setSqlarkBackend 后为 mock', () => {
|
||||
// memoryBackend() 已在 beforeEach 中调用了 setSqlarkBackend
|
||||
expect(getStorageBackendType()).toBe('mock')
|
||||
})
|
||||
})
|
||||
|
||||
describe('0.1.38 存档根治 · 存储链路回归(hybrid memory backend)', () => {
|
||||
beforeEach(() => {
|
||||
_resetSlotCache()
|
||||
_resetStorageProbe()
|
||||
memoryBackend()
|
||||
})
|
||||
|
||||
it('save→list→load 全链路 6 种子不崩', async () => {
|
||||
for (let k = 0; k < 6; k++) {
|
||||
const slot = (k % 4) + 1
|
||||
const w = World.create({ seed: `regr-${k}`, surname: '孙', familyName: '孙家', motto: 'm', difficulty: 'normal' })
|
||||
for (let i = 0; i < 8; i++) w.advanceMonth()
|
||||
|
||||
const { getSaveSlot } = await import('../src/renderer/game/storage/db')
|
||||
const file = await getSaveSlot(slot)
|
||||
await file.open()
|
||||
await file.saveState(saveClone(w), 'auto')
|
||||
|
||||
const loaded = await file.loadState()
|
||||
expect(loaded).toBeTruthy()
|
||||
expect(loaded!.year).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('多次 save 后快照不超过 12 份', async () => {
|
||||
const { getSaveSlot } = await import('../src/renderer/game/storage/db')
|
||||
const file = await getSaveSlot(4)
|
||||
await file.open()
|
||||
const w = World.create({ seed: 'trim-1', surname: '李', familyName: '李家', motto: 'm', difficulty: 'normal' })
|
||||
for (let i = 0; i < 20; i++) {
|
||||
w.state.year = i + 1
|
||||
await file.saveState(saveClone(w), 'auto')
|
||||
}
|
||||
const snaps = await file.listSnapshots()
|
||||
expect(snaps.length).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
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.41')
|
||||
})
|
||||
|
||||
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.41')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 0.1.39 P3:worldsim 能力卡关停根治测试
|
||||
* 验证关停后世界自主演进完全冻结——潮汐、NPC 动力学、时代弧、快讯不再推进。
|
||||
* 注意:玩家行为(修炼耗草→tradeSettle 回写池、买卖交易回写池)不受 worldsim
|
||||
* 能力卡关停影响——这是玩家与世界的交互而非世界自主演进。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/runtime/World'
|
||||
import { WorldSim } from '../src/renderer/game/engine/sim/WorldSim'
|
||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||
import type { WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data'
|
||||
|
||||
function baseWorld(seed: string): World {
|
||||
const w = World.create({ seed, surname: '封', familyName: '封家', motto: 'm', difficulty: 'normal' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
return w
|
||||
}
|
||||
|
||||
describe('0.1.39 worldsim 关停根治', () => {
|
||||
it('sysEnabled("worldsim") 默认开 → toggleSystem 后关', () => {
|
||||
const w = baseWorld('ws-toggle-1')
|
||||
expect(w.sysEnabled('worldsim')).toBe(true)
|
||||
const after = w.toggleSystem('worldsim')
|
||||
expect(after).toBe(false)
|
||||
expect(w.sysEnabled('worldsim')).toBe(false)
|
||||
})
|
||||
|
||||
it('关停后 tick() 直接 return(入口双闸守卫)', () => {
|
||||
const w = baseWorld('ws-freeze-1')
|
||||
// 推进 12 月让 worldSim 有初始状态
|
||||
for (let i = 0; i < 12; i++) w.advanceMonth()
|
||||
const ws = w.state.worldSim as WorldSimState
|
||||
expect(ws).toBeDefined()
|
||||
|
||||
// 快照关键字段
|
||||
const tideBefore = ws.tide
|
||||
const tideTicksBefore = ws.tideTicks
|
||||
const poolBefore = { ...ws.marketPool }
|
||||
|
||||
// 关停 worldsim
|
||||
w.toggleSystem('worldsim')
|
||||
expect(w.sysEnabled('worldsim')).toBe(false)
|
||||
|
||||
// 直接调 tick() 应被入口守卫拦截
|
||||
new WorldSim(w).tick()
|
||||
|
||||
// 状态完全冻结(tick 内所有逻辑未执行)
|
||||
expect(ws.tide).toBe(tideBefore)
|
||||
expect(ws.tideTicks).toBe(tideTicksBefore)
|
||||
expect(ws.marketPool).toEqual(poolBefore)
|
||||
})
|
||||
|
||||
it('关停后 advanceMonth 不再自主演进世界(潮汐/NPC/时代弧/快讯冻结)', () => {
|
||||
const w = baseWorld('ws-freeze-2')
|
||||
// 推进 24 月让世界有实质演化
|
||||
for (let i = 0; i < 24; i++) w.advanceMonth()
|
||||
const ws = w.state.worldSim as WorldSimState
|
||||
expect(ws).toBeDefined()
|
||||
|
||||
// 快照世界自主演进指标
|
||||
const tideTicksBefore = ws.tideTicks
|
||||
const npcDynBefore = JSON.stringify(ws.npcDyn)
|
||||
const secretQiBefore = JSON.stringify(ws.secretQi)
|
||||
const eraBefore = ws.era
|
||||
const newsLenBefore = ws.newsFeed.length
|
||||
const calamityBefore = ws.calamity
|
||||
const warFatigueBefore = ws.warFatigue
|
||||
|
||||
// 关停
|
||||
w.toggleSystem('worldsim')
|
||||
|
||||
// 推进 36 月——世界自主演进应纹丝不动
|
||||
for (let i = 0; i < 36; i++) w.advanceMonth()
|
||||
|
||||
// tick 内逻辑全部冻结
|
||||
expect(ws.tideTicks).toBe(tideTicksBefore) // 潮汐不推进
|
||||
expect(JSON.stringify(ws.npcDyn)).toBe(npcDynBefore) // NPC 动力学不变
|
||||
expect(JSON.stringify(ws.secretQi)).toBe(secretQiBefore) // 秘境灵气不自恢复
|
||||
expect(ws.era).toBe(eraBefore) // 时代弧不变
|
||||
expect(ws.newsFeed.length).toBe(newsLenBefore) // 无新快讯
|
||||
expect(ws.calamity).toBe(calamityBefore) // 灾年状态不变
|
||||
expect(ws.warFatigue).toBe(warFatigueBefore) // 战争疲劳不变
|
||||
})
|
||||
|
||||
it('重新开启后世界恢复演进(可逆性)', () => {
|
||||
const w = baseWorld('ws-resume-1')
|
||||
for (let i = 0; i < 12; i++) w.advanceMonth()
|
||||
const ws = w.state.worldSim as WorldSimState
|
||||
|
||||
// 关停 12 月
|
||||
w.toggleSystem('worldsim')
|
||||
const tideFrozen = ws.tide
|
||||
const ticksFrozen = ws.tideTicks
|
||||
for (let i = 0; i < 12; i++) w.advanceMonth()
|
||||
expect(ws.tide).toBe(tideFrozen)
|
||||
expect(ws.tideTicks).toBe(ticksFrozen)
|
||||
|
||||
// 重新开启
|
||||
w.toggleSystem('worldsim')
|
||||
expect(w.sysEnabled('worldsim')).toBe(true)
|
||||
for (let i = 0; i < 12; i++) w.advanceMonth()
|
||||
|
||||
// 世界恢复演进——tideTicks 必须增长
|
||||
expect(ws.tideTicks).toBeGreaterThan(ticksFrozen)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 0.1.39 P4:purgeNpc 清理 worldGen.npcs/relations 僵尸定义测试
|
||||
* 验证 NPC 灭亡后 worldGen.npcs 和 worldGen.relations 中的对应条目被同步移除。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/runtime/World'
|
||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||
import { stateFingerprint, longRun } from './fingerprint.helper'
|
||||
|
||||
function baseWorld(seed: string): World {
|
||||
const w = World.create({ seed, surname: '封', familyName: '封家', motto: 'm', difficulty: 'normal' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
return w
|
||||
}
|
||||
|
||||
describe('0.1.39 purgeNpc 存档体积精简', () => {
|
||||
it('开局 worldGen.npcs 非空且与 npcFamilies 一一对应', () => {
|
||||
const w = baseWorld('purge-1')
|
||||
const genNpcs = w.state.worldGen?.npcs ?? []
|
||||
const famIds = Object.keys(w.state.npcFamilies)
|
||||
expect(genNpcs.length).toBe(famIds.length)
|
||||
for (const n of genNpcs) {
|
||||
expect(famIds).toContain(n.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('长跑后灭亡 NPC 不残留于 worldGen.npcs', () => {
|
||||
// 用长跑让 NPC 有机会灭亡(180 年 = 2160 月)
|
||||
const w = longRun('purge-2', 2160)
|
||||
if (w.state.gameOver) return // 族灭则跳过
|
||||
|
||||
const genNpcs = w.state.worldGen?.npcs ?? []
|
||||
const famIds = new Set(Object.keys(w.state.npcFamilies))
|
||||
|
||||
// 所有 worldGen.npcs 中的 id 必须在 npcFamilies 中仍然存活
|
||||
for (const n of genNpcs) {
|
||||
expect(famIds.has(n.id)).toBe(true)
|
||||
}
|
||||
// 反过来,所有存活 NPC 必须在 worldGen.npcs 中有定义
|
||||
for (const fid of famIds) {
|
||||
expect(genNpcs.some((n) => n.id === fid)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('长跑后灭亡 NPC 不残留于 worldGen.relations', () => {
|
||||
const w = longRun('purge-3', 2160)
|
||||
if (w.state.gameOver) return
|
||||
|
||||
const famIds = new Set(Object.keys(w.state.npcFamilies))
|
||||
const rels = w.state.worldGen?.relations ?? {}
|
||||
|
||||
// relations 的 key 必须都是存活 NPC
|
||||
for (const rid of Object.keys(rels)) {
|
||||
expect(famIds.has(rid)).toBe(true)
|
||||
// relations 的 value 中的 key 也必须都是存活 NPC
|
||||
for (const oid of Object.keys(rels[rid] ?? {})) {
|
||||
expect(famIds.has(oid)).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('确定性:同 seed 同月数 worldGen.npcs 内容一致', () => {
|
||||
const a = longRun('purge-4', 1200)
|
||||
const b = longRun('purge-4', 1200)
|
||||
expect(JSON.stringify(a.state.worldGen?.npcs)).toBe(JSON.stringify(b.state.worldGen?.npcs))
|
||||
expect(stateFingerprint(a.state)).toBe(stateFingerprint(b.state))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user