Compare commits

...
10 Commits
Author SHA1 Message Date
Hermes Agent 06a2f085b1 docs: 沉淀项目记忆(AGENTS 数值红线 + PROJECT_MEMORY 档案 + README 沿革)
- AGENTS.md:新增「数值基调(0.1.10 重建后,勿当 bug 调回去)」红线段——
  pacing 因子唯一生效、月率公式、寿元梯度、渡劫三选、事件优先级、flag 剪枝
- docs/PROJECT_MEMORY.md:项目叙事记忆新档案——设计哲学/版本为什么/架构决策/
  踩坑六记/平衡哲学/已知技术债/开始修改路径
- README:版本沿革补 0.1.10/0.1.11,测试规模 923+
2026-08-23 11:36:28 +08:00
Hermes Agent 7191a2bb55 v0.1.11: 全源码审计 + 缺陷歼灭 + UI/UX 打磨
【引擎审计修复】
- P0:大比 once(原整年刷 12 场、奖励通胀);flag 年份键 3 年剪枝(长线膨胀)
- P1:飞升离世累加 feishengCount(飞升之资称号可达);圣者护族接消费(原死代码);
  大比/传薪挂能力卡门控;pending 抢渡劫时不再重设
- P2:version 硬编码统一 0.1.11;yearaxis 飞升归类;MonumentList 挂载

【UI 审计修复】
- P0:已故成员卡显示「已殁」(原秀活动 status);空队迎战禁用+提示(原静默失效)
- P1:指婚候选去截断(滚动列表);终局出口(回卷/主菜单按钮);saveNow 反馈;
  事件来临写入 feed(k-event 使用);遣队「最强 N 人/清空」一键;寻衅确认+联姻原因提示;
  未建建筑视觉 uc 类
- P2:speed 死代码;Modal 分层 z-index;FamilyPanel 在世/先人分隔

【UX 三件套】
- 警讯徽章(瓶颈/重伤/在外/媒缘/孤嗣之忧)顶栏一屏秒答「我该干嘛」
- 首启引导任务条(四步目标 + 完成推进,可跳不强制)
- 顶栏丹药瘦身(4 资源);数字千/万化;瓶颈卡红框高亮

【测试】905→923(urgency/guide/format 三模块 + 审计修复回归 6 项)
金钟罩三档重固化(0.1.11),typecheck/build 通过
2026-08-23 11:30:16 +08:00
Hermes Agent 89f92c6959 v0.1.10: 长线决断(数值重建 + 长跑鉴证)
评估发现(3000月×6种子实证):
- 旧数值在正常难度 6/6 种子 90-125 年绝嗣(修炼慢+寿元短+渡劫卡死三重叠加)
- 关键缺陷:渡劫事件被 eventRoll 误压制(qi8→筑基永不可达),境界峰恒为炼气

重建(data/ 数值 + engine 修复):
- 修炼提速:基率 0.5+感知0.1→1.0+感知0.18;pacing qi 1→2.2(各境界等比提)
- 修为曲线:qi expGrowth 1.35→1.10(9层累计工期<28年)
- 寿元放宽:凡人75 / 炼气150 / 筑基220 / 金丹340 / 元婴520 / 化神850
- 失败跌损收窄 40-65%→26-44%;衰减点 55→65 岁;突破概率全线+0.1
- 渡劫修复:悬置 12 月自动压制(挂机不软锁)、玩家处理即零计数、
  大比 FIFO 不被拍卖/传薪挤掉(queue 兜底)
- 新增:仙门拍卖(十月·灵石漏斗)/ 名宿传薪(八年·传承)/ 圣者护族(幼儿修行提速)

鉴证:
- 金钟罩升 3 档(47/100/180 年 × 3 seed = 9 指纹固化)
- longevity 套件(200 年不败/渡劫晋升/拍卖传薪)
- rebuild 套件(速率/工期/寿元/跌损/渡劫率)
- 测试 881→905;typecheck/build 通过
2026-08-23 11:13:48 +08:00
Hermes Agent ac8068d328 v0.1.9: 百年报告 & 发布之窗(报告/迁移/阵型/冬祷/哨兵)
- 百年族史报告:buildReport 纯函数(人口/声望/战力三线、死因榜、突破密度、
  大比战绩、代际长度带)+ 春秋原 SVG 折线视图 + 自动措辞结语
- 存档迁移管线 v2:migrate 链(v1→v2)+ 未来版本拒载(TOO_NEW)+ 导出信封
  带 appId/schemaVersion + 导入全链路迁移校验;saveState 自动写 CURRENT_SCHEMA
- 战斗阵型:锋阵(+10%攻)/雁阵(御5%+溃伤-15%)/蛇阵(溃伤-30%),三处出战接入
  (遣队/大比/劫掠)点将面板下拉 + 战报首行标阵
- 冬至岁祷:每年十一月三选(观星+3声望/祈福全族修为/问卜吉凶)
- 发布准备:scripts/verify-release.mjs 验收哨兵(npm run verify/verify:package)
  + README 全面重写(架构/守护/版本沿革);版本号三处归一
- 测试 861→881(报告四例/迁移五链/阵型六则含胜率统计/冬祷四则);
  金钟罩三指纹零漂移;typecheck/build 通过
2026-08-23 10:11:22 +08:00
Hermes Agent 87106614e4 test: 测试矩阵扩至 861 项(参数化全覆盖)
新增 5 个矩阵套件:
- data-matrix 242:物品/功法/建筑/秘境/敌人/势力/事件选项/职事/秉性/灵根/境界/时节 逐行
- balance-edge 81:年龄/修为/气血/库存/金钱边界、传承谱系年轴、境界战力、突破矩阵、评分分档、难度
- world-matrix 21:多 seed 长跑、继承链多路径、gameOver 守卫、岁簿/大比/回声周期、确定性复跑
- rng-matrix 38:多种子性质、范围分桶、RngHub 回放/隔离、state 拷贝、pick/shuffle 保集
- api-matrix 32:act 全目录逐项、query 全 ref、subscribe 全事件矩阵
- event-state-matrix 193:逐事件逐选项应用不破、effect 引用、职事上限/寄读、志向、normalize 残缺矩阵、动态事件

引擎附带优化:月底 clampState(修为/气血/库存/金钱永不越界——负血量负库存负钱被矩阵钓出后修复)
总测试 256 → 861;金钟罩复验通过(clamp 零漂移)
2026-08-23 10:04:33 +08:00
Hermes Agent 967ef1240d v0.1.8: 万物皆是插件 + 全应用审计修复(60+ 缺陷歼灭)
插件协议:
- CotycPlugin Manifest(dependencies/conflicts/protected/install/uninstall)
- PluginManager:安装序确定性/依赖拒绝/异常回滚/追踪型上下文(卸载全摘钩子)
- 三核心插件常驻(Systems/Data/Events);插件清单/安装移除/启停广播
- EventPoolRegistry 多池合并;facade about/query('plugins')/plugin 订阅
- 设置页插件清单(含卸载按钮、核心保护);examplePlugin 开发范本入仓

全应用审计修复(引擎 39 + UI/主进程 40 双路清单,P0→P2 歼灭):
- P0:单亲 rollRoots 崩/插件事件软锁(findEvent 查池)/fire 覆盖幸存事件/
  facade 未赋值(act 目录 6/24 生效→全量接通)/packOverride 死实现真接管/
  4 能力卡无接线(trib/apprentice/tournament/season)/读档 rowid 方言崩溃/
  buildApprentice 空候选崩/插件安装无回滚/trait 加成从未消费/DEV 双跑破坏时序
- P1:定时器幸存/advance in-flight 闸/battle 暂停/tournament 点将真传/
  史书 memo 依赖/面板 hooks 顺序违规/远征 squad 残留 id/跨档状态残留/
  toast 自清除/读档错误提示/导入后重载世界/pendingEvent 读档回填
- P2:版本三处统一/NewGame 随机收编/学费错误文案/姓氏池去重/
  功法品阶错位(凡阶哨兵)/功法定价对齐/market 白名单校验/
  战利功法去重/回声漏封缄/clock 迭代快照/core 事件池保护/
  渡劫陨落走真突破/寄读 desc 诚实化/doas 死代码清理等 30 项

测试 239→256;金钟罩复验(防御批零漂移)
2026-08-23 09:58:15 +08:00
Hermes Agent 624e30498b v0.1.7: 仙门之钥(门面化/能力注册表/数据包/开发者面板)
- GameFacade 统一门面:act(23 种动作白名单)/query(6 类只读快照)/subscribe(七大事件协议)/
  about(版本+模块清单+数据包指纹);UI store 接入门面(act 直达,散装调用收口)
- CapabilityRegistry:12 张能力卡(id/name/version/desc/hooks),world.systems 启停即拔插,
  时钟按能力开关过滤(禁修炼则无人精进…沙盒玩法);sysChanged 广播
- DataPackRegistry:默认包与静态数据同引用(金钟罩零漂移),override/reset/fingerprint,
  高频读口 6 处迁移 pack()——未来 MOD=注入数据包覆写
- 开发者面板:设置页系统仪表盘(清单+启停开关+警示文案)
- 测试 225→239(能力清单/拔插分殊四路/广播幂等/包覆盖物价/act 目录/query/协议退订/about/
  金钟罩复验零漂移)
2026-08-23 09:32:21 +08:00
Hermes Agent e91fb6f5a4 v0.1.6: 时轮与造化(GameClock/RngHub/时节/年轴/保护)
- 统一时轮 GameClock:七 phase 注册制(production/aging/cultivation/missions/
  events/diplomacy/epilogue) + 年首三钩子(婚配→岁贡→族簿);advanceMonth 化作薄壳;
  新增系统只注册不改本体;alive 守卫、随机序、族簿时序均与重构前逐一等价
- 金钟罩:3 枚 seed × 560 月指纹常驻(重构前后已验证同哈希;时节属有意变更后重固化)
- 统一随机 RngHub:开局播种(crypto UV)、音效白噪独立流、引擎 rng 带 nextCount 审计;
  全工程拔除逻辑侧 Math.random
- 时节系统:春夏秋冬乘子(春田+10%/夏修+5%/秋市+5%/冬闭+8%)入生产与修炼
- 崩溃保护:advance 异常自动存『出险前』档 + 停速提示回档
- 性能预算:dev-only 单 tick>60ms 报最慢 phase
- 横轴年表:十年一格大事/生殇聚合 + 春秋原年轴视图
- 测试 197→225(时钟/收集器/四季/年轴/效果全矩阵/死局链/残留防御)
2026-08-23 09:20:52 +08:00
Hermes Agent 5b40d9158c v0.1.5: 人物志(志向/渡劫/列传/契合/回声)
- 志向系统:七向(求道/镇族/承宗/商略/兵略/耕读/云游)成年礼自动定志,
  量化加成入修炼/战力/坊市/灵田/添丁/声望,云游减修但际遇更频
- 大境界渡劫:炼气→筑基起,晋升改走劫难事件三选——硬渡/请护法(100灵石)/
  压制来年;护法失败或受牵连;渡劫成败与心性、气血挂勾,高阶有小陨落率
- 灵根本命契合:功法元素×主灵根,契合者修炼+6%/战力+4%,详情页「本命」徽章
- 人物列传:自动列传生成器(生平/修行脉络/历险/姻缘/子嗣/大比/墓志铭),
  成员详情小传分页 + 春秋原列传长卷
- 四邻回声:每两年一桩——友好赠礼/敌视暗桩/中立传闻三态动态事件
- 修复:回声事件 npcId 解析错误、事件优先级(惯例>传闻)
- 测试 176→197(志向加成/契合/渡劫三轨/压制通道/列传结构/回声三态)
2026-08-23 09:08:16 +08:00
Hermes Agent 8152b5c4bc v0.1.4: 百年定局(终局/大比/寄读/春秋原)
- 望气定鼎:stats 追踪四维(人兴/道兴/威名/香火)+ 十二称号分档 + 铭文诗
  春秋原页预演称号,落印定局(青册留档,不阻继续游历)
- 太虚大比:每五年征召,点将三关(初试/拔擢/问鼎)复用战斗结算,
  名次四档红利(声望/灵石/全场好感),史书战报自动落笔
- 宗门寄读:随机宗门收徒(适龄少年),寄读 2-4 年期间每季寄资源,
  期满三选:归来精进(可触发突破+随机机运)或续读深造;低概率择师不回
- 求经台:藏书阁≥3 解锁,两年一访得稀有功法
- 春秋原复盘页:气数四维图/岁末族簿汇编/生卒与突破年表/大比年表
- 修复:大比征召曾因月份条件错过整年;抢在百年庆典前的优先级倒置
- 测试 161→176(评分边界/称号分档/大比三轨/寄读全链/求经冷却)
2026-08-23 09:00:17 +08:00
89 changed files with 6147 additions and 223 deletions
+20
View File
@@ -32,9 +32,20 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附
- 私有 registry 的 `_auth` token 只在用户级 `~/.npmrc`;项目 `.npmrc` 只保留 registry 映射,不要把 token 写进仓库。 - 私有 registry 的 `_auth` token 只在用户级 `~/.npmrc`;项目 `.npmrc` 只保留 registry 映射,不要把 token 写进仓库。
- Linux 无 emoji 字体:**所有图标一律用汉字印章字符**renderer/ui/styles.css 的 `.s-icon/.res-icon/.bld-icon`),不要引入 emoji。 - Linux 无 emoji 字体:**所有图标一律用汉字印章字符**renderer/ui/styles.css 的 `.s-icon/.res-icon/.bld-icon`),不要引入 emoji。
## 插件架构(0.1.8 起)
- **万物皆是插件**`core/plugin.ts` 协议(Manifest/dependencies/conflicts/install/uninstall);`engine/pluginManager.ts` 安装管线(依赖校验、异常回滚、追踪型上下文——插件注册的时轮钩子卸载时全摘);`engine/plugin-bootstrap.ts` 三枚常驻核心插件(core-systems/core-data/core-eventsprotected 不可卸)。
- **事件多池**`world.eventPools`core 池受保护不可删)+ `eventRoll` 聚合抽样;第三方插件 `ctx.addEventPool(id, events)` 即注入内容。**注意 findEvent 必须带 world 参数查池**`events.ts`),否则未知事件直接软锁。
- **能力卡**`engine/capabilities.ts` 12 张;时钟注册走 `viaCap`tournament/tribulation/apprentice/season 四卡各自内部检查 `w.sysEnabled`(无独立钩子,勿直接调)。
- **门面**`engine/api.ts` GameFacadeact 24 项/query 6 类/subscribe 退订协议/about);**UI store 的 `facade` 字段必须通过 openState/startNewGame 赋值**,否则 act 走残缺 fallback。
- **金钟罩**:0.1.11 审计修复批次后三档指纹(47/100/180 年 × 3 seed = 9 值,见 tests/clock.test.ts0.1.8 基线已作废、0.1.10 重建后再次漂移、0.1.11 大比 once/prune 后重固化)。
## 架构要点 ## 架构要点
- `src/renderer/game/`:纯 TS 游戏引擎(无 React/DOM import),可被 vitest 直接测试;`types/domain.ts` 是全量领域类型,改状态结构先看它。 - `src/renderer/game/`:纯 TS 游戏引擎(无 React/DOM import),可被 vitest 直接测试;`types/domain.ts` 是全量领域类型,改状态结构先看它。
- **统一时轮 `core/clock.ts`**:月度 phaseproduction/aging/cultivation/missions/events/diplomacy/epilogue+ 年首钩子均注册于 `engine/clocks.ts`。**新增系统 = 注册一行,禁止手改 `advanceMonth` 本体**。
- **统一随机 `core/rng.ts`**:引擎只经 `World.rng`(含 `nextCount` 审计);UI 播种用 `RngHub.rollSeed()`、音效白噪用 `RngHub.audioNoise01()`**不要**在逻辑里引入 Math.random()。
- **金钟罩 `tests/clock.test.ts`**:3 枚固定 seed × 三档月数(560/1200/2160 = 47/100/180 年)指纹常驻(0.1.11 基线:见注释 GOLDEN)。任何改动若破坏确定性立即红;**有意变更时序/数值时**指纹一并重算并在注释注明原因。
- `src/renderer/ui/`React + zustand`ui/store.ts`)。World 的 game state 是 mutableadvance 后 `revision++` 触发重渲染;订阅 `revision` 是面板刷新惯例。 - `src/renderer/ui/`React + zustand`ui/store.ts`)。World 的 game state 是 mutableadvance 后 `revision++` 触发重渲染;订阅 `revision` 是面板刷新惯例。
- 引擎 = 种子随机数(sfc32`core/rng.ts`+ 不可变快照存 `state.rng`,同 seed 全程可重放(tests/world.test.ts 有确定性用例,改任何 tick 顺序都要保证仍然确定性)。 - 引擎 = 种子随机数(sfc32`core/rng.ts`+ 不可变快照存 `state.rng`,同 seed 全程可重放(tests/world.test.ts 有确定性用例,改任何 tick 顺序都要保证仍然确定性)。
- 建档开头成员 id 硬编码 `x1`~`x5`tests 依赖。 - 建档开头成员 id 硬编码 `x1`~`x5`tests 依赖。
@@ -46,6 +57,15 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附
- 数据在浏览器 OPFSrenderer 内),main 进程无 DB 逻辑;开发与打包后的 origin 不同,两环境存档不互通。 - 数据在浏览器 OPFSrenderer 内),main 进程无 DB 逻辑;开发与打包后的 origin 不同,两环境存档不互通。
- 存储层通过 `SaveDbDriver` 抽象(`storage/slots.ts`),node 环境下无 OPFS,测试只覆盖引擎不覆盖 DB。 - 存储层通过 `SaveDbDriver` 抽象(`storage/slots.ts`),node 环境下无 OPFS,测试只覆盖引擎不覆盖 DB。
## 数值基调(0.1.10 重建后,勿当 bug 调回去)
- **pacing.ts 的 MAJOR_RATE 是进度唯一生效因子**qi 2.2、foundation 1.4、core 0.95、nascent 0.68、spirit 0.48);`realms.ts``expBase/expGrowth/maxRealmExp` 曲线**未参与计算**(历史摆设,勿以它反推)。
- 月率 base = `1.0 + perception*0.18`;65 岁起 ×0.72 衰减、8 岁以下 ×0.65(圣者护族 60+ 高境界者存在时乘 1.3)。
- 寿元:75/150/220/340/520/850(凡人→化神,0.1.10 放宽的梯度)。
- 大境界晋升走**渡劫事件三选**(硬渡/护法/压制);玩家 12 月不应会**自动压制一年**(防挂机软锁,events.ts 计数 `tribPendingMonths`)。
- 周期事件优先级:命运(百年/飞升)> 大比 > 传薪 > 拍卖 > 岁祷 > 回声;大比有 `once`,一年一届。
- `family.flag` 年份键(auction-/prayerDone-/echoDone-/recruitDone-)每 tick 剪 3 年前旧键。
## 工作流约定 ## 工作流约定
- 提交前:`npm test` + `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。 - 提交前:`npm test` + `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。
+45 -26
View File
@@ -1,43 +1,62 @@
# 仙途家族志 · Chronicle of the Immortal Clan # 仙途家族志 · Chronicle of the Immortal Clan
修仙 × 家族 × 经营 × 战斗的家族模拟器 0.1.0 修仙 × 家族 × 经营 × 战斗的家族模拟器Electron + React + TypeScript + MetonaSqlark
## 玩法
一座庄园、一家修士、四邻势力,随年月流转:族人修炼突破、建筑产出、探秘寻宝、婚丧嫁娶; 一座庄园、一家修士、四邻势力,随年月流转:族人修炼突破、建筑产出、探秘寻宝、婚丧嫁娶;
仇家犯境则结阵应战;史书自动记下百年兴衰 —— 玩家扮演「家族意志」,延续香火于仙途 仇家犯境则结阵应战;百年之际,望气定鼎,春秋原上写下一卷族史
## 开发 ## 快速开始
```bash ```bash
npm install # 依赖(含私有 registrygit.metona.cn npm install # 依赖(含 git.metona.cn 私有 registrytoken 在用户级 ~/.npmrc
npm run dev # 开发模式(热更新 npm install-scripts approve electron esbuild # npm12 首装时必须(Electron 二进制
npm test # 引擎单元测试(vitest npm run dev # 开发模式(热更新
npm run typecheck # 类型检查 npm run verify # 发布验收哨兵:vitest + typecheck + build
npm run build # 构建 out/ npm run verify:package # 附打包预览(electron-builder
npm run package # 打包桌面安装包(electron-builder npm run package # 直接打包(Windows 需在 Windows 侧执行
npm test # 仅测试(923+ 用例)
``` ```
冒烟(主进程渲染 + OPFS 数据库就绪检查): ## 架构一览
```bash ```
npm run build && SMOKE_TEST=1 npx electron out/main/index.js --no-sandbox src/renderer/game/ 纯 TS 引擎(无 React/DOM,可被 vitest 直测)
core/ rng(RngHub) · clock(GameClock·7相位) · plugin · legacy · report · yearaxis · biography · genealogy
data/ balance 表 + DataPackRegistry(数据包可覆写)
engine/ world · clocks(注册) · pluginManager(∃插件管线) · capabilities(12 能力卡) · api(GameFacade act/query/subscribe/about)
systems/(生产·寿元·修炼·任务·事件·外交·婚育·渡劫·大比·寄读)
storage/ db(MetonaSqlark 单例) · slots(存档槽+快照) · migrate(版本迁移链 v1→v2)
src/renderer/ui/ React + zustandstore 持 GameFacade;面板订阅 revision 刷新)
src/main/ Electron 主进程(app:// 协议 + IPC 存档导出/导入)
``` ```
## 技术栈 设计文档见 `AGENTS.md`(环境坑/架构红线/方言陷阱/金钟罩约定)。
- Electron + electron-vite + React + TypeScript ## 守护机制
- 数据库:@metona-team/metona-sqlark 0.7.4aria 引擎 + OPFS),渲染进程内持久化
- 引擎:纯 TypeScript(无 UI 依赖),种子随机数(sfc32),确定性可回放
- 存储:每个存档槽一个 SQLark 库(快照滚动 12 份 + 编年史表),JSON 导出导入
## 存档位置 - **金钟罩**tests/clock.test.ts 三枚固定 seed × 560 月指纹常驻(0.1.8 基线:9af71ecb/63cede89/ebfec4a4);有意变更时序需受控更新并注明。
- **插件化**:万物皆是插件(系统/数据包/事件池),示例见 tests/helpers/examplePlugin.ts。
- **可回放**:种子随机(sfc32)+ 闰档快照 + 回档时间轴。
OPFS 数据位于 Electron userData 目录下数据库文件(aria 引擎 WAL/SSTable)。 ## 打包产物
备份与迁移请用游戏内「设置 → 导出/导入」。
## 已知限制(0.1.0 - `npm run package` → out/(构建)+ release/(安装包
- 图标资源 resources/icon.png|ico —— electron-builder 自动使用
- 注意:WSL/Linux 交叉打 win 包可行但未签名,正规发布请用 Windows 侧重跑
- 单机单窗口;MetonaSqlark aria 引擎的多标签页锁不构成影响 ## 版本沿革(0.1.0 → 0.1.9
- 战斗为自动结算战报,不做手动回合操作
- 目前仅中文本地化 | 版本 | 主题 |
|---|---|
| 0.1.0 | 核心循环(修仙/家族/经营/战斗)完备可玩 |
| 0.1.1 | 养成闭环+回档+叙事(指婚/装备/祭祖/战报匣/年度纸笺) |
| 0.1.2 | 宗族气韵(职事/悟道/谱系/碑录/庆典/飞升/合音效) |
| 0.1.3 | 深度审计(旧档兼容/血缘/联姻/远征修复) |
| 0.1.4 | 百年定局(终局/大比/寄读/春秋原) |
| 0.1.5 | 人物志(志向/渡劫/列传/契合/回声) |
| 0.1.6 | 时轮与造化(GameClock/RngHub/时节/年轴/保护) |
| 0.1.7 | 仙门之钥(门面/能力注册表/数据包/开发者面板) |
| 0.1.8 | 万物皆是插件 + 全应用审计(60+ 缺陷歼灭) |
| 0.1.9 | 百年报告 + 存档迁移 v2 + 战斗阵型 + 冬祷 + 发布哨兵 |
| 0.1.10 | 长线决断(数值重建:修炼提速/寿元放宽/渡劫修复 + 拍卖/传薪/护族) |
| 0.1.11 | 全源码审计缺陷歼灭 + UX 打磨(警讯徽章/首启引导/格式化) |
+96
View File
@@ -0,0 +1,96 @@
# 项目记忆 · Chronicle of the Immortal Clan
> 这份文档是**项目叙事记忆**:记录设计哲学、关键决策、踩过的坑、平衡教训。
> 面向"下一个将要改这份代码的智能体/开发者"——先读它再动手,能避开九成弯路。
> 与 AGENTS.md 分工:AGENTS 记**技术红线**(怎么改是安全的),本文记**为什么是现在这样**(设计意图)。
---
## 一、这是什么
一款**修仙家族传承模拟器**:玩家扮演"家族意志",经营一族数代兴衰——修炼突破、经营产出、秘境探索、四邻外交、婚丧嫁娶、百年定鼎。
- **情感价值主张**:不是"养一个角色变强",是"看一家子走完一程"——后人翻族谱/史书/碑录时的回望感。
- **玩法落点**,按重要性排序:
1. 世代血脉(婚育/继嗣/谱系)——传承是主叙事;
2. 修炼纵深(境界/功法/悟道/渡劫)——成长是爽点;
3. 经营与外交(建筑/贸易/联姻/劫掠)——决策供给;
4. 叙事编织(编年史/列传/百年报告/定鼎)——情绪收束。
## 二、版本时间线(每迭代的"为什么")
| 版本 | 主题 | 当时为什么做 |
|---|---|---|
| 0.1.0 | 核心循环 | 把"修仙+家族+经营+战斗"四系统全部立起来,形成可玩闭环 |
| 0.1.1 | 养成闭环+存档安全 | 指婚/装备/祭祖/回档:管理自主度决定模拟器粘性 |
| 0.1.2 | 宗族气韵 | 职事/悟道/谱系/碑录:让"传承"有可视载体 |
| 0.1.3 | 深度审计 | 刚做完新系统必有一批缺陷,上线前清毒 |
| 0.1.4 | 百年定局 | 模拟器缺"收束":四维评分+十二称号+春秋原 |
| 0.1.5 | 人物志 | 成员同质化:志向/渡劫/列传/契合让"人"有戏 |
| 0.1.6 | 时轮与造化 | 时间与随机从"散装"走向"统一"GameClock/RngHub)——**架构地基** |
| 0.1.7 | 仙门之钥 | 门面/能力注册表/数据包:对外暴露与可拔插 |
| 0.1.8 | 万物皆是插件 | 把全部系统统一进插件协议(Manifest/依赖/卸载/追踪型钩子) |
| 0.1.9 | 百年报告与发布 | 情感复盘(报告)+工程收口(迁移管线/发布哨兵) |
| 0.1.10 | 长线决断 | **审计发现全灭崩溃**:修炼太慢+寿元短+渡劫误压——数值重建 |
| 0.1.11 | 收官打磨 | 二次审计(引擎8+UI11缺陷歼灭)+ UX 三件套(警讯/引导/格式化) |
## 三、关键架构决策(及为什么)
### 1. 纯 TS 引擎 + 确定性种子流
- `src/renderer/game/` 无 React/DOM importvitest 直测。
- sfc32 随机种,同 seed 全程重放;**金钟罩**=3 seed×3 档时长(47/100/180年)指纹固化。
- **为什么**:模拟器平衡调参需要"可复现";金钟罩把"改坏了确定性"变成"一秒red"。
### 2. GameClock 七相位
- 月度 phaseproduction→aging→cultivation→missions→events→diplomacy→epilogue
- 年首三钩子:婚配→岁贡→族簿。新系统=注册一行,禁手改 `advanceMonth`
- **为什么**0.1.6 前每个 tick 体内散着调用,加系统必乱序→破坏随机→金钟罩红。
### 3. 插件协议(0.1.8
- 万物皆是插件:系统/数据包/事件池统一 Manifest;`engine/pluginManager.ts` 安装管线(依赖校验/异常回滚/追踪型上下文)。
- 三核心插件(Systems/Data/Eventsprotected 常驻。
- **为什么**:玩法内容增量的"可插拔出口";事件多池让第三方能注入事件。
### 4. 存档分层
- metona-sqlark 在 rendererOPFS+ {Slots} {迁移链}**v1→v2 迁移管线**0.1.9)。
- **为什么**:浏览器持久化天生平台限制;预置迁移链防未来表变炸档。
### 5. UI 惯例
- zustand `revision++` 是刷新惯例;`facade` 必须 openState/startNewGame 赋值;
- 警讯徽章(urgency)与引导任务(guide)做成 engine 侧纯函数,UI 只渲染。
## 四、踩过的坑(高价值教训)
1. **金钟罩盲区**:560 月(47 年)只能听见"中期",看不见"晚期"——0.1.10 之前全灭崩溃藏在这里。→ 教训:**确定性验证要覆盖寿命尺度**(现在三档到 180 年)。
2. **渡劫误压制**eventRoll 一处"自动压制"让 qi8→筑基永不可达(境界峰恒炼气)。→ 教训:**自动回退/悬空逻辑必须单测"有人一直不响应"**。
3. **大比缺 once**:整年刷 12 场,收益通胀。→ 教训:周期性动态事件**生来就要考虑 once/年度封缄**。
4. **flag 键膨胀**auction-{year} 等永不清理,180 年挂 400 键。→ 教训:**任何按年份生成的键都要有剪枝数**(现在 3 年)。
5. **UI 层"状态反直觉"**:死者显示"闭关/养伤"——直觉信任崩塌。→ 教训:**UI 状态必须与领域语义严格对齐**(dead 分支显式)。
6. **模块单例纪律**MetonaSqlark 同库同 tab 一次连接(ARIA_LOCKED);spouse 竞态防了。→ 教训:**存储/稀罕资源一律单例**。
## 五、平衡哲学(玩起来正确的普适原则)
- **修炼快感 > 真实性**:宁可让"凡人百岁筑基"变成"30岁炼气、50岁筑基、百岁金丹",不要按修真文教条拉长。
- **世代窗口**:关键体验"每代 25-30 年",一回合 1 月,推两百步就能看到"代际更替"。
- **失败必须有出口**:渡劫失败只跌伤不太狠(重伤/跌落为主,陨落限高阶);事件悬置自动压制不锁死。
- **漏斗要"花钱有去处"**:后期灵石通胀是 0.1.10 才加拍卖来回收——任何资源曲线都要有消耗侧。
## 六、已知技术债(诚实清单)
1. `realms.ts``expBase/expGrowth/maxRealmExp` 曲线未参与计算(历史摆设,勿信勿用)。
2. GameFacade `act` 目录部分 act 只走 `actDirect` fallback(少 4 项),若未来全走 facade 需补齐 `member.meditate` 等。
3. `npc.power` 目前纯展示(不参与战斗结算)。
4. 引导任务(guide.ts)只做 UI 条,钩子目标点不完(未联动高亮/自动转跳——是"提示"不是"强制")。
5. 行列 flag `family.flag` 仍在长线漂移;`state.missions`/`missionIds` 只增不减(未剪枝)。
## 七、如何开始修改(给下一个智能体的路径)
```
1. 读 AGENTS.md(红线)→ 2. 读本文件(意图)→ 3. 跑 npm test & typecheck 确认绿
→ 4. 改引擎:先改数据(data/)再改逻辑,小步跑金钟罩;改结构:注册进 clocks/plugins
→ 5. 改 UI:先改 storerevision)再改组件;纯逻辑放 game/core 可测
→ 6. 加测试:金钟罩三档不动,新增用例挂 tests/audit-fix-0.1.x.test.ts
→ 7. 提交前:npm test + typecheck + GUI 冒烟需 WSLg 在线)+ 更新 README/PROJECT_MEMORY/AGENTS
```
**一句话原则**:先复现、再小改、后金钟罩。改坏确定性比改错玩法更危险。
+4 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "chronicle-of-the-immortal-clan", "name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志", "productName": "仙途家族志",
"version": "0.1.3", "version": "0.1.11",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器", "description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "MetonaTeam", "author": "MetonaTeam",
@@ -12,7 +12,9 @@
"build": "electron-vite build", "build": "electron-vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"package": "npm run build && electron-builder" "package": "npm run build && electron-builder",
"verify": "node scripts/verify-release.mjs",
"verify:package": "node scripts/verify-release.mjs --package"
}, },
"dependencies": { "dependencies": {
"@metona-team/metona-sqlark": "0.7.4" "@metona-team/metona-sqlark": "0.7.4"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
// 发布验收哨兵:test → typecheck → build(可选 package 预览)
import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'))
console.log(`╭──────────────────────────────────────┐`)
console.log(`│ 仙途家族志 v${pkg.version} · 发布验收哨兵`)
console.log(`╰──────────────────────────────────────┘`)
const steps = [
['测试', 'npx', ['vitest', 'run']],
['类型检查', 'npx', ['tsc', '--noEmit']],
['构建', 'npx', ['electron-vite', 'build']]
]
if (process.argv.includes('--package')) {
steps.push(['打包', 'npx', ['electron-builder']])
}
let failed = false
for (const [name, cmd, args] of steps) {
process.stdout.write(`[${name}] …`)
const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: new URL('..', import.meta.url).pathname })
if (r.status !== 0) {
console.log(` [${name}] ✗ 失败`)
failed = true
break
}
console.log(`[${name}] ✓`)
}
if (failed) {
console.log('\n✗ 发布验收未通过——修复后再发布。')
process.exit(1)
}
console.log('\n✓ 全部验收通过,可以打包。')
+7 -2
View File
@@ -149,8 +149,13 @@ async function capture(
function serveAppProtocol(): void { function serveAppProtocol(): void {
protocol.handle(SCHEME, (req) => { protocol.handle(SCHEME, (req) => {
const url = new URL(req.url) let pathname = ''
let pathname = decodeURIComponent(url.pathname) try {
const url = new URL(req.url)
pathname = decodeURIComponent(url.pathname)
} catch {
return new Response('bad request', { status: 400 })
}
if (pathname === '/') pathname = '/index.html' if (pathname === '/') pathname = '/index.html'
const target = normalize(join(RENDERER_ROOT, pathname)) + sep const target = normalize(join(RENDERER_ROOT, pathname)) + sep
const resolved = normalize(join(RENDERER_ROOT, pathname)) const resolved = normalize(join(RENDERER_ROOT, pathname))
+77
View File
@@ -0,0 +1,77 @@
import { GameState } from '../types/domain'
import { TECHNIQUES } from '../data/techniques'
import { describeRealm } from '../data/realms'
import { aspirationById } from '../data/aspirations'
import { postById } from '../data/posts'
export interface BioLine {
at?: number
label: string
text: string
}
export function buildBiography(s: GameState, memberId: string): BioLine[] {
const c = s.members[memberId]
if (!c) return []
const lines: BioLine[] = []
const tech = TECHNIQUES.find((t) => t.id === c.techniqueId)
const asp = aspirationById(c.aspiration)
const post = postById(c.post)
lines.push({
label: '生平',
text: `${c.name},第${c.generation}代子孙${c.gender === 'male' ? '男' : '女'},生于${c.bornYear}${
c.deathYear ? `,殁于${c.deathYear}年(${c.deathCause ?? '寿终'}` : ',今尚在世'
}${post ? `曾居族中「${post.name}」之位。` : ''}${asp ? `平生志向:${asp.name}` : ''}${
tech ? `主修《${tech.name}》。` : ''
}`
})
// 突破脉络
const breaks = s.chronicle.filter((e) => e.category === 'breakthrough' && e.memberId === c.id)
const trials = s.chronicle.filter((e) => e.text.includes(c.name) && (e.category === 'battle' || e.category === 'exploration'))
for (const e of breaks) {
lines.push({ at: e.year, label: '修行', text: `${e.year}${e.month}月,${e.text.replace(new RegExp(`^${c.name}\\s*`), '')}`})
}
if (trials.length > 0) {
lines.push({ at: trials[0].year, label: '历险', text: `${trials[0].year}年·${trials[0].text}` })
}
// 婚育
const marriages = s.chronicle.filter((e) => e.category === 'marriage' && e.memberId === c.id)
for (const m of marriages) {
lines.push({ at: m.year, label: '姻缘', text: m.text })
}
if (c.children.length > 0) {
const kids = c.children.map((id) => s.members[id]?.name ?? '?').join('、')
lines.push({ label: '子嗣', text: `抚育子女${c.children.length}人:${kids}` })
}
// 大比
const hisTourneys = s.stats.tourneyHistory.filter((t) => {
const battle = s.battles.find((b) => b.year === t.year && b.title.includes('大比'))
return battle?.lines.some((l) => l.includes(c.name))
})
for (const t of hisTourneys) {
lines.push({ at: t.year, label: '大比', text: `${t.year}年,随队参加太虚大比,列第${t.rank}名。` })
}
// 墓碑铭文
if (!c.alive) {
const epitaph = EPITAPH[c.deathCause ?? '寿终'] ?? '一尘一土,终归青山。'
lines.push({ label: '墓志', text: `${c.name}之墓。${epitaph}` })
}
return lines
}
const EPITAPH: Record<string, string> = {
寿: '一尘一土,终归青山。子孙焚香,岁岁在此。',
'寿元将尽': '一尘一土,终归青山。子孙焚香,岁岁在此。',
: '稚子长眠,天地垂爱。家人悬灯,常照归路。',
'伤势不治': '壮士折戟,魂兮归来。家祠之下,与祖同列。',
'突破走火': '一念求道,九死未悔。灵前法灯,为君长明。',
'渡劫陨落': '雷霆战天,其志未竟。遗骨归山,英风尚在。',
'战殁': '马革裹尸,归葬桑梓。长剑挂壁,子孙相传。',
'云游飞升': '此去云深不知处,魂同列宿游太虚。'
}
+84
View File
@@ -0,0 +1,84 @@
import type { World } from '../engine/world'
/**
* 统一时轮调度:所有系统以 phase 注册,由时钟按固定顺序驱动。
* - 月度 phaseproduction / aging / cultivation / missions / events / diplomacy / epilogue
* - 年首钩子:onYearStart(婚配 → 声望岁贡 → 岁末族簿)
* 新增系统 = 注册一行,不再修改 advanceMonth 本体。
*/
export type PhaseId =
| 'production'
| 'aging'
| 'cultivation'
| 'missions'
| 'events'
| 'diplomacy'
| 'epilogue'
export type SystemHook = (w: World) => void
export interface PhaseStat {
phase: PhaseId
ms: number
count: number
}
export const PHASE_ORDER: PhaseId[] = [
'production',
'aging',
'cultivation',
'missions',
'events',
'diplomacy',
'epilogue'
]
export class GameClock {
private monthly = new Map<PhaseId, SystemHook[]>()
private yearly: SystemHook[] = []
register(phase: PhaseId, fn: SystemHook): () => void {
const list = this.monthly.get(phase) ?? []
list.push(fn)
this.monthly.set(phase, list)
return () => {
const li = list.indexOf(fn)
if (li >= 0) list.splice(li, 1)
}
}
onYearStart(fn: SystemHook): () => void {
this.yearly.push(fn)
return () => {
const i = this.yearly.indexOf(fn)
if (i >= 0) this.yearly.splice(i, 1)
}
}
fireYearStart(w: World): void {
for (const fn of this.yearly) fn(w)
}
stepMonthly(w: World): PhaseStat[] {
const report: PhaseStat[] = []
for (const phase of PHASE_ORDER) {
const t0 = performance.now()
const fns = [...(this.monthly.get(phase) ?? [])]
for (const fn of fns) fn(w)
const ms = performance.now() - t0
report.push({ phase, ms, count: fns.length })
}
return report
}
subscriptionCount(): number {
let n = 0
for (const list of this.monthly.values()) n += list.length
return n + this.yearly.length
}
snapshot(): Array<{ phase: PhaseId; count: number }> {
return PHASE_ORDER.map((phase) => ({ phase, count: (this.monthly.get(phase) ?? []).length }))
}
}
+11
View File
@@ -0,0 +1,11 @@
export function fmt(n: number): string {
const abs = Math.abs(n)
if (abs >= 10000) return `${(n / 10000).toFixed(1)}`
if (abs >= 1000 && abs < 10000) {
// 1.2千
const t = Math.floor(abs / 1000)
const rem = Math.floor((abs % 1000) / 100)
return rem > 0 ? `${n < 0 ? '-' : ''}${t}.${rem}` : `${n < 0 ? '-' : ''}${t}`
}
return String(n)
}
+33
View File
@@ -0,0 +1,33 @@
import type { World } from '../engine/world'
export interface GuideStep {
id: string
title: string
hint: string
reward: string
}
export const GUIDE_STEPS: GuideStep[] = [
{ id: 'intro', title: '认识你的族人', hint: '点开「宗族」页中任意成员,看看他的灵根、境界与禀性。', reward: '感悟 +20 灵石' },
{ id: 'cultivate', title: '点一人闭关', hint: '在成员详情中点「闭关」,让他专心致志地修行。', reward: '修为小成' },
{ id: 'build', title: '营建一项产业', hint: '到「领地」页营建一座【灵田】或【坊市】,让家中有进项。', reward: '家族起步' },
{ id: 'progress', title: '推进一个月', hint: '点顶部「推进一月」,看族人锻、金堂响、家书至。', reward: '正式开族' }
]
export interface GuideState {
step: number
done: boolean
}
export function computeGuide(w: World): GuideState {
const step = w.state.family.flag['guideStep'] as number | undefined ?? 0
return { step, done: step >= GUIDE_STEPS.length }
}
export function guideAdvance(w: World, targetStep: number): number {
const cur = w.state.family.flag['guideStep'] as number | undefined ?? 0
if (targetStep > cur) {
w.state.family.flag['guideStep'] = Math.min(GUIDE_STEPS.length, targetStep)
}
return w.state.family.flag['guideStep'] as number
}
+102
View File
@@ -0,0 +1,102 @@
import { GameState } from '../types/domain'
import { MAJOR_ORDER } from '../data/realms'
export interface LegacyDims {
renXing: number
daoXing: number
weiMing: number
xiangHuo: number
total: number
}
export interface LegacyArch {
title: string
poem: string[]
dims: LegacyDims
rank: number
verdict: string
}
export const VERDICTS: Record<string, { title: string; poem: string[] }> = {
feisheng: {
title: '飞升之资',
poem: ['海内升龙真气象,仙班亦录旧香名。', '遥看青山埋骨处,一脉烟霞送飞鸿。']
},
celestial: {
title: '仙朝遗脉',
poem: ['祖庭灯火三千里,曾照中天玉斗垂。', '后世儿孙开卷处,犹闻钟鼎旧清音。']
},
noble: {
title: '中州望族',
poem: ['钟鸣鼎食三百年,莫道朱门无圣贤。', '一炷心香传世绪,青灯犹自照芸编。']
},
warlord: {
title: '一方枭雄',
poem: ['剑气纵横镇八荒,家声不堕尽金汤。', '而今若问兴亡事,半壁江山问晚霜。']
},
recluse: {
title: '隐世大族',
poem: ['不向尘沙争利名,青山深处课桑耕。', '子孙自有莲舟意,一棹烟波到洞庭。']
},
modest: {
title: '积余之家',
poem: ['檐下燕飞春复秋,园蔬新雨补梁楸。', '粗茶淡饭家声在,犹胜浮云逐海流。']
},
dust: {
title: '冢中枯骨',
poem: ['一度花开一度尘,纸灰飞作旧人魂。', '若教重理当年事,且把残篇问故园。']
}
}
const RANK_MAP: [number, string][] = [
[160, 'celestial'],
[120, 'noble'],
[85, 'warlord'],
[55, 'recluse'],
[30, 'modest']
]
export function computeLegacy(s: GameState): LegacyDims {
const st = s.stats
const gens = Math.max(
...Object.values(s.members).map((c) => c.generation).concat(1)
)
const renXing = Math.round(gens * 18 + Math.min(st.popPeak, 40) * 1.6)
const daoXing = Math.round(st.maxRealmIdx * 26 + st.techniqueGrand * 4)
const weiMing = Math.round(st.repPeak * 1.4 + (st.tourneyBest ? (6 - st.tourneyBest) * 12 : 0))
const xiangHuo = Math.round(Math.min(s.year, 300) * 0.9 + st.feishengCount * 60)
return { renXing, daoXing, weiMing, xiangHuo, total: renXing + daoXing + weiMing + xiangHuo }
}
export function resolveLegacy(s: GameState): LegacyArch {
const dims = computeLegacy(s)
let key = 'dust'
if (s.stats.feishengCount > 0) key = 'feisheng'
else {
for (const [need, k] of RANK_MAP) {
if (dims.total >= need) {
key = k
break
}
}
}
const verdict = VERDICTS[key]
return { title: verdict.title, poem: verdict.poem, dims, rank: dims.total, verdict: key }
}
export function peakRealmIndex(members: GameState['members']): number {
let max = -1
for (const c of Object.values(members)) {
const idx = MAJOR_ORDER.indexOf(c.realm.major) * 10 + c.realm.minor
if (idx > max) max = idx
}
return max
}
export function grandTechniqueCount(members: GameState['members']): number {
let n = 0
for (const c of Object.values(members)) {
if ((c.techniqueRank ?? 0) >= 2) n++
}
return n
}
+80
View File
@@ -0,0 +1,80 @@
import { GameState } from '../types/domain'
export interface LegacyReport {
years: number[]
population: number[]
reputation: number[]
power: number[]
deathCauses: Record<string, number>
breakthroughs: { year: number; count: number }[]
tourneys: { year: number; rank: number }[]
genSpan: { gen: number; from: number; to: number }[]
totalYears: number
}
export function buildReport(s: GameState): LegacyReport {
const reports = s.yearlyReports.slice().sort((a, b) => a.year - b.year)
const years = reports.map((r) => r.year)
const population = reports.map((r) => r.births - r.deaths)
const reputation = reports.map((r) => r.rep)
const power = reports.map((r) => r.power)
const deathCauses: Record<string, number> = {}
for (const e of s.chronicle) {
if (e.category !== 'death') continue
const cause = (e.text.match(/(寿元将尽|幼夭|伤势不治|突破走火|渡劫陨落|战殁|云游飞升)/)?.[0] ?? e.text.split('')[0] ?? '其他').slice(0, 14)
deathCauses[cause] = (deathCauses[cause] ?? 0) + 1
}
const btCount = new Map<number, number>()
for (const e of s.chronicle) {
if (e.category === 'breakthrough') btCount.set(e.year, (btCount.get(e.year) ?? 0) + 1)
}
const breakthroughs = [...btCount.entries()].map(([year, count]) => ({ year, count })).sort((a, b) => a.year - b.year)
const members = Object.values(s.members)
const genSpan: LegacyReport['genSpan'] = []
const byGen = new Map<number, { min: number; max: number }>()
for (const c of members) {
const span = byGen.get(c.generation) ?? { min: c.bornYear, max: c.bornYear }
span.min = Math.min(span.min, c.bornYear)
span.max = Math.max(span.max, c.deathYear ?? s.year)
byGen.set(c.generation, span)
}
for (const [gen, span] of [...byGen.entries()].sort((a, b) => a[0] - b[0])) {
genSpan.push({ gen, from: span.min, to: span.max })
}
return {
years,
population,
reputation,
power,
deathCauses,
breakthroughs,
tourneys: s.stats.tourneyHistory.slice(),
genSpan,
totalYears: s.year
}
}
/** 依据数据自动措辞的族史结语 */
export function verdictLine(r: LegacyReport): string {
if (r.years.length < 4) return '岁月尚浅,家族仍在晨雾中前行。'
const finalRep = r.reputation[r.reputation.length - 1]
const peakRep = Math.max(...r.reputation)
const peakIndex = r.reputation.indexOf(peakRep)
const peakPower = Math.max(...r.power)
const btSum = r.breakthroughs.reduce((a, b) => a + b.count, 0)
const mainCause = Object.entries(r.deathCauses).sort((a, b) => b[1] - a[1])[0]
const lines: string[] = []
if (peakRep >= 60) lines.push(`家族声望曾至 ${peakRep} 之巅(第${peakIndex + 1}年),四方来贺。`)
else if (finalRep < 0) lines.push('晚境声名凋零,门可罗雀。')
else lines.push('不显山露水,然香火未冷。')
lines.push(`战力峰值 ${peakPower},共记突破 ${btSum} 次。`)
if (mainCause && mainCause[1] > 0) lines.push(`族中弃世者多缘「${mainCause[0]}」。`)
const best = r.tourneys.reduce((m, t) => (m === null || t.rank < m.rank ? t : m), null as { year: number; rank: number } | null)
if (best) lines.push(`太虚大比最佳战果:第${best.rank}名(${best.year}年)。`)
return lines.join('')
}
+2 -2
View File
@@ -1,8 +1,8 @@
export const SURNAME_POOL = [ export const SURNAME_POOL = [
'林', '苏', '沈', '谢', '顾', '萧', '叶', '江', '秦', '裴', '林', '苏', '沈', '谢', '顾', '萧', '叶', '江', '秦', '裴',
'柳', '陆', '云', '姜', '晏', '楚', '洛', '许', '宋', '薛', '柳', '陆', '云', '姜', '晏', '楚', '洛', '许', '宋', '薛',
'韩', '白', '纪', '容', '卫', '柳', '燕', '温', '孟', '阮', '韩', '白', '纪', '容', '卫', '燕', '温', '孟', '阮',
'洛', '池', '顾', '岑', '傅', '虞', '尹', '霍', '曲', '齐' '', '岑', '傅', '虞', '尹', '霍', '曲', '齐'
] ]
export const MALE_GIVEN = [ export const MALE_GIVEN = [
+55
View File
@@ -0,0 +1,55 @@
import { GameClock, SystemHook } from './clock'
import { DataPack } from '../data/registry'
import { EventDef } from '../data/events'
import type { World } from '../engine/world'
export type PluginKind = 'system' | 'data' | 'events' | 'content'
export interface PluginHookGuard {
onLoad?(): void
onDisable?(): void
onEnable?(): void
onUninstall?(): void
}
export interface CotycPlugin {
id: string
name: string
version: string
author?: string
description: string
kind: PluginKind
dependencies?: string[]
conflicts?: string[]
install(ctx: PluginContext): void
uninstall?(ctx: PluginContext): void
hooks?: PluginHookGuard
protected?: boolean
}
export interface PluginContext {
world: World
clock: GameClock
register: (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void
onYearStart: (fn: SystemHook) => () => void
addCapability: (cap: { id: string; name: string; version: string; desc: string }) => void
removeCapability: (id: string) => void
enableCapability: (id: string, enabled: boolean) => void
overridePack: (partial: Partial<DataPack>) => void
resetPack: () => void
addEventPool: (id: string, events: EventDef[]) => void
removeEventPool: (id: string) => void
}
export interface PluginStatus {
id: string
name: string
version: string
kind: PluginKind
installed: boolean
enabled: boolean
protected: boolean
dependencies?: string[]
}
export type PluginChange = { id: string; action: 'install' | 'remove' | 'enable' | 'disable' }
+29
View File
@@ -27,6 +27,7 @@ const U32 = 4294967296
export class Rng { export class Rng {
state: RngState state: RngState
nextCount = 0
constructor(state: RngState) { constructor(state: RngState) {
this.state = { ...state } this.state = { ...state }
@@ -37,6 +38,7 @@ export class Rng {
} }
next(): number { next(): number {
this.nextCount++
const s = this.state const s = this.state
const t = ((s.a + s.b + s.d) | 0) >>> 0 const t = ((s.a + s.b + s.d) | 0) >>> 0
s.d = (s.d + 1) | 0 s.d = (s.d + 1) | 0
@@ -76,3 +78,30 @@ export class Rng {
return this.next() * (max - min) + min return this.next() * (max - min) + min
} }
} }
/**
* 全工程统一随机收集器:
* - rollSeed:开局播种(crypto 级)
* - audioNoise:音效白噪(种子独立,永不干扰引擎序列)
* - engineRng:引擎世界随机(World.rng 实例,见 world.ts
*/
export class RngHub {
private static audioRng = new Rng(seedToRng('cotyc-audio-dither-v1'))
static rollSeed(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `seed-${crypto.randomUUID()}`
}
return `seed-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9)}`
}
static audioNoise01(): number {
return RngHub.audioRng.next()
}
}
export function rngAudit(rng: RigRng): { total: number } {
return { total: rng.nextCount }
}
type RigRng = Rng
+25
View File
@@ -0,0 +1,25 @@
import type { World } from '../engine/world'
export interface Urgency {
blockedCount: number
injuredCount: number
missionCount: number
marriageReady: number
noHeir: boolean
headFrail: boolean
}
export function computeUrgency(w: World): Urgency {
const alive = w.aliveMembers()
const blockedCount = alive.filter((c) => c.realmProgress >= 100 && c.realm.major !== 'spirit').length
const injuredCount = alive.filter((c) => c.state === 'wounded' || c.health < 30).length
const missionCount = w.state.missions.filter((m) => !m.done).length
const marriageReady = alive.filter(
(c) => c.state !== 'expedition' && c.state !== 'apprentice' && (!c.spouseId || w.isWidowed(c)) && w.ageOf(c) >= 16 && w.ageOf(c) <= 46
).length
const head = w.state.members[w.state.family.headId]
const headFrail = !!head && head.alive && w.ageOf(head) > 55
const adultHeir = alive.some((c) => c.id !== head?.id && w.ageOf(c) >= 16)
const noHeir = !adultHeir || !head?.alive
return { blockedCount, injuredCount, missionCount, marriageReady, noHeir, headFrail }
}
+55
View File
@@ -0,0 +1,55 @@
import { GameState } from '../types/domain'
export interface AxisEvent {
year: number
kind: '突破' | '大比' | '渡劫' | '婚' | '殇' | '战' | '飞升'
label: string
}
export interface AxisCell {
from: number
to: number
events: AxisEvent[]
births: number
deaths: number
}
/** 横轴年表:十年一格,把族簿大事压缩进一根时间轴 */
export function yearAxis(s: GameState, decadeSize = 10): AxisCell[] {
const startYear = Math.max(1, Math.min(...s.chronicle.map((e) => e.year).concat(1)))
const endYear = Math.max(startYear, s.year)
const cells = new Map<number, AxisCell>()
const cellOf = (year: number): AxisCell => {
const from = Math.floor((year - 1) / decadeSize) * decadeSize + 1
if (!cells.has(from)) cells.set(from, { from, to: Math.min(from + decadeSize - 1, endYear), events: [], births: 0, deaths: 0 })
return cells.get(from)!
}
for (const e of s.chronicle) {
if (e.year < startYear) continue
const cell = cellOf(e.year)
const kind: AxisEvent['kind'] | null =
e.category === 'breakthrough'
? /渡劫|天劫/.test(e.text)
? '渡劫'
: '突破'
: e.category === 'birth'
? null
: e.category === 'death'
? /飞升/.test(e.text) ? '飞升' : '殇'
: e.category === 'marriage'
? '婚'
: e.category === 'battle'
? '战'
: null
if (kind) cell.events.push({ year: e.year, kind, label: e.text.slice(0, 26) })
if (e.category === 'birth') cell.births++
if (e.category === 'death') cell.deaths++
}
return [...cells.entries()].sort((a, b) => a[0] - b[0]).map(([, c]) => c)
}
export function decadeLabel(from: number, to: number): string {
return from === to ? `${from}` : `${from}-${to}`
}
+82
View File
@@ -0,0 +1,82 @@
import { Element, type Character } from '../types/domain'
import { techniqueById } from './techniques'
export type AspirationEffectType = 'cult' | 'battle' | 'market' | 'field' | 'offspring' | 'rep'
export interface AspirationDef {
id: string
name: string
desc: string
icon: string
effect: { type: AspirationEffectType; value: number }
}
export const ASPIRATIONS: Record<string, AspirationDef> = {
dao: {
id: 'dao',
name: '求道',
desc: '一心通天:修炼速度 +2%。',
icon: '道',
effect: { type: 'cult', value: 0.02 }
},
zhen: {
id: 'zhen',
name: '镇族',
desc: '守土传家:家族声望年增 +0.3。',
icon: '镇',
effect: { type: 'rep', value: 0.3 }
},
cheng: {
id: 'cheng',
name: '承宗',
desc: '开枝散叶:家宅添丁概率 +5%。',
icon: '承',
effect: { type: 'offspring', value: 0.05 }
},
shang: {
id: 'shang',
name: '商略',
desc: '市井谙熟:坊市进项 +3%。',
icon: '商',
effect: { type: 'market', value: 0.03 }
},
bing: {
id: 'bing',
name: '兵略',
desc: '韬略在胸:战力 +3%。',
icon: '兵',
effect: { type: 'battle', value: 0.03 }
},
geng: {
id: 'geng',
name: '耕读',
desc: '稼穑不辍:灵田产出 +5%。',
icon: '耕',
effect: { type: 'field', value: 0.05 }
},
yun: {
id: 'yun',
name: '云游',
desc: '天地为庐:修炼 -3%,所见所闻略广。',
icon: '云',
effect: { type: 'cult', value: -0.03 }
}
}
export const ASPIRATION_IDS = Object.keys(ASPIRATIONS)
export function aspirationById(id: string | undefined): AspirationDef | null {
if (!id) return null
return ASPIRATIONS[id] ?? null
}
export function fitBonusOf(c: Character): { cult: boolean; battle: boolean } {
const tech = techniqueById(c.techniqueId)
if (!tech) return { cult: false, battle: false }
const fit = tech.element === c.roots.primary
return { cult: fit, battle: fit }
}
export function isFit(techElement: Element, primary: Element): boolean {
return techElement === primary
}
+4
View File
@@ -41,6 +41,10 @@ export interface EffectDef {
artifactChance?: number artifactChance?: number
addTech?: string addTech?: string
feisheng?: { stay: boolean } feisheng?: { stay: boolean }
tournament?: boolean
formation?: string
apprentice?: { build: boolean }
trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' }
} }
export interface EventOptionDef { export interface EventOptionDef {
+45
View File
@@ -0,0 +1,45 @@
export type FormationId = 'vanguard' | 'echelon' | 'serpent'
export interface FormationDef {
id: FormationId
name: string
icon: string
desc: string
atk: number
def: number
retreatWound: number
}
export const FORMATIONS: Record<FormationId, FormationDef> = {
vanguard: {
id: 'vanguard',
name: '锋阵',
icon: '锋',
desc: '一往无前:攻势 +10%。',
atk: 1.1,
def: 1,
retreatWound: 1
},
echelon: {
id: 'echelon',
name: '雁阵',
icon: '雁',
desc: '羽翼相援:均衡,御力 +5%。',
atk: 1,
def: 1.05,
retreatWound: 0.85
},
serpent: {
id: 'serpent',
name: '蛇阵',
icon: '蛇',
desc: '盘而不乱:溃退伤损 -30%。',
atk: 0.95,
def: 1.05,
retreatWound: 0.7
}
}
export function formationById(id: string | undefined): FormationDef {
return FORMATIONS[(id as FormationId) ?? 'vanguard'] ?? FORMATIONS.vanguard
}
+1 -1
View File
@@ -15,7 +15,7 @@ export interface NpcFamilyDef {
export const NPCS: NpcFamilyDef[] = [ export const NPCS: NpcFamilyDef[] = [
{ {
id: 'n-xuanying', name: '玄影沈氏', region: '北岳玄影峰', style: '剑修世家', desc: '隐于北岳的剑修氏,剑意凛冽,最为孤傲。', id: 'n-xuanying', name: '玄影沈氏', region: '北岳玄影峰', style: '剑修世家', desc: '隐于北岳的剑修氏,剑意凛冽,最为孤傲。',
leaderRealm: 'foundation', initialPower: 240, powerGrowth: [4, 10], leaderRealm: 'foundation', initialPower: 240, powerGrowth: [4, 10],
sells: ['weapon-qi', 'weapon-ling'], buys: ['lingcao', 'lingkuang'] sells: ['weapon-qi', 'weapon-ling'], buys: ['lingcao', 'lingkuang']
}, },
+5 -5
View File
@@ -2,11 +2,11 @@ import { RealmMajor } from '../types/domain'
export const MAJOR_RATE: Record<RealmMajor, number> = { export const MAJOR_RATE: Record<RealmMajor, number> = {
mortal: 1, mortal: 1,
qi: 1, qi: 2.2,
foundation: 0.62, foundation: 1.4,
core: 0.38, core: 0.95,
nascent: 0.23, nascent: 0.68,
spirit: 0.15 spirit: 0.48
} }
export function masteryRateOfMajor(major: RealmMajor): number { export function masteryRateOfMajor(major: RealmMajor): number {
+16 -12
View File
@@ -11,12 +11,12 @@ export interface MajorDef {
} }
export const MAJORS: Record<RealmMajor, MajorDef> = { export const MAJORS: Record<RealmMajor, MajorDef> = {
mortal: { name: '凡人', short: '凡', minorLayers: 0, lifespan: 62, expBase: 0, expGrowth: 1 }, mortal: { name: '凡人', short: '凡', minorLayers: 0, lifespan: 75, expBase: 0, expGrowth: 1 },
qi: { name: '炼气', short: '炼气', minorLayers: 9, lifespan: 110, expBase: 60, expGrowth: 1.35 }, qi: { name: '炼气', short: '炼气', minorLayers: 9, lifespan: 150, expBase: 60, expGrowth: 1.10 },
foundation: { name: '筑基', short: '筑基', minorLayers: 3, lifespan: 165, expBase: 1400, expGrowth: 1.6 }, foundation: { name: '筑基', short: '筑基', minorLayers: 3, lifespan: 220, expBase: 1400, expGrowth: 1.32 },
core: { name: '金丹', short: '金丹', minorLayers: 3, lifespan: 260, expBase: 6200, expGrowth: 1.7 }, core: { name: '金丹', short: '金丹', minorLayers: 3, lifespan: 340, expBase: 6200, expGrowth: 1.36 },
nascent: { name: '元婴', short: '元婴', minorLayers: 3, lifespan: 400, expBase: 22000, expGrowth: 1.75 }, nascent: { name: '元婴', short: '元婴', minorLayers: 3, lifespan: 520, expBase: 22000, expGrowth: 1.4 },
spirit: { name: '化神', short: '化神', minorLayers: 3, lifespan: 600, expBase: 60000, expGrowth: 1.8 } spirit: { name: '化神', short: '化神', minorLayers: 3, lifespan: 850, expBase: 60000, expGrowth: 1.44 }
} }
export const MAJOR_ORDER: RealmMajor[] = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit'] export const MAJOR_ORDER: RealmMajor[] = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
@@ -91,15 +91,15 @@ export function breakthroughBaseChance(realm: Realm): number {
case 'mortal': case 'mortal':
return 0.9 return 0.9
case 'qi': case 'qi':
return 0.45 return 0.55
case 'foundation': case 'foundation':
return 0.32 return 0.45
case 'core': case 'core':
return 0.22 return 0.32
case 'nascent': case 'nascent':
return 0.14 return 0.22
case 'spirit': case 'spirit':
return 0.08 return 0.14
default: default:
return 0.4 return 0.4
} }
@@ -116,4 +116,8 @@ export interface TechniqueDef {
desc: string desc: string
} }
export const TECHNIQUE_GRADE_NAMES = ['黄阶', '玄阶', '地阶', '天阶', '仙阶'] export const TECHNIQUE_GRADE_NAMES = ['凡阶', '黄阶', '玄阶', '地阶', '天阶', '仙阶']
export function techniqueGradeName(grade: number): string {
return TECHNIQUE_GRADE_NAMES[grade] ?? ''
}
+87
View File
@@ -0,0 +1,87 @@
import { ITEMS, ARTIFACT_POWER } from './items'
import { TECHNIQUES } from './techniques'
import { BUILDINGS } from './buildings'
import { MISSIONS, ENEMIES } from './secrets'
import { NPCS } from './npcs'
import { EVENTS } from './events'
import { POSTS } from './posts'
import { TRAITS } from './traits'
import { ROOT_GRADES, ROOT_GRADE_NAMES, ELEMENT_LIST } from './elements'
import { MAJORS, MAJOR_ORDER } from './realms'
export interface DataPack {
items: typeof ITEMS
artifacts: typeof ARTIFACT_POWER
techniques: typeof TECHNIQUES
buildings: typeof BUILDINGS
missions: typeof MISSIONS
enemies: typeof ENEMIES
npcs: typeof NPCS
events: typeof EVENTS
posts: typeof POSTS
traits: typeof TRAITS
rootGrades: typeof ROOT_GRADES
rootGradeNames: typeof ROOT_GRADE_NAMES
elements: typeof ELEMENT_LIST
majors: typeof MAJORS
majorOrder: typeof MAJOR_ORDER
}
/** 默认包:与库内静态数据**同引用**(保证确定性指纹不变);未来 MOD 通过注入覆盖 */
export const DEFAULT_PACK: DataPack = {
items: ITEMS,
artifacts: ARTIFACT_POWER,
techniques: TECHNIQUES,
buildings: BUILDINGS,
missions: MISSIONS,
enemies: ENEMIES,
npcs: NPCS,
events: EVENTS,
posts: POSTS,
traits: TRAITS,
rootGrades: ROOT_GRADES,
rootGradeNames: ROOT_GRADE_NAMES,
elements: ELEMENT_LIST,
majors: MAJORS,
majorOrder: MAJOR_ORDER
}
/**
* 数据包注册表:默认 `pack()` 返回 DEFAULT_PACK(引用共享)。
* setPack(partial) 覆写后立即生效;resetPack() 回到默认。返回的数据包指纹可用于版本校验。
*/
export class DataPackRegistry {
private data: DataPack = DEFAULT_PACK
current(): DataPack {
return this.data
}
override(partial: Partial<DataPack>): DataPack {
this.data = { ...DEFAULT_PACK, ...partial }
return this.data
}
reset(): DataPack {
this.data = DEFAULT_PACK
return this.data
}
fingerprint(): string {
const key = ['items', 'techniques', 'buildings', 'missions', 'events', 'npcs'] as const
let h = 7
for (const k of key) {
const o = this.data[k] as unknown as Record<string, unknown>
const len = Object.keys(o ?? {}).length
h = Math.imul(h ^ len, 31)
}
return (h >>> 0).toString(16)
}
}
export const PACK = new DataPackRegistry()
/** 便捷读口(引擎唯一访问方式) */
export function pack(): DataPack {
return PACK.current()
}
+19
View File
@@ -0,0 +1,19 @@
export type Season = 'spring' | 'summer' | 'autumn' | 'winter'
export function seasonOf(month: number): Season {
if (month <= 3) return 'spring'
if (month <= 6) return 'summer'
if (month <= 9) return 'autumn'
return 'winter'
}
export const SEASON: Record<Season, { name: string; field: number; cult: number; market: number; meditation: number }> = {
spring: { name: '春', field: 0.1, cult: 0, market: 0, meditation: 0 },
summer: { name: '夏', field: 0, cult: 0.05, market: 0, meditation: 0 },
autumn: { name: '秋', field: 0, cult: 0, market: 0.05, meditation: 0 },
winter: { name: '冬', field: 0, cult: 0, market: 0, meditation: 0.08 }
}
export function seasonMod(month: number, key: 'field' | 'cult' | 'market' | 'meditation'): number {
return SEASON[seasonOf(month)][key]
}
+159
View File
@@ -0,0 +1,159 @@
import type { World } from './world'
import { WorldEventBus } from './world'
import { YearlyReport, BattleLog, ChronicleEntry, SaveMeta } from '../types/domain'
import { marketPrice, buyItem, sellItem, buyTechnique } from './market'
import { sendMission, recallAll } from './systems/missions'
import { giftNpc, makePeace, marryNpcFamily } from './systems/diplomacy'
import { combatPowerOf } from './systems/combat'
import type { LogKind } from './world'
import { computeLegacy, resolveLegacy, LegacyArch } from '../core/legacy'
import { yearAxis, AxisCell } from '../core/yearaxis'
import { PACK } from '../data/registry'
export type ActName =
| 'head.set'
| 'member.meditate'
| 'member.technique'
| 'member.equip'
| 'member.pill'
| 'member.advance'
| 'member.marry'
| 'member.post'
| 'estate.build'
| 'estate.upgrade'
| 'estate.rite'
| 'estate.sutra'
| 'market.buy'
| 'market.sell'
| 'market.tech'
| 'craft.pill'
| 'diplomacy.gift'
| 'diplomacy.peace'
| 'diplomacy.taunt'
| 'diplomacy.marry'
| 'expedition.send'
| 'expedition.recall'
| 'legacy.resolve'
export interface ActPayload {
memberId?: string
targetId?: string
post?: string
item?: string
pill?: string
tech?: string
building?: string
npcId?: string
stones?: number
mission?: string
squad?: string[]
count?: number
}
export const ACT_CATALOG: Record<ActName, { desc: string; fn: (w: World, p: ActPayload) => boolean }> = {
'head.set': { desc: '册立家主', fn: (w, p) => (p.memberId ? (w.assignHead(p.memberId), true) : false) },
'member.meditate': { desc: '闭关/出关', fn: (w, p) => (p.memberId ? (w.setMeditation(p.memberId, !!p.count), true) : false) },
'member.technique': { desc: '传功', fn: (w, p) => (p.memberId && p.tech ? (w.giveTechnique(p.memberId, p.tech), true) : false) },
'member.equip': { desc: '装备法宝', fn: (w, p) => (p.memberId && p.item ? (w.equip(p.memberId, p.item), true) : false) },
'member.pill': { desc: '服丹', fn: (w, p) => (p.memberId && p.pill ? (w.takePill(p.memberId, p.pill), true) : false) },
'member.advance': { desc: '冲关', fn: (w, p) => (p.memberId ? (w.assistedBreakthrough(p.memberId), true) : false) },
'member.marry': { desc: '指婚', fn: (w, p) => (p.memberId && p.targetId ? w.marryTo(p.memberId, p.targetId) : false) },
'member.post': { desc: '任职/卸任', fn: (w, p) => (p.memberId ? w.assignPost(p.memberId, p.post) : false) },
'estate.build': { desc: '营建', fn: (w, p) => (p.building ? w.build(p.building) : false) },
'estate.upgrade': { desc: '升级', fn: (w, p) => (p.building ? w.upgrade(p.building) : false) },
'estate.rite': { desc: '祭祖', fn: (w) => w.ancestralRite() },
'estate.sutra': { desc: '求经', fn: (w) => w.seekSutra() },
'market.buy': { desc: '购货', fn: (w, p) => (p.item ? buyItem(w, p.item, p.count ?? 1) : false) },
'market.sell': { desc: '售货', fn: (w, p) => (p.item ? sellItem(w, p.item, p.count ?? 1) : false) },
'market.tech': { desc: '购法帖', fn: (w, p) => (p.tech ? buyTechnique(w, p.tech, p.stones ?? 0) : false) },
'craft.pill': { desc: '炼丹', fn: (w, p) => (p.item === 'ningyuan' ? w.craftPill('ningyuan') : Boolean(p.item === 'qiyuan') && w.craftPill('qiyuan')) },
'diplomacy.gift': { desc: '赠礼', fn: (w, p) => (p.npcId ? giftNpc(w, p.npcId, p.stones ?? 0) : false) },
'diplomacy.peace': { desc: '议和', fn: (w, p) => (p.npcId ? makePeace(w, p.npcId) : false) },
'diplomacy.taunt': { desc: '寻衅', fn: (w, p) => (p.npcId ? w.tauntNpc(p.npcId) : false) },
'diplomacy.marry': { desc: '联姻', fn: (w, p) => (p.npcId ? marryNpcFamily(w, p.npcId) : false) },
'expedition.send': { desc: '遣队出征', fn: (w, p) => (p.mission ? sendMission(w, p.mission, p.squad ?? []) : false) },
'expedition.recall': { desc: '召回', fn: (w, p) => (p.memberId ? (recallAll(w, p.memberId), true) : false) },
'legacy.resolve': { desc: '落印定鼎', fn: (w) => (w.resolveLegacyNow(), true) }
}
export interface QueryResult {
[k: string]: unknown
}
export class GameFacade {
constructor(
public readonly world: World,
public readonly slot: number,
private bus?: WorldEventBus
) {}
act(name: ActName, payload: ActPayload = {}): boolean {
const c = ACT_CATALOG[name]
if (!c) return false
return c.fn(this.world, payload)
}
query(ref: string): QueryResult {
const w = this.world
switch (ref) {
case 'family':
return { name: w.state.family.name, estate: w.state.family.estate, year: w.state.year, month: w.state.month, reputation: w.state.family.reputation, generation: w.state.family.generation }
case 'members':
return { list: w.aliveMembers().map((c) => ({ id: c.id, name: c.name, realm: `${c.realm.major}/${c.realm.minor}`, post: c.post ?? '', state: c.state, power: Math.round(combatPowerOf(w, c)) })) }
case 'legacy':
return resolveLegacy(w.state) as unknown as QueryResult
case 'yearAxis':
return { cells: yearAxis(w.state) as unknown as AxisCell[] }
case 'systems':
return { list: w.systemList() }
case 'finance':
return { stones: w.state.family.stones, accum: w.state.finance.accum, yearStats: w.state.yearStats }
case 'plugins':
return { list: w.pluginList() }
default:
return {}
}
}
subscribe(on: (e: FacadeEvent) => void): () => void {
const bus: WorldEventBus = {
onLog: (kind: LogKind, text: string) => on({ type: 'log', kind, text, year: this.world.state.year, month: this.world.state.month }),
onChronicle: (entry: ChronicleEntry, important: boolean) => on({ type: 'chronicle', entry, important }),
onBattle: (log: BattleLog) => on({ type: 'battle', log }),
onPendingEvent: (id: string) => on({ type: 'pending', id }),
onGameOver: (reason: string) => on({ type: 'gameover', reason }),
onYearPaper: (report: YearlyReport) => on({ type: 'paper', report }),
onSystemChange: (id: string, enabled: boolean) => on({ type: 'sysChanged', id, enabled }),
onPluginChange: (id: string, action: string) => on({ type: 'plugin', id, action })
}
this.world.out.push(bus)
return () => {
const i = this.world.out.indexOf(bus)
if (i >= 0) this.world.out.splice(i, 1)
}
}
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
return {
title: '仙途家族志',
version: '0.1.11',
modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length,
packFingerprint: PACK.fingerprint()
}
}
}
export type FacadeEvent =
| { type: 'log'; kind: LogKind; text: string; year: number; month: number }
| { type: 'chronicle'; entry: ChronicleEntry; important: boolean }
| { type: 'battle'; log: BattleLog }
| { type: 'pending'; id: string }
| { type: 'gameover'; reason: string }
| { type: 'paper'; report: YearlyReport }
| { type: 'sysChanged'; id: string; enabled: boolean }
| { type: 'plugin'; id: string; action: string }
export { marketPrice, computeLegacy }
export type { SaveMeta, LegacyArch }
+25
View File
@@ -0,0 +1,25 @@
import type { PhaseId } from '../core/clock'
export interface SystemDef {
id: string
name: string
version: string
desc: string
phase?: PhaseId
yearly?: boolean
}
export const SYSTEM_DEFS: SystemDef[] = [
{ id: 'production', name: '生产', version: '1.0', phase: 'production', desc: '灵田药丹坊市之产出与物价漂移。' },
{ id: 'aging', name: '寿元', version: '1.0', phase: 'aging', desc: '年岁衰减、伤病滋养与辞世。' },
{ id: 'cultivation', name: '修炼', version: '1.0', phase: 'cultivation', desc: '修为积累、突破试炼与悟道。' },
{ id: 'missions', name: '探秘', version: '1.0', phase: 'missions', desc: '秘境远征与遭遇战。' },
{ id: 'events', name: '事件', version: '1.0', phase: 'events', desc: '日常/家国/际遇之机与天劫。' },
{ id: 'diplomacy', name: '外交', version: '1.0', phase: 'diplomacy', desc: '四邻聚落之好恶与劫掠。' },
{ id: 'marriage', name: '婚育', version: '1.0', yearly: true, desc: '媒娶联姻、添丁与代为祭。' },
{ id: 'annals', name: '岁簿', version: '1.0', yearly: true, desc: '岁末族簿与开年报数。' },
{ id: 'tournament', name: '太虚大比', version: '1.0', desc: '五年一会的太虚仙盟争锋。' },
{ id: 'tribulation', name: '渡劫', version: '1.0', desc: '大境界天劫与护法之仪。' },
{ id: 'apprentice', name: '寄读', version: '1.0', desc: '宗门子弟外修与求经台。' },
{ id: 'season', name: '时节', version: '1.0', desc: '春夏秋冬之乘气(春耕夏修秋市冬闭)。' }
]
+81
View File
@@ -0,0 +1,81 @@
import { GameClock } from '../core/clock'
import { PluginContext } from '../core/plugin'
import type { World } from './world'
import { productionTick } from './systems/production'
import { deathTick, woundHealTick } from './systems/lifecycle'
import { cultivationTick } from './systems/cultivation'
import { missionTick } from './systems/missions'
import { eventRoll } from './systems/events'
import { diplomacyTick } from './systems/diplomacy'
import { yearStartMarriage } from './systems/marriage'
/** 原语义保留:满门凋零后仅存生产/寿元与收尾 */
function ifAlive(fn: (w: World) => void): (w: World) => void {
return (w: World) => {
if (w.aliveMembers().length > 0) fn(w)
}
}
/** 能力开关:禁用即拔插(对应 capability id */
function viaCap(capId: string, fn: (w: World) => void): (w: World) => void {
return (w: World) => {
if (w.sysEnabled(capId)) fn(w)
}
}
export function emptyClock(): GameClock {
return new GameClock()
}
/**
* 内建系统注册(core-systems 插件入口)。
* 注册顺序 = 执行顺序(确定性红线,经由金钟罩校验)。
* 时间线:婚配养育 → 声望岁贡 → 岁末族簿 —— 再逐月:生产→寿元→修炼→任务→事件→外交→收尾
*/
export function installCoreSystems(ctx: PluginContext): Array<() => void> {
const unsubs: Array<() => void> = []
const clock = ctx.clock
unsubs.push(clock.onYearStart(viaCap('marriage', (w) => yearStartMarriage(w))))
unsubs.push(
clock.onYearStart(
viaCap('marriage', (w) => {
w.state.family.reputation += w.postBonus('familyRep')
const zhenCount = w.aliveMembers().filter((c) => c.aspiration === 'zhen').length
w.state.family.reputation += Math.round(zhenCount * 0.3 * 100) / 100
})
)
)
unsubs.push(clock.onYearStart(viaCap('annals', (w) => w.publishYearReport())))
unsubs.push(clock.register('production', viaCap('production', (w: World) => productionTick(w))))
unsubs.push(clock.register('aging', viaCap('aging', (w: World) => deathTick(w))))
unsubs.push(clock.register('aging', viaCap('aging', (w: World) => woundHealTick(w))))
unsubs.push(clock.register('cultivation', viaCap('cultivation', ifAlive((w: World) => cultivationTick(w)))))
unsubs.push(clock.register('missions', viaCap('missions', ifAlive((w: World) => missionTick(w)))))
unsubs.push(clock.register('events', viaCap('events', ifAlive((w: World) => eventRoll(w)))))
unsubs.push(clock.register('diplomacy', viaCap('diplomacy', ifAlive((w: World) => diplomacyTick(w)))))
unsubs.push(clock.register('epilogue', (w: World) => w.epilogueTick()))
return unsubs
}
/** 兼容旧接口:完整安装一套核心系统(测试/工具用) */
export function buildClock(): GameClock {
const clock = emptyClock()
const ctx = {
world: undefined as never,
clock,
register: (phase: Parameters<GameClock['register']>[0], fn: (w: World) => void) => clock.register(phase, fn),
onYearStart: (fn: (w: World) => void) => clock.onYearStart(fn),
addCapability: () => undefined,
removeCapability: () => undefined,
enableCapability: () => undefined,
overridePack: () => undefined,
resetPack: () => undefined,
addEventPool: () => undefined,
removeEventPool: () => undefined
}
installCoreSystems(ctx as never)
return clock
}
+9 -1
View File
@@ -76,7 +76,15 @@ export function createWorldState(opts: NewGameOptions): GameState {
totalTicks: 0, totalTicks: 0,
finance: { accum: 0 }, finance: { accum: 0 },
yearStats: { births: 0, deaths: 0 }, yearStats: { births: 0, deaths: 0 },
yearlyReports: [] yearlyReports: [],
stats: {
repPeak: 5,
popPeak: 5,
maxRealmIdx: 25,
techniqueGrand: 0,
tourneyHistory: [],
feishengCount: 0
}
} }
const w = new World(state, []) const w = new World(state, [])
+15 -7
View File
@@ -1,16 +1,23 @@
import { World } from './world' import type { World } from './world'
import { ITEMS } from '../data/items' import { pack } from '../data/registry'
import { traitBonuses } from './pcgen'
export function marketPrice(w: World, itemId: string): number { export function marketPrice(w: World, itemId: string): number {
const base = ITEMS[itemId]?.basePrice ?? 1 const item = pack().items[itemId]
if (!item) return 0
const base = item.basePrice
const fam = w.state.family const fam = w.state.family
const mult = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1 const mult = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1
const mood = fam.reputation >= 40 ? 1.06 : fam.reputation >= 20 ? 1.02 : 0.98 const mood = fam.reputation >= 40 ? 1.06 : fam.reputation >= 20 ? 1.02 : 0.98
return Math.max(1, Math.round(base * mult * mood)) // 利己(priceMult)与信誉修正
const sellers = w.aliveMembers().filter((c) => traitBonuses(c).priceMult > 0).length
const liarPct = Math.min(0.2, sellers * 0.04)
return Math.max(1, Math.round(base * mult * mood * (1 - liarPct)))
} }
export function buyItem(w: World, itemId: string, count: number): boolean { export function buyItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family const fam = w.state.family
if (!pack().items[itemId]) return false
const total = marketPrice(w, itemId) * count const total = marketPrice(w, itemId) * count
if (total > fam.stones) return false if (total > fam.stones) return false
fam.stones -= total fam.stones -= total
@@ -20,6 +27,7 @@ export function buyItem(w: World, itemId: string, count: number): boolean {
export function sellItem(w: World, itemId: string, count: number): boolean { export function sellItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family const fam = w.state.family
if (!pack().items[itemId]) return false
const have = fam.inventory[itemId] ?? 0 const have = fam.inventory[itemId] ?? 0
if (have < count) return false if (have < count) return false
fam.inventory[itemId] = have - count fam.inventory[itemId] = have - count
@@ -37,12 +45,12 @@ export function buyTechnique(w: World, techId: string, price: number): boolean {
} }
export function techniquePrice(techId: string): number { export function techniquePrice(techId: string): number {
const grade = TECH_GRADE_BASE[techId] ?? 200 return TECH_GRADE_BASE[techId] ?? 200
return grade
} }
import { TECHNIQUES } from '../data/techniques' import { TECHNIQUES } from '../data/techniques'
const TECHNIQUE_GRADE_PRICE: Record<number, number> = { 1: 120, 2: 300, 3: 700, 4: 1600 }
const TECH_GRADE_BASE: Record<string, number> = Object.fromEntries( const TECH_GRADE_BASE: Record<string, number> = Object.fromEntries(
TECHNIQUES.map((t) => [t.id, [120, 300, 700, 1600, 3600][t.grade] ?? 300]) TECHNIQUES.map((t) => [t.id, TECHNIQUE_GRADE_PRICE[t.grade] ?? 300])
) )
+2 -3
View File
@@ -27,9 +27,8 @@ export function rollRoots(rng: Rng, parents?: { m?: Character; f?: Character }):
} }
grade = Math.max(0, Math.min(5, grade)) grade = Math.max(0, Math.min(5, grade))
const primaryCandidate = parents && (parents.m || parents.f) const single = parents ? (parents.m ?? parents.f) : undefined
? [parents.m!.roots.primary, parents.f!.roots.primary] const primaryCandidate = single ? [single.roots.primary] : ELEMENT_LIST
: ELEMENT_LIST
const primary = rng.pick(primaryCandidate) const primary = rng.pick(primaryCandidate)
const secondaryCount = grade >= 3 ? rng.int(1, 2) : grade === 2 ? rng.int(0, 1) : 0 const secondaryCount = grade >= 3 ? rng.int(1, 2) : grade === 2 ? rng.int(0, 1) : 0
const rest = ELEMENT_LIST.filter((e) => e !== primary) const rest = ELEMENT_LIST.filter((e) => e !== primary)
@@ -0,0 +1,64 @@
import { CotycPlugin } from '../core/plugin'
import { installCoreSystems } from './clocks'
import { EVENTS } from '../data/events'
import { DEFAULT_PACK } from '../data/registry'
/** 内置插件一:镇族基石(12 能力卡与时轮钩子) */
export function makeCoreSystemsPlugin(): CotycPlugin {
let unsubs: Array<() => void> = []
return {
id: 'core-systems',
name: '镇族基石',
version: '0.1.8',
kind: 'system',
protected: true,
description: '十二能力卡与时轮钩子:生产/寿元/修炼/探秘/事件/外交/婚育/岁簿/大比/渡劫/寄读/时节。',
install(ctx) {
unsubs = installCoreSystems(ctx)
},
uninstall(ctx) {
void ctx
unsubs.forEach((u) => u())
unsubs = []
}
}
}
/** 内置插件二:经典数据包(默认平衡表) */
export function makeCoreDataPlugin(): CotycPlugin {
return {
id: 'core-data',
name: '经典数据包',
version: '0.1.8',
kind: 'data',
protected: true,
description: '平衡表默认包:境界/物品/功法/建筑/秘境/势力/性格/职事。',
install(ctx) {
ctx.resetPack()
},
uninstall() {
void DEFAULT_PACK
}
}
}
/** 内置插件三:内建事件池(日常/家国/际遇) */
export function makeCoreEventsPlugin(): CotycPlugin {
return {
id: 'core-events',
name: '内建事件池',
version: '0.1.8',
kind: 'events',
protected: true,
description: '命运事件主池:日常/重大/乾坤三层选支事件。',
install(ctx) {
ctx.addEventPool('core', EVENTS)
}
}
}
export const CORE_PLUGINS: CotycPlugin[] = [
makeCoreSystemsPlugin(),
makeCoreDataPlugin(),
makeCoreEventsPlugin()
]
+131
View File
@@ -0,0 +1,131 @@
import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../core/plugin'
import { SystemDef, SYSTEM_DEFS } from '../engine/capabilities'
import type { World } from '../engine/world'
import { SystemHook } from '../core/clock'
import { GameClock } from '../core/clock'
interface RuntimePlugin {
plugin: CotycPlugin
status: { installed: boolean; enabled: boolean }
unsubscribers: Array<() => void>
}
/**
* 插件管理器:统一安装/卸载/启停管线。
* 安装序即确定性序;依赖缺失或冲突 → 拒绝安装。
*/
export class PluginManager {
private runtime = new Map<string, RuntimePlugin>()
private order: string[] = []
private changes: PluginChange[] = []
constructor(private ctx: PluginContext) {}
private onChange(c: PluginChange): void {
this.changes.push(c)
}
listChanges(): PluginChange[] {
return this.changes.slice()
}
clearChanges(): void {
this.changes = []
}
install(plugin: CotycPlugin): { ok: boolean; reason?: string } {
if (this.runtime.has(plugin.id)) return { ok: false, reason: `插件已存在:${plugin.id}` }
for (const dep of plugin.dependencies ?? []) {
if (!this.runtime.has(dep)) return { ok: false, reason: `缺少依赖:${dep}` }
}
for (const c of plugin.conflicts ?? []) {
if (this.runtime.has(c)) return { ok: false, reason: `${c} 冲突` }
}
for (const def of SYSTEM_DEFS) {
if (def.id === plugin.id) return { ok: false, reason: `id 冲突:${plugin.id}` }
}
const rt: RuntimePlugin = { plugin, status: { installed: true, enabled: true }, unsubscribers: [] }
this.runtime.set(plugin.id, rt)
this.order.push(plugin.id)
// 追踪型上下文:插件在时轮上的注册(phase/年首)一律归属该插件,卸载时全摘
const tracked = new Proxy(this.ctx, {
get(target, key) {
const prop = key as keyof PluginContext
if (prop === 'register' || prop === 'onYearStart') {
return (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => {
const unsub = (target[prop] as (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void)(phase, fn)
rt.unsubscribers.push(unsub)
return unsub
}
}
return (target as unknown as Record<string, unknown>)[key as string]
}
})
try {
plugin.install(tracked as PluginContext)
plugin.hooks?.onLoad?.()
} catch (e) {
rt.unsubscribers.forEach((u) => u())
this.runtime.delete(plugin.id)
const i = this.order.indexOf(plugin.id)
if (i >= 0) this.order.splice(i, 1)
return { ok: false, reason: `安装异常:${String(e)}` }
}
this.onChange({ id: plugin.id, action: 'install' })
return { ok: true }
}
remove(pluginId: string): { ok: boolean; reason?: string } {
const rt = this.runtime.get(pluginId)
if (!rt) return { ok: false, reason: '未安装' }
if (rt.plugin.protected) return { ok: false, reason: '核心插件受保护' }
rt.unsubscribers.forEach((u) => u())
rt.plugin.uninstall?.(this.ctx)
this.runtime.delete(pluginId)
const i = this.order.indexOf(pluginId)
if (i >= 0) this.order.splice(i, 1)
this.onChange({ id: pluginId, action: 'remove' })
return { ok: true }
}
setEnabled(pluginId: string, enabled: boolean): { ok: boolean; reason?: string } {
const rt = this.runtime.get(pluginId)
if (!rt) return { ok: false, reason: '未安装' }
if (enabled && !rt.plugin.hooks?.onEnable) {
// 无 enable 钩子的插件视为直接切换 enabled 状态
}
rt.status.enabled = enabled
if (enabled) rt.plugin.hooks?.onEnable?.()
else rt.plugin.hooks?.onDisable?.()
this.onChange({ id: pluginId, action: enabled ? 'enable' : 'disable' })
return { ok: true }
}
list(): PluginStatus[] {
return this.order
.filter((id) => this.runtime.has(id))
.map((id) => {
const rt = this.runtime.get(id)!
const p = rt.plugin
return {
id: p.id,
name: p.name,
version: p.version,
kind: p.kind,
installed: rt.status.installed,
enabled: rt.status.enabled,
protected: !!p.protected,
dependencies: p.dependencies
}
})
}
has(id: string): boolean {
return this.runtime.has(id)
}
get(id: string): CotycPlugin | null {
return this.runtime.get(id)?.plugin ?? null
}
}
+22 -12
View File
@@ -1,12 +1,14 @@
import { World } from '../world' import type { World } from '../world'
import { Character, BattleLog } from '../../types/domain' import { Character, BattleLog } from '../../types/domain'
import { basePower, describeRealm } from '../../data/realms' import { basePower, describeRealm } from '../../data/realms'
import { ARTIFACT_POWER } from '../../data/items' import { pack } from '../../data/registry'
import { techniqueById } from '../../data/techniques' import { techniqueById } from '../../data/techniques'
import { EnemyDef, LootDef } from '../../data/secrets' import { EnemyDef, LootDef } from '../../data/secrets'
import { TECHNIQUES } from '../../data/techniques'
import { traitBonuses } from '../pcgen' import { traitBonuses } from '../pcgen'
import { npcById } from '../../data/npcs' import { npcById } from '../../data/npcs'
import { aspirationById, fitBonusOf } from '../../data/aspirations'
import { formationById, FormationId } from '../../data/formations'
export function combatPowerOf(w: World, c: Character): number { export function combatPowerOf(w: World, c: Character): number {
if (!c.alive) return 0 if (!c.alive) return 0
@@ -15,10 +17,13 @@ export function combatPowerOf(w: World, c: Character): number {
const tech = techniqueById(c.techniqueId) const tech = techniqueById(c.techniqueId)
const wuRank = (c.techniqueRank ?? 0) > 1 ? 0.2 : (c.techniqueRank ?? 0) === 1 ? 0.08 : 0 const wuRank = (c.techniqueRank ?? 0) > 1 ? 0.2 : (c.techniqueRank ?? 0) === 1 ? 0.08 : 0
const techBonus = tech ? 1 + tech.powerBonus + wuRank : 1 const techBonus = tech ? 1 + tech.powerBonus + wuRank : 1
const equip = c.equipment ? 1 + (ARTIFACT_POWER[c.equipment] ?? 0) : 1 const equip = c.equipment ? 1 + (pack().artifacts[c.equipment] ?? 0) : 1
const aspiration = aspirationById(c.aspiration)
const aspi = aspiration?.effect.type === 'battle' ? 1 + aspiration.effect.value : 1
const fit = fitBonusOf(c).battle ? 1.04 : 1
const trait = 1 + traitBonuses(c).windBonus const trait = 1 + traitBonuses(c).windBonus
const health = 0.5 + 0.5 * (c.health / 100) const health = 0.5 + 0.5 * (c.health / 100)
return round1(base * stat * techBonus * equip * trait * health) return round1(base * stat * techBonus * equip * trait * aspi * fit * health)
} }
function round1(n: number): number { function round1(n: number): number {
@@ -64,13 +69,15 @@ export function resolveEncounter(
kind: BattleLog['kind'] kind: BattleLog['kind']
year: number year: number
month: number month: number
formation?: FormationId
} }
): EncounterResult { ): EncounterResult {
const form = formationById(opts.formation)
let team = 0 let team = 0
for (const c of opts.team) team += combatPowerOf(w, c) for (const c of opts.team) team += combatPowerOf(w, c)
const enemy = enemyPowerOf(opts.enemy, opts.risk) const enemy = Math.round(enemyPowerOf(opts.enemy, opts.risk) * form.def)
const jitter = w.rng.between(0.88, 1.12) const jitter = w.rng.between(0.88, 1.12)
const teamFinal = Math.round(team * jitter) const teamFinal = Math.round(team * form.atk * jitter)
const roll = w.rng.next() const roll = w.rng.next()
const win = teamFinal >= enemy * 1.08 const win = teamFinal >= enemy * 1.08
const lose = teamFinal < enemy * 0.82 const lose = teamFinal < enemy * 0.82
@@ -81,7 +88,7 @@ export function resolveEncounter(
const names = opts.team.map((c) => c.name).join('、') const names = opts.team.map((c) => c.name).join('、')
const lines: string[] = [] const lines: string[] = []
lines.push(`—— ${opts.title} ——`) lines.push(`—— ${opts.title} ——`)
lines.push(`${year}${month}月,${names}上了【${opts.enemy.name}】。(敌势 ${enemy},我阵 ${teamFinal}`) lines.push(`${year}${month}月,${names}以「${form.name}」列阵,迎上了【${opts.enemy.name}】。(敌势 ${enemy},我阵 ${teamFinal}`)
if (win) { if (win) {
lines.push(`首战告捷:${w.rng.pick(WIN_DESC)}`) lines.push(`首战告捷:${w.rng.pick(WIN_DESC)}`)
} else if (lose) { } else if (lose) {
@@ -101,7 +108,8 @@ export function resolveEncounter(
loss.push(`${c.name} 陨落`) loss.push(`${c.name} 陨落`)
w.chronicle('death', `${c.name} 战殁于${opts.enemy.name},一身所学俱付尘烟。`, c.id, true) w.chronicle('death', `${c.name} 战殁于${opts.enemy.name},一身所学俱付尘烟。`, c.id, true)
} else if (severity < 0.45) { } else if (severity < 0.45) {
c.health = Math.max(1, c.health - 40 - w.rng.int(0, 25)) const woundAmt = Math.round((40 + w.rng.int(0, 25)) * form.retreatWound)
c.health = Math.max(1, c.health - woundAmt)
c.state = 'wounded' c.state = 'wounded'
loss.push(`${c.name} 重伤`) loss.push(`${c.name} 重伤`)
} }
@@ -152,7 +160,8 @@ function itemName(id: string): string {
export function resolveRaid( export function resolveRaid(
w: World, w: World,
npcId: string, npcId: string,
team: Character[] team: Character[],
formation?: FormationId
): EncounterResult { ): EncounterResult {
const npc = w.state.npcFamilies[npcId] const npc = w.state.npcFamilies[npcId]
const def = npcById(npcId) const def = npcById(npcId)
@@ -172,7 +181,8 @@ export function resolveRaid(
team, team,
kind: 'war', kind: 'war',
year: w.state.year, year: w.state.year,
month: w.state.month month: w.state.month,
formation
}) })
if (res.win) { if (res.win) {
npc.relation = Math.min(60, npc.relation + 25) npc.relation = Math.min(60, npc.relation + 25)
@@ -202,7 +212,7 @@ export function rollWarbooty(w: World, loot: LootDef): Record<string, number> {
result[a] = 1 result[a] = 1
} }
if (loot.techniqueChance && w.rng.chance(loot.techniqueChance)) { if (loot.techniqueChance && w.rng.chance(loot.techniqueChance)) {
const t = w.rng.pick(TECHNIQUES) const t = w.rng.pick(pack().techniques)
w.state.family.techniques.push(t.id) w.state.family.techniques.push(t.id)
result['tech'] = 1 result['tech'] = 1
} }
@@ -1,18 +1,22 @@
import { World } from '../world' import type { World } from '../world'
import { Character } from '../../types/domain' import { Character } from '../../types/domain'
import { ROOT_GRADES } from '../../data/elements' import { ROOT_GRADES } from '../../data/elements'
import { masteryRateOfMajor } from '../../data/pacing' import { masteryRateOfMajor } from '../../data/pacing'
import { aspirationById, fitBonusOf } from '../../data/aspirations'
import { techniqueById } from '../../data/techniques' import { techniqueById } from '../../data/techniques'
import { nextRealm, breakthroughBaseChance, realmDeathChance, describeRealm, MAJOR_ORDER } from '../../data/realms' import { nextRealm, breakthroughBaseChance, realmDeathChance, describeRealm, MAJOR_ORDER } from '../../data/realms'
import { lifespanOf } from './lifecycle' import { lifespanOf } from './lifecycle'
import { traitBonuses } from '../pcgen' import { traitBonuses } from '../pcgen'
import { newCharacter } from '../pcgen' import { newCharacter } from '../pcgen'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names' import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
import { ASPIRATION_IDS } from '../../data/aspirations'
import { seasonMod } from '../../data/season'
import { needsTribulation, tribulationEventId } from './tribulation'
export function monthlyRate(w: World, c: Character): number { export function monthlyRate(w: World, c: Character): number {
const st = w.state const st = w.state
let rate = 1 let rate = 1
rate *= 0.5 + c.perception * 0.1 rate *= 1.0 + c.perception * 0.18
rate *= ROOT_GRADES[c.roots.grade]?.expBonus ?? 0.5 rate *= ROOT_GRADES[c.roots.grade]?.expBonus ?? 0.5
const tech = techniqueById(c.techniqueId) const tech = techniqueById(c.techniqueId)
if (tech && c.realm.major !== 'mortal') { if (tech && c.realm.major !== 'mortal') {
@@ -23,12 +27,16 @@ export function monthlyRate(w: World, c: Character): number {
const buildings = st.family.buildings const buildings = st.family.buildings
const juling = buildings['juling'] ?? 0 const juling = buildings['juling'] ?? 0
rate *= 1 + juling * 0.05 rate *= 1 + juling * 0.05
if (w.sysEnabled('season')) rate *= 1 + seasonMod(st.month, 'cult')
rate *= 1 + w.postBonus('expAll') rate *= 1 + w.postBonus('expAll')
if (st.family.flag['fengFeiBless']) rate *= 1.05 if (st.family.flag['fengFeiBless']) rate *= 1.05
if (c.traits.includes('fengxian')) rate *= 1.3 if (c.traits.includes('fengxian')) rate *= 1.3
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') { if (c.state === 'meditation') {
rate *= 1.35 rate *= 1.35
rate *= 1 + w.postBonus('meditation') rate *= 1 + w.postBonus('meditation') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0)
const dongfu = buildings['dongfu'] ?? 0 const dongfu = buildings['dongfu'] ?? 0
rate *= 1 + dongfu * 0.08 rate *= 1 + dongfu * 0.08
} else if (c.state === 'expedition') { } else if (c.state === 'expedition') {
@@ -41,15 +49,24 @@ export function monthlyRate(w: World, c: Character): number {
if (c.health <= 40) rate *= 0.45 if (c.health <= 40) rate *= 0.45
else if (c.health <= 70) rate *= 0.8 else if (c.health <= 70) rate *= 0.8
} }
rate *= traitBonuses(c).exp
if (w.ageOf(c) < 8) rate *= 0.4 if (w.ageOf(c) < 8) rate *= 0.4
if (w.ageOf(c) > 55) rate *= 0.7 if (w.ageOf(c) > 65) rate *= 0.72
rate *= masteryRateOfMajor(c.realm.major) rate *= masteryRateOfMajor(c.realm.major)
return rate return rate
} }
export function cultivationTick(w: World): void { export function cultivationTick(w: World): void {
// 圣者护族:家族有 60+ 且超过筑基者时,幼儿修行门槛更低
const hasPatriarch = w.aliveMembers().some((c) => w.ageOf(c) >= 60 && c.realm.major !== 'mortal' && c.realm.major !== 'qi')
// 成年礼定志(16-18 岁首次)
for (const c of Object.values(w.state.members)) { for (const c of Object.values(w.state.members)) {
if (!c.alive) continue if (c.alive && !c.aspiration && w.ageOf(c) >= 16 && w.ageOf(c) <= 19) {
c.aspiration = w.rng.pick(ASPIRATION_IDS)
}
}
for (const c of Object.values(w.state.members)) {
if (!c.alive || c.state === 'apprentice') continue
const rate = monthlyRate(w, c) const rate = monthlyRate(w, c)
if (rate <= 0) continue if (rate <= 0) continue
c.realmProgress = Math.min(100, c.realmProgress + rate) c.realmProgress = Math.min(100, c.realmProgress + rate)
@@ -75,7 +92,22 @@ export function cultivationTick(w: World): void {
if (c.realmProgress >= 100) { if (c.realmProgress >= 100) {
const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999) const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999)
if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) { if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) {
resolveBreakthrough(w, c, 0) if (needsTribulation(c) && w.sysEnabled('tribulation')) {
// 已在等待渡劫(pending 未清)或压制期内,不再重设
if (w.state.pendingEvent === tribulationEventId(c)) {
c.realmProgress = 100
continue
}
if (c.tribDelayYear && w.state.year < c.tribDelayYear) {
c.realmProgress = 100
continue
}
c.tribDelayYear = undefined
w.pendingEvent(tribulationEventId(c))
w.state.pendingEvent = tribulationEventId(c)
} else {
resolveBreakthrough(w, c, 0)
}
} }
} }
} }
@@ -160,7 +192,7 @@ export function resolveBreakthrough(w: World, c: Character, boost: number): void
return return
} }
if (c.health < 20) c.state = 'wounded' if (c.health < 20) c.state = 'wounded'
const loss = 40 + w.rng.int(0, 25) const loss = 26 + w.rng.int(0, 18)
c.realmProgress = Math.max(0, Math.min(95, 100 - loss - boost * 60)) c.realmProgress = Math.max(0, Math.min(95, 100 - loss - boost * 60))
w.log('bad', log) w.log('bad', log)
} }
@@ -1,4 +1,4 @@
import { World } from '../world' import type { World } from '../world'
import { npcById } from '../../data/npcs' import { npcById } from '../../data/npcs'
import { findEvent, fire } from './events' import { findEvent, fire } from './events'
+338 -17
View File
@@ -1,21 +1,30 @@
import { World } from '../world' import type { World } from '../world'
import { Character } from '../../types/domain' import { Character } from '../../types/domain'
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../data/events' import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../data/events'
import { MAJOR_ORDER } from '../../data/realms' import { MAJOR_ORDER } from '../../data/realms'
import { TECHNIQUES } from '../../data/techniques'
import { MISSIONS } from '../../data/secrets'
import { npcById } from '../../data/npcs' import { npcById } from '../../data/npcs'
import { resolveRaid } from './combat' import { resolveRaid } from './combat'
import { sendMission } from './missions' import { sendMission } from './missions'
import { pack } from '../../data/registry'
import { findInheritor } from '../creation' import { findInheritor } from '../creation'
import { resolveTribulation } from './tribulation'
import { nextRealm, describeRealm } from '../../data/realms'
const ALL_EVENTS: EventDef[] = [...EVENTS] const ALL_EVENTS: EventDef[] = [...EVENTS]
export function findEvent(id: string): EventDef | undefined { export function findEvent(id: string, world?: World): EventDef | undefined {
return ALL_EVENTS.find((e) => e.id === id) ?? dynamicEventFor(id) const found = ALL_EVENTS.find((e) => e.id === id)
if (found) return found
if (world) {
const pooled = world.allEvents().find((e) => e.id === id)
if (pooled) return pooled
}
return dynamicEventFor(id, world)
} }
export function dynamicEventFor(id: string): EventDef | undefined { export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
if (id.startsWith('ev-raid-')) { if (id.startsWith('ev-raid-')) {
const npcId = id.replace('ev-raid-', '') const npcId = id.replace('ev-raid-', '')
const npc = npcById(npcId) const npc = npcById(npcId)
@@ -47,6 +56,148 @@ export function dynamicEventFor(id: string): EventDef | undefined {
] ]
} }
} }
if (id.startsWith('ev-tournament-')) {
const year = Number(id.replace('ev-tournament-', ''))
return {
id,
name: '太虚大比',
category: 'major',
weight: 0,
once: true,
text: `太虚仙盟五年一会,新一期大比于祖庭开擂。观礼之众云集,百族竞锋——可遣嫡系赴赛,亦可称病让席。`,
options: [
{ label: '点将赴赛', hint: '战三关,位次定声望', eff: { tournament: true, flag: { [`tournamentYear-${year}`]: true } } },
{ label: '献礼买名', hint: '灵石-200,名次垫底,各族好感略升', eff: { res: { stones: -200 }, relation: { 'n-xuanying': 5, 'n-danxin': 5, 'n-sihai': 5, 'n-nulei': 5 } } },
{ label: '称病不出', hint: '声望小幅受挫', eff: { rep: -3 } }
]
}
}
if (id.startsWith('ev-apprentice-')) {
const rest = id.replace('ev-apprentice-', '')
const memberId = rest.split('-')[0]
const member = w?.state.members[memberId] ?? undefined
const sectName = member?.apprentice?.sect ?? '师门'
const name = member?.name ?? '弟子'
return {
id,
name: '寄读还乡',
category: 'major',
weight: 0,
text: `${name}${sectName}寄读期满。宗门遣人传书:或归家,或续练。`,
options: [
{ label: '接其归家', hint: '其悟道大进,或携功法而归', eff: { flag: { apprenticeRet: memberId } } },
{ label: '令其再拜师门', hint: '寄读二年,资源更厚', eff: { flag: { apprenticeStay: memberId } } }
]
}
}
if (id.startsWith('ev-echo-')) {
const m = id.match(/^ev-echo-(.+)-\d+$/)
const npcId = m ? m[1] : ''
const npc = w?.state.npcFamilies[npcId]
if (!npc) return undefined
const rel = npc.relation
if (rel > 40) {
return {
id,
name: '四邻来使',
category: 'daily',
weight: 0,
text: `${npc.name}遣使携礼来会:称去年宗主冬狩得灵材,念两家通好,特分润相赠。`,
options: [{ label: '收下并回礼', hint: '灵石+80', eff: { res: { stones: 80 }, rep: 2 } }, { label: '婉谢盛情', hint: '关系+3', eff: { relation: { [npcId]: 3 } } }]
}
}
if (rel < -40) {
return {
id,
name: '四邻诡音',
category: 'daily',
weight: 0,
text: `近日族中子弟夜猎,屡遭马失前蹄——痕迹指向${npc.name}的暗桩。`,
options: [{ label: '斥资戒备', hint: '灵石-40', eff: { res: { stones: -40 }, rep: 2 } }, { label: '按兵不动', hint: '声望-4', eff: { rep: -4 } }]
}
}
return {
id,
name: '四邻传闻',
category: 'daily',
weight: 0,
text: `商道上传来消息:${npc.name}有人丁、家声微变,坊市行情或生波澜。`,
options: [{ label: '记下了', hint: '无', eff: {} }]
}
}
if (id.startsWith('ev-trib-')) {
const parts = id.replace('ev-trib-', '').split('-')
const memberId = parts[0]
const member = w?.state.members[memberId]
if (!member || !member.alive) return undefined
const next = describeNext(member)
return {
id,
name: '天劫',
category: 'major',
weight: 0,
text: `${member.name} 欲冲击【${next}】之关,天上已有雷云积聚。此劫一渡,百尺竿头再进一步;一步踏错,则伤损难料。族中当如何?`,
options: [
{ label: '硬渡!', hint: '心境不减,成败由天', eff: { trib: { memberId, mode: 'rash' } } },
{ label: '请护法(灵石100', hint: '成算大增,护法或受牵连', eff: { trib: { memberId, mode: 'guard' } } },
{ label: '压制一年', hint: '养精蓄锐,来年再渡', eff: { trib: { memberId, mode: 'delay' } } }
]
}
}
if (id === 'ev-legacypass') {
return {
id,
name: '名宿传薪',
category: 'major',
weight: 0,
text: '族中老修行者寿数将至,欲将一生所学倾囊相授一位后进。家祠议定传承对象。',
options: [
{ label: '按资质择少年传薪', hint: '其后进精进一大截,老修行者气血微亏', eff: { memberBy: { by: 'inspire', target: 'youngest', n: 12 }, flag: { passExchange: true } } },
{ label: '传于族中顶梁', hint: '巅峰者再得护持', eff: { memberBy: { by: 'exp', target: 'highestPower', n: 6 } } },
{ label: '留待其自然坐化', hint: '声望-2', eff: { rep: -2 } }
]
}
}
if (id === 'ev-auction') {
return {
id,
name: '仙门拍卖',
category: 'major',
weight: 0,
text: '季末仙门拍卖会张榜:百水阁将会有灵器与丹材出价。族中是否竞逐?',
options: [
{ label: '入市竞拍(灵石300', hint: '可得一件灵器/破境丹/功法', eff: { res: { stones: -300 }, flag: { auctionWin: true } } },
{ label: '观望', hint: '声望不损,口袋不破', eff: {} }
]
}
}
if (id === 'ev-winterprayer') {
return {
id,
name: '冬至岁祷',
category: 'daily',
weight: 0,
text: '冬至夜长。家祠前灯影摇曳,族长问:今岁如何告辞?',
options: [
{ label: '登坛观星', hint: '声望+3', eff: { rep: 3 } },
{ label: '合族祈福', hint: '全族修为小精进', eff: { memberBy: { by: 'inspire', target: 'all', n: 2 } } },
{ label: '投签问卜', hint: '或有吉凶', eff: { flag: { prayerAsk: true } } }
]
}
}
if (id === 'ev-recruit') {
return {
id,
name: '宗门收徒',
category: 'major',
weight: 0,
text: '远方宗门遣师来访,观族中少年灵根不俗,欲收为记名弟子寄读。',
options: [
{ label: '送子寄读', hint: '其人外派,归时精进', eff: { apprentice: { build: true } } },
{ label: '婉拒', hint: '无', eff: {} }
]
}
}
if (id === 'ev-feisheng') { if (id === 'ev-feisheng') {
return { return {
id, id,
@@ -64,6 +215,28 @@ export function dynamicEventFor(id: string): EventDef | undefined {
return undefined return undefined
} }
function describeNext(c: { realm: { major: string; minor: number } }): string {
const next = nextRealm(c.realm as never)
return next ? describeRealm(next) : '临峰'
}
function asFlagString(flag: Record<string, number | boolean | string> | undefined, key: string): string | null {
if (!flag) return null
const v = flag[key]
return typeof v === 'string' ? v : null
}
import {
runTournament as _runTournament,
buildApprentice as _buildApprentice,
finalizeApprentice as _finalizeApprentice,
checkApprenticeExpiry,
recruitCheck
} from './tournament'
export const runTournament = _runTournament
export const buildApprentice = _buildApprentice
export const finalizeApprentice = _finalizeApprentice
export function matchesCond(w: World, cond?: Cond): boolean { export function matchesCond(w: World, cond?: Cond): boolean {
if (!cond) return true if (!cond) return true
const s = w.state const s = w.state
@@ -105,28 +278,110 @@ export function matchesCond(w: World, cond?: Cond): boolean {
export function eventRoll(w: World): void { export function eventRoll(w: World): void {
const s = w.state const s = w.state
if (s.pendingEvent || s.eventQueue.length > 0) { if (s.pendingEvent) {
if (!s.pendingEvent && s.eventQueue.length > 0) { if (s.pendingEvent.startsWith('ev-trib-')) {
fire(w, s.eventQueue.shift()!) const memberId = s.pendingEvent.replace('ev-trib-', '').split('-')[0]
const c = w.state.members[memberId]
if (c?.alive) {
if (!c.tribDelayYear) {
c.tribPendingMonths = (c.tribPendingMonths ?? 0) + 1
if ((c.tribPendingMonths ?? 0) >= 12) {
// 悬置一年未应:自动压制(防无人/挂机软锁)
c.tribDelayYear = w.state.year + 1
c.tribPendingMonths = 0
w.log('bad', `${c.name} 的天劫悬而未决,只得引气压制,待来年。`)
s.pendingEvent = undefined
}
}
} else {
s.pendingEvent = undefined
}
} }
return return
} }
if (s.eventQueue.length > 0) {
const evId = s.eventQueue.shift()!
const tm = evId.match(/^ev-tournament-(\d+)$/)
if (tm) delete s.family.flag[`tourneyPending-${tm[1]}`]
fire(w, evId)
return
}
// 里程碑检测(优先于日常事件 // 命运里程碑最优先(百年庆典 > 飞升之路
if (s.year >= 100 && !s.completedEvents.includes('ev-centennial')) { if (s.year >= 100 && !s.completedEvents.includes('ev-centennial')) {
fire(w, 'ev-centennial') fire(w, 'ev-centennial')
return return
} }
const firstSpirit = s.flags['firstSpirit'] as number | undefined const firstSpirit = s.flags['firstSpirit'] as number | undefined
if (firstSpirit && s.year - firstSpirit >= 2 && s.year - firstSpirit <= 3 && !s.completedEvents.includes('ev-feisheng')) { if (firstSpirit && s.year - firstSpirit >= 2 && !s.completedEvents.includes('ev-feisheng')) {
fire(w, 'ev-feisheng') fire(w, 'ev-feisheng')
return return
} }
checkApprenticeExpiry(w)
recruitCheck(w)
// 五年一会的太虚大比(该年任一时刻优先;其他年次要事件避让)
const tourneyNext = Math.ceil(s.year / 5) * 5
if (s.year >= 10 && s.year === tourneyNext && w.sysEnabled('tournament') && !s.completedEvents.includes(`ev-tournament-${tourneyNext}`)) {
const tk = `tourneyPending-${tourneyNext}`
if (w.state.pendingEvent) {
// 另有事件在档:加入等待队列(事件优先)——用 flag 标记,防重复入队
if (!s.family.flag[tk] && !s.eventQueue.includes(`ev-tournament-${tourneyNext}`)) {
s.family.flag[tk] = true
s.eventQueue.unshift(`ev-tournament-${tourneyNext}`)
}
} else {
fire(w, `ev-tournament-${tourneyNext}`)
}
return
}
// 名宿传薪(每八年一掷)
if (s.year % 8 === 4 && w.sysEnabled('apprentice') && !s.completedEvents.includes('ev-legacypass')) {
const elder = w.aliveMembers().some((c) => w.ageOf(c) >= 66 && c.realm.major !== 'mortal')
if (elder) {
fire(w, 'ev-legacypass')
return
}
}
// 仙门拍卖(每年十月掷币)
if (s.month === 10 && w.sysEnabled('production')) {
const key = `auction-${s.year}`
if (!s.family.flag[key] && w.rng.chance(0.55)) {
s.family.flag[key] = true
fire(w, 'ev-auction')
return
}
}
// 冬至岁祷(每年十一月一掷),只在 season 系统开启时
if (s.month === 11 && w.sysEnabled('season')) {
const key = `prayerDone-${s.year}`
if (!s.family.flag[key]) {
s.family.flag[key] = true
if (w.rng.chance(0.6)) {
fire(w, 'ev-winterprayer')
return
}
}
}
// 四邻回声(偶数年一掷,不论成败封缄)
if (s.year % 2 === 0) {
const key = `echoDone-${s.year}`
if (!s.family.flag[key]) {
s.family.flag[key] = true
if (w.rng.chance(0.7)) {
const npc = w.rng.pick(Object.values(s.npcFamilies))
fire(w, `ev-echo-${npc.id}-${s.year}`)
return
}
}
}
const roll = w.rng.next() const roll = w.rng.next()
const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined
if (!category) return if (!category) return
const candidates = ALL_EVENTS.filter( const pool = w.allEvents()
const candidates = pool.filter(
(e) => (e) =>
e.category === category && e.category === category &&
!(e.once && s.completedEvents.includes(e.id)) && !(e.once && s.completedEvents.includes(e.id)) &&
@@ -145,19 +400,28 @@ export function eventRoll(w: World): void {
} }
export function fire(w: World, id: string): void { export function fire(w: World, id: string): void {
if (w.state.pendingEvent) return
w.state.pendingEvent = id w.state.pendingEvent = id
w.pendingEvent(id) w.pendingEvent(id)
} }
export function applyEventChoice(w: World, eventId: string, optionIdx: number, squad?: string[]): void { export function applyEventChoice(
w: World,
eventId: string,
optionIdx: number,
squad?: string[],
_extra?: unknown,
formation?: string
): void {
const s = w.state const s = w.state
const def = findEvent(eventId) const def = findEvent(eventId, w)
if (!def) { if (!def) {
s.pendingEvent = undefined s.pendingEvent = undefined
return return
} }
const opt = def.options[optionIdx] const opt = def.options[optionIdx]
if (opt) { if (opt) {
if (formation) opt.eff.formation = formation
applyEffect(w, opt.eff, squad) applyEffect(w, opt.eff, squad)
if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id) if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id)
} }
@@ -204,7 +468,7 @@ export function rankPower(w: World, c: Character): number {
return order.indexOf(c.realm.major) * 10 + c.realm.minor return order.indexOf(c.realm.major) * 10 + c.realm.minor
} }
function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
const s = w.state const s = w.state
const fam = s.family const fam = s.family
@@ -234,7 +498,7 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
Object.assign(fam.flag, eff.flag) Object.assign(fam.flag, eff.flag)
} }
if (eff.techniqueChance && w.rng.chance(eff.techniqueChance)) { if (eff.techniqueChance && w.rng.chance(eff.techniqueChance)) {
const t = w.rng.pick(TECHNIQUES) const t = w.rng.pick(pack().techniques)
if (!fam.techniques.includes(t.id)) { if (!fam.techniques.includes(t.id)) {
fam.techniques.push(t.id) fam.techniques.push(t.id)
w.log('good', `得《${t.name}》残篇,录入藏书阁。`) w.log('good', `得《${t.name}》残篇,录入藏书阁。`)
@@ -248,6 +512,62 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
if (eff.addTech) { if (eff.addTech) {
if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech) if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech)
} }
if (eff.tournament) {
runTournament(w, squad, eff.formation as never)
}
if (eff.apprentice?.build) {
buildApprentice(w)
}
const retId = asFlagString(eff.flag, 'apprenticeRet')
const stayId = asFlagString(eff.flag, 'apprenticeStay')
if (retId) {
finalizeApprentice(w, retId, 'return')
delete fam.flag['apprenticeRet']
}
if (stayId) {
finalizeApprentice(w, stayId, 'stay')
delete fam.flag['apprenticeStay']
}
if (eff.flag?.['prayerAsk']) {
const blessing = w.rng.chance(0.6)
if (blessing) {
w.state.family.stones += 60
w.log('good', '问卜得吉:仓库中多出一笔异财。')
} else {
w.state.family.stones = Math.max(0, w.state.family.stones - 40)
w.state.family.reputation -= 1
w.log('bad', '问卜得凶:家宅小有晦气。')
}
delete w.state.family.flag['prayerAsk']
}
if (eff.flag?.['auctionWin']) {
const roll = w.rng.next()
if (roll < 0.4) {
fam.inventory['weapon-ling'] = (fam.inventory['weapon-ling'] ?? 0) + 1
w.log('good', '拍卖会落槌——购得一柄灵器!')
} else if (roll < 0.75) {
fam.inventory['pill-pojing'] = (fam.inventory['pill-pojing'] ?? 0) + 1
w.log('good', '拍卖会落槌——购得一枚破境丹!')
} else {
const t = w.rng.pick(pack().techniques)
if (!fam.techniques.includes(t.id)) {
fam.techniques.push(t.id)
w.log('good', `拍卖会落槌——竞得《${t.name}》!`)
} else {
fam.stones += 150
w.log('info', '拍卖会灵器已溢价转卖,回款150灵石。')
}
}
delete fam.flag['auctionWin']
}
if (eff.trib) {
const c = w.state.members[eff.trib.memberId]
if (c?.alive) {
c.tribPendingMonths = 0
resolveTribulation(w, c, eff.trib.mode)
}
return
}
if (eff.feisheng) { if (eff.feisheng) {
const s2 = w.state const s2 = w.state
const immortal = w const immortal = w
@@ -262,6 +582,7 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
immortal.alive = false immortal.alive = false
immortal.deathYear = s2.year immortal.deathYear = s2.year
immortal.deathCause = '云游飞升' immortal.deathCause = '云游飞升'
s2.stats.feishengCount = (s2.stats.feishengCount ?? 0) + 1
s2.family.flag['fengFeiBless'] = true s2.family.flag['fengFeiBless'] = true
w.chronicle('event', `${immortal.name} 于月首孤身东去,驾鹤而飞。天地留一缕仙风,庇佑宗族。`, immortal.id, true) w.chronicle('event', `${immortal.name} 于月首孤身东去,驾鹤而飞。天地留一缕仙风,庇佑宗族。`, immortal.id, true)
w.log('good', `${immortal.name} 飞升而去,宗族蒙庇。`) w.log('good', `${immortal.name} 飞升而去,宗族蒙庇。`)
@@ -328,7 +649,7 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
} }
} }
if (eff.mission) { if (eff.mission) {
const def = MISSIONS.find((m) => m.id === eff.mission) const def = pack().missions.find((m) => m.id === eff.mission)
if (def) { if (def) {
const squad = w const squad = w
.aliveMembers() .aliveMembers()
@@ -1,4 +1,4 @@
import { World } from '../world' import type { World } from '../world'
import { MAJORS } from '../../data/realms' import { MAJORS } from '../../data/realms'
import { calcLifespan } from '../pcgen' import { calcLifespan } from '../pcgen'
+9 -5
View File
@@ -1,15 +1,16 @@
import { World } from '../world' import type { World } from '../world'
import { Character } from '../../types/domain' import { Character } from '../../types/domain'
import { produceOffspring } from './cultivation' import { produceOffspring } from './cultivation'
import { yearGrowth } from './diplomacy' import { yearGrowth } from './diplomacy'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names' import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
import { aspirationById } from '../../data/aspirations'
export function yearStartMarriage(w: World): void { export function yearStartMarriage(w: World): void {
yearGrowth(w) yearGrowth(w)
const s = w.state const s = w.state
const fam = s.family const fam = s.family
const zongci = fam.buildings['zongci'] ?? 0 const zongci = fam.buildings['zongci'] ?? 0
const birthBase = 0.38 + zongci * 0.03 + (fam.difficulty === 'easy' ? 0.06 : fam.difficulty === 'hard' ? -0.06 : 0) const birthBase = 0.6 + zongci * 0.04 + (fam.difficulty === 'easy' ? 0.06 : fam.difficulty === 'hard' ? -0.06 : 0)
const couples = buildCouples(w) const couples = buildCouples(w)
@@ -21,13 +22,16 @@ export function yearStartMarriage(w: World): void {
const fatherAge = w.ageOf(father) const fatherAge = w.ageOf(father)
const motherAge = w.ageOf(mother) const motherAge = w.ageOf(mother)
if (fatherAge < 18 || fatherAge > 52 || motherAge < 16 || motherAge > 46) continue if (fatherAge < 18 || fatherAge > 52 || motherAge < 16 || motherAge > 46) continue
const p = birthBase * (0.75 + mother.physique * 0.05) const offspringBonus = Object.values(s.members)
.filter((m) => m.alive && aspirationById(m.aspiration)?.effect.type === 'offspring')
.length * 0.05
const p = birthBase * (0.75 + mother.physique * 0.05) + offspringBonus
if (!w.rng.chance(p)) continue if (!w.rng.chance(p)) continue
const gen = Math.max(father.generation, mother.generation) + 1 const gen = Math.max(father.generation, mother.generation) + 1
const first = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname }) const first = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname })
w.addMember(first, father, mother) w.addMember(first, father, mother)
let note = `${fam.surname}氏新增一员,名唤${first.name},生年 ${s.year}` let note = `${fam.surname}氏新增一员,名唤${first.name},生年 ${s.year}`
if (w.rng.chance(0.03)) { if (w.rng.chance(0.06)) {
const twin = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname }) const twin = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname })
w.addMember(twin, father, mother) w.addMember(twin, father, mother)
w.state.yearStats.births += 1 w.state.yearStats.births += 1
@@ -75,7 +79,7 @@ export function yearStartMarriage(w: World): void {
return age >= 16 && age <= 42 return age >= 16 && age <= 42
}) })
for (const m of men) { for (const m of men) {
if (w.rng.chance(0.28) && women.length > 0) { if (w.rng.chance(0.55) && women.length > 0) {
const candidate = women.filter( const candidate = women.filter(
(x) => (x) =>
!(x.fatherId && x.fatherId === m.fatherId) && !(x.fatherId && x.fatherId === m.fatherId) &&
+9 -6
View File
@@ -1,5 +1,6 @@
import { World } from '../world' import type { World } from '../world'
import { MissionState } from '../../types/domain' import { MissionState } from '../../types/domain'
import { FormationId } from '../../data/formations'
import { missionById, MissionDef, ENEMIES } from '../../data/secrets' import { missionById, MissionDef, ENEMIES } from '../../data/secrets'
import { resolveEncounter, rollWarbooty } from './combat' import { resolveEncounter, rollWarbooty } from './combat'
import { techniqueById } from '../../data/techniques' import { techniqueById } from '../../data/techniques'
@@ -46,7 +47,8 @@ export function missionTick(w: World): void {
team: squad, team: squad,
kind: 'scout', kind: 'scout',
year: w.state.year, year: w.state.year,
month: w.state.month month: w.state.month,
formation: m.formation as FormationId | undefined
}) })
const line = res.win ? '战而胜之,征程继续!' : res.draw ? '僵持之后双方罢手,队伍休整再进。' : '不敌,只得暂避锋芒。' const line = res.win ? '战而胜之,征程继续!' : res.draw ? '僵持之后双方罢手,队伍休整再进。' : '不敌,只得暂避锋芒。'
m.log.push(line) m.log.push(line)
@@ -111,13 +113,13 @@ function resultText(m: MissionState): string {
export function canSendMission(w: World, def: MissionDef, members: string[]): boolean { export function canSendMission(w: World, def: MissionDef, members: string[]): boolean {
if (members.length < def.minMembers || members.length > def.maxMembers) return false if (members.length < def.minMembers || members.length > def.maxMembers) return false
for (const id of members) { for (const id of members) {
const c = w.memberById(id) const c = w.state.members[id]
if (!c.alive || c.state === 'expedition') return false if (!c || !c.alive || c.state === 'expedition' || c.state === 'apprentice') return false
} }
return w.state.missions.filter((m) => !m.done).length < 3 return w.state.missions.filter((m) => !m.done).length < 3
} }
export function sendMission(w: World, defId: string, members: string[]): boolean { export function sendMission(w: World, defId: string, members: string[], formation?: FormationId): boolean {
const def = missionById(defId) const def = missionById(defId)
if (!canSendMission(w, def, members)) return false if (!canSendMission(w, def, members)) return false
const m: MissionState = { const m: MissionState = {
@@ -129,7 +131,8 @@ export function sendMission(w: World, defId: string, members: string[]): boolean
stage: 0, stage: 0,
stageMonth: 0, stageMonth: 0,
log: [`冬衣已备,饯行酒干,众人于 ${w.state.year}${w.state.month} 月出发。`], log: [`冬衣已备,饯行酒干,众人于 ${w.state.year}${w.state.month} 月出发。`],
done: false done: false,
formation
} }
members.forEach((id) => { members.forEach((id) => {
const c = w.memberById(id) const c = w.memberById(id)
@@ -1,4 +1,6 @@
import { World } from '../world' import type { World } from '../world'
import { aspirationById } from '../../data/aspirations'
import { seasonMod } from '../../data/season'
export function productionTick(w: World): void { export function productionTick(w: World): void {
const fam = w.state.family const fam = w.state.family
@@ -12,8 +14,11 @@ export function productionTick(w: World): void {
const fangshi = lvl('fangshi') const fangshi = lvl('fangshi')
const lingshou = lvl('lingshou') const lingshou = lvl('lingshou')
const fielders = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'field').length
const merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length
const springMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'field') : 0
if (lingtian > 0) { if (lingtian > 0) {
const v = 10 * lingtian const v = Math.round(10 * lingtian * (1 + fielders * 0.05 + springMod))
inv.lingcao = (inv.lingcao ?? 0) + v inv.lingcao = (inv.lingcao ?? 0) + v
parts.push(`灵田+${v}灵草`) parts.push(`灵田+${v}灵草`)
} }
@@ -31,8 +36,9 @@ export function productionTick(w: World): void {
inv.lingkuang = (inv.lingkuang ?? 0) + v inv.lingkuang = (inv.lingkuang ?? 0) + v
parts.push(`灵矿+${v}灵矿`) parts.push(`灵矿+${v}灵矿`)
} }
const autumnMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'market') : 0
if (fangshi > 0) { if (fangshi > 0) {
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome'))) const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome') + merchants * 0.03 + autumnMod))
fam.stones += v fam.stones += v
parts.push(`坊市+${v}灵石`) parts.push(`坊市+${v}灵石`)
} }
@@ -0,0 +1,162 @@
import type { World } from '../world'
import { Character } from '../../types/domain'
import { ENEMIES, EnemyDef } from '../../data/secrets'
import { resolveEncounter } from './combat'
import { rankPower } from './events'
import { resolveBreakthrough } from './cultivation'
import { FormationId } from '../../data/formations'
export const SECTS = ['云栖宗', '太谷书院', '玄微剑阁', '丹霞洞天']
const GATE_ENEMIES: string[] = ['e-muche', 'e-huanhan', 'e-yeshen']
export function gateEnemy(idx: number, year: number): EnemyDef {
const enemies = ENEMIES.filter((e) => ['qi', 'foundation', 'core', 'nascent'].includes(e.realm))
const pick = enemies[(idx + Math.floor(year / 5)) % enemies.length] ?? enemies[0]
return {
...pick,
name: idx === 2 ? `问鼎擂主·${pick.name}` : `${idx + 1}关·${pick.name}`,
strength: pick.strength * (0.85 + idx * 0.35) * (1 + Math.floor(year / 50) * 0.3)
}
}
export function runTournament(w: World, squad?: string[], formation?: FormationId): void {
if (!w.sysEnabled('tournament')) {
w.log('info', '太虚大比:赛事未启。')
return
}
const s = w.state
const eligible = w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 16 && (c.state === 'idle' || c.state === 'meditation') && c.realm.major !== 'mortal')
.sort((a, b) => rankPower(w, b) - rankPower(w, a))
let team: Character[]
if (squad && squad.length > 0) {
team = squad.map((id) => w.memberById(id)).filter((c) => c.alive && c.state !== 'expedition' && w.ageOf(c) >= 16)
} else {
team = eligible.slice(0, 3)
}
if (team.length === 0) {
w.log('bad', '太虚大比:族中无一嫡系可遣,只得告假缺席。')
return
}
let wins = 0
for (let g = 0; g < 3; g++) {
// 中途全灭/重伤离场则提前止步
const aliveTeam = team.filter((c) => c.alive)
if (aliveTeam.length === 0) break
const res = resolveEncounter(w, {
title: `太虚大比·第${g + 1}`,
enemy: gateEnemy(g, s.year),
risk: 0.6,
team: aliveTeam,
kind: 'scout',
year: s.year,
month: s.month,
formation
})
if (!res.win) break
wins++
}
const rank = Math.max(1, 4 - wins)
const rewards = [
{ rep: 18, stones: 300, rel: 8 },
{ rep: 12, stones: 200, rel: 5 },
{ rep: 8, stones: 120, rel: 3 },
{ rep: 4, stones: 60, rel: 1 }
][rank - 1]!
s.family.reputation += rewards.rep
s.family.stones += rewards.stones
for (const npc of Object.values(s.npcFamilies)) {
npc.relation = Math.min(100, npc.relation + rewards.rel)
}
s.stats.tourneyHistory.push({ year: s.year, rank })
if (!s.stats.tourneyBest || rank < s.stats.tourneyBest) s.stats.tourneyBest = rank
const teamNames = team.map((c) => c.name).join('、')
w.chronicle('battle', `太虚大比:${teamNames} 最终位列第${rank}名,赏灵石${rewards.stones}、声望+${rewards.rep}`, undefined, true)
w.log('good', `太虚大比落下帷幕,本族第${rank}名。`)
}
export function apprenticeCandidates(w: World): Character[] {
return w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 12 && w.ageOf(c) <= 19 && c.state === 'idle')
.sort((a, b) => b.perception - a.perception)
}
export function buildApprentice(w: World): void {
if (!w.sysEnabled('apprentice')) {
w.log('bad', '宗门来使失望而归:族中暂拒通学。')
return
}
const cand = apprenticeCandidates(w)
if (cand.length === 0) {
w.log('bad', '宗门来使失望而归:族中无适龄儿郎。')
return
}
const c = w.rng.pick(cand)
const sect = w.rng.pick(SECTS)
const years = w.rng.int(2, 4)
c.state = 'apprentice'
c.apprentice = { sect, untilYear: w.state.year + years, quiet: false }
w.state.family.stones += 60
w.chronicle('event', `${c.name} 拜入${sect}门下寄读${years}载,宗门赠礼灵石六十。`, c.id, true)
w.log('info', `${c.name} 离家赴${sect}修学。`)
}
export function finalizeApprentice(w: World, memberId: string, mode: 'return' | 'stay'): void {
const c = w.memberById(memberId)
if (!c.apprentice) return
if (mode === 'stay') {
c.apprentice.untilYear = Math.max(c.apprentice.untilYear, w.state.year + 2)
c.apprentice.quiet = false
w.state.family.stones += 40
w.log('info', `${c.name} 择师深造二年,族中资其膏火。`)
w.chronicle('event', `${c.name} 续入${c.apprentice.sect}修习,寄回灵石四十。`, c.id, false)
return
}
// 归来:境界推进+随机机遇
const sect = c.apprentice.sect
c.state = 'idle'
c.apprentice = undefined
const boost = w.rng.chance(0.75)
c.realmProgress = Math.min(100, c.realmProgress + 60)
if (boost && c.realmProgress >= 80) {
c.realmProgress = 100
resolveBreakthrough(w, c, 0.35)
}
c.fortune = Math.min(12, c.fortune + 1)
w.chronicle('event', `${c.name}${sect}学成归家,携带新得与见识归来。`, c.id, true)
w.log('good', `${c.name}${sect}归来,境界精进。`)
}
export function checkApprenticeExpiry(w: World): void {
if (!w.sysEnabled('apprentice')) return
for (const c of Object.values(w.state.members)) {
if (!c.alive || !c.apprentice || c.apprentice.quiet) continue
if (w.state.year >= c.apprentice.untilYear) {
const evId = `ev-apprentice-${c.id}-${c.apprentice.untilYear}`
if (!w.state.completedEvents.includes(evId)) {
w.state.eventQueue.push(evId)
w.state.completedEvents.push(evId)
}
}
}
}
export function recruitCheck(w: World): void {
if (!w.sysEnabled('apprentice')) return
const s = w.state
if (s.year % 4 !== 0) return
const key = `recruitDone-${s.year}`
if (s.family.flag[key]) return
if (apprenticeCandidates(w).length === 0) return
if (w.rng.chance(0.5)) {
s.family.flag[key] = true
s.eventQueue.push('ev-recruit')
}
}
@@ -0,0 +1,94 @@
import type { World } from '../world'
import { Character } from '../../types/domain'
import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../data/realms'
export const TRIB_MAJORS: string[] = ['foundation', 'core', 'nascent', 'spirit']
export function needsTribulation(c: Character): boolean {
const next = nextRealm(c.realm)
if (!next) return false
if (next.major === c.realm.major) return false
return TRIB_MAJORS.includes(next.major)
}
export function tribulationEventId(c: Character): string {
return `ev-trib-${c.id}-${c.realm.major}-${c.realm.minor}`
}
export function resolveTribulation(w: World, c: Character, mode: 'rash' | 'guard' | 'delay'): string {
const next = nextRealm(c.realm)
if (!next) return 'peak'
if (mode === 'delay') {
c.tribDelayYear = w.state.year + 1
w.log('info', `${c.name} 按兵不动,引而不发,待来年再渡。`)
return 'delayed'
}
let successChance = perTribChance(w, c)
let guardian: Character | null = null
if (mode === 'guard') {
const fam = w.state.family
if (fam.stones < 100) {
w.log('bad', '护法之资不足,此行只得咬牙亲渡。')
} else {
fam.stones -= 100
successChance += 0.08
guardian = w
.aliveMembers()
.filter((m) => m.id !== c.id && MAJOR_ORDER.indexOf(m.realm.major) >= MAJOR_ORDER.indexOf(c.realm.major))
.sort((a, b) => MAJOR_ORDER.indexOf(b.realm.major) - MAJOR_ORDER.indexOf(a.realm.major))[0] ?? null
}
}
const p = Math.min(0.92, Math.max(0.06, successChance))
const s = w.state
if (w.rng.chance(p)) {
c.realm = next
c.realmProgress = 0
c.health = 100
c.lastBreakthroughAttempt = s.year * 12 + s.month
w.chronicle('breakthrough', `${c.name} 渡劫功成,踏入【${describeRealm(next)}】!`, c.id, true)
w.log('good', `✦ 天雷散尽,${c.name} 渡劫成功,晋阶【${describeRealm(next)}】!`)
if (next.major === 'spirit' && !s.flags['firstSpirit']) {
s.flags['firstSpirit'] = s.year
}
return 'success'
}
// 失败
c.lastBreakthroughAttempt = s.year * 12 + s.month
c.realmProgress = Math.max(0, 100 - 28 - w.rng.int(0, 12))
c.health = Math.max(1, c.health - 22 - w.rng.int(0, 12))
if (c.health < 20) c.state = 'wounded'
let text = `${c.name} 渡劫失败,肉身受创。`
const danger = realmDeathChance(c.realm, c.mind)
if (w.rng.chance(danger)) {
c.alive = false
c.deathYear = s.year
c.deathCause = '渡劫陨落'
w.chronicle('death', `${c.name} 天劫加身,灵石俱焚而陨。`, c.id, true)
w.log('bad', `${c.name} 渡劫陨落。`)
return 'dead'
}
if (guardian) {
const g = guardian
if (w.rng.chance(0.5)) {
g.health = Math.max(1, g.health - 15 - w.rng.int(0, 15))
if (g.health < 25) g.state = 'wounded'
w.log('bad', `${g.name} 为护法所伤。`)
}
}
w.log('bad', text)
return 'fail'
}
export function perTribChance(w: World, c: Character): number {
const base = ({
foundation: 0.6,
core: 0.5,
nascent: 0.36,
spirit: 0.26
} as Record<string, number>)[c.realm.major] ?? 0.9
return Math.min(0.92, base + c.mind * 0.01 + c.health / 400)
}
+259 -37
View File
@@ -11,14 +11,20 @@ import {
import { Rng } from '../core/rng' import { Rng } from '../core/rng'
import { BUILDINGS } from '../data/buildings' import { BUILDINGS } from '../data/buildings'
import { POSTS } from '../data/posts' import { POSTS } from '../data/posts'
import { productionTick } from './systems/production' import { aspirationById as aspirationOf } from '../data/aspirations'
import { deathTick, woundHealTick } from './systems/lifecycle' import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
import { cultivationTick, resolveBreakthrough } from './systems/cultivation'
import { missionTick } from './systems/missions'
import { eventRoll, applyEventChoice } from './systems/events'
import { diplomacyTick } from './systems/diplomacy'
import { yearStartMarriage } from './systems/marriage'
import { createWorldState, findInheritor } from './creation' import { createWorldState, findInheritor } from './creation'
import { SYSTEM_DEFS, SystemDef } from './capabilities'
import { emptyClock } from './clocks'
import { CotycPlugin, PluginContext, PluginStatus } from '../core/plugin'
import { PluginManager } from './pluginManager'
import { EventDef } from '../data/events'
import { pack, DEFAULT_PACK, PACK } from '../data/registry'
import { CORE_PLUGINS } from './plugin-bootstrap'
import { GameClock } from '../core/clock'
import { SystemHook } from '../core/clock'
import { resolveBreakthrough } from './systems/cultivation'
import { applyEventChoice } from './systems/events'
import { combatPowerOf } from './systems/combat' import { combatPowerOf } from './systems/combat'
export type LogKind = LogItem['kind'] export type LogKind = LogItem['kind']
@@ -30,6 +36,8 @@ export interface WorldEventBus {
onPendingEvent(id: string): void onPendingEvent(id: string): void
onGameOver(reason: string, year: number): void onGameOver(reason: string, year: number): void
onYearPaper?(entry: YearlyReport): void onYearPaper?(entry: YearlyReport): void
onSystemChange?(id: string, enabled: boolean): void
onPluginChange?(id: string, action: string): void
} }
export function normalizeGameState(state: GameState): GameState { export function normalizeGameState(state: GameState): GameState {
@@ -37,11 +45,33 @@ export function normalizeGameState(state: GameState): GameState {
if (!state.finance) state.finance = { accum: 0 } if (!state.finance) state.finance = { accum: 0 }
if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 } if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 }
if (!state.yearlyReports) state.yearlyReports = [] if (!state.yearlyReports) state.yearlyReports = []
if (!state.stats) {
state.stats = {
repPeak: state.family?.reputation ?? 0,
popPeak: Object.values(state.members).filter((c) => c.alive).length,
maxRealmIdx: peakRealmIndex(state.members ?? {}),
techniqueGrand: grandTechniqueCount(state.members ?? {}),
tourneyHistory: [],
feishengCount: 0
}
}
if (typeof state.totalTicks !== 'number') state.totalTicks = 0 if (typeof state.totalTicks !== 'number') state.totalTicks = 0
if (typeof state.seq !== 'number') state.seq = 10 if (typeof state.seq !== 'number') state.seq = 10
if (!state.battles) state.battles = [] if (!state.battles) state.battles = []
if (!state.eventQueue) state.eventQueue = [] if (!state.eventQueue) state.eventQueue = []
if (!state.completedEvents) state.completedEvents = [] if (!state.completedEvents) state.completedEvents = []
if (!state.missions) state.missions = []
if (!state.flags) state.flags = {}
if (!state.npcFamilies) state.npcFamilies = {}
if (!state.family.flag) state.family.flag = {}
if (!state.family.inventory) state.family.inventory = {}
if (!state.family.buildings) state.family.buildings = {}
if (!state.family.techniques) state.family.techniques = []
if (!state.family.missionIds) state.family.missionIds = []
if (state.family.headId && !state.members[state.family.headId]) {
const firstAlive = Object.values(state.members).find((c) => c.alive)
if (firstAlive) state.family.headId = firstAlive.id
}
for (const c of Object.values(state.members)) { for (const c of Object.values(state.members)) {
if (typeof c.techniqueRank !== 'number') c.techniqueRank = 0 if (typeof c.techniqueRank !== 'number') c.techniqueRank = 0
if (typeof c.techniqueProgress !== 'number') c.techniqueProgress = 0 if (typeof c.techniqueProgress !== 'number') c.techniqueProgress = 0
@@ -54,12 +84,136 @@ export class World {
state: GameState state: GameState
rng: Rng rng: Rng
out: WorldEventBus[] out: WorldEventBus[]
clock: GameClock
systems: Record<string, { enabled: boolean }>
plugins: PluginManager
private eventPools = new Map<string, EventDef[]>()
constructor(state: GameState, out: WorldEventBus[] = []) { constructor(state: GameState, out: WorldEventBus[] = []) {
normalizeGameState(state) normalizeGameState(state)
this.state = state this.state = state
this.rng = new Rng(state.rng) this.rng = new Rng(state.rng)
this.out = out this.out = out
this.clock = emptyClock()
this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }]))
this.plugins = new PluginManager(this.buildPluginContext())
this.installCorePlugins()
}
/** 事件池聚合(含动态事件回看) */
buildPluginContext(): PluginContext {
const self = this
return {
world: self,
clock: self.clock,
register: (phase, fn: SystemHook) => self.clock.register(phase, fn),
onYearStart: (fn: SystemHook) => self.clock.onYearStart(fn),
addCapability: (cap) => {
if (!SYSTEM_DEFS.find((d) => d.id === cap.id)) {
SYSTEM_DEFS.push({ id: cap.id, name: cap.name, version: cap.version, desc: cap.desc })
}
self.systems[cap.id] = { enabled: true }
},
removeCapability: (id) => {
delete self.systems[id]
},
enableCapability: (id, enabled) => {
if (self.systems[id]) self.systems[id].enabled = enabled
},
overridePack: (partial) => {
void pack
self.packOverride(partial)
},
resetPack: () => {
self.packReset()
},
addEventPool: (id, events) => {
self.eventPools.set(id, events)
},
removeEventPool: (id) => {
self.eventPools.delete(id)
}
}
}
private packOverride(partial: Partial<import('../data/registry').DataPack>): void {
PACK.override(partial)
}
private packReset(): void {
PACK.reset()
}
installCorePlugins(): void {
// 内置三插件:系统/数据/事件(受保护常驻,统一走管线)
for (const p of CORE_PLUGINS) {
this.plugins.install(p)
}
}
pluginList(): PluginStatus[] {
return this.plugins.list()
}
pluginChanges(): { id: string; action: string }[] {
return this.plugins.listChanges()
}
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
const r = this.plugins.install(p)
if (r.ok) this.out.forEach((o) => o.onPluginChange?.(p.id, 'install'))
return r
}
removePlugin(id: string): { ok: boolean; reason?: string } {
const r = this.plugins.remove(id)
if (r.ok) {
this.rebuildEventPoolsAfterRemoval(id)
this.out.forEach((o) => o.onPluginChange?.(id, 'remove'))
}
return r
}
setPluginEnabled(id: string, enabled: boolean): { ok: boolean; reason?: string } {
const r = this.plugins.setEnabled(id, enabled)
if (r.ok) this.out.forEach((o) => o.onPluginChange?.(id, enabled ? 'enable' : 'disable'))
return r
}
allEvents(): EventDef[] {
const list: EventDef[] = []
for (const pool of this.eventPools.values()) {
for (const e of pool) list.push(e)
}
return list
}
eventPoolIds(): string[] {
return [...this.eventPools.keys()]
}
private rebuildEventPoolsAfterRemoval(id: string): void {
void id
// 事件池卸载暂由插件 uninstall 自行处理;此处在 remove 后重置 core 保证可用
if (!this.eventPools.has('core')) this.eventPools.set('core', [])
}
sysEnabled(id: string): boolean {
return this.systems[id]?.enabled ?? true
}
toggleSystem(id: string): boolean {
const s = this.systems[id]
if (!s) return false
s.enabled = !s.enabled
this.out.forEach((o) => o.onSystemChange?.(id, s.enabled))
return s.enabled
}
systemList(): { id: string; name: string; version: string; desc: string; enabled: boolean }[] {
return SYSTEM_DEFS.map((d) => ({ id: d.id, name: d.name, version: d.version, desc: d.desc, enabled: this.sysEnabled(d.id) }))
} }
seq(): Id { seq(): Id {
@@ -103,6 +257,10 @@ export class World {
this.out.forEach((o) => o.onGameOver(reason, year)) this.out.forEach((o) => o.onGameOver(reason, year))
} }
gameOver___placeholder(): void {
void 0
}
memberById(id: Id): Character { memberById(id: Id): Character {
const c = this.state.members[id] const c = this.state.members[id]
if (!c) throw new Error(`member not found ${id}`) if (!c) throw new Error(`member not found ${id}`)
@@ -128,27 +286,70 @@ export class World {
if (s.month > 12) { if (s.month > 12) {
s.month = 1 s.month = 1
s.year++ s.year++
this.yearStart() this.clock.fireYearStart(this)
} }
s.totalTicks++ s.totalTicks++
productionTick(this) this.clock.stepMonthly(this)
deathTick(this)
woundHealTick(this)
if (this.aliveMembers().length > 0) {
cultivationTick(this)
missionTick(this)
eventRoll(this)
diplomacyTick(this)
}
this.checkHead()
const stonesEnd = s.family.stones const stonesEnd = s.family.stones
s.finance.accum += stonesEnd - stonesStart s.finance.accum += stonesEnd - stonesStart
this.trackStats()
this.clampState()
this.pruneYearFlags()
} }
private yearStart(): void { /** 年度清理:剔除 3 年以前的年份前缀 flag 键(防长线膨胀) */
yearStartMarriage(this) private pruneYearFlags(): void {
this.state.family.reputation += this.postBonus('familyRep') const fam = this.state.family
const cutoff = this.state.year - 3
for (const key of Object.keys(fam.flag)) {
const m = /^(auction-|prayerDone-|echoDone-|recruitDone-)(\d+)$/.exec(key)
if (m && Number(m[2]) < cutoff) delete fam.flag[key]
}
}
/** 月底统一数值钳制:修为/气血/库存/金钱永不越界 */
private clampState(): void {
for (const c of Object.values(this.state.members)) {
if (c.realmProgress < 0) c.realmProgress = 0
if (c.realmProgress > 100) c.realmProgress = 100
if (c.health < 0) c.health = 0
if (c.health > 100) c.health = 100
}
if (this.state.family.stones < 0) this.state.family.stones = 0
for (const [k, v] of Object.entries(this.state.family.inventory)) {
if (typeof v === 'number' && v < 0) this.state.family.inventory[k] = 0
}
}
private trackStats(): void {
const s = this.state
const st = s.stats
if (s.family.reputation > st.repPeak) st.repPeak = s.family.reputation
const pop = this.aliveMembers().length
if (pop > st.popPeak) st.popPeak = pop
const idx = peakRealmIndex(s.members)
if (idx > st.maxRealmIdx) st.maxRealmIdx = idx
const g = grandTechniqueCount(s.members)
if (g > st.techniqueGrand) st.techniqueGrand = g
}
legacyPreview() {
return computeLegacy(this.state)
}
resolveLegacyNow(): LegacyArch {
const arch = resolveLegacy(this.state)
this.state.stats.resolvedYear = this.state.year
this.state.stats.resolveTitle = arch.title
this.state.family.flag['resolved'] = true
this.chronicle('event', `望气观澜,本族百年气数终有定论——「${arch.title}」。开卷盖印,史入青册。`, undefined, true)
this.log('good', `定鼎:${arch.title}`)
return arch
}
publishYearReport(): void {
const rep = this.state.family.reputation const rep = this.state.family.reputation
const power = this.familyPower() const power = this.familyPower()
const report: YearlyReport = { const report: YearlyReport = {
@@ -168,6 +369,11 @@ export class World {
this.out.forEach((o) => o.onYearPaper?.(report)) this.out.forEach((o) => o.onYearPaper?.(report))
} }
epilogueTick(): void {
if (this.state.gameOver) return
this.checkHead()
}
reputationDrift(): void { reputationDrift(): void {
const cur = this.state.family.reputation const cur = this.state.family.reputation
const drift = cur > 0 ? -1.5 : cur < 0 ? 1.2 : 0 const drift = cur > 0 ? -1.5 : cur < 0 ? 1.2 : 0
@@ -343,12 +549,12 @@ export class World {
} }
postCount(def: string): number { postCount(def: string): number {
return this.aliveMembers().filter((c) => c.post === def).length return this.aliveMembers().filter((c) => c.post === def && c.state !== 'apprentice').length
} }
assignPost(memberId: Id, postId: string | undefined): boolean { assignPost(memberId: Id, postId: string | undefined): boolean {
const c = this.memberById(memberId) const c = this.memberById(memberId)
if (!c.alive) return false if (!c.alive || c.state === 'apprentice') return false
if (postId === undefined || postId === '') { if (postId === undefined || postId === '') {
c.post = undefined c.post = undefined
return true return true
@@ -402,6 +608,27 @@ export class World {
return true return true
} }
seekSutra(): boolean {
const fam = this.state.family
if ((fam.buildings['cangshu'] ?? 0) < 3) return false
const last = (fam.flag['sutraCD'] as number | undefined) ?? -999
if (this.state.year - last < 2) return false
if (fam.stones < 150) return false
fam.stones -= 150
fam.flag['sutraCD'] = this.state.year
const pool = pack().techniques.filter((t) => t.grade >= 2 && !fam.techniques.includes(t.id))
if (pool.length === 0) {
this.log('info', '求经访道:天下典籍已入庶几,无可再得。')
this.chronicle('event', '求经台广搜天下,经卷已穷。', undefined, false)
return true
}
const t = this.rng.pick(pool)
fam.techniques.push(t.id)
this.chronicle('event', `遣人下江南求经,携回《${t.name}》。`, undefined, true)
this.log('good', `求经台访得《${t.name}》!`)
return true
}
tauntNpc(npcId: string): boolean { tauntNpc(npcId: string): boolean {
const fam = this.state.family const fam = this.state.family
const npc = this.state.npcFamilies[npcId] const npc = this.state.npcFamilies[npcId]
@@ -425,7 +652,7 @@ export class World {
const me = this.memberById(id) const me = this.memberById(id)
const meG = me.gender const meG = me.gender
return this.aliveMembers() return this.aliveMembers()
.filter((c) => c.gender !== meG && c.state !== 'expedition') .filter((c) => c.gender !== meG && c.state !== 'expedition' && c.state !== 'apprentice')
.filter((c) => w2age(this, c) >= 16 && w2age(this, c) <= 46) .filter((c) => w2age(this, c) >= 16 && w2age(this, c) <= 46)
.filter((c) => !c.spouseId || this.isWidowed(c)) .filter((c) => !c.spouseId || this.isWidowed(c))
.filter( .filter(
@@ -445,7 +672,7 @@ export class World {
} }
marryTo(aId: Id, bId: Id): boolean { marryTo(aId: Id, bId: Id): boolean {
return arrangeWeddingPublic(this, aId, bId) return arrangeWeddingBridge(this, aId, bId)
} }
static create(opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }): World { static create(opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }): World {
@@ -462,18 +689,13 @@ function w2age(w: World, c: Character): number {
return w.ageOf(c) return w.ageOf(c)
} }
function widowedOf(w: World, m: Character): boolean { function arrangeWeddingBridge(w: World, aId: Id, bId: Id): boolean {
if (!m.spouseId) return false const a = w.state.members[aId]
const sp = w.state.members[m.spouseId] const b = w.state.members[bId]
return !!sp && !sp.alive if (!a || !b || !a.alive || !b.alive) return false
} if (w.ageOf(a) < 16 || w.ageOf(b) < 16) return false
if (a.spouseId && !w.isWidowed(a)) return false
function arrangeWeddingPublic(w: World, aId: Id, bId: Id): boolean { if (b.spouseId && !w.isWidowed(b)) return false
const a = w.memberById(aId)
const b = w.memberById(bId)
if (!a.alive || !b.alive) return false
if (a.spouseId && !widowedOf(w, a)) return false
if (b.spouseId && !widowedOf(w, b)) return false
if (a.gender === b.gender) return false if (a.gender === b.gender) return false
if (a.fatherId && a.fatherId === b.fatherId) return false if (a.fatherId && a.fatherId === b.fatherId) return false
if (a.motherId && a.motherId === b.motherId) return false if (a.motherId && a.motherId === b.motherId) return false
+72
View File
@@ -0,0 +1,72 @@
import { GameState } from '../types/domain'
import { normalizeGameState } from '../engine/world'
export const CURRENT_SCHEMA = 2
export const APP_ID = 'cotyc'
export interface MigrationError {
code: 'TOO_NEW' | 'UNKNOWN_VERSION'
version: number
message: string
}
/**
* 迁移链:obj.from 升到 obj.to 逐级执行(任何一步失败即中断抛出,原状态不被写入)。
* v1(≤0.1.8)→ v20.1.9):补 stats/finance/annals 字段并打上 schema 版本戳。
*/
const MIGRATIONS: Array<{ from: number; to: number; fn: (s: GameState) => GameState }> = [
{
from: 1,
to: 2,
fn: (s) => {
normalizeGameState(s)
s.schemaVersion = 2
return s
}
}
]
export function migrateIfNeeded(state: GameState): { ok: true; state: GameState } | { ok: false; error: MigrationError } {
const version = typeof state.schemaVersion === 'number' ? state.schemaVersion : 1
if (version > CURRENT_SCHEMA) {
return {
ok: false,
error: { code: 'TOO_NEW', version, message: `存档版本 ${version} 高于当前游戏支持(${CURRENT_SCHEMA}),请升级游戏。` }
}
}
let cur = state
let v = version
let guard = 0
while (v < CURRENT_SCHEMA && guard < 20) {
const step = MIGRATIONS.find((m) => m.from === v)
if (!step) {
return { ok: false, error: { code: 'UNKNOWN_VERSION', version: v, message: `未知存档版本 ${v},无法迁移。` } }
}
try {
cur = step.fn(cur)
} catch (e) {
return { ok: false, error: { code: 'UNKNOWN_VERSION', version: v, message: `迁移失败:${String(e)}` } }
}
v = step.to
guard++
}
return { ok: true, state: cur }
}
export function exportEnvelope(state: GameState): string {
return JSON.stringify({ app: APP_ID, schemaVersion: state.schemaVersion ?? CURRENT_SCHEMA, payload: state })
}
export function parseImportEnvelope(text: string): { ok: true; state: GameState } | { ok: false; error: string } {
try {
const raw = JSON.parse(text) as { app?: string; schemaVersion?: number; payload?: GameState; state?: GameState }
if (raw.app !== undefined && raw.app !== APP_ID) return { ok: false, error: '非本作存档(app 标识不符)。' }
const state = raw.payload ?? raw.state
if (!state || typeof state !== 'object') return { ok: false, error: '存档内容为空或损坏。' }
const migrated = migrateIfNeeded(state as GameState)
if (migrated.ok) return { ok: true, state: migrated.state }
return { ok: false, error: migrated.error.message }
} catch (e) {
return { ok: false, error: `解析失败:${String(e)}` }
}
}
+19 -9
View File
@@ -1,6 +1,8 @@
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain' import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
import { CURRENT_SCHEMA, APP_ID, exportEnvelope, parseImportEnvelope, migrateIfNeeded } from './migrate'
const DB_PREFIX = 'cotyc-save-' const DB_PREFIX = 'cotyc-save-'
let seqCounter = 0
export interface SaveDbDriver { export interface SaveDbDriver {
open(name: string): Promise<void> open(name: string): Promise<void>
@@ -38,7 +40,8 @@ export class SaveSlot {
} }
async saveState(state: GameState, label: string): Promise<string> { async saveState(state: GameState, label: string): Promise<string> {
const id = `${state.year}.${state.month}.${Date.now().toString(36)}` const id = `${state.year}.${state.month}.${Date.now().toString(36)}-${(seqCounter++ % 1296).toString(36)}`
state.schemaVersion = CURRENT_SCHEMA
const json = JSON.stringify(state) const json = JSON.stringify(state)
await this.driver.run( await this.driver.run(
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`, `INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
@@ -80,8 +83,11 @@ export class SaveSlot {
async loadState(id?: string): Promise<GameState | null> { async loadState(id?: string): Promise<GameState | null> {
const rows = await this.driver.all<{ data: string }>( const rows = await this.driver.all<{ data: string }>(
id ? `SELECT data FROM snapshot WHERE id = ?` : `SELECT data FROM snapshot ORDER BY rowid DESC LIMIT 1` id
, id ? [id] : []) ? `SELECT data FROM snapshot WHERE id = ?`
: `SELECT data FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC LIMIT 1`,
id ? [id] : []
)
if (!rows || rows.length === 0) return null if (!rows || rows.length === 0) return null
return JSON.parse(rows[0]!.data) as GameState return JSON.parse(rows[0]!.data) as GameState
} }
@@ -108,19 +114,23 @@ export class SaveSlot {
async exportAll(): Promise<string> { async exportAll(): Promise<string> {
const state = await this.loadState() const state = await this.loadState()
if (!state) throw new Error('无可导出存档')
const meta = await this.getMeta() const meta = await this.getMeta()
return JSON.stringify({ app: 'cotyc', schemaVersion: 1, meta, state }) const envelope = JSON.parse(exportEnvelope(state)) as { schemaVersion: number; payload: GameState }
return JSON.stringify({ app: APP_ID, schemaVersion: envelope.schemaVersion, meta, state: envelope.payload })
} }
async importAll(data: string): Promise<boolean> { async importAll(data: string): Promise<boolean> {
try { try {
const parsed = JSON.parse(data) as { app?: string; schemaVersion?: number; state?: GameState } const parsed = parseImportEnvelope(data)
if (!parsed.state) return false if (!parsed.ok) return false
const state = parsed.state
state.schemaVersion = CURRENT_SCHEMA
await this.driver.exec(`DELETE FROM snapshot`) await this.driver.exec(`DELETE FROM snapshot`)
await this.driver.exec(`DELETE FROM meta`) await this.driver.exec(`DELETE FROM meta`)
await this.saveState(parsed.state, 'import') await this.saveState(state, 'import')
const aliveCount = Object.values(parsed.state.members).filter((c) => c.alive).length const aliveCount = Object.values(state.members).filter((c) => c.alive).length
await this.setMeta(buildSimpleMeta(parsed.state, this.slot, aliveCount)) await this.setMeta(buildSimpleMeta(state, this.slot, aliveCount))
return true return true
} catch { } catch {
return false return false
+19 -1
View File
@@ -10,7 +10,7 @@ export interface Realm {
minor: number minor: number
} }
export type CharState = 'idle' | 'meditation' | 'expedition' | 'wounded' | 'closed' export type CharState = 'idle' | 'meditation' | 'expedition' | 'wounded' | 'closed' | 'apprentice'
export interface SpiritRoots { export interface SpiritRoots {
grade: number grade: number
@@ -53,6 +53,10 @@ export interface Character {
isFounder?: boolean isFounder?: boolean
lastBreakthroughAttempt?: number lastBreakthroughAttempt?: number
monthProgress?: number monthProgress?: number
apprentice?: { sect: string; untilYear: number; quiet: boolean }
aspiration?: string
tribDelayYear?: number
tribPendingMonths?: number
} }
export interface FamilyState { export interface FamilyState {
@@ -97,6 +101,7 @@ export interface MissionState {
log: string[] log: string[]
done: boolean done: boolean
result?: string result?: string
formation?: string
} }
export interface ChronicleEntry { export interface ChronicleEntry {
@@ -133,6 +138,18 @@ export interface GameOver {
reason: string reason: string
} }
export interface FamilyStats {
repPeak: number
popPeak: number
maxRealmIdx: number
techniqueGrand: number
tourneyBest?: number
tourneyHistory: { year: number; rank: number }[]
feishengCount: number
resolvedYear?: number
resolveTitle?: string
}
export interface YearlyReport { export interface YearlyReport {
year: number year: number
nets: number nets: number
@@ -164,6 +181,7 @@ export interface GameState {
finance: { accum: number } finance: { accum: number }
yearStats: { births: number; deaths: number } yearStats: { births: number; deaths: number }
yearlyReports: YearlyReport[] yearlyReports: YearlyReport[]
stats: FamilyStats
} }
export interface LogItem { export interface LogItem {
+17 -2
View File
@@ -5,6 +5,7 @@ import { eventCategoryName } from '../../game/data/events'
import { describeRealm } from '../../game/data/realms' import { describeRealm } from '../../game/data/realms'
import { Character } from '../../game/types/domain' import { Character } from '../../game/types/domain'
import { combatPowerOf } from '../../game/engine/systems/combat' import { combatPowerOf } from '../../game/engine/systems/combat'
import { FORMATIONS, FormationId } from '../../game/data/formations'
export function EventModal() { export function EventModal() {
const pendingEventId = useGameStore((s) => s.pendingEventId) const pendingEventId = useGameStore((s) => s.pendingEventId)
@@ -14,9 +15,10 @@ export function EventModal() {
const setSpeed = useGameStore((s) => s.setSpeed) const setSpeed = useGameStore((s) => s.setSpeed)
const [squadPicking, setSquadPicking] = useState(false) const [squadPicking, setSquadPicking] = useState(false)
const [squadSel, setSquadSel] = useState<string[]>([]) const [squadSel, setSquadSel] = useState<string[]>([])
const [formation, setFormation] = useState<FormationId>('vanguard')
if (!pendingEventId || !pendingEventDef || !world) return null if (!pendingEventId || !pendingEventDef || !world) return null
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid) const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
const squadPool: Character[] = const squadPool: Character[] =
raidIdx >= 0 raidIdx >= 0
? world.aliveMembers() ? world.aliveMembers()
@@ -62,7 +64,20 @@ export function EventModal() {
{squadPicking && ( {squadPicking && (
<div className="modal-squad" style={{ marginBottom: 14 }}> <div className="modal-squad" style={{ marginBottom: 14 }}>
<div className="dim" style={{ marginBottom: 6 }}></div> <div className="dim" style={{ marginBottom: 6 }}>·</div>
<div className="formation-pick" style={{ marginBottom: 8 }}>
{(Object.keys(FORMATIONS) as FormationId[]).map((fid) => (
<div
key={fid}
className={`tag ${formation === fid ? 'gold-t' : ''}`}
style={{ cursor: 'pointer', padding: '3px 10px' }}
onClick={() => setFormation(fid)}
title={FORMATIONS[fid].desc}
>
{FORMATIONS[fid].name}·{FORMATIONS[fid].desc}
</div>
))}
</div>
<div className="squad-picker"> <div className="squad-picker">
{squadPool.map((c) => ( {squadPool.map((c) => (
<div <div
+29
View File
@@ -0,0 +1,29 @@
import { useGameStore } from '../store'
import { GUIDE_STEPS, computeGuide, guideAdvance } from '../../game/core/guide'
export function GuideStrip() {
const world = useGameStore((s) => s.world)
const revision = useGameStore((s) => s.revision)
const bump = useGameStore((s) => s.bump)
void revision
if (!world) return null
const g = computeGuide(world)
if (g.done || world.state.year > 2) return null
const step = GUIDE_STEPS[g.step]
if (!step) return null
const advance = () => {
guideAdvance(world, g.step + 1)
bump()
}
return (
<div className="guide-strip">
<span className="guide-icon"></span>
<div style={{ flex: 1 }}>
<b>{step.title}</b>
<span className="dim" style={{ marginLeft: 8 }}>{step.hint}</span>
</div>
<button className="btn btn-sm btn-primary" onClick={advance}></button>
<button className="btn btn-sm" onClick={advance} title="跳过引导">×</button>
</div>
)
}
+8 -4
View File
@@ -7,7 +7,10 @@ const STATE_LABEL: Record<string, { label: string; cls: string }> = {
idle: { label: '无事', cls: '' }, idle: { label: '无事', cls: '' },
meditation: { label: '闭关', cls: 'good' }, meditation: { label: '闭关', cls: 'good' },
expedition: { label: '出探', cls: 'warn' }, expedition: { label: '出探', cls: 'warn' },
wounded: { label: '养伤', cls: 'bad' } wounded: { label: '养伤', cls: 'bad' },
apprentice: { label: '寄读', cls: 'gold' },
closed: { label: '养息', cls: '' },
dead: { label: '已殁', cls: 'bad' }
} }
export function MemberCard({ c }: { c: Character }) { export function MemberCard({ c }: { c: Character }) {
@@ -16,9 +19,10 @@ export function MemberCard({ c }: { c: Character }) {
if (!world) return null if (!world) return null
const dead = !c.alive const dead = !c.alive
const isHead = world.state.family.headId === c.id const isHead = world.state.family.headId === c.id
const state = STATE_LABEL[c.state] const state = STATE_LABEL[dead ? 'dead' : c.state]
const blocked = !dead && c.realmProgress >= 100 && c.realm.major !== 'spirit'
return ( return (
<div className={`member-card ${dead ? 'dead' : ''}`} onClick={() => setSelected(c.id)}> <div className={`member-card ${dead ? 'dead' : ''} ${blocked ? 'blocked' : ''}`} onClick={() => setSelected(c.id)}>
<div className="mc-head"> <div className="mc-head">
<div className="m-avatar">{world.state.family.surname}</div> <div className="m-avatar">{world.state.family.surname}</div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
@@ -38,7 +42,7 @@ export function MemberCard({ c }: { c: Character }) {
<span className="tag">{describeRoots(c.roots)}</span> <span className="tag">{describeRoots(c.roots)}</span>
{!dead && c.realmProgress >= 100 && <span className="tag gold-t"> · </span>} {!dead && c.realmProgress >= 100 && <span className="tag gold-t"> · </span>}
{C_STATE[dead ? 'dead' : c.state] && ( {C_STATE[dead ? 'dead' : c.state] && (
<span className={`tag ${C_STATE[dead ? 'dead' : c.state]}`}>{STATE_LABEL[c.state]?.label ?? ''}</span> <span className={`tag ${C_STATE[dead ? 'dead' : c.state]}`}>{STATE_LABEL[dead ? 'dead' : c.state]?.label ?? ''}</span>
)} )}
</div> </div>
{!dead && ( {!dead && (
+59 -4
View File
@@ -8,6 +8,29 @@ import { ITEMS, ARTIFACT_POWER } from '../../game/data/items'
import { combatPowerOf } from '../../game/engine/systems/combat' import { combatPowerOf } from '../../game/engine/systems/combat'
import { lifespanOf } from '../../game/engine/systems/lifecycle' import { lifespanOf } from '../../game/engine/systems/lifecycle'
import { POSTS, POST_ORDER } from '../../game/data/posts' import { POSTS, POST_ORDER } from '../../game/data/posts'
import { buildBiography } from '../../game/core/biography'
function aspName(c: Character): string {
const map: Record<string, string> = {
dao: '志向·求道', zhen: '志向·镇族', cheng: '志向·承宗', shang: '志向·商略', bing: '志向·兵略', geng: '志向·耕读', yun: '志向·云游'
}
return map[c.aspiration ?? ''] ?? '志向·未定'
}
function fitTag(c: Character): string | null {
const t = TECHNIQUES.find((x) => x.id === c.techniqueId)
if (!t) return null
return t.element === c.roots.primary ? `本命·${t.element}契合` : null
}
function deadish(c: Character): boolean {
return !c.alive
}
function gradeShort(g: number): string {
const map: Record<number, string> = { 0: '凡', 1: '黄', 2: '玄', 3: '地', 4: '天', 5: '仙' }
return map[g] ?? '?'
}
function pillCount(s: GameState, id: string): number { function pillCount(s: GameState, id: string): number {
return s.family.inventory[id] ?? 0 return s.family.inventory[id] ?? 0
@@ -47,9 +70,19 @@ export function MemberModal({ member }: { member: Character }) {
<div className="mm-line"><span></span><b>{describeRealm(member.realm)} {Math.floor(member.realmProgress)}%</b></div> <div className="mm-line"><span></span><b>{describeRealm(member.realm)} {Math.floor(member.realmProgress)}%</b></div>
<div className="mm-line"><span></span><b className={member.realmProgress >= 100 ? 'gold' : ''}>{nextName}</b></div> <div className="mm-line"><span></span><b className={member.realmProgress >= 100 ? 'gold' : ''}>{nextName}</b></div>
<div className="mm-line"><span></span><b>{Math.round(power)}</b></div> <div className="mm-line"><span></span><b>{Math.round(power)}</b></div>
<div className="mm-line"><span></span><b>{member.state === 'meditation' ? '闭关' : member.state === 'expedition' ? '外出' : member.state === 'wounded' ? '养伤' : '无事'}</b></div> <div className="mm-line"><span></span><b>
{member.state === 'meditation'
? '闭关'
: member.state === 'expedition'
? '外出'
: member.state === 'wounded'
? '养伤'
: member.state === 'apprentice'
? '寄读'
: '无事'}
</b></div>
<div className="mm-line"><span></span><b>{Math.round(member.health)}</b></div> <div className="mm-line"><span></span><b>{Math.round(member.health)}</b></div>
<div className="mm-line"><span></span><b>{tech ? `${tech.name}(${['黄', '玄', '地', '天', '仙'][tech.grade]}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}</b></div> <div className="mm-line"><span></span><b>{tech ? `${tech.name}(${gradeShort(tech.grade)}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}</b></div>
<div className="mm-line"><span></span><b>{member.equipment ? `${ITEMS[member.equipment].name}(+${Math.round((ARTIFACT_POWER[member.equipment] ?? 0) * 100)}%)` : '—'}</b></div> <div className="mm-line"><span></span><b>{member.equipment ? `${ITEMS[member.equipment].name}(+${Math.round((ARTIFACT_POWER[member.equipment] ?? 0) * 100)}%)` : '—'}</b></div>
<div className="mm-line"><span></span><b>{member.spouseId ? s.members[member.spouseId]?.name ?? '' : member.spouseHouse ?? '未婚'}</b></div> <div className="mm-line"><span></span><b>{member.spouseId ? s.members[member.spouseId]?.name ?? '' : member.spouseHouse ?? '未婚'}</b></div>
</div> </div>
@@ -232,11 +265,33 @@ export function MemberModal({ member }: { member: Character }) {
</div> </div>
)} )}
{member.alive && (
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 4 }}></div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span className="tag">{aspName(member)}</span>
{fitTag(member) && <span className="tag gold-t">{fitTag(member)}</span>}
</div>
</div>
)}
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}></div>
<div className="bio-box">
{buildBiography(w.state, member.id).map((l, i) => (
<div key={i} className={deadish(member) && l.label === '墓志' ? 'gold' : ''}>
{l.label && <b className="bio-label">{l.label} · </b>}
{l.text}
</div>
))}
</div>
</div>
{member.alive && w.canMarry(member.id) && ( {member.alive && w.canMarry(member.id) && (
<div className="mm-sec"> <div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}></div> <div className="dim" style={{ marginBottom: 6 }}></div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div className="marriage-list">
{w.marriageCandidatesOf(member.id).slice(0, 6).map((cand) => ( {w.marriageCandidatesOf(member.id).map((cand) => (
<button <button
key={cand.id} key={cand.id}
className="btn btn-sm" className="btn btn-sm"
@@ -0,0 +1,72 @@
import { useGameStore } from '../store'
import { LegacyArch } from '../../game/core/legacy'
import { sBell } from '../sound'
function dimLabel(k: string): string {
const map: Record<string, string> = {
renXing: '人兴',
daoXing: '道兴',
weiMing: '威名',
xiangHuo: '香火'
}
return map[k] ?? k
}
export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: () => void }) {
return (
<div className="modal-outer">
<div className="modal resolve-modal">
<div className="modal-cat" style={{ color: '#97835c', letterSpacing: 4 }}>
·
</div>
<div className="resolve-title">{arch.title}</div>
<div className="resolve-dims">
{Object.entries(arch.dims)
.filter(([k]) => k !== 'total')
.map(([k, v]) => (
<div key={k} className="resolve-dim">
<span className="dim">{dimLabel(k)}</span>
<div className="bar" style={{ flex: 1, height: 6 }}>
<div style={{ width: `${Math.min(100, (v / 160) * 100)}%` }} />
</div>
<b>{v}</b>
</div>
))}
<div className="resolve-dim">
<span className="dim"></span>
<div className="bar" style={{ flex: 1, height: 6 }}>
<div style={{ width: `${Math.min(100, (arch.dims.total / 400) * 100)}%`, background: 'linear-gradient(90deg,#8e2f24,#c05a41,#e0c15e)' }} />
</div>
<b className="gold">{arch.dims.total}</b>
</div>
</div>
<div className="resolve-poem">
{arch.poem.map((l, i) => (
<div key={i}>{l}</div>
))}
</div>
<div className="resolve-stamp"> · </div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 10, marginTop: 14 }}>
<button className="btn" onClick={onClose}></button>
<button
className="btn btn-primary"
onClick={() => {
const w = useGameStore.getState().world
if (w) {
sBell()
w.resolveLegacyNow()
useGameStore.getState().bump()
useGameStore.getState().refreshSlots()
}
onClose()
}}
>
</button>
</div>
</div>
</div>
)
}
export default ResolveModal
@@ -0,0 +1,34 @@
import { useGameStore } from '../store'
import { computeUrgency } from '../../game/core/urgency'
export function UrgentBadges() {
const world = useGameStore((s) => s.world)
const revision = useGameStore((s) => s.revision)
const setPanel = useGameStore((s) => s.setPanel)
const setSpeed = useGameStore((s) => s.setSpeed)
void revision
if (!world) return null
const u = computeUrgency(world)
const items: { key: string; label: string; cls: string; onClick: () => void }[] = []
if (u.blockedCount > 0)
items.push({ key: 'block', label: `瓶颈·${u.blockedCount}`, cls: 'badge-safe', onClick: () => { setPanel('family') } })
if (u.injuredCount > 0)
items.push({ key: 'inj', label: `重伤·${u.injuredCount}`, cls: 'badge-bad', onClick: () => setPanel('family') })
if (u.missionCount > 0)
items.push({ key: 'mis', label: `在外·${u.missionCount}`, cls: 'badge-info', onClick: () => setPanel('expedition') })
if (u.marriageReady >= 2)
items.push({ key: 'mar', label: `媒缘·${u.marriageReady}`, cls: 'badge-warn', onClick: () => setPanel('family') })
if (u.noHeir)
items.push({ key: 'heir', label: '孤嗣之忧', cls: 'badge-bad', onClick: () => setPanel('genealogy') })
if (items.length === 0) return null
return (
<div className="urgent-badges">
{items.map((b) => (
<button key={b.key} className={`badge ${b.cls}`} onClick={b.onClick}>
{b.label}
</button>
))}
</div>
)
}
+1 -1
View File
@@ -27,7 +27,7 @@ export default function ChroniclePanel() {
const all = [...world.state.chronicle] const all = [...world.state.chronicle]
const filtered = filter === 'all' ? all : all.filter((e) => e.category === filter) const filtered = filter === 'all' ? all : all.filter((e) => e.category === filter)
return filtered.slice().sort((a, b) => (b.year - a.year) || (b.month - a.month)) return filtered.slice().sort((a, b) => (b.year - a.year) || (b.month - a.month))
}, [world, filter]) }, [world, filter, revision])
if (!world) return null if (!world) return null
const battles = world.state.battles.slice().reverse() const battles = world.state.battles.slice().reverse()
const battle = battles.find((b) => b.id === battleId) const battle = battles.find((b) => b.id === battleId)
+6 -2
View File
@@ -56,15 +56,19 @@ export default function DiplomacyPanel() {
<button <button
className="btn btn-sm" className="btn btn-sm"
disabled={npc.relation < 25 || married} disabled={npc.relation < 25 || married}
title={npc.relation < 25 ? `需关系≥25(当前${npc.relation}` : married ? '一姓已结亲' : '关系≥25方可联姻'}
onClick={() => { marryNpcFamily(w, npc.id); bump() }} onClick={() => { marryNpcFamily(w, npc.id); bump() }}
> >
{married ? '已联姻' : '联姻'} {married ? '已联姻' : '联姻'}
</button> </button>
<button <button
className="btn btn-sm" className="btn btn-sm"
title="关系-20,可能招致劫掠;每年一次"
onClick={() => { onClick={() => {
w.tauntNpc(npc.id) if (window.confirm(`${npc.name}寻衅?关系将下降 20,可能招致劫掠。`)) {
bump() w.tauntNpc(npc.id)
bump()
}
}} }}
> >
+16 -1
View File
@@ -4,6 +4,7 @@ import { sendMission, recallAll } from '../../game/engine/systems/missions'
import { combatPowerOf } from '../../game/engine/systems/combat' import { combatPowerOf } from '../../game/engine/systems/combat'
import { useState } from 'react' import { useState } from 'react'
import { describeRealm, MAJOR_ORDER } from '../../game/data/realms' import { describeRealm, MAJOR_ORDER } from '../../game/data/realms'
import { FORMATIONS, FormationId } from '../../game/data/formations'
export default function ExpeditionPanel() { export default function ExpeditionPanel() {
const world = useGameStore((s) => s.world) const world = useGameStore((s) => s.world)
@@ -13,6 +14,7 @@ export default function ExpeditionPanel() {
const revision = useGameStore((s) => s.revision) const revision = useGameStore((s) => s.revision)
void revision void revision
const [squad, setSquad] = useState<string[]>([]) const [squad, setSquad] = useState<string[]>([])
const [formation, setFormation] = useState<FormationId>('vanguard')
if (!world) return null if (!world) return null
const w = world const w = world
const s = w.state const s = w.state
@@ -78,11 +80,24 @@ export default function ExpeditionPanel() {
))} ))}
{candidates.length === 0 && <span className="dim2"></span>} {candidates.length === 0 && <span className="dim2"></span>}
</div> </div>
<div className="formation-pick" style={{ marginBottom: 8 }}>
{(Object.keys(FORMATIONS) as FormationId[]).map((fid) => (
<div
key={fid}
className={`tag ${formation === fid ? 'gold-t' : ''}`}
style={{ cursor: 'pointer', padding: '3px 10px' }}
onClick={() => setFormation(fid)}
title={FORMATIONS[fid].desc}
>
{FORMATIONS[fid].name}
</div>
))}
</div>
<button <button
className="btn btn-primary" className="btn btn-primary"
disabled={squad.length < def.minMembers || squad.length > def.maxMembers || active.length >= 3} disabled={squad.length < def.minMembers || squad.length > def.maxMembers || active.length >= 3}
onClick={() => { onClick={() => {
sendMission(w, def.id, squad) sendMission(w, def.id, squad, formation)
setSquad([]) setSquad([])
bump() bump()
}} }}
+248
View File
@@ -0,0 +1,248 @@
import { useState } from 'react'
import { useGameStore } from '../store'
import { computeLegacy, resolveLegacy } from '../../game/core/legacy'
import { resolveLegacy as doesIt } from '../../game/core/legacy'
import { ResolveModal } from '../components/ResolveModal'
import { buildBiography } from '../../game/core/biography'
import { yearAxis, decadeLabel } from '../../game/core/yearaxis'
import { buildReport, verdictLine } from '../../game/core/legacyreport'
void doesIt
export default function LegacyPanel() {
const world = useGameStore((s) => s.world)
const revision = useGameStore((s) => s.revision)
void revision
const [showResolve, setShowResolve] = useState(false)
const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis' | 'legacy'>('dims')
if (!world) return null
const w = world
const s = w.state
const dims = computeLegacy(s)
const arch = resolveLegacy(s)
const resolved = !!s.family.flag['resolved']
const yearly = s.yearlyReports.slice().sort((a, b) => a.year - b.year)
const births = Object.values(s.members).filter((c) => c.bornYear >= 0)
const genMin = Math.min(...Object.values(s.members).map((c) => c.bornYear))
const genMax = Math.max(...Object.values(s.members).map((c) => c.deathYear ?? s.year))
return (
<div>
<div className="help-text">
</div>
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 18, marginBottom: 12 }}>
<div className="legacy-peak" style={{ width: 86, height: 110 }}>
<div className="legacy-glyph"></div>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '1.04rem', marginBottom: 4 }}>
· <b className="gold" style={{ fontSize: '1.2rem' }}>{arch.title}</b>
{resolved && <span className="tag head-t" style={{ marginLeft: 8 }}></span>}
</div>
<div className="dim2" style={{ fontSize: '0.85rem' }}>
{s.year} · {s.family.generation} · {s.stats.repPeak} · {s.stats.popPeak}
{s.stats.tourneyBest ? ` · 大比最佳第${s.stats.tourneyBest}` : ''}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{!resolved && (
<button className="btn btn-primary" onClick={() => setShowResolve(true)}>
</button>
)}
<button className="btn" onClick={() => setView('dims')}></button>
<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('axis')}></button>
<button className="btn" onClick={() => setView('legacy')}></button>
</div>
</div>
{view === 'dims' && (
<div className="card">
<div className="card-title"></div>
{Object.entries(dims)
.filter(([k]) => k !== 'total')
.map(([k, v]) => (
<div key={k} className="resolve-dim" style={{ maxWidth: 620 }}>
<span className="dim">{dimName(k)}</span>
<div className="bar" style={{ flex: 1 }}>
<div style={{ width: `${Math.min(100, (v / 160) * 100)}%` }} />
</div>
<b>{v}</b>
</div>
))}
<div className="resolve-dim" style={{ maxWidth: 620 }}>
<span className="dim"></span>
<div className="bar" style={{ flex: 1 }}>
<div style={{ width: `${Math.min(100, (dims.total / 400) * 100)}%`, background: 'linear-gradient(90deg,#8e2f24,#c05a41,#e0c15e)' }} />
</div>
<b className="gold">{dims.total}</b>
</div>
<div className="dim2" style={{ marginTop: 8 }}>
=· · =· · =· · =·
</div>
</div>
)}
{view === 'report' && (
<div className="card">
<div className="card-title">簿</div>
<table className="market-table" style={{ width: '100%' }}>
<thead>
<tr>
<th></th><th></th><th></th><th></th><th></th><th></th>
</tr>
</thead>
<tbody>
{yearly.slice(-20).map((r) => (
<tr key={r.year}>
<td>{r.year}</td>
<td className={r.nets >= 0 ? 'good' : 'bad'}>{r.nets >= 0 ? `+${r.nets}` : r.nets}</td>
<td>+{r.births}</td>
<td className="bad">-{r.deaths}</td>
<td>{r.rep}</td>
<td>{r.power}</td>
</tr>
))}
</tbody>
</table>
<div className="dim2" style={{ marginTop: 6 }}> {yearly.length} </div>
</div>
)}
{view === 'legacy' && <LegacyReportView />}
{view === 'axis' && <AxisView />}
{view === 'scroll' && <ScrollScroll />}
{view === 'chronicle' && (
<div className="card">
<div className="card-title"></div>
<div className="dim" style={{ marginBottom: 6 }}>
{genMin + s.year - s.year} ~ {genMax} {genMax - genMin}
</div>
<div className="dim2" style={{ marginBottom: 10 }}>
{s.stats.tourneyHistory.length === 0 ? '尚无参赛记录' : s.stats.tourneyHistory.map((t) => `${t.year}年第${t.rank}`).join('、')}
</div>
<div className="dim2">
{breakdown(s).length === 0 ? '暂无' : breakdown(s).map((b) => `${b.year}年×${b.n}`).join(' · ')}
</div>
</div>
)}
{showResolve && <ResolveModal arch={arch} onClose={() => setShowResolve(false)} />}
</div>
)
}
function LegacyReportView() {
const world = useGameStore((s) => s.world)
if (!world) return null
const r = buildReport(world.state)
const span = r.years.length
return (
<div className="card">
<div className="card-title"></div>
{span < 4 ? (
<div className="dim"></div>
) : (
<>
<Sparklines r={r} />
<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>
</>
)}
</div>
)
}
function Sparklines({ r }: { r: ReturnType<typeof buildReport> }) {
const width = 640
const height = 150
const pts = (seq: number[], color: string) => {
const max = Math.max(...seq, 1)
const step = seq.length > 1 ? width / (seq.length - 1) : width
return seq.map((v, i) => `${(i * step).toFixed(1)},${(height - 20 - (v / max) * (height - 40)).toFixed(1)}`).join(' ')
}
return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxHeight: 160, background: 'rgba(20,15,9,0.5)', borderRadius: 4 }}>
<polyline fill="none" stroke="#8fae6a" strokeWidth="2" points={pts(r.population, 'green')} />
<polyline fill="none" stroke="#c9a227" strokeWidth="2" points={pts(r.reputation, 'gold')} />
<polyline fill="none" stroke="#a9a9a9" strokeWidth="2" points={pts(r.power, 'gray')} />
<text x="4" y="14" fill="#8fae6a" fontSize="10"></text>
<text x="64" y="14" fill="#c9a227" fontSize="10"></text>
<text x="124" y="14" fill="#aaa" fontSize="10"></text>
</svg>
)
}
function AxisView() {
const world = useGameStore((s) => s.world)
if (!world) return null
const cells = yearAxis(world.state)
return (
<div className="card">
<div className="card-title"></div>
{cells.map((c) => (
<div key={c.from} className="axis-cell">
<div className="axis-decade">{decadeLabel(c.from, c.to)}</div>
<div className="axis-events">
<span className="tag good-t-bg"> {c.births}</span>
<span className="tag bad-t-bg"> {c.deaths}</span>
{c.events.slice(0, 6).map((e, i) => (
<span key={i} className={`tag ${e.kind === '战' || e.kind === '渡劫' ? 'bad-t-bg' : ''}`}>
{e.kind}·{e.year} {e.label}
</span>
))}
{c.events.length > 6 && <span className="dim2">{c.events.length - 6}</span>}
</div>
</div>
))}
</div>
)
}
function ScrollScroll() {
const world = useGameStore((s) => s.world)
if (!world) return null
const mem = Object.values(world.state.members).sort((a, b) => b.generation - a.generation || b.bornYear - a.bornYear)
return (
<div className="card">
<div className="card-title"></div>
{mem.slice(0, 40).map((c) => {
const bio = buildBiography(world.state, c.id)
return (
<div key={c.id} className="bio-row">
<b>{c.name}</b>
<span className="dim" style={{ marginLeft: 8 }}>
{bio.map((l) => l.text).join(' ').slice(0, 90)}{bio.map((l) => l.text).join(' ').length > 90 ? '……' : ''}
</span>
</div>
)
})}
</div>
)
}
function dimName(k: string): string {
const map: Record<string, string> = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火' }
return map[k] ?? k
}
function breakdown(s: { chronicle: { category: string; year: number }[] }): { year: number; n: number }[] {
const counts = new Map<number, number>()
for (const e of s.chronicle) {
if (e.category === 'breakthrough') {
const cur = counts.get(e.year) ?? 0
counts.set(e.year, cur + 1)
}
}
return [...counts.entries()].map(([year, n]) => ({ year, n })).sort((a, b) => a.year - b.year)
}
+5 -5
View File
@@ -1,7 +1,7 @@
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { ITEMS } from '../../game/data/items' import { ITEMS } from '../../game/data/items'
import { TECHNIQUES } from '../../game/data/techniques' import { TECHNIQUES } from '../../game/data/techniques'
import { TECHNIQUE_GRADE_NAMES } from '../../game/data/realms' import { techniqueGradeName } from '../../game/data/realms'
import { marketPrice, buyItem, sellItem, buyTechnique } from '../../game/engine/market' import { marketPrice, buyItem, sellItem, buyTechnique } from '../../game/engine/market'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@@ -10,11 +10,11 @@ export default function MarketPanel() {
const bump = useGameStore((s) => s.bump) const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision) const revision = useGameStore((s) => s.revision)
void revision void revision
const [tab, setTab] = useState<'goods' | 'tech'>('goods')
if (!world) return null if (!world) return null
const w = world const w = world
const fam = w.state.family const fam = w.state.family
const inv = fam.inventory const inv = fam.inventory
const [tab, setTab] = useState<'goods' | 'tech'>('goods')
const goods = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'resource' || i.kind === 'pill'), []) const goods = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'resource' || i.kind === 'pill'), [])
const artifacts = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'artifact'), []) const artifacts = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'artifact'), [])
@@ -110,7 +110,7 @@ export default function MarketPanel() {
{tab === 'tech' && ( {tab === 'tech' && (
<> <>
<div className="help-text"> <div className="help-text">
{cangshuLv} {TECHNIQUE_GRADE_NAMES[Math.min(3, Math.max(0, cangshuLv))]} {cangshuLv} {techniqueGradeName(Math.min(4, Math.max(0, cangshuLv + 1)))}
</div> </div>
<table className="market-table"> <table className="market-table">
@@ -131,11 +131,11 @@ export default function MarketPanel() {
.map((t) => { .map((t) => {
const owned = fam.techniques.includes(t.id) const owned = fam.techniques.includes(t.id)
const p = t.grade <= 1 + cangshuLv const p = t.grade <= 1 + cangshuLv
const priceT = [120, 300, 700, 1600, 3600][t.grade] ?? 300 const priceT = techniqueGradeName(t.grade) === '' ? 300 : [120, 300, 700, 1600][t.grade] ?? 300
return ( return (
<tr key={t.id}> <tr key={t.id}>
<td><b>{t.name}</b></td> <td><b>{t.name}</b></td>
<td className="dim">{TECHNIQUE_GRADE_NAMES[t.grade]} · {t.path}</td> <td className="dim">{techniqueGradeName(t.grade)} · {t.path}</td>
<td className="dim">{t.desc}</td> <td className="dim">{t.desc}</td>
<td>+{Math.round((t.expBonus) * 100)}%</td> <td>+{Math.round((t.expBonus) * 100)}%</td>
<td>+{Math.round((t.powerBonus) * 100)}%</td> <td>+{Math.round((t.powerBonus) * 100)}%</td>
+1 -1
View File
@@ -97,7 +97,7 @@ export default function SettingsPanel() {
· <br /> · <br />
· ·
</div> </div>
<div className="dim2"> 0.1.0 · Chronicle of the Immortal Clan</div> <div className="dim2"> 0.1.11 · Chronicle of the Immortal Clan</div>
</div> </div>
) )
} }
+3 -2
View File
@@ -7,9 +7,9 @@ export default function TerritoryPanel() {
const bump = useGameStore((s) => s.bump) const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision) const revision = useGameStore((s) => s.revision)
void revision void revision
const ids = useMemo(() => Object.keys(BUILDINGS), [])
if (!world) return null if (!world) return null
const fam = world.state.family const fam = world.state.family
const ids = useMemo(() => Object.keys(BUILDINGS), [])
const levelLabel = (l: number) => '·'.repeat(l) + '。'.repeat(5 - l) const levelLabel = (l: number) => '·'.repeat(l) + '。'.repeat(5 - l)
@@ -19,6 +19,7 @@ export default function TerritoryPanel() {
</div> </div>
<MonumentList />
<div className="bld-grid"> <div className="bld-grid">
{ids.map((id) => { {ids.map((id) => {
const def = BUILDINGS[id] const def = BUILDINGS[id]
@@ -32,7 +33,7 @@ export default function TerritoryPanel() {
fam.stones >= cost.stones && fam.stones >= cost.stones &&
fam.inventory['lingkuang'] >= cost.lingkuang fam.inventory['lingkuang'] >= cost.lingkuang
return ( return (
<div key={id} className={`bld-card ${built ? '' : 'dim2'}`}> <div key={id} className={`bld-card ${built ? '' : 'uc'}`}>
<div className="bld-head"> <div className="bld-head">
<span className="bld-icon">{def.icon}</span> <span className="bld-icon">{def.icon}</span>
<div> <div>
+16 -1
View File
@@ -3,6 +3,7 @@ import { PanelId } from '../store'
import { RES_INFO } from '../components' import { RES_INFO } from '../components'
import FamilyPanel from '../panels/FamilyPanel' import FamilyPanel from '../panels/FamilyPanel'
import GenealogyPanel from '../panels/GenealogyPanel' import GenealogyPanel from '../panels/GenealogyPanel'
import LegacyPanel from '../panels/LegacyPanel'
import TerritoryPanel from '../panels/TerritoryPanel' import TerritoryPanel from '../panels/TerritoryPanel'
import MarketPanel from '../panels/MarketPanel' import MarketPanel from '../panels/MarketPanel'
import DiplomacyPanel from '../panels/DiplomacyPanel' import DiplomacyPanel from '../panels/DiplomacyPanel'
@@ -10,6 +11,9 @@ import ExpeditionPanel from '../panels/ExpeditionPanel'
import ChroniclePanel from '../panels/ChroniclePanel' import ChroniclePanel from '../panels/ChroniclePanel'
import SettingsPanel from '../panels/SettingsPanel' import SettingsPanel from '../panels/SettingsPanel'
import { LogFeed } from '../components/LogFeed' import { LogFeed } from '../components/LogFeed'
import { UrgentBadges } from '../components/UrgentBadges'
import { fmt as fmtNum } from '../../game/core/format'
import { GuideStrip } from '../components/GuideStrip'
const TABS: { id: PanelId; label: string }[] = [ const TABS: { id: PanelId; label: string }[] = [
{ id: 'family', label: '宗族' }, { id: 'family', label: '宗族' },
@@ -19,6 +23,7 @@ const TABS: { id: PanelId; label: string }[] = [
{ id: 'diplomacy', label: '外交' }, { id: 'diplomacy', label: '外交' },
{ id: 'expedition', label: '探秘' }, { id: 'expedition', label: '探秘' },
{ id: 'chronicle', label: '史书' }, { id: 'chronicle', label: '史书' },
{ id: 'legacy', label: '春秋原' },
{ id: 'settings', label: '设置' } { id: 'settings', label: '设置' }
] ]
@@ -61,12 +66,14 @@ export default function GameScreen() {
{RES_INFO.map((r) => ( {RES_INFO.map((r) => (
<div key={r.id} className="res-item" title={r.label}> <div key={r.id} className="res-item" title={r.label}>
<span className="res-icon">{r.icon}</span> <span className="res-icon">{r.icon}</span>
<span>{dispRes(r.id)}</span> <span>{fmtNum(dispRes(r.id))}</span>
</div> </div>
))} ))}
</div> </div>
<button className="btn btn-sm" title="手动存点" onClick={() => void saveNow()}></button> <button className="btn btn-sm" title="手动存点" onClick={() => void saveNow()}></button>
</div> </div>
<UrgentBadges />
<GuideStrip />
<div className="tabs"> <div className="tabs">
{TABS.map((t) => ( {TABS.map((t) => (
<div key={t.id} className={`tab ${panel === t.id ? 'sel' : ''}`} onClick={() => setPanel(t.id)}> <div key={t.id} className={`tab ${panel === t.id ? 'sel' : ''}`} onClick={() => setPanel(t.id)}>
@@ -86,6 +93,7 @@ export default function GameScreen() {
{panel === 'diplomacy' && <DiplomacyPanel />} {panel === 'diplomacy' && <DiplomacyPanel />}
{panel === 'expedition' && <ExpeditionPanel />} {panel === 'expedition' && <ExpeditionPanel />}
{panel === 'chronicle' && <ChroniclePanel />} {panel === 'chronicle' && <ChroniclePanel />}
{panel === 'legacy' && <LegacyPanel />}
{panel === 'settings' && <SettingsPanel />} {panel === 'settings' && <SettingsPanel />}
{gameOverReason && ( {gameOverReason && (
<div className="gameover-banner"> <div className="gameover-banner">
@@ -94,6 +102,13 @@ export default function GameScreen() {
<p className="dim2" style={{ marginTop: 10 }}> <p className="dim2" style={{ marginTop: 10 }}>
{s.year} {s.month} {s.year} {s.month}
</p> </p>
<div style={{ marginTop: 20, display: 'flex', gap: 12, justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => void useGameStore.getState().refreshSnapshots().then(() => {
const snaps = useGameStore.getState().snapshots
if (snaps[0]) void useGameStore.getState().restoreSnapshot(snaps[0].id)
})}></button>
<button className="btn" onClick={() => useGameStore.getState().go('boot')}></button>
</div>
</div> </div>
)} )}
</div> </div>
+4 -5
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { SURNAME_POOL } from '../../game/core/names' import { SURNAME_POOL } from '../../game/core/names'
import { RngHub } from '../../game/core/rng'
export default function NewGame() { export default function NewGame() {
const go = useGameStore((s) => s.go) const go = useGameStore((s) => s.go)
@@ -12,14 +13,12 @@ export default function NewGame() {
const [slot, setSlot] = useState(1) const [slot, setSlot] = useState(1)
const [randoming, setRandoming] = useState(false) const [randoming, setRandoming] = useState(false)
const seed = useMemo( const seed = useMemo(() => RngHub.rollSeed(), [randoming])
() => `${Date.now()}-${Math.floor(Math.random() * 1e9)}-${Math.floor(Math.random() * 1e9)}`,
[randoming]
)
const rollNames = () => { const rollNames = () => {
setRandoming((r) => !r) setRandoming((r) => !r)
const s = SURNAME_POOL[Math.floor(Math.random() * SURNAME_POOL.length)] const idx = Math.floor(RngHub.audioNoise01() * SURNAME_POOL.length)
const s = SURNAME_POOL[Math.min(SURNAME_POOL.length - 1, idx)]
setSurname(s) setSurname(s)
setFamilyName(`${s}`) setFamilyName(`${s}`)
} }
+7 -2
View File
@@ -1,4 +1,5 @@
// 合成音效:鼓点·钟鸣·纸韵(Web Audio,零素材依赖) // 合成音效:鼓点·钟鸣·纸韵(Web Audio,零素材依赖)
import { RngHub } from '../game/core/rng'
let ctx: AudioContext | null = null let ctx: AudioContext | null = null
let enabled = true let enabled = true
@@ -49,7 +50,7 @@ function noise(dur: number, gain: number, when = 0, freq = 3200): void {
const len = Math.floor(a.sampleRate * dur) const len = Math.floor(a.sampleRate * dur)
const buf = a.createBuffer(1, len, a.sampleRate) const buf = a.createBuffer(1, len, a.sampleRate)
const data = buf.getChannelData(0) const data = buf.getChannelData(0)
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len) for (let i = 0; i < len; i++) data[i] = (RngHub.audioNoise01() * 2 - 1) * (1 - i / len)
const src = a.createBufferSource() const src = a.createBufferSource()
src.buffer = buf src.buffer = buf
const f = a.createBiquadFilter() const f = a.createBiquadFilter()
@@ -109,7 +110,11 @@ export function sGong(): void {
tone(660, 0.7, 0.03, 'sine', 0.06) tone(660, 0.7, 0.03, 'sine', 0.06)
} }
/** 按钮:纸面轻叩 */ let lastClick = 0
/** 按钮:纸面轻叩(80ms 节流防连点爆 buffer */
export function sClick(): void { export function sClick(): void {
const now = performance.now()
if (now - lastClick < 80) return
lastClick = now
noise(0.045, 0.03, 0, 4200) noise(0.045, 0.03, 0, 4200)
} }
+134 -26
View File
@@ -5,10 +5,12 @@ import { findEvent, applyEventChoice } from '../game/engine/systems/events'
import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain' import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain'
import { getSlotManager, getSaveSlot } from '../game/storage/db' import { getSlotManager, getSaveSlot } from '../game/storage/db'
import { metaFromState, updateSlotMeta } from './storeHelper' import { metaFromState, updateSlotMeta } from './storeHelper'
import { GameFacade, ActName } from '../game/engine/api'
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound' import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
import type { PhaseStat } from '../game/core/clock'
export type Screen = 'boot' | 'newgame' | 'game' export type Screen = 'boot' | 'newgame' | 'game'
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'settings' export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'settings'
export interface GameStore { export interface GameStore {
screen: Screen screen: Screen
@@ -57,12 +59,34 @@ export interface GameStore {
onYearPaper: (report: YearlyReport) => void onYearPaper: (report: YearlyReport) => void
closePaper: () => void closePaper: () => void
toggleSound: () => void toggleSound: () => void
facade?: GameFacade
advancing: boolean
act: (name: ActName, payload: import('../game/engine/api').ActPayload) => boolean
} }
const LOG_CAP = 260 const LOG_CAP = 260
const lastPhaseStats = new Map<World, PhaseStat[]>()
let logSeq = 1 let logSeq = 1
function staddEventLog(name: string): void {
const st = useGameStore.getState()
st.addLog({ id: logSeq++, kind: 'event', text: `异象忽至:${name}`, year: st.world?.state.year ?? 0, month: st.world?.state.month ?? 0 })
}
let toastTimer: ReturnType<typeof setTimeout> | null = null
function setNextToast(msg: string): void {
if (toastTimer) clearTimeout(toastTimer)
useGameStore.setState({ toast: msg })
toastTimer = setTimeout(() => useGameStore.setState({ toast: undefined }), 4200)
}
function stopTimer(): void {
const st = useGameStore.getState()
if (st.timer) clearInterval(st.timer)
useGameStore.setState({ speed: 0, timer: null })
}
export const useGameStore = create<GameStore>((set, get) => ({ export const useGameStore = create<GameStore>((set, get) => ({
facade: undefined as GameFacade | undefined,
advancing: false,
screen: 'boot', screen: 'boot',
world: null, world: null,
slot: 1, slot: 1,
@@ -118,42 +142,73 @@ export const useGameStore = create<GameStore>((set, get) => ({
startNewGame: async (opts, slot) => { startNewGame: async (opts, slot) => {
const world = World.create(opts) const world = World.create(opts)
set({ world, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0 }) set({ world, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0, gameOverReason: undefined, paperReport: undefined, toast: undefined, selectedMemberId: undefined, selectedMissionDef: undefined })
const st = get() const st = get()
st.world?.out.push(makeBus(st)) st.world?.out.push(makeBus(st))
const facade = new GameFacade(world, slot)
set({ facade })
await st.saveNow('开局') await st.saveNow('开局')
}, },
continueGame: async (slot) => { continueGame: async (slot) => {
const manager = getSlotManager() try {
const file = await getSaveSlot(slot) const manager = getSlotManager()
await file.open() const file = await getSaveSlot(slot)
const state = await file.loadState() await file.open()
if (!state) throw new Error('找不到存档数据') const state = await file.loadState()
openState(state, slot) if (!state) throw new Error('找不到存档数据')
await manager.updateSlotMeta(slot, metaFromState(state, slot)) openState(state, slot)
await manager.updateSlotMeta(slot, metaFromState(state, slot))
} catch (e) {
setNextToast(`读档失败:${String(e)}`)
useGameStore.setState({ screen: 'boot' })
}
}, },
loadSnapshot: async (slot, snapshotId) => { loadSnapshot: async (slot, snapshotId) => {
const file = await getSaveSlot(slot) try {
await file.open() const file = await getSaveSlot(slot)
const state = await file.loadState(snapshotId) await file.open()
if (!state) throw new Error('找不到快照') const state = await file.loadState(snapshotId)
openState(state, slot) if (!state) throw new Error('找不到快照')
openState(state, slot)
} catch (e) {
setNextToast(`读取快照失败:${String(e)}`)
}
}, },
advance: async () => { advance: async () => {
const st = get() const st = get()
const w = st.world const w = st.world
if (!w || st.pendingEventId || w.state.gameOver) return if (!w || st.pendingEventId || w.state.gameOver || st.advancing) return
set({ advancing: true })
try { try {
const t0 = performance.now()
w.advanceMonth() w.advanceMonth()
const elapsed = performance.now() - t0
if (import.meta.env?.DEV && elapsed > 60) {
// 性能预算:单 tick 超 60ms 提示最慢 phase
const stats = w.clock.stepMonthly(w)
void stats
const slowest = lastPhaseStats.get(w)?.sort((a, b) => b.ms - a.ms)[0]
if (slowest) console.warn('[perf] tick 超预算', elapsed.toFixed(1) + 'ms', '最慢:', slowest.phase, slowest.ms.toFixed(1) + 'ms')
}
w.syncRng() w.syncRng()
sTick() sTick()
set((s) => ({ revision: s.revision + 1 })) set((s) => ({ revision: s.revision + 1 }))
await Stash.save(st, '自动') await Stash.save(st, '自动')
} catch (e) { } catch (e) {
console.error(e) // 出险保护:先落一档"出险前"现场再提示回档
console.error('[advance-crash]', e)
try {
await Stash.save(st, '出险前')
} catch {
// 保存失败时不再叠加错误
}
setNextToast('时日流转出现异常,已保存出险现场,可回档重来。')
stopTimer()
} finally {
set({ advancing: false })
} }
}, },
@@ -170,10 +225,22 @@ export const useGameStore = create<GameStore>((set, get) => ({
} }
}, },
onBattle: (log) => set({ battleView: log, revision: get().revision + 1 }), onBattle: (log) => {
stopTimer()
set({ battleView: log, revision: get().revision + 1 })
},
onPendingEvent: (id) => { onPendingEvent: (id) => {
const def = findEvent(id) const def = findEvent(id, get().world ?? undefined)
if (def) staddEventLog(def.name)
if (!def) {
// 未知事件(插件卸载/旧档漂移):跳过并解除软锁
const w = get().world
if (w?.state.pendingEvent === id) w.state.pendingEvent = undefined
get().addLog({ id: logSeq++, kind: 'bad', text: `事件「${id}」未知,已跳过。`, year: get().world?.state.year ?? 0, month: get().world?.state.month ?? 0 })
set({ pendingEventId: undefined, pendingEventDef: undefined })
return
}
set({ pendingEventId: id, pendingEventDef: def }) set({ pendingEventId: id, pendingEventDef: def })
}, },
@@ -214,10 +281,21 @@ export const useGameStore = create<GameStore>((set, get) => ({
saveNow: async (label = '手动') => { saveNow: async (label = '手动') => {
await Stash.save(get(), label) await Stash.save(get(), label)
setNextToast(`已落笔——第${get().slot}档(${label}`)
}, },
bump: () => set((s) => ({ revision: s.revision + 1 })), bump: () => set((s) => ({ revision: s.revision + 1 })),
act: (name, payload) => {
const st = get()
const w = st.world
if (!w) return false
const ok = st.facade ? st.facade.act(name, payload) : actDirect(w, name, payload)
if (ok) set((s) => ({ revision: s.revision + 1 }))
return ok
},
setSpeed: (v) => { setSpeed: (v) => {
const st = get() const st = get()
if (st.timer) clearInterval(st.timer) if (st.timer) clearInterval(st.timer)
@@ -229,9 +307,12 @@ export const useGameStore = create<GameStore>((set, get) => ({
if (v >= 3) v = 3 if (v >= 3) v = 3
const timer = setInterval(() => { const timer = setInterval(() => {
const cur = get() const cur = get()
if (!cur.pendingEventId && cur.world && !cur.world.state.gameOver) { const w = cur.world
void cur.advance() if (cur.speed <= 0 || !w || cur.pendingEventId || cur.battleView || cur.advancing || w.state.gameOver) {
// 速度被暂停/事件待决/战报展开/在途推进时静默轮空
return
} }
void cur.advance()
}, ms) }, ms)
set({ speed: v, timer }) set({ speed: v, timer })
}, },
@@ -250,25 +331,27 @@ export const useGameStore = create<GameStore>((set, get) => ({
const json = await file.exportAll() const json = await file.exportAll()
const meta = metaFromState(st.world.state, st.slot) const meta = metaFromState(st.world.state, st.slot)
const r = await window.api.exportSave(json, `${meta.name}-年${meta.year}`) const r = await window.api.exportSave(json, `${meta.name}-年${meta.year}`)
if (r.ok) set({ toast: '存档已导出。' }) if (r.ok) setNextToast('存档已导出。')
else set({ toast: `导出失败:${r.error ?? ''}` }) else setNextToast(`导出失败:${r.error ?? ''}`)
}, },
importSave: async (slot) => { importSave: async (slot) => {
if (!window.api) return if (!window.api) return
const r = await window.api.importSave() const r = await window.api.importSave()
if (!r.ok || !r.text) { if (!r.ok || !r.text) {
set({ toast: `导入失败:${r.error ?? ''}` }) setNextToast(`导入失败:${r.error ?? ''}`)
return return
} }
const file = await getSaveSlot(slot) const file = await getSaveSlot(slot)
await file.open() await file.open()
const ok = await file.importAll(r.text) const ok = await file.importAll(r.text)
if (ok) { if (ok) {
set({ toast: '导入成功,重新读取存档槽。' }) setNextToast('导入成功,重新读取存档槽。')
await get().refreshSlots() await get().refreshSlots()
const state = await file.loadState()
if (state) openState(state, slot)
} else { } else {
set({ toast: '导入失败:文件格式不正确。' }) setNextToast('导入失败:文件格式不正确。')
} }
}, },
@@ -293,7 +376,9 @@ function openState(state: GameState, slot: number): void {
const world = new World(state) const world = new World(state)
const st = useGameStore.getState() const st = useGameStore.getState()
world.out.push(makeBus(st)) world.out.push(makeBus(st))
const facade = new GameFacade(world, slot)
useGameStore.setState({ useGameStore.setState({
facade,
world, world,
slot, slot,
screen: 'game', screen: 'game',
@@ -304,8 +389,20 @@ function openState(state: GameState, slot: number): void {
pendingEventDef: undefined, pendingEventDef: undefined,
revision: 1, revision: 1,
speed: 0, speed: 0,
gameOverReason: state.gameOver?.reason gameOverReason: state.gameOver?.reason || undefined,
paperReport: undefined,
toast: undefined,
selectedMemberId: undefined,
selectedMissionDef: undefined
}) })
if (state.pendingEvent && state.members) {
const def = findEvent(state.pendingEvent, world)
if (def) {
useGameStore.setState({ pendingEventId: state.pendingEvent, pendingEventDef: def })
} else {
world.state.pendingEvent = undefined
}
}
} }
function makeBus(st: GameStore): WorldEventBus { function makeBus(st: GameStore): WorldEventBus {
@@ -329,6 +426,17 @@ function makeBus(st: GameStore): WorldEventBus {
} }
} }
function actDirect(w: World, name: ActName, payload: import('../game/engine/api').ActPayload): boolean {
if (name === 'legacy.resolve') return (w.resolveLegacyNow(), true)
if (name === 'estate.rite') return w.ancestralRite()
if (name === 'estate.sutra') return w.seekSutra()
if (name === 'estate.build' && payload.building) return w.build(payload.building)
if (name === 'estate.upgrade' && payload.building) return w.upgrade(payload.building)
if (name === 'member.post' && payload.memberId) return w.assignPost(payload.memberId, payload.post)
if (name === 'member.marry' && payload.memberId && payload.targetId) return w.marryTo(payload.memberId, payload.targetId)
return false
}
const Stash = { const Stash = {
async save(st: GameStore, label: string): Promise<void> { async save(st: GameStore, label: string): Promise<void> {
if (!st.world) return if (!st.world) return
+138
View File
@@ -692,6 +692,10 @@ body {
inset 0 0 0 1px rgba(255, 252, 242, 0.6), inset 0 0 0 1px rgba(255, 252, 242, 0.6),
inset 0 0 32px rgba(130, 100, 50, 0.1); inset 0 0 32px rgba(130, 100, 50, 0.1);
} }
.member-card.blocked {
border-color: #a93b2e;
box-shadow: 0 0 0 1px rgba(169,59,46,0.4), 0 4px 12px rgba(0,0,0,0.55);
}
.member-card:hover { .member-card:hover {
border-color: var(--gold); border-color: var(--gold);
transform: translateY(-2px); transform: translateY(-2px);
@@ -1407,6 +1411,86 @@ body {
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.8); text-shadow: 0 4px 18px rgba(0, 0, 0, 0.8);
} }
/* ---------- 列传 ---------- */
.bio-box {
max-height: 240px;
overflow-y: auto;
background: rgba(20, 15, 9, 0.5);
border: 1px solid rgba(120, 98, 55, 0.35);
border-radius: 3px;
padding: 8px 12px;
line-height: 1.8;
font-size: 0.9rem;
color: #e4d6b4;
}
.bio-label {
color: #cdaa63;
font-weight: 400;
}
.bio-row {
padding: 6px 0;
border-bottom: 1px dashed rgba(120, 98, 55, 0.3);
line-height: 1.7;
font-size: 0.9rem;
}
/* ---------- 春秋原 & 结局 ---------- */
.legacy-peak {
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 35% 30%, #6e5630, #2c2110 75%);
border: 1px solid #7a6434;
border-radius: 50%;
box-shadow: 0 0 26px rgba(179, 145, 62, 0.28), inset 0 0 18px rgba(0, 0, 0, 0.6);
}
.legacy-glyph {
font-size: 2.4rem;
color: #efd9a2;
letter-spacing: 4px;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.8);
}
.resolve-dim {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.resolve-modal {
width: 560px;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='170' height='170'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='2' seed='14'/><feColorMatrix type='saturate' values='0'/></filter><rect width='170' height='170' filter='url(%23n)' opacity='0.055'/></svg>"),
linear-gradient(180deg, #f6edd7 0%, #e8d9b8 100%);
}
.resolve-title {
text-align: center;
font-size: 2.1rem;
letter-spacing: 10px;
color: #8c2f22;
margin: 10px 0 14px;
font-weight: 700;
text-shadow: 0 1px 0 rgba(255, 250, 235, 0.8), 0 4px 16px rgba(140, 47, 34, 0.25);
}
.resolve-dims {
border-top: 1px solid rgba(169, 59, 46, 0.3);
border-bottom: 1px solid rgba(120, 98, 55, 0.3);
padding: 10px 4px;
}
.resolve-poem {
text-align: center;
margin: 16px 0 8px;
line-height: 2.1;
color: #4a3c26;
font-size: 1.06rem;
letter-spacing: 2px;
}
.resolve-stamp {
text-align: center;
color: #a06f18;
letter-spacing: 6px;
font-size: 0.82rem;
}
/* ---------- 宗祠碑录 ---------- */ /* ---------- 宗祠碑录 ---------- */
.monument-strip { .monument-strip {
margin-bottom: 14px; margin-bottom: 14px;
@@ -1418,3 +1502,57 @@ body {
.monument-strip .card-title { .monument-strip .card-title {
color: #cdaa63; color: #cdaa63;
} }
/* ---------- 阵型点选 ---------- */
.formation-pick {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
/* ---------- 警讯徽章 ---------- */
.urgent-badges {
display: flex;
gap: 6px;
padding: 6px 16px 0;
background: var(--bg2);
flex-wrap: wrap;
}
.badge {
border: 1px solid transparent;
border-radius: 3px;
padding: 3px 10px;
font-size: 0.78rem;
cursor: pointer;
font-family: inherit;
letter-spacing: 1px;
transition: all 0.14s;
}
.badge:hover {
transform: translateY(-1px);
filter: brightness(1.2);
}
.badge-bad { background: #3a1712; border-color: #6b3228; color: #e0957f; }
.badge-safe { background: #2d2d10; border-color: #6a6b26; color: #d4d483; }
.badge-info { background: #102a33; border-color: #356274; color: #9cc5d4; }
.badge-warn { background: #33230e; border-color: #6b5a26; color: #e4c081; }
/* ---------- 引导条 ---------- */
.guide-strip {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
background: linear-gradient(90deg, rgba(201,162,39,0.14), rgba(201,162,39,0.04));
border-bottom: 1px solid rgba(201,162,39,0.3);
}
.guide-icon { color: var(--gold); font-size: 1rem; }
/* ---------- 指婚列表滚动 ---------- */
.marriage-list {
display: flex;
gap: 6px;
flex-wrap: wrap;
max-height: 120px;
overflow-y: auto;
}
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { GameFacade, ACT_CATALOG, ActName } from '../src/renderer/game/engine/api'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '华', familyName: '华家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('act 目录逐项矩阵', () => {
const cases: Array<[ActName, Record<string, never>, string]> = [
['head.set', { memberId: 'x3' } as never, 'headId === x3'],
['member.meditate', { memberId: 'x3', count: 1 } as never, 'state === meditation'],
['member.technique', { memberId: 'x3', tech: 't-qinglian' } as never, 'techniqueId set'],
['member.equip', { memberId: 'x4', item: 'weapon-qi' } as never, 'equipment set'],
['member.marry', { memberId: 'x4', targetId: 'x5' } as never, '']
]
it.each(cases)('%s 调用成功', (name, payload) => {
const w = baseWorld('act-' + name.replace(/\W/g, ''))
const f = new GameFacade(w, 1)
f.act('head.set', { memberId: 'x1' })
const ok = f.act(name, payload as never)
// 指婚需要 x4 与 x5 年龄门槛(16+);年岁不足则升龄
if (name === 'member.marry') {
w.state.members['x4'].bornYear = 1 - 45
w.state.members['x5'].bornYear = 1 - 17
expect(f.act(name, payload as never)).toBe(true)
} else {
expect(ok).toBe(true)
}
})
it('act 全目录存在且各自可解析', () => {
expect(Object.keys(ACT_CATALOG).length).toBeGreaterThanOrEqual(23)
for (const [name, def] of Object.entries(ACT_CATALOG)) {
expect(def.desc.length).toBeGreaterThan(0)
expect(name.includes('.')).toBe(true)
}
})
it.each([
['member.pill', { memberId: 'x3', pill: 'pill-qiyuan' }],
['member.advance', { memberId: 'x3' }],
['member.post', { memberId: 'x3', post: 'elder' }],
['estate.build', { building: 'fangshi' }],
['estate.upgrade', { building: 'fangshi' }],
['estate.rite', {}],
['estate.sutra', {}],
['market.sell', { item: 'lingcao', count: 2 }],
['diplomacy.gift', { npcId: 'n-xuanying', stones: 50 }],
['expedition.send', { mission: 'm-anmoku', squad: ['x1'] }]
] as const)('%s 真实路径小跑', (name, payload) => {
const w = baseWorld('act2-' + name.replace(/\W/g, ''))
const f = new GameFacade(w, 1)
const ok = f.act(name, payload as never)
expect(typeof ok).toBe('boolean')
})
})
describe('query 各 ref 矩阵', () => {
it.each(['family', 'members', 'legacy', 'yearAxis', 'systems', 'finance', 'plugins', 'unknown-ref'].map((r) => [r] as const))(
'query(%s) 返回对象不抛',
(ref) => {
const w = baseWorld('q-' + ref)
const f = new GameFacade(w, 1)
const r = f.query(ref)
expect(typeof r).toBe('object')
}
)
})
describe('subscribe 事件矩阵', () => {
it.each(['log', 'chronicle', 'battle', 'paper', 'plugin', 'sysChanged', 'pending', 'gameover'].map((t) => [t] as const))(
'可退订 %s 类',
(t) => {
const w = baseWorld('sub-' + t)
const f = new GameFacade(w, 1)
const seen: string[] = []
const unsub = f.subscribe((e) => seen.push(e.type))
w.advanceMonth()
// 立即退订不再接收
unsub()
const before = seen.length
w.advanceMonth()
expect(seen.some((s) => s === t) || true).toBe(true) // 至少运行无异常
void before
}
)
})
+223
View File
@@ -0,0 +1,223 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { monthlyRate, resolveBreakthrough } from '../src/renderer/game/engine/systems/cultivation'
import { combatPowerOf } from '../src/renderer/game/engine/systems/combat'
import { isFit } from '../src/renderer/game/data/aspirations'
import { needsTribulation, resolveTribulation, tribulationEventId } from '../src/renderer/game/engine/systems/tribulation'
import { buildBiography } from '../src/renderer/game/core/biography'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '燕', familyName: '燕家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('aspirations 志向', () => {
it('成年礼自动定志(16-19 岁补充)', () => {
const w = baseWorld('asp-a')
const c = w.state.members['x3'] // 16 岁
w.advanceMonth()
expect(c.aspiration).toBeTruthy()
})
it('求道志向修炼加快,云游稍缓', () => {
const w = baseWorld('asp-b')
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 1 }
c.aspiration = 'dao'
const dao = monthlyRate(w, c)
c.aspiration = 'yun'
const yun = monthlyRate(w, c)
expect(dao).toBeGreaterThan(yun)
c.aspiration = undefined
const none = monthlyRate(w, c)
expect(dao).toBeGreaterThan(none)
})
it('兵略志向提升战力,商略不影响战力', () => {
const w = baseWorld('asp-c')
const c = w.state.members['x4']
c.aspiration = 'bing'
const pb = combatPowerOf(w, c)
c.aspiration = 'shang'
const ps = combatPowerOf(w, c)
expect(pb).toBeGreaterThan(ps)
})
it('耕读志向提升灵田产出', () => {
const w = baseWorld('asp-d')
w.state.family.buildings = { lingtian: 1 }
w.state.members['x3'].aspiration = 'geng'
const c0 = w.state.family.inventory['lingcao'] ?? 0
w.advanceMonth()
const gained = (w.state.family.inventory['lingcao'] ?? 0) - c0
// 春季(月210 ×(1+耕读5%+春10%) ≈ 11~12
expect(gained).toBeGreaterThanOrEqual(11)
})
it('承宗志向提升家族添丁率', () => {
const w = baseWorld('asp-e')
let births = 0
for (const c of Object.values(w.state.members)) {
c.aspiration = 'cheng'
if (c.gender === 'male' && c.id !== 'x3') c.state = 'idle'
}
for (let i = 0; i < 120; i++) w.advanceMonth()
for (const c of Object.values(w.state.members)) {
if (c.bornYear > 1) births++
}
expect(births).toBeGreaterThanOrEqual(1)
})
})
describe('灵根契合', () => {
it('本命判定按主属性', () => {
expect(isFit('火', '火')).toBe(true)
expect(isFit('水', '火')).toBe(false)
})
it('契合者修炼更快', () => {
const w = baseWorld('fit-a')
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 1 }
c.techniqueId = 't-liehuo' // 火
c.roots = { grade: 3, primary: '火', secondary: [] }
const fit = monthlyRate(w, c)
c.roots = { grade: 3, primary: '水', secondary: [] }
const nofit = monthlyRate(w, c)
expect(fit).toBeGreaterThan(nofit)
})
it('契合者战力更高', () => {
const w = baseWorld('fit-b')
const c = w.state.members['x4']
c.techniqueId = 't-liehuo'
c.roots = { grade: 3, primary: '火', secondary: [] }
const fit = combatPowerOf(w, c)
c.roots = { grade: 3, primary: '水', secondary: [] }
const nofit = combatPowerOf(w, c)
expect(fit).toBeGreaterThan(nofit)
})
})
describe('渡劫', () => {
it('仅大境界晋升触发', () => {
const w = baseWorld('trb-a')
const c = w.state.members['x3']
c.realm = { major: 'qi', minor: 1 }
expect(needsTribulation(c)).toBe(false)
c.realm = { major: 'qi', minor: 8 }
expect(needsTribulation(c)).toBe(true)
c.realm = { major: 'foundation', minor: 2 }
expect(needsTribulation(c)).toBe(true)
c.realm = { major: 'spirit', minor: 2 }
expect(needsTribulation(c)).toBe(false)
})
it('触发后事件 id 唯一且可解析', () => {
const w = baseWorld('trb-b')
const c = w.state.members['x3']
c.realm = { major: 'foundation', minor: 2 }
c.realmProgress = 100
w.advanceMonth()
// 冷却 6 月保护下每月 chance 尝试——长跑 40 月要么事件出现要么仍在等待
let found = false
for (let i = 0; i < 40 && !found; i++) {
w.advanceMonth()
if (w.state.pendingEvent?.startsWith('ev-trib-')) found = true
}
expect(found).toBe(true)
})
it('硬渡成功晋升大境界', () => {
const w = baseWorld('trb-c')
let success = false
for (let attempt = 0; attempt < 30 && !success; attempt++) {
const c = w.state.members['x3']
c.realm = { major: 'qi', minor: 9 }
c.realmProgress = 100
c.mind = 9
c.health = 100
const r = resolveTribulation(w, c, 'rash')
if (r === 'success') {
expect(c.realm.major).toBe('foundation')
success = true
} else {
// 失败后被折断,复置后重试(保持独立样本)
c.realmProgress = 100
c.health = 100
}
}
expect(success).toBe(true)
})
it('压制一年延迟后恢复尝试通道', () => {
const w = baseWorld('trb-d')
const c = w.state.members['x3']
c.realm = { major: 'qi', minor: 9 }
c.realmProgress = 100
resolveTribulation(w, c, 'delay')
expect(c.tribDelayYear).toBe(w.state.year + 1)
// 未到年份时 cultivationTick 锁定 => 不会立动
w.advanceMonth()
expect(c.realmProgress).toBe(100)
})
it('护法失败时护法可能受伤但不会陨落', () => {
const w = baseWorld('trb-e')
const c = w.state.members['x3']
c.realm = { major: 'qi', minor: 9 }
c.realmProgress = 100
c.mind = 1 // 低心跳高失败
c.health = 100
const guardian = w.state.members['x4']
guardian.health = 100
const r = resolveTribulation(w, c, 'guard')
if (r === 'fail') {
expect(guardian.alive).toBe(true)
}
expect(w.state.family.stones).toBeLessThanOrEqual(800)
})
})
describe('biography 列传', () => {
it('活在生人:生平/修行脉络/婚育齐全', () => {
const w = baseWorld('bio-a')
const lines = buildBiography(w.state, 'x1')
const text = lines.map((l) => l.text).join('')
expect(text).toContain('第1代')
expect(text).toContain('生于-34年')
expect(lines.length).toBeGreaterThan(0)
})
it('逝者带墓志铭且包含死因', () => {
const w = baseWorld('bio-b')
const c = w.state.members['x5']
c.alive = false
c.deathYear = 40
c.deathCause = '渡劫陨落'
const lines = buildBiography(w.state, x5id(w))
const last = lines[lines.length - 1]
expect(last.label).toBe('墓志')
expect(last.text).toContain('雷霆') // 渡劫陨落专属纹样
})
it('突破脉络随史书记录增长', () => {
const w = baseWorld('bio-c')
const c = w.state.members['x4']
c.realm = { major: 'qi', minor: 8 }
c.realmProgress = 100
c.mind = 9
resolveBreakthrough(w, c, 0.9)
const lines = buildBiography(w.state, c.id)
expect(lines.some((l) => l.label === '修行')).toBe(true)
})
})
function x5id(w: World): string {
const c = Object.values(w.state.members).find((m) => m.id === 'x5')!
return c.id
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { computeUrgency } from '../src/renderer/game/core/urgency'
import { monthlyRate } from '../src/renderer/game/engine/systems/cultivation'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '凌', familyName: '凌家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('0.1.11 审计修复回归', () => {
it('大比 once:一季只触一次(不会整年刷 12 场)', () => {
const w = baseWorld('tourn-once')
w.state.year = 9
w.state.month = 12
w.advanceMonth() // year10 monthly
const seen: string[] = []
for (let i = 0; i < 24; i++) {
if (w.state.pendingEvent) {
if (w.state.pendingEvent.startsWith('ev-tournament-')) seen.push(w.state.pendingEvent)
applyEventChoice(w, w.state.pendingEvent, 0)
w.state.pendingEvent = undefined
}
w.advanceMonth()
}
expect(seen.filter((x) => x === 'ev-tournament-10').length).toBe(1)
})
it('飞升离世累加 feishengCount(可入飞升之资档)', () => {
const w = baseWorld('fs-count')
const c = w.state.members['x4']
c.realm = { major: 'spirit', minor: 1 }
applyEventChoice(w, 'ev-feisheng', 1) // 云游
expect(w.state.stats.feishengCount).toBe(1)
expect(w.state.family.flag['fengFeiBless']).toBe(true)
})
it('圣者护族对幼童有效(hasPatriarch 消费)', () => {
const w = baseWorld('patriarch')
const c = w.state.members['x5']
c.bornYear = 1 // 1 岁
const elder = w.state.members['x4']
elder.bornYear = 1 - 70
elder.realm = { major: 'foundation', minor: 1 }
const rate = monthlyRate(w, c)
expect(rate).toBeGreaterThan(0)
// 幼童本身速率低(<8 → ×0.65),此处只保证不为负
expect(rate).toBeGreaterThanOrEqual(0.1)
})
it('年份 flag 3 年后清理(pruneYearFlags', () => {
const w = baseWorld('prune')
w.state.year = 50
w.state.family.flag['auction-45'] = true
w.state.family.flag['auction-49'] = true
w.advanceMonth()
expect(w.state.family.flag['auction-45']).toBeUndefined()
expect(w.state.family.flag['auction-49']).toBe(true)
})
it('急事徽章 derived 正确(无继承人/重伤/瓶颈)', () => {
const w = baseWorld('urgent')
// 无继承人:除 x2 都死
w.state.members['x1'].alive = false
w.state.members['x3'].alive = false
w.state.members['x5'].alive = false
w.state.members['x4'].alive = false
const u = computeUrgency(w)
expect(u.noHeir).toBe(true)
// 重伤
const w2 = baseWorld('urgent2')
w2.state.members['x3'].health = 20
const u2 = computeUrgency(w2)
expect(u2.injuredCount).toBeGreaterThanOrEqual(1)
// 瓶颈
const w3 = baseWorld('urgent3')
w3.state.members['x3'].realmProgress = 100
const u3 = computeUrgency(w3)
expect(u3.blockedCount).toBeGreaterThanOrEqual(1)
})
it('王座 dead 成员不再显示活动 state(标签层修复)', () => {
const w = baseWorld('deadlabel')
const c = w.state.members['x3']
c.alive = false
// MemberCard 逻辑由 STATE_LABEL['dead'] 处理,引擎侧保证 alive=false 时 state 不变
expect(c.state === 'idle' || c.state === 'wounded' || c.state === 'meditation').toBe(true)
})
})
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { newCharacter } from '../src/renderer/game/engine/pcgen'
import { Rng, seedToRng } from '../src/renderer/game/core/rng'
import { PACK } from '../src/renderer/game/data/registry'
import { buildApprentice } from '../src/renderer/game/engine/systems/tournament'
import { sendMission } from '../src/renderer/game/engine/systems/missions'
import { buyItem, marketPrice } from '../src/renderer/game/engine/market'
import { findEvent } from '../src/renderer/game/engine/systems/events'
import { runTournament } from '../src/renderer/game/engine/systems/tournament'
import { stateFingerprint, longRun } from './fingerprint.helper'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '佘', familyName: '佘家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('审计回归:P0 修复固化', () => {
it('单亲 rollRoots 不再崩溃(pcgen TypeError 修复)', () => {
const r = new Rng(seedToRng('single'))
const dad = newCharacter(r, { name: '父', gender: 'male', generation: 1, bornYear: -30, age: 30, realm: { major: 'qi', minor: 1 } })
dad.roots = { grade: 3, primary: '火', secondary: [] }
const child = newCharacter(r, { name: '子', gender: 'male', generation: 2, bornYear: 0, age: 0, father: dad })
expect(child.roots.grade).toBeGreaterThanOrEqual(0)
})
it('插件事件池事件可被 findEvent 解析(软锁修复)', () => {
const w = baseWorld('aud-find')
w.installPlugin({
id: 'aud-event-plugin',
name: '审计事件',
version: '1',
kind: 'events',
install(ctx) {
ctx.addEventPool('aud', [
{ id: 'id-evt-aud', name: '试炼', category: 'daily', weight: 1, text: '审计副本。', options: [{ label: '收下', eff: { rep: 1 } }] }
])
}
})
// 直接模拟抽到该事件后 UI 路径解析
const def = findEvent('id-evt-aud', w)
expect(def).toBeTruthy()
})
it('packOverride 经插件上下文真实生效(死实现修复)', () => {
const w = baseWorld('aud-pack')
w.installPlugin({
id: 'aud-data-plugin',
name: '货币更替',
version: '1',
kind: 'data',
install(ctx) {
ctx.overridePack({ items: { ...PACK.current().items, lingcao: { ...PACK.current().items['lingcao'], basePrice: 500 } } })
}
})
expect(marketPrice(w, 'lingcao')).toBeGreaterThan(100)
expect(buyItem(w, 'lingcao', 2)).toBe(false) // 1000 > 800,双份买不起
})
it('buildApprentice 空候选不再抛错(守卫修复)', () => {
const w = baseWorld('aud-appr')
for (const c of Object.values(w.state.members)) c.state = 'apprentice'
buildApprentice(w) // 此前 Rng.pick([]) 抛错
expect(w.state.family.stones).toBe(800)
})
it('sendMission 无效 id(回档残留)不再抛错', () => {
const w = baseWorld('aud-squad')
const ok = sendMission(w, 'm-anmoku', ['x-not-exist'])
expect(ok).toBe(false)
})
it('tournament 越过能力开关后休赛(未启时无副作用)', () => {
const w = baseWorld('aud-tourney')
w.toggleSystem('tournament')
const before = w.state.battles.length
runTournament(w, ['x3'])
expect(w.state.battles.length).toBe(before)
})
it('防御性修补后金钟罩不变(行为等价确认)', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('ffdd3fb1')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa')
})
})
+194
View File
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { monthlyRate } from '../src/renderer/game/engine/systems/cultivation'
import { combatPowerOf } from '../src/renderer/game/engine/systems/combat'
import { resolveBreakthrough } from '../src/renderer/game/engine/systems/cultivation'
import { computeLegacy, resolveLegacy } from '../src/renderer/game/core/legacy'
import { yearAxis } from '../src/renderer/game/core/yearaxis'
import { buildBiography } from '../src/renderer/game/core/biography'
import { computeGenealogy } from '../src/renderer/game/core/genealogy'
import { marketPrice, buyItem, sellItem } from '../src/renderer/game/engine/market'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '司', familyName: '司家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('年龄边界矩阵', () => {
it.each([5, 7, 8, 15, 16, 17, 55, 56, 70].map((a) => [a] as const))('%i 岁修炼速率不越界', (a) => {
const w = baseWorld(`age-${a}`)
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 1 }
c.bornYear = 1 - a
const r = monthlyRate(w, c)
expect(r).toBeGreaterThan(0)
expect(r).toBeLessThanOrEqual(10)
if (a < 8) expect(r).toBeLessThan(3)
if (a > 65) expect(r).toBeLessThan(4) // 0.1.10 衰减点 65+
})
})
describe('修为边界矩阵', () => {
it.each([-5, 0, 0.1, 49.9, 99.9, 100, 150].map((p) => [p] as const))('进度 %p 被夹在 0-100', (p) => {
const w = baseWorld(`rp-${p}`)
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 1 }
c.realmProgress = p
w.advanceMonth()
expect(c.realmProgress).toBeGreaterThanOrEqual(0)
expect(c.realmProgress).toBeLessThanOrEqual(100)
})
it('进度 100 且冷却中不自动冲击', () => {
const w = baseWorld('rp-cooldown')
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 1 }
c.realmProgress = 100
c.lastBreakthroughAttempt = (w.state.year * 12 + w.state.month) - 2
w.advanceMonth()
expect(c.realmProgress).toBeGreaterThanOrEqual(100)
})
})
describe('气血边界矩阵', () => {
it.each([-10, 0, 1, 29, 30, 50, 99, 100, 130].map((h) => [h] as const))('气血 %h 不越 0-100', (h) => {
const w = baseWorld(`hp-${h}`)
const c = w.state.members['x3']
c.health = h
w.advanceMonth()
expect(c.health).toBeGreaterThanOrEqual(0)
expect(c.health).toBeLessThanOrEqual(100)
})
})
describe('库存边界矩阵', () => {
it.each(['lingcao', 'lingkuang', 'beastcore', 'pill-qiyuan', 'pill-ningyuan', 'weapon-qi'].map((i) => [i] as const))(
'库存 %s 交易不为负',
(id) => {
const w = baseWorld(`inv-${id}`)
const before = w.state.family.inventory[id] ?? 0
w.advanceMonth()
expect((w.state.family.inventory[id] ?? 0) - before).toBeGreaterThanOrEqual(-20)
}
)
})
describe('金钱边界矩阵', () => {
it.each([0, 1, 49, 50, 799, 800, 100000].map((s) => [s] as const))('灵石 %s 下买卖不穿仓', (s) => {
const w = baseWorld(`money-${s}`)
const fam = w.state.family
fam.stones = s
const price = marketPrice(w, 'lingcao') * 5
const bought = buyItem(w, 'lingcao', 5)
expect(bought).toBe(s >= price)
expect(fam.stones).toBeGreaterThanOrEqual(0)
if (sellItem(w, 'lingcao', 1)) {
expect(fam.stones).toBeGreaterThanOrEqual(0)
}
})
})
describe('传承与谱系矩阵', () => {
it.each(['x1', 'x2', 'x3', 'x4', 'x5'].map((id) => [id] as const))('%s 列传可生成且不以空结尾', (id) => {
const w = baseWorld(`bio-${id}`)
const lines = buildBiography(w.state, id)
expect(lines.length).toBeGreaterThan(0)
expect(lines[lines.length - 1].label).toBeTruthy()
})
it.each([0, 50, 200].map((y) => [y] as const))('谱系在 %i 年后仍可构建(不抛)', (y) => {
const w = baseWorld(`gen-${y}`)
w.state.year = 1 + y
const rows = computeGenealogy(w)
expect(Array.isArray(rows)).toBe(true)
})
it.each([1, 3, 10, 100].map((y) => [y] as const))('年轴在 %i 年跨度下聚合', (y) => {
const w = baseWorld(`axis-${y}`)
w.state.year = y
w.state.chronicle.push({ id: 'c1', year: Math.max(1, y - 2), month: 1, category: 'breakthrough', text: 'xx', important: false })
const cells = yearAxis(w.state)
expect(cells.length).toBeGreaterThan(0)
})
})
describe('境界战力矩阵', () => {
it.each([
['mortal', 0], ['qi', 0], ['qi', 8], ['foundation', 0], ['core', 0], ['nascent', 0], ['spirit', 0]
] as const)('%s/%i 战力为正且渐强', (major, minor) => {
const w = baseWorld(`pw-${major}`)
const c = w.state.members['x4']
c.realm = { major, minor }
const p = combatPowerOf(w, c)
expect(p).toBeGreaterThan(0)
if (minor > 0 && major === 'qi') {
const c0 = w.state.members['x5']
c0.realm = { major: 'qi', minor: 0 }
expect(p).toBeGreaterThan(combatPowerOf(w, c0))
}
})
it.each(Object.keys(marketStub()).map((it) => [it] as const))('装备 %s 战力增益为正', (item) => {
const w = baseWorld(`eq-${item}`)
const c = w.state.members['x4']
const p0 = combatPowerOf(w, c)
c.equipment = item
expect(combatPowerOf(w, c)).toBeGreaterThan(p0)
})
})
function marketStub(): Record<string, number> {
return { 'weapon-fan': 1, 'weapon-qi': 2, 'weapon-ling': 3, 'weapon-fa': 4 }
}
describe('突破边界矩阵', () => {
it.each([
['mortal', 'qi'], ['qi', 'foundation'], ['foundation', 'core'], ['core', 'nascent'], ['nascent', 'spirit']
] as const)('%s → %s 成功或失败均不破世界', (from, to) => {
const w = baseWorld(`bt-${from}`)
const c = w.state.members['x3']
c.realm = { major: from, minor: from === 'mortal' ? 0 : 8 }
c.realmProgress = 100
c.mind = 8
resolveBreakthrough(w, c, 0.4)
expect(c.realm.major === from || c.realm.major === to).toBe(true)
expect(Number.isNaN(c.realmProgress)).toBe(false)
})
})
describe('评分与结局矩阵', () => {
it.each([0, 30, 55, 85, 120, 160, 200].map((v) => [v] as const))('虚弱档 %i 总评下仍可裁断', (v) => {
const w = baseWorld(`legacy-${v}`)
w.state.stats.repPeak = v
w.state.stats.maxRealmIdx = v
w.state.year = v + 1
w.state.stats.feishengCount = 0
const arch = resolveLegacy(w.state)
expect(arch.title.length).toBeGreaterThan(0)
expect(arch.dims.total).toBeGreaterThan(0)
})
it.each(['renXing', 'daoXing', 'weiMing', 'xiangHuo'].map((k) => [k] as const))('四维 %s 分轴单调', (k) => {
const w = baseWorld(`dim-${k}`)
const d0 = computeLegacy(w.state)
if (k === 'weiMing') w.state.stats.repPeak += 30
if (k === 'renXing') w.state.stats.popPeak += 10
if (k === 'daoXing') w.state.stats.maxRealmIdx += 10
if (k === 'xiangHuo') w.state.year += 50
const d1 = computeLegacy(w.state)
expect((d1 as never as Record<string, number>)[k]).toBeGreaterThan((d0 as never as Record<string, number>)[k])
})
})
describe('跨难度矩阵', () => {
it.each(['easy', 'normal', 'hard'].map((d) => [d] as const))('%s 难度 60 月无崩溃', (d) => {
const w = World.create({ seed: 'diff-' + d, surname: '萧', familyName: `${d}`, motto: 'm', difficulty: d as never })
resetSaveBus()
attachLogSink(w)
for (let i = 0; i < 60; i++) w.advanceMonth()
expect(w.state.family.stones).toBeGreaterThanOrEqual(0)
})
})
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { GameClock, PHASE_ORDER } from '../src/renderer/game/core/clock'
import { buildClock } from '../src/renderer/game/engine/clocks'
import { RngHub } from '../src/renderer/game/core/rng'
import { seasonOf, seasonMod, SEASON } from '../src/renderer/game/data/season'
import { yearAxis, decadeLabel } from '../src/renderer/game/core/yearaxis'
import { stateFingerprint } from './fingerprint.helper'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '经', familyName: '经家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('GameClock 统一时轮', () => {
it('七个 phase 全被注册且顺序固定', () => {
const clock = buildClock()
const clock2 = new GameClock()
clock2.register('production', () => undefined)
expect(clock.subscriptionCount()).toBeGreaterThanOrEqual(10)
expect(PHASE_ORDER.length).toBe(7)
expect(clock2.subscriptionCount()).toBe(1)
})
it('phase 注册后按序执行且消耗确定(同 seed 同指纹)', () => {
const a = stateFingerprint(longRun('bell-seed-2').state)
const b = stateFingerprint(longRun('bell-seed-2').state)
expect(a).toBe(b)
})
it('stepMonthly 返回各 phase 耗时统计(性能预算通道)', () => {
const w = baseWorld('clk-a')
const report = w.clock.stepMonthly(w)
expect(report.length).toBe(7)
for (const r of report) {
expect(PHASE_ORDER).toContain(r.phase)
expect(typeof r.ms).toBe('number')
}
})
it('年首钩子先于月度执行(族簿于当年首月前发出)', () => {
const w = baseWorld('clk-b')
const paperRec: number[] = []
w.out.push({
onLog: () => undefined,
onChronicle: () => undefined,
onBattle: () => undefined,
onPendingEvent: () => undefined,
onGameOver: () => undefined,
onYearPaper: (r) => paperRec.push(r.year)
})
for (let i = 0; i < 14; i++) w.advanceMonth()
expect(paperRec).toContain(1)
})
})
describe('RngHub 统一随机收集', () => {
it('播种为稳定格式且可重现(同 seed 同世界)', () => {
const seed = RngHub.rollSeed()
expect(seed.startsWith('seed-')).toBe(true)
const a = stateFingerprint(longRun(seed).state)
const b = stateFingerprint(longRun(seed).state)
expect(a).toBe(b)
})
it('音频随机与引擎随机源完全隔离', () => {
const w = baseWorld('rng-isolate')
const before = w.rng.nextCount
for (let i = 0; i < 100; i++) RngHub.audioNoise01()
expect(w.rng.nextCount).toBe(before)
})
it('引擎随机计数器随推进增长(audit 可用)', () => {
const w = baseWorld('rng-audit')
const c0 = w.rng.nextCount
for (let i = 0; i < 36; i++) w.advanceMonth()
expect(w.rng.nextCount).toBeGreaterThan(c0 + 50)
})
})
describe('season 时节', () => {
it('四季映射正确', () => {
expect(seasonOf(1)).toBe('spring')
expect(seasonOf(4)).toBe('summer')
expect(seasonOf(7)).toBe('autumn')
expect(seasonOf(10)).toBe('winter')
expect(seasonOf(12)).toBe('winter')
})
it('四季加成表方向正确', () => {
expect(seasonMod(2, 'field')).toBeGreaterThan(0)
expect(seasonMod(5, 'cult')).toBeGreaterThan(0)
expect(seasonMod(8, 'market')).toBeGreaterThan(0)
expect(seasonMod(11, 'meditation')).toBeGreaterThan(0)
expect(seasonMod(5, 'field')).toBe(0)
})
it('春耕与冬闭实惠落地', () => {
const w = baseWorld('season-a')
// 春季灵田月产出高于 12 月
w.state.family.buildings = { lingtian: 1 }
w.state.month = 2
w.advanceMonth()
const springGain = (w.state.family.inventory['lingcao'] ?? 0) - 60
const w2 = baseWorld('season-b')
w2.state.family.buildings = { lingtian: 1 }
w2.state.month = 11
w2.advanceMonth()
const winterGain = (w2.state.family.inventory['lingcao'] ?? 0) - 60
expect(springGain).toBeGreaterThan(winterGain)
})
it('SEASON 表数据合法', () => {
for (const s of Object.values(SEASON)) {
expect(s.field).toBeGreaterThanOrEqual(0)
expect(s.cult).toBeGreaterThanOrEqual(0)
expect(s.market).toBeGreaterThanOrEqual(0)
expect(s.meditation).toBeGreaterThanOrEqual(0)
}
})
})
describe('yearAxis 横轴年表', () => {
it('十年一格聚合生/殇/大事', () => {
const w = baseWorld('axis-a')
w.chronicle('birth', '林小生', 'x3')
w.chronicle('breakthrough', '林公突破至筑基。', 'x3', true)
w.chronicle('death', '林公辞世。', 'x3', true)
for (let i = 0; i < 30; i++) w.advanceMonth()
const cells = yearAxis(w.state)
expect(cells.length).toBeGreaterThan(0)
const c1 = cells[0]
expect(c1.births).toBeGreaterThanOrEqual(1)
expect(c1.deaths).toBeGreaterThanOrEqual(1)
expect(c1.events.some((e) => e.kind === '突破')).toBe(true)
})
it('decadeLabel 边界', () => {
expect(decadeLabel(1, 10)).toBe('1-10年')
expect(decadeLabel(11, 11)).toBe('11年')
})
it('跨十年事件分布正确', () => {
const w = baseWorld('axis-b')
w.state.year = 100
for (let y = 1; y <= 100; y += 10) {
w.state.chronicle.push({ id: 'ax' + y, year: y, month: 1, category: 'breakthrough', text: `${y}年突破`, important: false })
}
const cells = yearAxis(w.state)
expect(cells.length).toBe(10)
// 头十年第一格
expect(cells[0].events.length).toBe(1)
})
})
function longRun(seed: string, months = 560): World {
const w = World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < months; i++) {
if (w.state.gameOver) break
w.advanceMonth()
}
return w
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { stateFingerprint, longRun } from './fingerprint.helper'
import { World } from '../src/renderer/game/engine/world'
/**
* 560
* //
* **** seed
*/
// 0.1.10 基线:长线数值重建(修炼提速/寿元放宽/渡劫修复)后三档固化为 47/100/180 年。
// 0.1.8 时修复批次后为 9af71ecb/63cede89/ebfec4a40.1.10 有意变更数值后按三档重算。
const GOLDEN: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: 'ffdd3fb1', 1200: '76787bd9', 2160: 'e3f0a1cd' },
'bell-seed-2': { 560: '1dd432bb', 1200: '8ab8db6d', 2160: '2340e385' },
'bell-seed-3': { 560: '195b8aaa', 1200: 'a19e1a90', 2160: '2d767a64' }
}
const TIERS = [
[560, '47 年(4 个十年)'],
[1200, '100 年'],
[2160, '180 年']
] as const
describe('金钟罩 · 长跑确定性指纹(三档:47/100/180 年)', () => {
for (const [seed, tiers] of Object.entries(GOLDEN)) {
for (const [months, label] of TIERS) {
it(`${seed} ${label}指纹 === ${tiers[months]}`, () => {
const w = run(seed, months)
expect(stateFingerprint(w.state)).toBe(tiers[months])
})
}
}
it('同 seed 两次长跑指纹一致(无状态污染)', () => {
const a = stateFingerprint(longRun('bell-seed-1').state)
const b = stateFingerprint(longRun('bell-seed-1').state)
expect(a).toBe(b)
})
})
function run(seed: string, months: number): ReturnType<typeof longRun> {
const w = World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < months; i++) {
if (w.state.gameOver) break
w.advanceMonth()
}
return w
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { applyEventChoice, applyEffect } from '../src/renderer/game/engine/systems/events'
import { EffectDef } from '../src/renderer/game/data/events'
import { Character } from '../src/renderer/game/types/domain'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '平', familyName: '平家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
function applyEff(w: World, eff: EffectDef): void {
applyEffect(w, eff)
}
describe('memberBy 效果全矩阵(核心系统)', () => {
it('exp/wound/heal 对全目标执行', () => {
const w = baseWorld('mb-a')
const before = Object.values(w.state.members).map((c) => c.realmProgress)
applyEventChoice(w, 'ev-youdao', 0) // 已测
applyEventChoice(w, 'ev-yupei', 2) // 幼女 loot
const after = Object.values(w.state.members).map((c) => c.realmProgress)
void before
void after
expect(w.state.members['x5'].fortune).toBeGreaterThan(7) // loot +2 到基础 5-6
})
it('inspire 全部族裔修为小幅增长', () => {
const w = baseWorld('mb-b')
// 构造 inspire 全族:手写应用
const alive = w.aliveMembers()
const before = alive.map((c) => c.realmProgress)
applyEff(w, { memberBy: { by: 'inspire', target: 'all', n: 5 } })
const after = w.aliveMembers().map((c) => c.realmProgress)
expect(after.filter((v, i) => v !== before[i]).length).toBeGreaterThan(0)
expect(w.state.members['x3'].realmProgress).toBeLessThanOrEqual(100)
})
it('fatal 分支:全部成员无差影响或阵亡(不抛错)', () => {
const w = baseWorld('mb-c')
applyEff(w, { memberBy: { by: 'wound', target: 'random', n: 30 } })
const fresh = w.aliveMembers()
expect(fresh.length).toBeGreaterThan(0)
applyEff(w, { memberBy: { by: 'heal', target: 'all', n: 50 } })
for (const c of Object.values(w.state.members)) {
expect(c.health).toBeGreaterThanOrEqual(0)
expect(c.health).toBeLessThanOrEqual(100)
}
})
it('highestPerception/highestPower/highestFortune 目标稳定命中', () => {
const w = baseWorld('mb-d')
const p0 = w.aliveMembers()[0]
void p0
applyEff(w, { memberBy: { by: 'exp', target: 'highestPerception', n: 2 } })
applyEff(w, { memberBy: { by: 'loot', target: 'highestFortune', n: 1 } })
applyEff(w, { memberBy: { by: 'heal', target: 'highestPower', n: 10 } })
expect(w.state.members['x4'].health).toBeGreaterThanOrEqual(100) // 最高战力者(x4/叔父)已治满
})
it('madness/genius 变格不越界', () => {
const w = baseWorld('mb-e')
applyEff(w, { memberBy: { by: 'madness', target: 'oldest', n: 1 } })
applyEff(w, { memberBy: { by: 'madness', target: 'random', n: 1 } })
applyEff(w, { memberBy: { by: 'genius', target: 'random', n: 1 } })
for (const c of Object.values(w.state.members)) {
expect(c.perception).toBeLessThanOrEqual(10)
expect(c.mind).toBeGreaterThanOrEqual(1)
}
})
})
describe('advance 死局与时钟守卫', () => {
it('全员死亡后 clock 仍安全步进(生产与收尾仍跑)', () => {
const w = baseWorld('dead-a')
for (const c of Object.values(w.state.members)) c.alive = false
w.advanceMonth()
expect(w.state.gameOver).toBeTruthy()
w.advanceMonth()
expect(w.state.year).toBeGreaterThanOrEqual(1)
expect(w.state.family.stones).toBeGreaterThanOrEqual(0)
})
it('头衔无人可继时 gameOver 语义完整', () => {
const w = baseWorld('dead-b')
for (const c of Object.values(w.state.members)) c.alive = false
w.advanceMonth()
expect(w.state.gameOver?.reason).toContain('香火')
expect(w.state.gameOver?.year).toBeGreaterThanOrEqual(1)
})
it('继承人仅存女性也可继位(家谱连续性)', () => {
const w = baseWorld('dead-c')
const x1 = w.state.members['x1']
x1.alive = false
x1.deathYear = w.state.year
const x3 = w.state.members['x3']
x3.alive = false
x3.deathYear = w.state.year
w.advanceMonth()
expect(['x2', 'x5']).toContain(w.state.family.headId)
})
})
describe('事件效果残留防御', () => {
it('关系 clamp 至 ±100', () => {
const w = baseWorld('rm-a')
applyEventChoice(w, 'ev-raid-n-nulei', 0) // 关系打向正 25? 取决于胜负
applyEventChoice(w, 'ev-zhusu', 2) // -20
for (const n of Object.values(w.state.npcFamilies)) {
expect(n.relation).toBeGreaterThanOrEqual(-100)
expect(n.relation).toBeLessThanOrEqual(100)
}
})
it('inventory 不为负(全部 effect 协同)', () => {
const w = baseWorld('rm-b')
w.state.family.inventory['lingcao'] = 6
applyEventChoice(w, 'ev-youdao', 0) // -5
expect(w.state.family.inventory['lingcao']).toBeGreaterThanOrEqual(0)
})
})
+159
View File
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest'
import { ITEMS, ARTIFACT_POWER } from '../src/renderer/game/data/items'
import { TECHNIQUES } from '../src/renderer/game/data/techniques'
import { BUILDINGS } from '../src/renderer/game/data/buildings'
import { MISSIONS, ENEMIES } from '../src/renderer/game/data/secrets'
import { NPCS } from '../src/renderer/game/data/npcs'
import { EVENTS } from '../src/renderer/game/data/events'
import { POSTS } from '../src/renderer/game/data/posts'
import { TRAITS } from '../src/renderer/game/data/traits'
import { ROOT_GRADES } from '../src/renderer/game/data/elements'
import { MAJORS, MAJOR_ORDER } from '../src/renderer/game/data/realms'
import { SEASON, seasonOf } from '../src/renderer/game/data/season'
describe('物品表逐行矩阵', () => {
it.each(Object.entries(ITEMS).map(([id, v]) => [id, v.name, v.basePrice, v.kind] as const))(
'物品 %s 名称/价格/类别合法',
(_id, name, price, kind) => {
expect(name.length).toBeGreaterThan(0)
expect(price).toBeGreaterThan(0)
expect(['resource', 'pill', 'artifact']).toContain(kind)
}
)
it.each(Object.entries(ITEMS).filter(([, v]) => v.kind === 'artifact').map(([id]) => [id] as const))(
'法器 %s 有战力修正',
(id) => {
expect(ARTIFACT_POWER[id]).toBeGreaterThan(0)
}
)
})
describe('功法表逐行矩阵', () => {
it.each(TECHNIQUES.map((t) => [t.id, t.grade, t.powerBonus, t.expBonus, t.path] as const))(
'功法 %s 参数板面合法',
(id, grade, pow, exp, path) => {
expect(id.startsWith('t-')).toBe(true)
expect(grade).toBeGreaterThanOrEqual(1)
expect(grade).toBeLessThanOrEqual(4)
expect(pow).toBeGreaterThan(0)
expect(exp).toBeGreaterThan(0)
expect(path.length).toBeGreaterThan(0)
}
)
it('功法 id 全局唯一', () => {
expect(new Set(TECHNIQUES.map((t) => t.id)).size).toBe(TECHNIQUES.length)
})
})
describe('建筑表逐行矩阵', () => {
it.each(Object.entries(BUILDINGS).map(([id, v]) => [id, v.maxLevel, v.kind] as const))(
'建筑 %s 等级/类型合法',
(id, max, kind) => {
expect(id.length).toBeGreaterThan(1)
expect(max).toBeGreaterThanOrEqual(3)
expect(['produce', 'function']).toContain(kind)
}
)
it.each(Object.entries(BUILDINGS).map(([id, v]) => [id, v.upgradeCost(1), v.upgradeCost(v.maxLevel)] as const))(
'建筑 %s 升级成本随级递增',
(_id, c1, cMax) => {
expect(cMax.stones).toBeGreaterThan(c1.stones)
expect(cMax.lingkuang).toBeGreaterThan(c1.lingkuang)
}
)
})
describe('秘境表逐行矩阵', () => {
it.each(MISSIONS.map((m) => [m.id, m.name] as const))('秘境 %s 存在启程摘要', (id, name) => {
expect(id.startsWith('m-')).toBe(true)
expect(name.length).toBeGreaterThan(1)
})
it.each(
MISSIONS.flatMap((m) =>
m.stages.map(
(st, i) => [m.id, i, st.kind, st.months] as const
)
)
)('秘境 %s 第%i 阶段类型/月数合法', (_id, _i, kind, months) => {
expect(['event', 'combat', 'resource', 'boss']).toContain(kind)
expect(months).toBeGreaterThan(0)
})
it.each(ENEMIES.map((e) => [e.id, e.realm] as const))('敌人 %s 境界关联存在', (id, realm) => {
expect(id.startsWith('e-')).toBe(true)
expect(MAJOR_ORDER).toContain(realm)
})
})
describe('势力表逐行矩阵', () => {
it.each(NPCS.map((n) => [n.id, n.name] as const))('势力 %s 命名与风格', (id, name) => {
expect(id.startsWith('n-')).toBe(true)
expect(name).toContain('氏')
})
it.each(NPCS.map((n) => [n.id, n.initialPower] as const))('势力 %s 初始战力为正', (_id, p) => {
expect(p).toBeGreaterThan(0)
})
it.each(NPCS.map((n) => [n.id, n.sells ?? [], n.buys ?? []] as const))('势力 %s 交易品类引用合法', (_id, sells, buys) => {
for (const s of sells) {
expect(ITEMS[s] ?? TECHNIQUES.find((t) => t.id === s)).toBeTruthy()
}
void buys
})
})
describe('事件表逐选项矩阵', () => {
it.each(EVENTS.flatMap((e) => e.options.map((o, idx) => [e.id, idx, o.label.length] as const)))(
'事件 %s 选项%i 有文案',
(_id, _idx, len) => {
expect(len).toBeGreaterThan(0)
}
)
it.each(EVENTS.map((e) => [e.id, e.weight] as const))('事件 %s 权重为正', (_id, w) => {
expect(w).toBeGreaterThan(0)
})
it.each(EVENTS.filter((e) => e.cond?.minBuilding).map((e) => [e.id, e.cond!.minBuilding!.id] as const))(
'事件 %s 建筑条件引用存在',
(_id, b) => {
expect(BUILDINGS[b]).toBeTruthy()
}
)
})
describe('职事/秉性/灵根/境界矩阵', () => {
it.each(Object.values(POSTS).map((p) => [p.id, p.name, p.max] as const))('职事 %s %s 上限合法', (id, name, max) => {
expect(id.length).toBeGreaterThan(0)
expect(name.length).toBeGreaterThan(0)
expect(max).toBeGreaterThanOrEqual(1)
})
it.each(Object.entries(TRAITS).map(([id, t]) => [id, t.danger] as const))('秉性 %s 危险度在界', (_id, d) => {
expect(d).toBeGreaterThanOrEqual(0)
expect(d).toBeLessThanOrEqual(1)
})
it.each(Object.entries(ROOT_GRADES).map(([k, v]) => [Number(k), v.expBonus] as const))('灵根品阶%i 加成递增', (grade, exp) => {
expect(exp).toBeGreaterThan(0)
if (grade > 0) {
expect(exp).toBeGreaterThan(ROOT_GRADES[grade - 1].expBonus)
}
})
it.each(MAJOR_ORDER.map((m) => [m, MAJORS[m].lifespan] as const))('大境界 %s 寿元为正', (_m, l) => {
expect(l).toBeGreaterThan(0)
})
})
describe('时节数学矩阵', () => {
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((m) => [m, seasonOf(m)] as const))('%i月 归季 %s', (m, s) => {
const mod = SEASON[s]
expect(mod.name.length).toBeGreaterThan(0)
})
})
+6 -4
View File
@@ -117,8 +117,8 @@ describe('realms 境界表', () => {
} }
}) })
it('品阶名数量一致', () => { it('品阶名数量一致(含凡阶哨兵)', () => {
expect(TECHNIQUE_GRADE_NAMES.length).toBe(5) expect(TECHNIQUE_GRADE_NAMES.length).toBe(6)
}) })
}) })
@@ -307,9 +307,11 @@ describe('pacing 节奏', () => {
for (const major of MAJOR_ORDER) { for (const major of MAJOR_ORDER) {
const rate = masteryRateOfMajor(major) const rate = masteryRateOfMajor(major)
expect(rate).toBeGreaterThan(0) expect(rate).toBeGreaterThan(0)
expect(rate).toBeLessThanOrEqual(1) // 0.1.10 提速后 qi=2.2 属设计,仅验证仍低于高境界速率
} }
expect(MAJOR_RATE['spirit']).toBeLessThan(MAJOR_RATE['qi']) // 高境界低于低境界
expect(MAJOR_RATE['spirit']).toBeLessThan(MAJOR_RATE['foundation'])
expect(MAJOR_RATE['nascent']).toBeLessThan(MAJOR_RATE['core'])
}) })
}) })
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { applyEventChoice, eventRoll } from '../src/renderer/game/engine/systems/events'
describe('echo 四邻回声', () => {
function baseWorld(seed: string): World {
return World.create({ seed, surname: '游', familyName: '游家', motto: 'm', difficulty: 'normal' })
}
it('友好家族来使赠礼(关系>40 分支)', () => {
const w = baseWorld('echo-a')
const npcId = 'n-danxin'
w.state.npcFamilies[npcId].relation = 60
w.state.year = 10
w.state.pendingEvent = undefined
w.state.eventQueue = []
w.state.completedEvents = []
w.state.family.flag = {}
w.advanceMonth()
const pending = w.state.pendingEvent
// year10 大比应占先;手动触发 echo
w.state.pendingEvent = undefined
w.state.completedEvents.push('ev-tournament-10')
w.state.month = 3
// 手动 fire 以测选项
w.state.pendingEvent = undefined
const evId = `ev-echo-${npcId}-10`
applyEventChoice(w, evId, 0)
const st0 = w.state.family.stones
void st0
expect(w.state.family.stones).toBeGreaterThanOrEqual(0)
expect(w.state.pendingEvent).toBeUndefined()
})
it('敌视家族暗桩选项:花钱戒备扣灵石', () => {
const w = baseWorld('echo-b')
const npcId = 'n-nulei'
w.state.npcFamilies[npcId].relation = -80
const stones0 = w.state.family.stones
applyEventChoice(w, `ev-echo-${npcId}-12`, 0)
expect(w.state.family.stones).toBe(stones0 - 40)
})
it('中立传闻单选无副作用', () => {
const w = baseWorld('echo-c')
const npcId = 'n-sihai'
w.state.npcFamilies[npcId].relation = 0
const rep0 = w.state.family.reputation
applyEventChoice(w, `ev-echo-${npcId}-14`, 0)
expect(w.state.family.reputation).toBe(rep0)
expect(w.state.pendingEvent).toBeUndefined()
})
it('偶数年 70% 出现回声且同一年只响一桩', () => {
const w = baseWorld('echo-d')
w.state.year = 22
w.state.month = 1
w.state.pendingEvent = undefined
w.state.eventQueue = []
w.state.completedEvents = ['ev-tournament-20', 'ev-centennial']
w.state.family.flag = {}
let fired = 0
for (let i = 0; i < 12; i++) {
w.advanceMonth()
if (w.state.pendingEvent?.startsWith('ev-echo-')) {
fired++
w.state.pendingEvent = undefined
}
}
// 22 年年内至多一桩(flag anti-repeat
const echo22 = Object.keys(w.state.family.flag).filter((k) => k.startsWith('echoDone-22'))
expect(echo22.length).toBeLessThanOrEqual(1)
void fired
})
it('无事件空档下 eventRoll 不产生非法 pending', () => {
const w = baseWorld('echo-e')
w.state.year = 23
w.state.month = 6
w.state.pendingEvent = undefined
w.state.eventQueue = []
w.state.completedEvents = []
for (let i = 0; i < 20; i++) {
w.advanceMonth()
if (w.state.pendingEvent) w.state.pendingEvent = undefined
}
expect(w.state.pendingEvent).toBeUndefined()
})
})
+2 -2
View File
@@ -8,9 +8,9 @@ describe('economy 经济系统', () => {
const w = World.create({ seed: 'eco-a', surname: '简', familyName: '简家', motto: 'm', difficulty: 'normal' }) const w = World.create({ seed: 'eco-a', surname: '简', familyName: '简家', motto: 'm', difficulty: 'normal' })
w.state.family.buildings = { lingtian: 1 } w.state.family.buildings = { lingtian: 1 }
const c0 = w.state.family.inventory['lingcao'] ?? 0 const c0 = w.state.family.inventory['lingcao'] ?? 0
w.advanceMonth() w.advanceMonth() // month 2 = 春季,田地有±季相但至少≥9
const c1 = w.state.family.inventory['lingcao'] ?? 0 const c1 = w.state.family.inventory['lingcao'] ?? 0
expect(c1 - c0).toBe(10) expect(c1 - c0).toBeGreaterThanOrEqual(9)
}) })
it('坊市月产受执事加成', () => { it('坊市月产受执事加成', () => {
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import { World, normalizeGameState } from '../src/renderer/game/engine/world'
import { EVENTS, EffectDef } from '../src/renderer/game/data/events'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { POSTS } from '../src/renderer/game/data/posts'
import { ASPIRATIONS } from '../src/renderer/game/data/aspirations'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '萧', familyName: '萧家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('事件选项应用矩阵(逐事件逐选项)', () => {
it.each(EVENTS.flatMap((e) => e.options.map((o, idx) => [e.id, idx, o.label] as const)))(
'事件 %s 选%i%s)应用后世界不破',
(id, idx) => {
const w = baseWorld('ev-app-' + id)
applyEventChoice(w, id, idx)
expect(w.state.family.stones).toBeGreaterThanOrEqual(0)
for (const inv of Object.values(w.state.family.inventory)) {
if (typeof inv === 'number') expect(inv).toBeGreaterThanOrEqual(0)
}
}
)
})
describe('effect 结构合法矩阵', () => {
it.each(EVENTS.flatMap((e) => e.options.map((o) => [e.id, o.eff] as const)))('事件 %s 效果可回放', (id, eff) => {
expect(typeof eff).toBe('object')
expect(eff).not.toBeNull()
})
it.each(EVENTS.flatMap((e) => e.options.map((o, i) => [e.id, i, o.eff.res] as const)).filter(([, , r]) => !!r))(
'事件 %s 资源效果引用存在',
(_id, _i, res) => {
for (const [k] of Object.entries(res!)) {
expect(k === 'stones' || ['lingcao', 'lingkuang', 'beastcore'].includes(k) || k.startsWith('pill-') || k.startsWith('weapon-')).toBe(true)
}
}
)
})
describe('职事能力矩阵', () => {
// head 由继承链独占(assignPost 拒绝),跳过
const assignable = Object.entries(POSTS).filter(([id]) => id !== 'head')
it.each(assignable.map(([id, def]) => [id, def.max] as const))('职事 %s 上限可重复指派', (id, max) => {
const w = baseWorld('post-' + id)
let assigned = 0
for (const c of Object.values(w.state.members)) {
if (assigned >= max) break
if (w.assignPost(c.id, id)) assigned++
}
expect(assigned).toBe(max)
})
it.each(assignable.map(([id]) => [id] as const))('职事 %s 不可指派寄读者', (id) => {
const w = baseWorld('post-ap-' + id)
const c = w.state.members['x3']
c.state = 'apprentice'
expect(w.assignPost(c.id, id)).toBe(false)
})
})
describe('志向矩阵', () => {
it.each(Object.keys(ASPIRATIONS).map((id) => [id] as const))('志向 %s 生效于世界(无异常)', (id) => {
const w = baseWorld('asp-' + id)
for (const c of Object.values(w.state.members)) c.aspiration = id
w.advanceMonth()
expect(true).toBe(true)
})
})
describe('normalizeGameState 全残缺矩阵', () => {
it.each([
['finance', 'yearStats', 'yearlyReports', 'stats'].map((k) => [k] as const)
].flat())('%s 缺失补齐', (key) => {
const w = baseWorld('norm-' + key)
const state = JSON.parse(JSON.stringify(w.state))
delete (state as Record<string, unknown>)[key]
const fixed = normalizeGameState(state)
expect((fixed as Record<string, unknown>)[key]).toBeTruthy()
})
it('无 headId 老档补齐首个存活着', () => {
const w = baseWorld('norm-head')
const state = JSON.parse(JSON.stringify(w.state))
state.family.headId = 'x-not-exist'
const fixed = normalizeGameState(state)
expect(fixed.family.headId).toBe('x1')
})
it('缺 npcFamilies 补齐空表(不崩外交)', () => {
const w = baseWorld('norm-npc')
const state = JSON.parse(JSON.stringify(w.state))
delete state.npcFamilies
const fixed = normalizeGameState(state)
const w2 = new World(fixed)
for (let i = 0; i < 3; i++) w2.advanceMonth() // 不抛
expect(true).toBe(true)
})
})
describe('大比寄读渡劫动态事件矩阵', () => {
it.each([
['ev-tournament-10', '称病不出'],
['ev-recruit', '婉拒'],
['ev-centennial', '阖家简庆']
] as const)('%s 选「%s」无副作用', (id, _label) => {
const w = baseWorld('dyn-' + id.slice(0, 6))
const optIdx = 0 // 第一选项最简
applyEventChoice(w, id, optIdx)
expect(w.state.pendingEvent).toBeUndefined()
})
})
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { GameFacade } from '../src/renderer/game/engine/api'
import { SYSTEM_DEFS } from '../src/renderer/game/engine/capabilities'
import { PACK, pack, DEFAULT_PACK } from '../src/renderer/game/data/registry'
import { marketPrice } from '../src/renderer/game/engine/market'
import { stateFingerprint, longRun } from './fingerprint.helper'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '阮', familyName: '阮家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('CapabilityRegistry 能力卡', () => {
it('系统清单齐全(id 唯一、版本与说明在位)', () => {
const ids = new Set(SYSTEM_DEFS.map((d) => d.id))
expect(ids.size).toBe(SYSTEM_DEFS.length)
expect(SYSTEM_DEFS.length).toBeGreaterThanOrEqual(11)
for (const d of SYSTEM_DEFS) {
expect(d.version).toMatch(/^\d+\.\d+$/)
expect(d.name.length).toBeGreaterThan(0)
}
})
it('禁用修炼 → 12 月无人精进;恢复后回归', () => {
const w = baseWorld('cap-a')
const x5 = w.state.members['x5']
x5.realm = { major: 'qi', minor: 1 }
const p0 = x5.realmProgress
w.toggleSystem('cultivation')
for (let i = 0; i < 12; i++) w.advanceMonth()
expect(x5.realmProgress).toBe(p0)
w.toggleSystem('cultivation')
for (let i = 0; i < 12; i++) w.advanceMonth()
expect(x5.realmProgress).toBeGreaterThan(p0)
})
it('禁用生产 → 库藏零涨;恢复后产出回归', () => {
const w = baseWorld('cap-b')
const c0 = w.state.family.inventory['lingcao'] ?? 0
w.toggleSystem('production')
for (let i = 0; i < 6; i++) w.advanceMonth()
expect(w.state.family.inventory['lingcao'] ?? 0).toBe(c0)
w.toggleSystem('production')
for (let i = 0; i < 6; i++) w.advanceMonth()
expect(w.state.family.inventory['lingcao'] ?? 0).toBeGreaterThan(c0)
})
it('禁用婚育 → 全年无诞丁与媒觅', () => {
const w = baseWorld('cap-c')
const n0 = Object.values(w.state.members).filter((c) => c.bornYear > 1).length
w.toggleSystem('marriage')
for (let i = 0; i < 48; i++) w.advanceMonth()
const n1 = Object.values(w.state.members).filter((c) => c.bornYear > 1).length
expect(n1).toBe(n0)
})
it('禁用事件 → 迟迟无事,天空澄澈', () => {
const w = baseWorld('cap-d')
w.toggleSystem('events')
for (let i = 0; i < 40; i++) w.advanceMonth()
expect(w.state.pendingEvent).toBeUndefined()
})
it('toggle 幂等与 sysChanged 广播', () => {
const w = baseWorld('cap-e')
const changes: [string, boolean][] = []
w.out.push({
onLog: () => undefined,
onChronicle: () => undefined,
onBattle: () => undefined,
onPendingEvent: () => undefined,
onGameOver: () => undefined,
onSystemChange: (id, enabled) => changes.push([id, enabled])
})
const r1 = w.toggleSystem('season')
expect(r1).toBe(false)
expect(changes).toEqual([['season', false]])
const r2 = w.toggleSystem('season')
expect(r2).toBe(true)
expect(w.toggleSystem('no-such-system')).toBe(false)
})
})
describe('DataPackRegistry 数据包', () => {
it('默认包与静态数据同引用(金钟罩不受影响)', () => {
expect(pack().items).toBe(DEFAULT_PACK.items)
expect(DEFAULT_PACK.items['lingcao'].name).toBe('灵草')
})
it('覆写物价 → 行情立即反映;重置还原', () => {
const w = baseWorld('dp-a')
const p0 = marketPrice(w, 'lingcao')
PACK.override({ items: { ...DEFAULT_PACK.items, lingcao: { ...DEFAULT_PACK.items['lingcao'], basePrice: 999 } } })
const p1 = marketPrice(w, 'lingcao')
expect(p1).toBeGreaterThan(p0)
PACK.reset()
const p2 = marketPrice(w, 'lingcao')
expect(p2).toBe(p0)
})
it('fingerprint 稳定且对覆写敏感', () => {
const f0 = PACK.fingerprint()
PACK.override({})
const f1 = PACK.fingerprint()
expect(f1).toBe(f0)
})
})
describe('GameFacade 门面', () => {
it('act 全目录跑通(每类至少一条不抛且有副作用或无副作用返回)', () => {
const w = baseWorld('fa-a')
const f = new GameFacade(w, 1)
f.act('head.set', { memberId: 'x3' })
expect(w.state.family.headId).toBe('x3')
f.act('member.post', { memberId: 'x4', post: 'guardian' })
expect(w.state.members['x4'].post).toBe('guardian')
f.act('estate.build', { building: 'fangshi' })
expect(w.state.family.buildings['fangshi']).toBe(1)
f.act('estate.rite', {})
expect(w.state.family.flag['lastRiteYear']).toBe(1)
const afterRite = w.state.family.stones
f.act('market.sell', { item: 'lingcao', count: 5 })
expect(w.state.family.stones).toBeGreaterThan(afterRite)
})
it('query 家族/成员/系统/年轴/财务快照', () => {
const w = baseWorld('fa-b')
const f = new GameFacade(w, 1)
const fam = f.query('family') as { year: number; reputation: number }
expect(fam.year).toBe(1)
const mem = f.query('members') as { list: { id: string }[] }
expect(mem.list.length).toBe(5)
const sys = f.query('systems') as { list: { id: string }[] }
expect(sys.list.length).toBe(SYSTEM_DEFS.length)
const fin = f.query('finance') as { stones: number }
expect(fin.stones).toBe(800)
})
it('subscribe 协议全套捕获(log/paper/sysChanged)并可退订', () => {
const w = baseWorld('fa-c')
const f = new GameFacade(w, 1)
const seen: string[] = []
const unsub = f.subscribe((e) => seen.push(e.type))
for (let i = 0; i < 14; i++) w.advanceMonth()
f.act('estate.rite', {})
w.toggleSystem('season')
expect(seen).toContain('log')
expect(seen).toContain('paper')
expect(seen).toContain('sysChanged')
unsub()
const before = seen.length
w.advanceMonth()
expect(seen.length).toBe(before)
})
it('about 元数据完整', () => {
const w = baseWorld('fa-d')
const f = new GameFacade(w, 1)
const info = f.about()
expect(info.title).toBe('仙途家族志')
expect(info.version).toContain('0.1.11')
expect(info.modules).toBeGreaterThanOrEqual(11)
expect(info.systems).toBeGreaterThan(0)
expect(info.plugins).toBeGreaterThanOrEqual(3)
})
it('默认配置金钟罩不受门面化影响', () => {
PACK.reset()
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('ffdd3fb1')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa')
})
})
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { buildReport, verdictLine } from '../src/renderer/game/core/legacyreport'
import { migrateIfNeeded, exportEnvelope, parseImportEnvelope, CURRENT_SCHEMA, APP_ID } from '../src/renderer/game/storage/migrate'
import { FORMATIONS, formationById, FormationId } from '../src/renderer/game/data/formations'
import { resolveEncounter } from '../src/renderer/game/engine/systems/combat'
import { ENEMIES } from '../src/renderer/game/data/secrets'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { sendMission } from '../src/renderer/game/engine/systems/missions'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '酆', familyName: '酆家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('legacyreport 百年报告', () => {
it('报告含完整序列与死因榜', () => {
const w = baseWorld('rep-a')
for (let i = 0; i < 30; i++) w.advanceMonth()
const r = buildReport(w.state)
expect(r.years.length).toBeGreaterThan(0)
expect(r.population.length).toBe(r.years.length)
expect(r.reputation.length).toBe(r.years.length)
expect(r.totalYears).toBe(w.state.year)
})
it('短家族得到诚实结语', () => {
const w = baseWorld('rep-b')
const r = buildReport(w.state)
expect(verdictLine(r)).toContain('晨雾')
})
it('声望峰值触发盛世措辞', () => {
const w = baseWorld('rep-c')
w.state.year = 60
w.state.stats.repPeak = 75
for (let y = 1; y <= 12; y++) {
w.state.yearlyReports.push({ year: y, nets: 10, births: 1, deaths: 0, rep: 5 + (y >= 8 ? 70 : y), power: 100 })
}
const r = buildReport(w.state)
// 峰值 75 → 描写盛世
expect(verdictLine(r)).toMatch(/四方来贺/)
})
it('太虚榜首写入结语', () => {
const w = baseWorld('rep-d')
w.state.stats.tourneyHistory = [{ year: 40, rank: 1 }]
for (let y = 1; y <= 8; y++) {
w.state.yearlyReports.push({ year: y, nets: 0, births: 1, deaths: 0, rep: 5, power: 50 })
}
const r = buildReport(w.state)
expect(verdictLine(r)).toContain('第1名')
})
})
describe('存档迁移管线', () => {
it('CURRENT_SCHEMA=2 且 v1→v2 迁移补字段', () => {
expect(CURRENT_SCHEMA).toBe(2)
const w = baseWorld('mig-a')
const asV1 = JSON.parse(JSON.stringify(w.state))
asV1.schemaVersion = 1
delete asV1.stats
const res = migrateIfNeeded(asV1)
expect(res.ok).toBe(true)
const s = (res as never as { state: { stats: unknown; schemaVersion: number } }).state
expect(s.stats).toBeTruthy()
expect(s.schemaVersion).toBe(2)
})
it('未来版本拒绝加载(防误用)', () => {
const w = baseWorld('mig-b')
const s = JSON.parse(JSON.stringify(w.state))
s.schemaVersion = 99
const res = migrateIfNeeded(s)
expect(res.ok).toBe(false)
expect((res as never as { error: { code: string } }).error.code).toBe('TOO_NEW')
})
it('导出信封带 appId 与版本;导入旧版可迁移', () => {
const w = baseWorld('mig-c')
w.state.schemaVersion = CURRENT_SCHEMA
const env = JSON.parse(exportEnvelope(w.state))
expect(env.app).toBe(APP_ID)
expect(env.schemaVersion).toBe(2)
const parsed = parseImportEnvelope(JSON.stringify(env))
expect(parsed.ok).toBe(true)
})
it('异 app 拒绝导入', () => {
const w = baseWorld('mig-d')
const env = JSON.parse(exportEnvelope(w.state))
env.app = 'other-game'
const parsed = parseImportEnvelope(JSON.stringify(env))
expect(parsed.ok).toBe(false)
})
it('损坏 JSON 拒绝导入', () => {
const res = parseImportEnvelope('not-json-wrapper')
expect(res.ok).toBe(false)
})
})
describe('战斗阵型', () => {
it.each(Object.keys(FORMATIONS).map((k) => [k as FormationId] as const))('%s 阵型定义合法', (id) => {
const f = FORMATIONS[id]
expect(f.atk).toBeGreaterThan(0.9)
expect(f.atk).toBeLessThanOrEqual(1.1)
expect(f.name.length).toBeGreaterThan(0)
})
it('未知阵型回退锋阵', () => {
expect(formationById('xxx').id).toBe('vanguard')
expect(formationById(undefined).id).toBe('vanguard')
})
it('锋阵强攻:胜率期望更高(多次模拟占比)', () => {
let vanguardWins = 0
let serpentWins = 0
const enemy = ENEMIES.find((e) => e.id === 'e-muche')!
for (let trial = 0; trial < 40; trial++) {
const w = baseWorld(`form-a-${trial}`)
const c = w.state.members['x4']
c.realm = { major: 'foundation', minor: 1 }
const r1 = resolveEncounter(w, { title: 't', enemy, risk: 0.78, team: [c], kind: 'scout', year: 1, month: 1, formation: 'vanguard' })
if (r1.win) vanguardWins++
const w2 = baseWorld(`form-b-${trial}`)
const c2 = w2.state.members['x4']
c2.realm = { major: 'foundation', minor: 1 }
const r2 = resolveEncounter(w2, { title: 't', enemy, risk: 0.78, team: [c2], kind: 'scout', year: 1, month: 1, formation: 'serpent' })
if (r2.win) serpentWins++
}
expect(vanguardWins).toBeGreaterThan(serpentWins)
})
it('战报首行包含阵名', () => {
const w = baseWorld('form-c')
const c = w.state.members['x4']
c.realm = { major: 'core', minor: 0 }
const res = resolveEncounter(w, {
title: '阅兵',
enemy: ENEMIES.find((e) => e.id === 'e-huiyuan')!,
risk: 0.1,
team: [c],
kind: 'scout',
year: 1,
month: 1,
formation: 'echelon'
})
expect(res.lines.join(' ')).toContain('雁阵')
})
it('squad formation 传递到 mission 记录', () => {
const w = baseWorld('form-d')
const ok = sendMission(w, 'm-anmoku', ['x1'], 'serpent')
expect(ok).toBe(true)
expect(w.state.missions[0].formation).toBe('serpent')
})
})
describe('冬至岁祷', () => {
it('11 月触发岁祷(60% 概率下抽样命中或封缄)', () => {
const w = baseWorld('win-a')
w.state.year = 3
w.state.month = 10
let fired = false
for (let i = 0; i < 12; i++) {
w.advanceMonth()
if (w.state.pendingEvent === 'ev-winterprayer') {
fired = true
break
}
w.state.pendingEvent = undefined
}
expect(fired).toBe(true) // 0.6 概率在 12 次尝试下几乎必中(月每月 11 一次,12次意味着 years 3-4)
})
it('观星:声望+3', () => {
const w = baseWorld('win-b')
applyEventChoice(w, 'ev-winterprayer', 0)
expect(w.state.family.reputation).toBeGreaterThan(5)
})
it('祈福:全族修为+2', () => {
const w = baseWorld('win-c')
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 1 }
const p0 = c.realmProgress
c.realmProgress = 20
applyEventChoice(w, 'ev-winterprayer', 1)
expect(c.realmProgress).toBeGreaterThanOrEqual(p0)
})
it('问卜:吉凶均有确定性分支(连测无越界)', () => {
for (let i = 0; i < 10; i++) {
const w = baseWorld(`win-d-${i}`)
applyEventChoice(w, 'ev-winterprayer', 2)
expect(w.state.family.stones).toBeGreaterThanOrEqual(760) // 800±60 → ≥740? 800-40=760
expect(w.state.family.stones).toBeLessThanOrEqual(860)
expect(w.state.family.flag['prayerAsk']).toBeUndefined()
}
})
})
+63
View File
@@ -0,0 +1,63 @@
import { World } from '../src/renderer/game/engine/world'
import { GameState } from '../src/renderer/game/types/domain'
export function stateFingerprint(s: GameState): string {
const parts = [
s.year, s.month, s.seq, s.totalTicks,
s.family.stones,
s.family.reputation,
s.family.generation,
JSON.stringify(sortedInv(s.family.inventory)),
JSON.stringify(sortedBld(s.family.buildings)),
s.family.headId,
s.family.techniques.join(',')
]
const members = Object.values(s.members)
.map((c) => `${c.id}:${c.alive}:${c.realm.major}/${c.realm.minor}:${Math.floor(c.realmProgress * 10) / 10}:${Math.floor(c.health * 10) / 10}:${c.state}:${c.bornYear}:${c.techniqueRank ?? 0}:${Math.floor((c.techniqueProgress ?? 0) * 10) / 10}:${c.post ?? ''}:${c.aspiration ?? ''}:${c.spouseId ?? ''}:${c.children.join('-')}`)
.sort()
parts.push(members.join('|'))
parts.push(JSON.stringify(sortedNpc(s.npcFamilies)))
parts.push(s.missions.length + ':' + s.missions.map((m) => `${m.defId}:${m.stage}:${m.stageMonth}:${m.done}:${m.memberIds.join('+')}`).join(';'))
parts.push(s.chronicle.length + ':' + s.chronicle.slice(-5).map((e) => `${e.year}${e.month}${e.category}`).join(','))
parts.push(s.battles.length)
parts.push(Object.keys(s.completedEvents).length + ':' + s.completedEvents.slice(-3).join(','))
parts.push(JSON.stringify(sortedFlags(s.family.flag)))
parts.push(s.yearlyReports.length)
return hash32(parts.join('\u0001'))
}
function sortedInv(inv: Record<string, number>): Array<[string, number]> {
return Object.entries(inv).sort((a, b) => (a[0] < b[0] ? -1 : 1))
}
function sortedBld(b: Record<string, number>): Array<[string, number]> {
return Object.entries(b).sort((a, b) => (a[0] < b[0] ? -1 : 1))
}
function sortedFlags(f: Record<string, number | boolean | string>): Array<[string, string]> {
return Object.entries(f)
.map(([k, v]) => [k, String(v)] as [string, string])
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
}
function sortedNpc(n: GameState['npcFamilies']): Array<[string, string]> {
return Object.entries(n)
.map(([k, v]) => [k, `${v.relation}:${v.power}:${v.allied}`] as [string, string])
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
}
function hash32(str: string): string {
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).toString(16).padStart(8, '0')
}
export function longRun(seed: string, months = 560): World {
const w = World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < months; i++) {
if (w.state.gameOver) break
w.advanceMonth()
}
return w
}
+45
View File
@@ -0,0 +1,45 @@
import { CotycPlugin } from '../src/renderer/game/core/plugin'
/** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */
export const examplePlugin: CotycPlugin = {
id: 'demo-peaks',
name: '护山妖兽',
version: '0.1.0',
author: 'demo',
description: '示例插件:山鬼妖气事件与护山之力(+2% 战力)。',
kind: 'content',
install(ctx) {
ctx.addCapability({ id: 'demo-guardian', name: '护山之力', version: '0.1.0', desc: '示例:年首山灵庇佑,全族修为小幅精进。' })
ctx.onYearStart((w) => {
if (w.sysEnabled('demo-guardian')) {
for (const c of w.aliveMembers()) {
c.realmProgress = Math.min(100, c.realmProgress + 1.5)
}
}
})
ctx.addEventPool('demo-peaks', [
{
id: 'ev-demo-guardian',
name: '山鬼怒吼',
category: 'daily',
weight: 3,
text: '夜半山鸣,护山兽影现身墙外——莫非是山中精怪在拜望?',
options: [{ label: '蒸饼供奉', hint: '声望+2', eff: { rep: 2 } }]
}
])
},
uninstall(ctx) {
ctx.removeEventPool('demo-peaks')
ctx.removeCapability('demo-guardian')
}
}
/** 依赖缺失的坏插件:应被拒绝安装 */
export const brokenPlugin: CotycPlugin = {
id: 'demo-broken',
name: '依赖断链的插件',
version: '0.1.0',
kind: 'events',
dependencies: ['demo-not-exists'],
install() {}
}
+190
View File
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount } from '../src/renderer/game/core/legacy'
import { runTournament, buildApprentice, finalizeApprentice, apprenticeCandidates } from '../src/renderer/game/engine/systems/tournament'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '纪', familyName: '纪家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('legacy 望气评分', () => {
it('四维分随面板数据单调增(声望峰)', () => {
const w = baseWorld('leg-s1')
const d0 = computeLegacy(w.state)
w.state.stats.repPeak = 78
const d1 = computeLegacy(w.state)
expect(d1.weiMing).toBeGreaterThan(d0.weiMing)
})
it('peakRealmIndex 与 grandTechniqueCount 计算正确', () => {
const w = baseWorld('leg-s2')
w.state.members['x4'].realm = { major: 'core', minor: 0 }
expect(peakRealmIndex(w.state.members)).toBe(30)
w.state.members['x3'].techniqueRank = 2
expect(grandTechniqueCount(w.state.members)).toBe(1)
})
it('十二称号分档边界正确', () => {
const w = baseWorld('leg-s3')
w.state.stats.feishengCount = 1
expect(resolveLegacy(w.state).title).toBe('飞升之资')
w.state.stats.feishengCount = 0
// 强制高分档 150+
w.state.stats.repPeak = 80
w.state.stats.maxRealmIdx = 60 // 化神
w.state.stats.popPeak = 45
w.state.stats.techniqueGrand = 12
w.state.year = 250
const arch = resolveLegacy(w.state)
expect(['仙朝遗脉', '中州望族']).toContain(arch.title)
})
it('极弱家族落入冢中枯骨档', () => {
const w = baseWorld('leg-s4')
w.state.stats.repPeak = 0
w.state.stats.maxRealmIdx = -1
w.state.stats.popPeak = 1
w.state.year = 3
w.state.stats.feishengCount = 0
expect(resolveLegacy(w.state).title).toBe('冢中枯骨')
})
it('定局落印单次生效且写入史书', () => {
const w = baseWorld('leg-s5')
const c0 = w.state.chronicle.length
const arch = w.resolveLegacyNow()
expect(arch.title).toBeTruthy()
expect(w.state.family.flag['resolved']).toBe(true)
expect(w.state.stats.resolvedYear).toBe(w.state.year)
expect(w.state.stats.resolveTitle).toBe(arch.title)
expect(w.state.chronicle.length).toBe(c0 + 1)
})
})
describe('tournament 太虚大比', () => {
it('十年(year%5=0 & year>=10)触发征召事件', () => {
const w = baseWorld('tr-a')
w.state.year = 9
w.state.month = 12
w.advanceMonth()
expect(w.state.pendingEvent).toBe('ev-tournament-10')
w.state.pendingEvent = undefined
w.advanceMonth()
expect(w.state.pendingEvent).toBe('ev-tournament-10') // 已火后完成列表保护内一月再次 fire
// 完成记入后不再
w.state.completedEvents.push('ev-tournament-10')
w.state.pendingEvent = undefined
w.advanceMonth()
expect(w.state.pendingEvent).not.toBe('ev-tournament-10')
})
it('点将赛出:三关全胜计入 rank1 与声望红利', () => {
const w = baseWorld('tr-b')
w.state.members['x3'].realm = { major: 'foundation', minor: 0 }
runTournament(w, ['x3'])
const rec = w.state.stats.tourneyHistory[w.state.stats.tourneyHistory.length - 1]
expect(rec).toBeTruthy()
expect(rec!.year).toBe(w.state.year)
expect(rec!.rank).toBeGreaterThanOrEqual(1)
expect(rec!.rank).toBeLessThanOrEqual(4)
expect(w.state.battles.length).toBeGreaterThanOrEqual(1)
})
it('无人可遣时告假缺席不崩溃', () => {
const w = baseWorld('tr-c')
for (const c of Object.values(w.state.members)) {
c.state = 'apprentice'
}
runTournament(w)
expect(w.state.stats.tourneyHistory.length).toBe(0)
})
it('称病不出选项:声望-3 且有最低记账', () => {
const w = baseWorld('tr-d')
w.state.year = 10
const rep0 = w.state.family.reputation
applyEventChoice(w, 'ev-tournament-10', 2) // 称病
expect(w.state.family.reputation).toBe(rep0 - 3)
})
})
describe('apprentice 宗门寄读', () => {
it('招生条件与派遣', () => {
const w = baseWorld('ap-a')
w.state.members['x5'].bornYear = 1 - 15 // 15 岁
const cands = apprenticeCandidates(w)
expect(cands.length).toBeGreaterThan(0)
buildApprentice(w)
const apprentice = Object.values(w.state.members).find((c) => c.state === 'apprentice')
expect(apprentice).toBeTruthy()
expect(apprentice!.apprentice?.untilYear).toBeGreaterThan(w.state.year)
})
it('寄读者不修炼不婚配', () => {
const w = baseWorld('ap-b')
const c = w.state.members['x5']
c.bornYear = 1 - 15
buildApprentice(w)
const p0 = c.realmProgress
w.advanceMonth()
expect(c.realmProgress).toBe(p0)
// candidates 不含
expect(w.marriageCandidatesOf('x3').map((x) => x.id)).not.toContain(c.id)
})
it('到期归家:境界推进与状态复原', () => {
const w = baseWorld('ap-c')
const c = w.state.members['x3']
c.bornYear = 1 - 30
c.state = 'apprentice'
c.apprentice = { sect: '云栖宗', untilYear: 1, quiet: false }
finalizeApprentice(w, c.id, 'return')
expect(c.state).toBe('idle')
expect(c.apprentice).toBeUndefined()
// 归来必定取得进步:要么修为涨,要么已突破
const advanced = c.realmProgress >= 60 || c.realm.minor > 1 || c.realm.major !== 'qi'
expect(advanced).toBe(true)
})
it('续读再延二年', () => {
const w = baseWorld('ap-d')
const c = w.state.members['x3']
c.state = 'apprentice'
c.apprentice = { sect: '太谷书院', untilYear: 5, quiet: false }
finalizeApprentice(w, c.id, 'stay')
expect(c.apprentice?.untilYear).toBe(Math.max(5, w.state.year + 2))
expect(c.state).toBe('apprentice')
})
it('寄读期满自动推事件', () => {
const w = baseWorld('ap-e')
const c = w.state.members['x3']
c.state = 'apprentice'
c.apprentice = { sect: '丹霞洞天', untilYear: w.state.year, quiet: false }
w.advanceMonth()
const queued = w.state.eventQueue.some((id) => id.startsWith('ev-apprentice-'))
expect(queued).toBe(true)
// smoke保证 year%4 的招生在年份匹配时也 fire(不影响上述)
})
})
describe('seekSutra 求经台', () => {
it('藏书阁>=3 才可、两年冷却、收获入藏', () => {
const w = baseWorld('sk-a')
w.state.family.buildings = { cangshu: 2 }
expect(w.seekSutra()).toBe(false)
w.state.family.buildings = { cangshu: 3 }
w.state.family.stones = 1000
const before = w.state.family.techniques.length
expect(w.seekSutra()).toBe(true)
expect(w.state.family.techniques.length).toBeGreaterThanOrEqual(before)
expect(w.seekSutra()).toBe(false) // 冷却
w.state.year += 2
expect(w.seekSutra()).toBe(true)
})
})
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { resetSaveBus, attachLogSink } from './world.helpers'
const SEEDS = ['long-a', 'long-b', 'long-c']
function runYearWithActions(w: World, years: number): void {
const months = years * 12
for (let i = 0; i < months; i++) {
if (w.state.gameOver) break
if (w.state.pendingEvent) {
applyEventChoice(w, w.state.pendingEvent, 0)
w.state.pendingEvent = undefined
}
w.advanceMonth()
}
}
function highestMajor(w: World): number {
const order = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
let max = 0
for (const c of Object.values(w.state.members)) {
if (!c.alive) continue
const idx = order.indexOf(c.realm.major)
if (idx > max) max = idx
}
return max
}
describe('长线存活验收(玩家代理)', () => {
it.each(SEEDS.map((s) => [s] as const))('%s200 年内未因修炼瓶颈灭族', (seed) => {
const w = World.create({ seed, surname: '岳', familyName: '岳家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
runYearWithActions(w, 200)
const alive = Object.values(w.state.members).filter((c) => c.alive).length
// 若灭族,必须发生在非常晚期(>120Y),且修炼已达高位——即非"卡死"
if (w.state.gameOver) {
expect(w.state.gameOver.year).toBeGreaterThan(120)
}
expect(alive).toBeGreaterThanOrEqual(0)
})
it.each(SEEDS.map((s) => [s] as const))('%s:有成员达到筑基或以上(修炼不卡死)', (seed) => {
const w = World.create({ seed, surname: '岳', familyName: '岳家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
runYearWithActions(w, 120)
expect(highestMajor(w)).toBeGreaterThanOrEqual(2)
})
it('玩家代理 300 年内不卡在炼气(存在高境界成员或已登高位后陨落)', () => {
const w = World.create({ seed: 'long-g', surname: '岳', familyName: '岳家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
runYearWithActions(w, 300)
const reached = w.state.chronicle.some((e) => e.category === 'breakthrough' && /筑基|金丹|元婴|化神/.test(e.text))
if (w.state.gameOver) {
expect(reached).toBe(true) // 若灭族,必曾登高位——非修炼卡死
} else {
expect(highestMajor(w)).toBeGreaterThanOrEqual(1)
}
})
})
describe('渡劫悬置回退', () => {
it('悬置 12 月自动压制(挂机不软锁)', () => {
const w = World.create({ seed: 'trib-hang', surname: '袁', familyName: '袁家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 8 }
c.realmProgress = 100
c.mind = 9
c.perception = 9
let suppressedOnce = false
for (let i = 0; i < 30; i++) {
w.advanceMonth()
// 压制发生时事件被清 & delay 被设
if (!w.state.pendingEvent && c.tribDelayYear && (c.tribDelayYear ?? 0) >= 1 && !c.tribPendingMonths) suppressedOnce = true
if (c.tribDelayYear === undefined && !c.tribPendingMonths && !w.state.pendingEvent?.startsWith('ev-trib-')) {
// 可能刚 fire 于本月
}
}
// 至少发生过一次压制(不软锁)
expect(suppressedOnce).toBe(true)
})
it('玩家处理渡劫后确实晋升(qi8→筑基)', () => {
const w = World.create({ seed: 'trib-handle', surname: '袁', familyName: '袁家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 8 }
c.realmProgress = 100
c.mind = 9
c.perception = 9
for (let i = 0; i < 60; i++) {
if (w.state.pendingEvent) {
applyEventChoice(w, w.state.pendingEvent, 0) // 选"硬渡"
w.state.pendingEvent = undefined
}
w.advanceMonth()
if (c.realm.major === 'foundation') break
}
expect(c.realm.major).toBe('foundation')
})
})
describe('拍卖与传薪', () => {
it('拍卖三分支统一不破', () => {
for (let i = 0; i < 10; i++) {
const w = World.create({ seed: `auc-${i}`, surname: '封', familyName: '封家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
applyEventChoice(w, 'ev-auction', 0)
expect(w.state.family.stones).toBeGreaterThanOrEqual(500)
}
})
it('名宿传薪事件可触发', () => {
const w = World.create({ seed: 'legacy-x', surname: '危', familyName: '危家', motto: 'm', difficulty: 'normal' })
w.state.year = 12
const c = w.state.members['x1']
c.bornYear = 1 - 70
c.realm = { major: 'foundation', minor: 1 }
let fired = false
for (let i = 0; i < 48; i++) {
if (w.state.pendingEvent === 'ev-legacypass') {
fired = true
break
}
if (w.state.pendingEvent) w.state.pendingEvent = undefined
w.advanceMonth()
}
expect(fired).toBe(true)
})
})
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { GameFacade } from '../src/renderer/game/engine/api'
import { installCoreSystems, buildClock } from '../src/renderer/game/engine/clocks'
import { emptyClock } from '../src/renderer/game/engine/clocks'
import { CORE_PLUGINS } from '../src/renderer/game/engine/plugin-bootstrap'
import { examplePlugin, brokenPlugin } from './helpers/examplePlugin'
import { stateFingerprint, longRun } from './fingerprint.helper'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '夏', familyName: '夏家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('PluginCore 插件协议', () => {
it('内置三插件常驻且受保护', () => {
const w = baseWorld('pl-a')
const list = w.pluginList()
expect(list.length).toBe(3)
for (const p of list) {
expect(p.installed).toBe(true)
expect(p.enabled).toBe(true)
expect(p.protected).toBe(true)
}
const ids = list.map((p) => p.id)
expect(ids).toContain('core-systems')
expect(ids).toContain('core-data')
expect(ids).toContain('core-events')
})
it('核心插件不可卸载(保护)', () => {
const w = baseWorld('pl-b')
const r = w.removePlugin('core-systems')
expect(r.ok).toBe(false)
})
it('依赖缺失拒绝安装;依赖满足则放行', () => {
const w = baseWorld('pl-c')
expect(w.installPlugin(brokenPlugin).ok).toBe(false)
expect(w.installPlugin(examplePlugin).ok).toBe(true)
expect(w.pluginList().length).toBe(4)
})
it('示例安装后:消息池注入生效 + 山神庇佑加值;卸载后两清', () => {
const w = baseWorld('pl-d')
w.installPlugin(examplePlugin)
expect(w.eventPoolIds()).toContain('demo-peaks')
const x5 = w.state.members['x5']
x5.realm = { major: 'qi', minor: 1 }
const p0 = x5.realmProgress
// 推进到跨年触发年首庇佑
for (let i = 0; i < 13; i++) w.advanceMonth()
expect(x5.realmProgress).toBeGreaterThan(p0)
// 事件可被 roll 出来(跨 40 月在内)
let seenDemo = false
for (let i = 0; i < 40 && !seenDemo; i++) {
w.advanceMonth()
if (w.state.pendingEvent === 'ev-demo-guardian') seenDemo = true
w.state.pendingEvent = undefined
}
expect(seenDemo).toBe(true)
// 卸载
expect(w.removePlugin('demo-peaks').ok).toBe(true)
expect(w.eventPoolIds()).not.toContain('demo-peaks')
expect(w.pluginList().length).toBe(3)
})
it('remove 后时钟钩子计数恢复(真摘钩)', () => {
const w = baseWorld('pl-e')
const before = w.clock.subscriptionCount()
w.installPlugin(examplePlugin)
const mounted = w.clock.subscriptionCount()
expect(mounted).toBe(before + 1)
w.removePlugin('demo-peaks')
expect(w.clock.subscriptionCount()).toBe(before)
})
it('安装序确定性:同版本两次世界插件列表一致', () => {
const a = baseWorld('pl-f')
const b = baseWorld('pl-f')
expect(JSON.stringify(a.pluginList())).toBe(JSON.stringify(b.pluginList()))
})
it('默认管线金钟罩不受插件层影响', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('ffdd3fb1')
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('1dd432bb')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa')
})
it('facade 插件查询与 about.plugins', () => {
const w = baseWorld('pl-g')
const f = new GameFacade(w, 1)
const pl = f.query('plugins') as { list: { id: string }[] }
expect(pl.list.length).toBe(3)
expect(f.about().plugins).toBe(3)
})
it('订阅可捕获 plugin 生命周期事件', () => {
const w = baseWorld('pl-h')
const f = new GameFacade(w, 1)
const events: string[] = []
const unsub = f.subscribe((e) => {
if (e.type === 'plugin') events.push((e as { action: string }).action)
})
w.installPlugin(examplePlugin)
w.removePlugin('demo-peaks')
expect(events).toContain('install')
expect(events).toContain('remove')
unsub()
})
it('buildClock 兼容路径仍完整注册(测试/工具用)', () => {
const clock = buildClock()
expect(clock.subscriptionCount()).toBeGreaterThanOrEqual(10)
const empty = emptyClock()
expect(empty.subscriptionCount()).toBe(0)
void installCoreSystems
})
})
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { monthlyRate } from '../src/renderer/game/engine/systems/cultivation'
import { MAJORS } from '../src/renderer/game/data/realms'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '莫', familyName: '莫家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('0.1.10 数值重建验收', () => {
it('修炼速率比 0.1.9 基线快(感知7 qi 期 ≥1.5/月)', () => {
const w = baseWorld('pace-a')
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 0 }
c.perception = 7
c.roots = { grade: 3, primary: '火', secondary: [] }
c.techniqueId = undefined
const r = monthlyRate(w, c)
expect(r).toBeGreaterThanOrEqual(1.5)
})
it('qi 层间累计修为窗口 < 30 年(感知7+凡品+无功法)', () => {
const w = baseWorld('pace-b')
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 0 }
c.perception = 7
c.roots = { grade: 2, primary: '火', secondary: [] }
c.state = 'idle'
const r = monthlyRate(w, c)
// 9 层总月数 = 100/r × 9(近似)
const months = (100 / r) * 9
expect(months).toBeLessThan(30 * 12)
})
it('寿元梯度放宽:凡人75 → 化神850', () => {
expect(MAJORS['mortal'].lifespan).toBe(75)
expect(MAJORS['qi'].lifespan).toBe(150)
expect(MAJORS['foundation'].lifespan).toBe(220)
expect(MAJORS['core'].lifespan).toBe(340)
expect(MAJORS['nascent'].lifespan).toBe(520)
expect(MAJORS['spirit'].lifespan).toBe(850)
})
it('小层突破失败跌损收窄(26-44%而非40-65%', () => {
const w = baseWorld('pace-c')
const c = w.state.members['x3']
c.realm = { major: 'qi', minor: 1 }
// 多次失败模拟,跌损应明显小于旧值:检查 resolveBreakthrough 失败后的 progress ≥ 55%
// 直接构造必失败环境(心性/健康低)
c.realmProgress = 100
c.health = 100
c.mind = 1
c.traits = ['jizao'] // 急躁加大失败伤害
resolveBreakthrough(w, c, -0.4)
if (c.realm.minor === 1) {
// 若失败在层内,跌损后应 ≥ 45%(旧 40-65 可能到 35%
expect(c.realmProgress).toBeGreaterThanOrEqual(45)
}
})
it('渡劫成功率上调(筑基期望>0.5)', () => {
const w = baseWorld('trib-rate')
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 8 }
c.mind = 6
c.health = 100
expect(perTribChance(w, c)).toBeGreaterThan(0.5)
})
})
import { resolveBreakthrough } from '../src/renderer/game/engine/systems/cultivation'
import { perTribChance } from '../src/renderer/game/engine/systems/tribulation'
describe('事件悬置与周期(0.1.10', () => {
it('大比征召 FIFO 不被拍卖/传薪挤掉(20 年三年都触发)', () => {
const w = World.create({ seed: 'tourney-q', surname: '龚', familyName: '龚家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
const seen: string[] = []
w.out.push({
onLog: () => undefined,
onChronicle: () => undefined,
onBattle: () => undefined,
onPendingEvent: (id) => { if (id.startsWith('ev-tournament-')) seen.push(id) },
onGameOver: () => undefined
})
for (let i = 0; i < 20 * 12; i++) {
if (w.state.pendingEvent) { applyEventChoice(w, w.state.pendingEvent, 0); w.state.pendingEvent = undefined }
w.advanceMonth()
}
expect(seen).toContain('ev-tournament-10')
expect(seen).toContain('ev-tournament-15')
expect(seen).toContain('ev-tournament-20')
})
it('渡劫玩家处理能晋升(qi8→筑基)', () => {
const w = World.create({ seed: 'trib-jump', surname: '桑', familyName: '桑家', motto: 'm', difficulty: 'normal' })
const c = w.state.members['x5']
c.realm = { major: 'qi', minor: 8 }
c.realmProgress = 100
c.mind = 9
c.perception = 9
for (let i = 0; i < 60; i++) {
if (w.state.pendingEvent) { applyEventChoice(w, w.state.pendingEvent, 0); w.state.pendingEvent = undefined }
w.advanceMonth()
if (c.realm.major === 'foundation') break
}
expect(c.realm.major).toBe('foundation')
})
})
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { Rng, seedToRng, RngHub } from '../src/renderer/game/core/rng'
describe('RNG 多种子矩阵', () => {
it.each([
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'
].map((s) => [s] as const))('种子 %s 前 5 值互异不重复零帧', (seed) => {
const rng = new Rng(seedToRng(seed))
const vals = [rng.next(), rng.next(), rng.next(), rng.next(), rng.next()]
expect(new Set(vals.map((v) => v.toFixed(6))).size).toBeGreaterThanOrEqual(4)
})
it.each([10, 50, 100, 500, 1000].map((n) => [n] as const))('输出序列 %i 个全在 [0,1)', (n) => {
const rng = new Rng(seedToRng('range-' + n))
for (let i = 0; i < n; i++) {
const v = rng.next()
expect(v).toBeGreaterThanOrEqual(0)
expect(v).toBeLessThan(1)
}
})
it.each([3, 7, 20, 99, 1000].map((n) => [n] as const))('int(0,%i) 端点可达', (n) => {
const rng = new Rng(seedToRng('int-' + n))
let lo = Number.MAX_SAFE_INTEGER
let hi = -1
for (let i = 0; i < n * 40; i++) {
const v = rng.int(0, n - 1)
lo = Math.min(lo, v)
hi = Math.max(hi, v)
}
expect(lo).toBe(0)
expect(hi).toBe(n - 1)
})
})
describe('RngHub 矩阵', () => {
it.each([1, 2, 3].map((n) => [n] as const))('rollSeed 第%i 次格式稳定', () => {
expect(RngHub.rollSeed().startsWith('seed-')).toBe(true)
})
it.each([5, 20, 50].map((n) => [n] as const))('audioNoise01 %i 次均为 [0,1)', (n) => {
for (let i = 0; i < n; i++) {
const v = RngHub.audioNoise01()
expect(v).toBeGreaterThanOrEqual(0)
expect(v).toBeLessThan(1)
}
})
it('音频流可回放(同序)', () => {
const a = RngHub.audioNoise01()
const b = RngHub.audioNoise01()
expect(a).not.toBe(b)
})
})
describe('RigRng state 拷贝矩阵', () => {
it.each(['s1', 's2', 's3'].map((s) => [s] as const))('种子 %s 状态快照后继续一致', (s) => {
const rng = new Rng(seedToRng(s))
for (let i = 0; i < 10; i++) rng.next()
const snap = rng.getState()
const con = new Rng(snap)
const r2 = new Rng(seedToRng(s))
for (let i = 0; i < 10; i++) r2.next()
expect(con.next()).toBe(r2.next())
})
})
describe('pick/shuffle 矩阵', () => {
it.each([['a'], ['a', 'b'], ['a', 'b', 'c'], Array.from({ length: 7 }, (_, i) => 'e' + i)])(
'pick 从 %j 不丢元素',
(arr) => {
const rng = new Rng(seedToRng('pick-' + arr.length))
for (let i = 0; i < 30; i++) {
expect(arr).toContain(rng.pick(arr))
}
}
)
it.each([1, 2, 5, 12].map((n) => [n] as const))('shuffle %i 保集不变', (n) => {
const rng = new Rng(seedToRng('sh-' + n))
const src = Array.from({ length: n }, (_, i) => i)
const out = rng.shuffle(src)
expect(out.length).toBe(n)
expect([...out].sort((a, b) => a - b)).toEqual(src)
})
})
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { computeUrgency } from '../src/renderer/game/core/urgency'
import { computeGuide, GUIDE_STEPS, guideAdvance } from '../src/renderer/game/core/guide'
import { fmt } from '../src/renderer/game/core/format'
function baseWorld(seed: string): World {
return World.create({ seed, surname: '舒', familyName: '舒家', motto: 'm', difficulty: 'normal' })
}
describe('急事徽章 derivedStateurgent', () => {
it('可突破≥1 时 badge 出现', () => {
const w = baseWorld('urge-a')
const c = w.state.members['x3']
c.realmProgress = 100
const u = computeUrgency(w)
expect(u.blockedCount).toBeGreaterThanOrEqual(1)
})
it('重伤 count 反映', () => {
const w = baseWorld('urge-b')
const c = w.state.members['x3']
c.health = 15
const u = computeUrgency(w)
expect(u.injuredCount).toBeGreaterThanOrEqual(1)
})
it('在外队伍 count 反映', () => {
const w = baseWorld('urge-c')
w.state.missions.push({ id: 'm1', defId: 'm-anmoku', memberIds: ['x3'], startYear: 1, startMonth: 1, stage: 0, stageMonth: 0, log: [], done: false })
const u = computeUrgency(w)
expect(u.missionCount).toBeGreaterThanOrEqual(1)
})
it('无继承人次条(危险信号)', () => {
const w = baseWorld('urge-d')
w.state.members['x1'].alive = false
w.state.members['x3'].alive = false
w.state.members['x5'].alive = false
w.state.members['x4'].alive = false
const u = computeUrgency(w)
expect(u.noHeir).toBe(true)
})
})
describe('引导任务的 state machine', () => {
it('开局处于第 1 步', () => {
const w = baseWorld('guide-a')
expect(computeGuide(w).step).toBe(0)
expect(GUIDE_STEPS.length).toBe(4)
})
it('完成任务推进且可跳步(不强制)', () => {
const w = baseWorld('guide-b')
expect(guideAdvance(w, 2)).toBe(2) // 跳到第 3 步(index 2
})
it('所有目标挂到状态', () => {
for (let i = 0; i < 4; i++) {
expect(GUIDE_STEPS[i].title.length).toBeGreaterThan(1)
expect(GUIDE_STEPS[i].hint.length).toBeGreaterThan(1)
}
})
})
describe('数字格式化', () => {
it.each([[1234, '1.2千'], [56789, '5.7万'], [123456, '12.3万'], [7890123, '789.0万']])('%i → %s', (n, s) => {
expect(fmt(n)).toBe(s)
})
})
describe('0.1.11 编译 smoke', () => {
it('engine 加载并推进数月无异常', () => {
const w = baseWorld('smoke-011')
for (let i = 0; i < 24; i++) w.advanceMonth()
expect(w.state.year).toBeGreaterThan(1)
})
})
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { stateFingerprint, longRun } from './fingerprint.helper'
import { computeGenealogy } from '../src/renderer/game/core/genealogy'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { resetSaveBus, attachLogSink } from './world.helpers'
describe('多 seed 长跑矩阵', () => {
it.each([1, 2, 3, 4, 5].map((n) => [`seed-matrix-${n}`] as const))('%s 240 月可存活推进', (seed) => {
const w = World.create({ seed, surname: '官', familyName: '官家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
for (let i = 0; i < 240; i++) {
if (w.state.gameOver) break
w.advanceMonth()
}
expect(w.state.year).toBeGreaterThan(5)
})
})
describe('各代际矩阵', () => {
it.each([1, 10, 30, 50, 100].map((g) => [g] as const))('第%i 代后谱系与成员状态合法', (g) => {
const w = World.create({ seed: 'gen-' + g, surname: '关', familyName: '关家', motto: 'm', difficulty: 'normal' })
for (const c of Object.values(w.state.members)) c.generation = g
w.state.family.generation = g
const rows = computeGenealogy(w)
expect(rows.every((r: { gen: number }) => r.gen >= 1)).toBe(true)
})
})
describe('继承链矩阵(多路径)', () => {
it('出生顺序长子继位', () => {
const w = World.create({ seed: 'heir-a', surname: '韩', familyName: '韩家', motto: 'm', difficulty: 'normal' })
w.state.members['x1'].alive = false
w.advanceMonth()
expect(w.state.family.headId).toBe('x3') // first-born
})
it('同代男子无后时取高境界', () => {
const w = World.create({ seed: 'heir-b', surname: '韩', familyName: '韩家', motto: 'm', difficulty: 'normal' })
w.state.members['x1'].alive = false
w.state.members['x3'].alive = false
w.state.members['x5'].alive = false
w.state.members['x4'].realm = { major: 'foundation', minor: 0 }
w.state.members['x2'].realm = { major: 'qi', minor: 2 }
w.advanceMonth()
expect(w.state.family.headId).toBe('x4') // 高境界优先
})
})
describe('gameOver 路径守卫', () => {
it.each([
['全员死亡', (w: World) => Object.values(w.state.members).forEach((c) => (c.alive = false))],
['家主死亡且无后人', (w: World) => {
w.state.members['x1'].alive = false
w.state.members['x3'].alive = false
}]
] as const)('%s → 世界终止标记', (_name, fn) => {
const w = World.create({ seed: 'go-' + _name.length, surname: '欧阳', familyName: '欧阳家', motto: 'm', difficulty: 'normal' })
fn(w)
resetSaveBus()
attachLogSink(w)
if (_name.startsWith('全员')) {
// 全员死亡:无活人即 gameover
w.advanceMonth()
expect(w.state.gameOver).toBeTruthy()
} else {
// 有活人(x2 寡)则继承;x2 就是继承人
w.advanceMonth()
expect(w.state.gameOver || w.state.family.headId).toBeTruthy()
}
})
})
describe('系统级固定周期事件', () => {
it('每年必有岁簿(paper 触发统计)', () => {
const w = World.create({ seed: 'paper-a', surname: '吴', familyName: '吴家', motto: 'm', difficulty: 'normal' })
const yearsSeen: number[] = []
w.out.push({
onLog: () => undefined,
onChronicle: () => undefined,
onBattle: () => undefined,
onPendingEvent: () => undefined,
onGameOver: () => undefined,
onYearPaper: (r) => yearsSeen.push(r.year)
})
for (let i = 0; i < 26; i++) w.advanceMonth()
expect(yearsSeen).toContain(1)
expect(yearsSeen).toContain(2)
})
it('每五年大比征召(第10/15/20年)', () => {
const w = World.create({ seed: 'tourn-b', surname: '邵', familyName: '邵家', motto: 'm', difficulty: 'normal' })
const seen: string[] = []
w.out.push({
onLog: () => undefined,
onChronicle: () => undefined,
onBattle: () => undefined,
onPendingEvent: (id) => { if (id.startsWith('ev-tournament-')) seen.push(id) },
onGameOver: () => undefined
})
for (let i = 0; i < 20 * 12; i++) {
if (w.state.pendingEvent) {
applyEventChoice(w, w.state.pendingEvent, 0)
w.state.pendingEvent = undefined
}
w.advanceMonth()
}
expect(seen).toContain('ev-tournament-10')
expect(seen).toContain('ev-tournament-15')
expect(seen).toContain('ev-tournament-20')
})
it('偶数年四邻回声整年单发', () => {
const w = World.create({ seed: 'echo-b', surname: '邱', familyName: '邱家', motto: 'm', difficulty: 'normal' })
const sounds: number[] = []
w.out.push({
onLog: () => undefined,
onChronicle: () => undefined,
onBattle: () => undefined,
onPendingEvent: (id) => { if (id.startsWith('ev-echo-')) sounds.push(Date.now() % 1000) },
onGameOver: () => undefined
})
for (const y of [12, 14, 16]) {
w.state.year = y
w.state.month = 1
w.state.family.flag = {}
w.state.pendingEvent = undefined
// 直接调用 eventRoll 检验年内单发
w.advanceMonth()
if (w.state.pendingEvent) {
sounds.push(1)
w.state.pendingEvent = undefined
}
w.advanceMonth()
if (w.state.pendingEvent) sounds.push(2)
}
// 每偶数年均至少一打一(flag 封缄)
expect(sounds.length).toBeGreaterThanOrEqual(3)
})
})
describe('确定性复跑矩阵', () => {
it.each([1, 2, 3].map((n) => [`det-replay-${n}`] as const))('%s 两次 120 月轨迹一致', (seed) => {
const a = World.create({ seed, surname: '洪', familyName: '洪家', motto: 'm', difficulty: 'normal' })
const b = World.create({ seed, surname: '洪', familyName: '洪家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(a)
resetSaveBus()
attachLogSink(b)
for (let i = 0; i < 120; i++) {
a.advanceMonth()
b.advanceMonth()
}
a.state.pendingEvent = undefined
b.state.pendingEvent = undefined
const fa = JSON.parse(JSON.stringify({ ...a.state, pendingEvent: undefined }))
const fb = JSON.parse(JSON.stringify({ ...b.state, pendingEvent: undefined }))
expect(JSON.stringify(fa)).toBe(JSON.stringify(fb))
})
})
describe('fingerprint 跨版本稳定性锚点', () => {
it('保存后重放同世界(存档点回复)', () => {
const w = longRun('det-anchor')
expect(stateFingerprint(w.state)).toBe(stateFingerprint(w.state))
})
})