Compare commits
10
Commits
322732f876
...
06a2f085b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a2f085b1 | ||
|
|
7191a2bb55 | ||
|
|
89f92c6959 | ||
|
|
ac8068d328 | ||
|
|
87106614e4 | ||
|
|
967ef1240d | ||
|
|
624e30498b | ||
|
|
e91fb6f5a4 | ||
|
|
5b40d9158c | ||
|
|
8152b5c4bc |
@@ -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-events,protected 不可卸)。
|
||||||
|
- **事件多池**:`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` GameFacade(act 24 项/query 6 类/subscribe 退订协议/about);**UI store 的 `facade` 字段必须通过 openState/startNewGame 赋值**,否则 act 走残缺 fallback。
|
||||||
|
- **金钟罩**:0.1.11 审计修复批次后三档指纹(47/100/180 年 × 3 seed = 9 值,见 tests/clock.test.ts;0.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`**:月度 phase(production/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 是 mutable,advance 后 `revision++` 触发重渲染;订阅 `revision` 是面板刷新惯例。
|
- `src/renderer/ui/`:React + zustand(`ui/store.ts`)。World 的 game state 是 mutable,advance 后 `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 ... # 附
|
|||||||
- 数据在浏览器 OPFS(renderer 内),main 进程无 DB 逻辑;开发与打包后的 origin 不同,两环境存档不互通。
|
- 数据在浏览器 OPFS(renderer 内),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 后再跑一次冒烟)。
|
||||||
|
|||||||
@@ -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 # 依赖(含私有 registry:git.metona.cn)
|
npm install # 依赖(含 git.metona.cn 私有 registry,token 在用户级 ~/.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 + zustand(store 持 GameFacade;面板订阅 revision 刷新)
|
||||||
|
src/main/ Electron 主进程(app:// 协议 + IPC 存档导出/导入)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术栈
|
设计文档见 `AGENTS.md`(环境坑/架构红线/方言陷阱/金钟罩约定)。
|
||||||
|
|
||||||
- Electron + electron-vite + React + TypeScript
|
## 守护机制
|
||||||
- 数据库:@metona-team/metona-sqlark 0.7.4(aria 引擎 + 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 打磨(警讯徽章/首启引导/格式化) |
|
||||||
|
|||||||
@@ -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 import,vitest 直测。
|
||||||
|
- sfc32 随机种,同 seed 全程重放;**金钟罩**=3 seed×3 档时长(47/100/180年)指纹固化。
|
||||||
|
- **为什么**:模拟器平衡调参需要"可复现";金钟罩把"改坏了确定性"变成"一秒red"。
|
||||||
|
|
||||||
|
### 2. GameClock 七相位
|
||||||
|
- 月度 phase:production→aging→cultivation→missions→events→diplomacy→epilogue;
|
||||||
|
- 年首三钩子:婚配→岁贡→族簿。新系统=注册一行,禁手改 `advanceMonth`。
|
||||||
|
- **为什么**:0.1.6 前每个 tick 体内散着调用,加系统必乱序→破坏随机→金钟罩红。
|
||||||
|
|
||||||
|
### 3. 插件协议(0.1.8)
|
||||||
|
- 万物皆是插件:系统/数据包/事件池统一 Manifest;`engine/pluginManager.ts` 安装管线(依赖校验/异常回滚/追踪型上下文)。
|
||||||
|
- 三核心插件(Systems/Data/Events)protected 常驻。
|
||||||
|
- **为什么**:玩法内容增量的"可插拔出口";事件多池让第三方能注入事件。
|
||||||
|
|
||||||
|
### 4. 存档分层
|
||||||
|
- metona-sqlark 在 renderer(OPFS)+ {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:先改 store(revision)再改组件;纯逻辑放 game/core 可测
|
||||||
|
→ 6. 加测试:金钟罩三档不动,新增用例挂 tests/audit-fix-0.1.x.test.ts
|
||||||
|
→ 7. 提交前:npm test + typecheck + (GUI 冒烟需 WSLg 在线)+ 更新 README/PROJECT_MEMORY/AGENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
**一句话原则**:先复现、再小改、后金钟罩。改坏确定性比改错玩法更危险。
|
||||||
+4
-2
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
寿终: '一尘一土,终归青山。子孙焚香,岁岁在此。',
|
||||||
|
'寿元将尽': '一尘一土,终归青山。子孙焚香,岁岁在此。',
|
||||||
|
幼夭: '稚子长眠,天地垂爱。家人悬灯,常照归路。',
|
||||||
|
'伤势不治': '壮士折戟,魂兮归来。家祠之下,与祖同列。',
|
||||||
|
'突破走火': '一念求道,九死未悔。灵前法灯,为君长明。',
|
||||||
|
'渡劫陨落': '雷霆战天,其志未竟。遗骨归山,英风尚在。',
|
||||||
|
'战殁': '马革裹尸,归葬桑梓。长剑挂壁,子孙相传。',
|
||||||
|
'云游飞升': '此去云深不知处,魂同列宿游太虚。'
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { World } from '../engine/world'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一时轮调度:所有系统以 phase 注册,由时钟按固定顺序驱动。
|
||||||
|
* - 月度 phase:production / 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 }))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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('')
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
export const SURNAME_POOL = [
|
export const SURNAME_POOL = [
|
||||||
'林', '苏', '沈', '谢', '顾', '萧', '叶', '江', '秦', '裴',
|
'林', '苏', '沈', '谢', '顾', '萧', '叶', '江', '秦', '裴',
|
||||||
'柳', '陆', '云', '姜', '晏', '楚', '洛', '许', '宋', '薛',
|
'柳', '陆', '云', '姜', '晏', '楚', '洛', '许', '宋', '薛',
|
||||||
'韩', '白', '纪', '容', '卫', '柳', '燕', '温', '孟', '阮',
|
'韩', '白', '纪', '容', '卫', '燕', '温', '孟', '阮',
|
||||||
'洛', '池', '顾', '岑', '傅', '虞', '尹', '霍', '曲', '齐'
|
'池', '岑', '傅', '虞', '尹', '霍', '曲', '齐'
|
||||||
]
|
]
|
||||||
|
|
||||||
export const MALE_GIVEN = [
|
export const MALE_GIVEN = [
|
||||||
|
|||||||
@@ -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' }
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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}年`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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']
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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] ?? '?'
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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: '春夏秋冬之乘气(春耕夏修秋市冬闭)。' }
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, [])
|
||||||
|
|||||||
@@ -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])
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)→ v2(0.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)}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
寻衅
|
寻衅
|
||||||
|
|||||||
@@ -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()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}氏`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
// 春季(月2)10 ×(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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/ebfec4a4;0.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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('坊市月产受执事加成', () => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))('%s:200 年内未因修炼瓶颈灭族', (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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('急事徽章 derivedState(urgent)', () => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user