Compare commits
7
Commits
fb23afa322
...
dcd2345192
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcd2345192 | ||
|
|
07c0a218eb | ||
|
|
7ba64750bc | ||
|
|
818c6025f4 | ||
|
|
fdc9d750f2 | ||
|
|
e2070c8fcb | ||
|
|
62da87a4e4 |
@@ -1,7 +1,7 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。
|
仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。
|
||||||
当前版本 **0.1.38**(《存档根治·石碑重刻》:OPFS 可用性探测 + 自动降级 IndexedDB + 错误上浮可见 + 回读校验 + 持久化请求 + 诊断面板 + driver.open 永久锁死修复 + exportSave open 补全 + 测试 N)。
|
当前版本 **0.1.39**(《淬体·洗髓》:missionIds 只增不减根治 + chronicle 智能裁剪保留里程碑 + worldsim tick 入口双闸守卫 + purgeNpc 清理 worldGen.npcs/relations 僵尸定义 + MemberCard memo 化脱离 store 订阅 + 飞升叙事视觉强化)。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
@@ -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`(无独立钩子)。
|
- **插件架构**(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` 落档保证读档永稳)。
|
- **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 赋值。
|
- **门面**:`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.39 基线**(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 三档受控漂移,其余零漂)。指纹 `tests/fingerprint.helper.ts` 含全世界轴(era/stance/prosperity/relationsWithOthers/calamity/pendingEvent/eventQueue/worldAnnals/**worldGen 摘要/sagaAnnals/distress/declineYears 汇总**——0.1.27 生灭/世鉴路径真实锁定)。**有意变更时序/数值时**:指纹一并重算并在注释注明原因。
|
||||||
|
|
||||||
## 世界自演进模型(0.1.14 sim/ 层,改数值勿动流程)
|
## 世界自演进模型(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` 核对改动面与预期一致。
|
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` 留痕。
|
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`(世界循环参数唯一权威),调平衡不改引擎流程。
|
- 平衡数值集中在 `game/data/` 与 `engine/sim/worldsim-data.ts`(世界循环参数唯一权威),调平衡不改引擎流程。
|
||||||
- 发布产物 `release/`、`out/` 均已 gitignore,勿入库。
|
- 发布产物 `release/`、`out/` 均已 gitignore,勿入库。
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
npm install # 依赖(含 git.metona.cn 私有 registry,token 在用户级 ~/.npmrc)
|
npm install # 依赖(含 git.metona.cn 私有 registry,token 在用户级 ~/.npmrc)
|
||||||
npm install-scripts approve electron esbuild # npm12 首装时必须(Electron 二进制)
|
npm install-scripts approve electron esbuild # npm12 首装时必须(Electron 二进制)
|
||||||
npm run dev # 开发模式(热更新)
|
npm run dev # 开发模式(热更新)
|
||||||
npm test # 引擎测试(89 套件 / 5230 用例,纯 Node 无 GUI)
|
npm test # 引擎测试(108 套件 / 7058 用例,纯 Node 无 GUI)
|
||||||
npm run build # 产物到 out/
|
npm run build # 产物到 out/
|
||||||
npm run package # 打包(Windows 需在 Windows 侧执行)
|
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.39 基线**。有意变更时序需受控更新并注明。
|
||||||
- **事件闸**:pending 唯一入口 `fire(w,id,priority)`——高优(raid/渡劫/大比)可顶替被占者,被顶替者入队下月兑现;`applyEventChoice` 前置校验防陈旧 Modal 二次结算。
|
- **事件闸**:pending 唯一入口 `fire(w,id,priority)`——高优(raid/渡劫/大比)可顶替被占者,被顶替者入队下月兑现;`applyEventChoice` 前置校验防陈旧 Modal 二次结算。
|
||||||
- **插件化**:万物皆是插件(系统/数据包/事件池),三核心插件 protected 常驻;示例见 tests/helpers/examplePlugin.ts。
|
- **插件化**:万物皆是插件(系统/数据包/事件池),三核心插件 protected 常驻;示例见 tests/helpers/examplePlugin.ts。
|
||||||
- **可回放**:种子随机(sfc32,引擎域零 Math.random)+ 快照回放保真(restore 重建内核+回置 rng)。
|
- **可回放**:种子随机(sfc32,引擎域零 Math.random)+ 快照回放保真(restore 重建内核+回置 rng)。
|
||||||
- **审计四轮**:0.1.15(引擎 P0 歼灭)/ 0.1.20(事件闸·市场黑洞·双金钟罩)/ 0.1.28(稳石:词库回滚·新贵落档·校验接生产·bundle 闭环·指纹补盲)/ **0.1.29(续命:续代断链·敌强度相对化·权重防洪)**。
|
- **审计五轮**:0.1.15(引擎 P0 歼灭)/ 0.1.20(事件闸·市场黑洞·双金钟罩)/ 0.1.28(稳石:词库回滚·新贵落档·校验接生产·bundle 闭环·指纹补盲)/ 0.1.29(续命:续代断链·敌强度相对化·权重防洪)/ **0.1.35(荡涤:17 缺陷歼灭·兼容层铲除·validateLoadedState 替代迁移)**。
|
||||||
- **测试规模**:**89 套件 / 5230 例**(41 个矩阵域:随机序列/世界生成/市场实体化/事件闸/数据表全量/插件 MOD 组合/省级矩阵等——语义真实,无刷水)。
|
- **测试规模**:**108 套件 / 7058 例**(矩阵域:随机序列/世界生成/市场实体化/事件闸/数据表全量/插件 MOD 组合/省级矩阵等——语义真实,无刷水)。
|
||||||
|
|
||||||
## 打包产物
|
## 打包产物
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
|
|||||||
- 图标资源 resources/icon.png|ico —— electron-builder 自动使用
|
- 图标资源 resources/icon.png|ico —— electron-builder 自动使用
|
||||||
- 注意:WSL/Linux 交叉打 win 包可行但未签名,正规发布请用 Windows 侧重跑
|
- 注意:WSL/Linux 交叉打 win 包可行但未签名,正规发布请用 Windows 侧重跑
|
||||||
|
|
||||||
## 版本沿革(0.1.0 → 0.1.21)
|
## 版本沿革(0.1.0 → 0.1.39)
|
||||||
|
|
||||||
| 版本 | 主题 |
|
| 版本 | 主题 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -82,5 +82,11 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
|
|||||||
| 0.1.31 | 万物皆可 MOD(内容面七口:物品/功法/建筑(表达式 DSL)/职事/志向/阵型/性格 + kind 分类 + ERA_DURA/REGION_TIDE 聚合 + 测试 4006) |
|
| 0.1.31 | 万物皆可 MOD(内容面七口:物品/功法/建筑(表达式 DSL)/职事/志向/阵型/性格 + kind 分类 + ERA_DURA/REGION_TIDE 聚合 + 测试 4006) |
|
||||||
| 0.1.32 | 万宝琳琅(物品六类化 + 新材料七种入市 + 符箓武备/灵酿回气 + 生产泛化 + 测试 5009) |
|
| 0.1.32 | 万宝琳琅(物品六类化 + 新材料七种入市 + 符箓武备/灵酿回气 + 生产泛化 + 测试 5009) |
|
||||||
| 0.1.33 | 闭环·流光(物品生态闭环(符箓生效/僵尸物品归位/灾年入产)+ 引擎止血 15 项 + UI 动效打磨 + 测试 5230) |
|
| 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) |
|
||||||
|
|
||||||
详见:`docs/PROJECT_MEMORY.md`(设计哲学与每个迭代的"为什么")。
|
详见:`docs/PROJECT_MEMORY.md`(设计哲学与每个迭代的"为什么")。
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# MOD 开发教程 · Chronicle of the Immortal Clan
|
# MOD 开发教程 · Chronicle of the Immortal Clan
|
||||||
|
|
||||||
> 版本 **0.1.36·《CSP 根因》** · MOD_SCHEMA_VERSION = 1 · 全口纯 JSON(零脚本)
|
> 版本 **0.1.39·《淬体·洗髓》** · MOD_SCHEMA_VERSION = 1 · 全口纯 JSON(零脚本)
|
||||||
> **本教程即全部所需**:你不需要读任何源码或类型文件——所有 MOD 能用的字段、取值、默认值、坑点、示例都写在下面。
|
> **本教程即全部所需**:你不需要读任何源码或类型文件——所有 MOD 能用的字段、取值、默认值、坑点、示例都写在下面。
|
||||||
> 照例动手即可:最快 5 分钟做出你的第一个 MOD。
|
> 照例动手即可:最快 5 分钟做出你的第一个 MOD。
|
||||||
|
|
||||||
@@ -989,4 +989,4 @@ MOD 可以注册**自定义奇观类型**,加入年首种子派生选取池:
|
|||||||
> 0.1.35 涤荡注:无存档迁移(游戏未发布——旧档随版本演进失效属预期);`patch` 层按插件归属回滚;validate 覆盖 §12 全部规则。
|
> 0.1.35 涤荡注:无存档迁移(游戏未发布——旧档随版本演进失效属预期);`patch` 层按插件归属回滚;validate 覆盖 §12 全部规则。
|
||||||
> 0.1.37 因果奇观注:奇观零 rng(种子派生选取池含 MOD 扩展);`eff.karma` 天然支持;`wonderChance`/`karmaDecayRate`/`karmaHighThreshold`/`karmaLowThreshold` 可经 worldNum 覆写;飞升事件 `ev-ascension-*` 前缀保护防劫持。
|
> 0.1.37 因果奇观注:奇观零 rng(种子派生选取池含 MOD 扩展);`eff.karma` 天然支持;`wonderChance`/`karmaDecayRate`/`karmaHighThreshold`/`karmaLowThreshold` 可经 worldNum 覆写;飞升事件 `ev-ascension-*` 前缀保护防劫持。
|
||||||
|
|
||||||
<sub>⚔️ 本文档随游戏版本更新(0.1.37)。字段若有新增,此处先行。</sub>
|
<sub>⚔️ 本文档随游戏版本更新(0.1.39)。字段若有新增,此处先行。</sub>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
| 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.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.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.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** |
|
||||||
|
|
||||||
## 三、关键架构决策(及为什么)
|
## 三、关键架构决策(及为什么)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronicle-of-the-immortal-clan",
|
"name": "chronicle-of-the-immortal-clan",
|
||||||
"productName": "仙途家族志",
|
"productName": "仙途家族志",
|
||||||
"version": "0.1.38",
|
"version": "0.1.39",
|
||||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "MetonaTeam",
|
"author": "MetonaTeam",
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ export function recallAll(w: World, missionId: string): void {
|
|||||||
w.log('info', '探索队伍奉命返家。')
|
w.log('info', '探索队伍奉命返家。')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 队伍成员状态归位:伤者保持养伤,其余恢复闲居 */
|
/** 队伍成员状态归位:伤者保持养伤,其余恢复闲居。
|
||||||
|
* 0.1.39 根治:完成后从 missionIds 移除——missionIds 只跟踪活跃任务 id,
|
||||||
|
* 避免长跑 180 年后死引用堆积百条。state.missions 全量保留用于史书回看。 */
|
||||||
function releaseSquad(w: World, m: MissionState): void {
|
function releaseSquad(w: World, m: MissionState): void {
|
||||||
for (const id of m.memberIds) {
|
for (const id of m.memberIds) {
|
||||||
const c = w.memberById(id)
|
const c = w.memberById(id)
|
||||||
@@ -199,6 +201,9 @@ function releaseSquad(w: World, m: MissionState): void {
|
|||||||
if (c.state === 'wounded') continue
|
if (c.state === 'wounded') continue
|
||||||
c.state = 'idle'
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 月度巡查:亡者除名、重伤者离队疗伤;人数不足则提前收队 */
|
/** 月度巡查:亡者除名、重伤者离队疗伤;人数不足则提前收队 */
|
||||||
|
|||||||
@@ -334,6 +334,13 @@ export class World {
|
|||||||
this.out.forEach((o) => o.onLog(kind, text))
|
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 {
|
chronicle(cat: ChronicleEntry['category'], text: string, memberId?: Id, important = false): void {
|
||||||
const entry: ChronicleEntry = {
|
const entry: ChronicleEntry = {
|
||||||
id: this.seq(),
|
id: this.seq(),
|
||||||
@@ -345,7 +352,16 @@ export class World {
|
|||||||
important
|
important
|
||||||
}
|
}
|
||||||
this.state.chronicle.push(entry)
|
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))
|
this.out.forEach((o) => o.onChronicle(entry, important))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export class WorldSim {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tick(): void {
|
tick(): void {
|
||||||
|
// 0.1.39 根治双闸:clocks.ts 外部守卫 + 此处入口二次守卫。
|
||||||
|
// 与 tournament/apprentice 等系统卡同模式(sysEnabled ?? true 兜底,
|
||||||
|
// 注册后默认开、关停后 false)。防止未来新增直接调 tick() 的路径绕过守卫。
|
||||||
|
if (!this.w.sysEnabled('worldsim')) return
|
||||||
const s = this.s()
|
const s = this.s()
|
||||||
const rng = this.w.rng
|
const rng = this.w.rng
|
||||||
// ---- C. 灵气潮汐(大周期) ----
|
// ---- C. 灵气潮汐(大周期) ----
|
||||||
@@ -739,7 +743,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 {
|
function purgeNpc(w: World, s: WorldSimState, id: string): void {
|
||||||
delete w.state.npcFamilies[id]
|
delete w.state.npcFamilies[id]
|
||||||
delete s.npcDyn[id]
|
delete s.npcDyn[id]
|
||||||
@@ -751,6 +758,18 @@ function purgeNpc(w: World, s: WorldSimState, id: string): void {
|
|||||||
w.state.eventQueue = (w.state.eventQueue ?? []).filter((e) => !e.startsWith('ev-raid-') || !e.endsWith(id))
|
w.state.eventQueue = (w.state.eventQueue ?? []).filter((e) => !e.startsWith('ev-raid-') || !e.endsWith(id))
|
||||||
if (s.distress?.id === id) s.distress = undefined
|
if (s.distress?.id === id) s.distress = undefined
|
||||||
unregisterNpcDef(id)
|
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}`)
|
w.emitFx('ripple', `purge:${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const RES_NAME: Record<string, string> = {
|
|||||||
|
|
||||||
function describeEff(eff: Record<string, never> | { [k: string]: unknown }): string {
|
function describeEff(eff: Record<string, never> | { [k: string]: unknown }): string {
|
||||||
const parts: 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) {
|
if (e.res) {
|
||||||
for (const [k, v] of Object.entries(e.res)) {
|
for (const [k, v] of Object.entries(e.res)) {
|
||||||
const name = RES_NAME[k] ?? k
|
const name = RES_NAME[k] ?? k
|
||||||
@@ -41,6 +41,12 @@ function describeEff(eff: Record<string, never> | { [k: string]: unknown }): str
|
|||||||
parts.push(e.trib.mode === 'rash' ? '硬渡天劫' : e.trib.mode === 'guard' ? '护法渡劫' : '渡劫延一年')
|
parts.push(e.trib.mode === 'rash' ? '硬渡天劫' : e.trib.mode === 'guard' ? '护法渡劫' : '渡劫延一年')
|
||||||
}
|
}
|
||||||
if (e.feisheng) parts.push(e.feisheng.stay ? '留世不飞升' : '飞升离世')
|
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(' · ')
|
return parts.join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +61,9 @@ export function EventModal() {
|
|||||||
const [formation, setFormation] = useState<FormationId>('vanguard')
|
const [formation, setFormation] = useState<FormationId>('vanguard')
|
||||||
if (!pendingEventId || !pendingEventDef || !world) return null
|
if (!pendingEventId || !pendingEventDef || !world) return null
|
||||||
|
|
||||||
|
// 0.1.39:飞升事件视觉强化——金色辉光标题 + 仙韵边框 + 金边选项
|
||||||
|
const isAscension = pendingEventId.startsWith('ev-ascension-')
|
||||||
|
|
||||||
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
|
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
|
||||||
const squadPool: Character[] =
|
const squadPool: Character[] =
|
||||||
raidIdx >= 0
|
raidIdx >= 0
|
||||||
@@ -103,6 +112,7 @@ export function EventModal() {
|
|||||||
allowOuter={false}
|
allowOuter={false}
|
||||||
onClose={dorm}
|
onClose={dorm}
|
||||||
closeLabel="稍后再议"
|
closeLabel="稍后再议"
|
||||||
|
className={isAscension ? 'modal-ascension' : undefined}
|
||||||
footer={
|
footer={
|
||||||
squadPicking ? (
|
squadPicking ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useGameStore } from '../store'
|
import { memo } from 'react'
|
||||||
import { Character } from '../../game/types/domain'
|
import { Character } from '../../game/types/domain'
|
||||||
import { describeRealm } from '../../game/data/realms'
|
import { describeRealm } from '../../game/data/realms'
|
||||||
import { describeRoots } from '../../game/data/elements'
|
import { describeRoots } from '../../game/data/elements'
|
||||||
@@ -13,18 +13,34 @@ const STATE_LABEL: Record<string, { label: string; cls: string }> = {
|
|||||||
dead: { label: '已殁', cls: 'bad' }
|
dead: { label: '已殁', cls: 'bad' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MemberCard({ c }: { c: Character }) {
|
const C_STATE: Record<string, string> = {
|
||||||
const world = useGameStore((s) => s.world)
|
idle: '',
|
||||||
const setSelected = useGameStore((s) => s.setSelected)
|
meditation: 'good',
|
||||||
if (!world) return null
|
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 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'
|
const blocked = !dead && c.realmProgress >= 100 && c.realm.major !== 'spirit'
|
||||||
return (
|
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="mc-head">
|
||||||
<div className="m-avatar">{world.state.family.surname}</div>
|
<div className="m-avatar">{surname}</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div className="member-name">
|
<div className="member-name">
|
||||||
<span>{c.name}</span>
|
<span>{c.name}</span>
|
||||||
@@ -34,7 +50,7 @@ export function MemberCard({ c }: { c: Character }) {
|
|||||||
<div className="member-realm">
|
<div className="member-realm">
|
||||||
{dead
|
{dead
|
||||||
? `殁于${c.deathYear}年 · ${c.deathCause ?? '寿终'}`
|
? `殁于${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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,12 +71,4 @@ export function MemberCard({ c }: { c: Character }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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'
|
footerAlign?: 'right' | 'center' | 'space'
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
closeLabel?: string
|
closeLabel?: string
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,7 +30,8 @@ export function ModalShell({
|
|||||||
footer,
|
footer,
|
||||||
footerAlign = 'right',
|
footerAlign = 'right',
|
||||||
children,
|
children,
|
||||||
closeLabel = '关闭'
|
closeLabel = '关闭',
|
||||||
|
className
|
||||||
}: ModalShellProps) {
|
}: ModalShellProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -46,7 +48,7 @@ export function ModalShell({
|
|||||||
// 顶部菜单/主域均在其下(0.1.22 修复:弹框出现在宗族/谱系菜单下方的 z-index 越界)。
|
// 顶部菜单/主域均在其下(0.1.22 修复:弹框出现在宗族/谱系菜单下方的 z-index 越界)。
|
||||||
const shell = (
|
const shell = (
|
||||||
<div className="modal-outer" onClick={allowOuter ? onClose : undefined}>
|
<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 className="modal-close" onClick={onClose} title={closeLabel} aria-label={closeLabel}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useGameStore } from '../store'
|
import { useGameStore } from '../store'
|
||||||
|
import { useMemo } from 'react'
|
||||||
import { MemberCard } from '../components/MemberCard'
|
import { MemberCard } from '../components/MemberCard'
|
||||||
import { MemberModal } from '../components/MemberModal'
|
import { MemberModal } from '../components/MemberModal'
|
||||||
import { Character } from '../../game/types/domain'
|
import { Character } from '../../game/types/domain'
|
||||||
@@ -11,25 +12,29 @@ export default function FamilyPanel() {
|
|||||||
void revision
|
void revision
|
||||||
if (!world) return null
|
if (!world) return null
|
||||||
const s = world.state
|
const s = world.state
|
||||||
|
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 alive = Object.values(s.members).filter((c) => c.alive)
|
||||||
const dead = 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 sorted = [...alive].sort((a, b) => {
|
||||||
const aHead = a.id === s.family.headId ? -1 : 0
|
const aHead = a.id === headId ? -1 : 0
|
||||||
const bHead = b.id === s.family.headId ? -1 : 0
|
const bHead = b.id === headId ? -1 : 0
|
||||||
return aHead - bHead || world.ageOf(b) - world.ageOf(a)
|
return aHead - bHead || world.ageOf(b) - world.ageOf(a)
|
||||||
})
|
})
|
||||||
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined
|
const marriageReady = alive.filter(
|
||||||
|
|
||||||
const marriageReady = (() => {
|
|
||||||
if (!world) return []
|
|
||||||
return alive.filter(
|
|
||||||
(c) =>
|
(c) =>
|
||||||
world.ageOf(c) >= 16 &&
|
world.ageOf(c) >= 16 &&
|
||||||
world.ageOf(c) <= 46 &&
|
world.ageOf(c) <= 46 &&
|
||||||
c.state !== 'expedition' &&
|
c.state !== 'expedition' &&
|
||||||
(!c.spouseId || world.isWidowed(c))
|
(!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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -78,10 +83,10 @@ export default function FamilyPanel() {
|
|||||||
)}
|
)}
|
||||||
<div className="member-grid">
|
<div className="member-grid">
|
||||||
{sorted.map((c) => (
|
{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) => (
|
{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>
|
</div>
|
||||||
{alive.length === 0 && (
|
{alive.length === 0 && (
|
||||||
|
|||||||
@@ -1910,3 +1910,35 @@ html[data-blade] .battle-lines {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #a8c2da;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+3
-3
@@ -7,11 +7,11 @@ import { World } from '../src/renderer/game/engine/runtime/World'
|
|||||||
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
|
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
|
||||||
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
|
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
|
||||||
*/
|
*/
|
||||||
// 0.1.37 天人感应基线:因果系统(karma衰减/灾年调制/NPC姿态调制/渡劫调制) + 飞升试炼三阶段 + 世界奇观(种子派生零rng)——
|
// 0.1.39 淬体·洗髓基线:purgeNpc 清理 worldGen.npcs/relations 僵尸定义(存档体积精简——时序内更改)。
|
||||||
// 受控变更重算(karma入指纹/assessStance karma调制/WorldSim.tick新增D-0.8/D-0.9段)。
|
// 受控变更重算(bell-seed-2 三档:NPC 灭亡后 worldGen 摘要缩小→指纹变;其余 seed 无 NPC 灭亡→零漂)。
|
||||||
const GOLDEN: Record<string, Record<number, string>> = {
|
const GOLDEN: Record<string, Record<number, string>> = {
|
||||||
'bell-seed-1': { 560: '45b0c17a', 1200: '62e037c1', 2160: 'b85dd7c7' },
|
'bell-seed-1': { 560: '45b0c17a', 1200: '62e037c1', 2160: 'b85dd7c7' },
|
||||||
'bell-seed-2': { 560: '1279a1da', 1200: '3439bd8f', 2160: 'a29efe9f' },
|
'bell-seed-2': { 560: 'be278aba', 1200: '01126e70', 2160: 'fd03f9b2' },
|
||||||
'bell-seed-3': { 560: 'f24480f4', 1200: 'e52bed85', 2160: 'abb2a5d2' }
|
'bell-seed-3': { 560: 'f24480f4', 1200: 'e52bed85', 2160: 'abb2a5d2' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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