Compare commits
102
Commits
06a2f085b1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
631099fd8c | ||
|
|
beeda036f6 | ||
|
|
54face8275 | ||
|
|
989bc3f444 | ||
|
|
2c7596cec7 | ||
|
|
dc970ff13b | ||
|
|
dcd2345192 | ||
|
|
07c0a218eb | ||
|
|
7ba64750bc | ||
|
|
818c6025f4 | ||
|
|
fdc9d750f2 | ||
|
|
e2070c8fcb | ||
|
|
62da87a4e4 | ||
|
|
fb23afa322 | ||
|
|
c0efc63434 | ||
|
|
5030eb04d2 | ||
|
|
8f1f8d48ce | ||
|
|
87cd71ad21 | ||
|
|
2984d05647 | ||
|
|
41cffdfdb6 | ||
|
|
9571c58c07 | ||
|
|
9323963dd4 | ||
|
|
e36937ade2 | ||
|
|
73fa9eb7f2 | ||
|
|
f5e49ef9bd | ||
|
|
293d04fc20 | ||
|
|
c9fca1b0b1 | ||
|
|
088fda2df0 | ||
|
|
b963e954c7 | ||
|
|
9b0f36c70a | ||
|
|
1b1dfe8ac8 | ||
|
|
b420fa0c67 | ||
|
|
ae3fcabc20 | ||
|
|
bb82947bb6 | ||
|
|
a7fe166e85 | ||
|
|
3fb64f6e78 | ||
|
|
a491b9c54a | ||
|
|
b7c5f1eca0 | ||
|
|
7839c7b55a | ||
|
|
2369f342dd | ||
|
|
3e4ed4d882 | ||
|
|
bce50b6e2b | ||
|
|
fa477fb3ef | ||
|
|
9fbbe16dd8 | ||
|
|
29eb25524b | ||
|
|
4dfa07cc9f | ||
|
|
c08f3c1f7f | ||
|
|
0965eaead4 | ||
|
|
716a91bd96 | ||
|
|
8a81022862 | ||
|
|
a99420da09 | ||
|
|
6cbd604e24 | ||
|
|
da4fb9f635 | ||
|
|
d8d34fcb74 | ||
|
|
c8c38ea3b3 | ||
|
|
ba02dde1a5 | ||
|
|
40c4da34a0 | ||
|
|
8b6f2f7189 | ||
|
|
d941df5c3d | ||
|
|
ba9766e152 | ||
|
|
5d9c333c76 | ||
|
|
ec25bf92fc | ||
|
|
04ef80faa5 | ||
|
|
d2787ee3c8 | ||
|
|
65ab00a677 | ||
|
|
4a1a034f1e | ||
|
|
8f9c3c9a72 | ||
|
|
10ed1e7d9b | ||
|
|
be6f2f5fe7 | ||
|
|
e853fbdb71 | ||
|
|
2f62eecb08 | ||
|
|
ea9f5c9c5d | ||
|
|
809707408b | ||
|
|
de46808188 | ||
|
|
c5fa2a3357 | ||
|
|
26bd61fba0 | ||
|
|
5dd5d6b558 | ||
|
|
8dfdaf843a | ||
|
|
ff6df8054c | ||
|
|
2528226a9f | ||
|
|
c52ecffcd9 | ||
|
|
607624695d | ||
|
|
f17d856f98 | ||
|
|
6ba4766f07 | ||
|
|
4df1200d52 | ||
|
|
5fef79705b | ||
|
|
12ab1e712a | ||
|
|
9bcb5d6af8 | ||
|
|
586fa643b1 | ||
|
|
f5a2c1b68a | ||
|
|
3195f67455 | ||
|
|
c09395a6d4 | ||
|
|
9e0b06e898 | ||
|
|
a7ee125866 | ||
|
|
106db18017 | ||
|
|
98b60076f0 | ||
|
|
09cbad6449 | ||
|
|
f38669fa4a | ||
|
|
fefbe1f1d7 | ||
|
|
6b15a97d0c | ||
|
|
4bacbd432e | ||
|
|
adb22f0558 |
@@ -1,6 +1,7 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。
|
仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。
|
||||||
|
当前版本 **0.1.41**(《天命·兑现》:天命 flag 全量接线 15+ 项即时兑现 + NPC 演化种子派生零主 rng 消耗 + pushNews/purgeNpc 确定性修复 + 族史卷叙事层 chronicle-scroll.ts + 时效 flag 自动清理 + UI 建筑专精加成预览 + LegacyPanel 族史卷视图)。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
@@ -30,25 +31,53 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附
|
|||||||
- **`npm rebuild electron esbuild` 会删掉 `node_modules/electron/dist/electron`**(Linux 二进制),修复:
|
- **`npm rebuild electron esbuild` 会删掉 `node_modules/electron/dist/electron`**(Linux 二进制),修复:
|
||||||
`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js`
|
`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js`
|
||||||
- 私有 registry 的 `_auth` token 只在用户级 `~/.npmrc`;项目 `.npmrc` 只保留 registry 映射,不要把 token 写进仓库。
|
- 私有 registry 的 `_auth` token 只在用户级 `~/.npmrc`;项目 `.npmrc` 只保留 registry 映射,不要把 token 写进仓库。
|
||||||
|
- **打包版≠开发版(CSP 分野)**:dev(vite server 无 CSP)能跑不代表打包版能跑——CSP 性 bug(如 0.1.36 unsafe-eval)只出现在 out/;改完务必 build +(可能的话)真实 GUI 冒烟。
|
||||||
|
- **WSL 无显示环境调试**:`npm run dev:web`(vite.web.config.ts,端口 5179 + host 0.0.0.0)纯浏览器通道——Windows 浏览器开 `http://<WSL_IP>:5179`;window.api(导出/导入/MOD 扫描)缺失自动跳过,其余全功能等价(存档走浏览器 OPFS)。**注意**:部分浏览器(如 TabbitDance 后台 node)会劫持 localhost 端口段(5173/5179 全 502)——用 WSL 网卡 IP 绕过。
|
||||||
- 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 起)
|
## 架构(0.1.14 大重构后,目录与路径以此为准)
|
||||||
|
|
||||||
- **万物皆是插件**:`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`),否则未知事件直接软锁。
|
src/renderer/game/ 纯 TS 引擎(无 React/DOM,vitest 直测)
|
||||||
- **能力卡**:`engine/capabilities.ts` 12 张;时钟注册走 `viaCap`;tournament/tribulation/apprentice/season 四卡各自内部检查 `w.sysEnabled`(无独立钩子,勿直接调)。
|
engine/GameEngine.ts 唯一门面:advance/restore/engineFromSnapshot/about/resolvePending
|
||||||
- **门面**:`engine/api.ts` GameFacade(act 24 项/query 6 类/subscribe 退订协议/about);**UI store 的 `facade` 字段必须通过 openState/startNewGame 赋值**,否则 act 走残缺 fallback。
|
engine/kernel/ Kernel.ts(三合一:Clock+Rng+BUS 转发)+ clock/rng/plugin/names/format/guide/urgency/timesense
|
||||||
- **金钟罩**:0.1.11 审计修复批次后三档指纹(47/100/180 年 × 3 seed = 9 值,见 tests/clock.test.ts;0.1.8 基线已作废、0.1.10 重建后再次漂移、0.1.11 大比 once/prune 后重固化)。
|
engine/runtime/ World.ts(大对象+normalize+bus 转发)/clocks.ts(时轮注册)/ApiFacade.ts(GameFacade)
|
||||||
|
Systems/(production/aging/cultivation/missions/events/diplomacy/marriage/tribulation/tournament/lifecycle/combat)
|
||||||
|
capabilities.ts(12 能力卡)/pluginManager.ts/plugin-bootstrap.ts/modSchema.ts(MOD 范式)/modManager.ts(MOD→插件)/creation.ts/pcgen.ts
|
||||||
|
engine/sim/ 世界自进化: WorldSim.ts / worldsim-data.ts(数值与常量唯一权威)/worldgen.ts(世界种子生成器)/Market.ts / worldsim-brief.ts
|
||||||
|
engine/narrative/ 族谱/传记/定鼎/年轴/百年报告/族史卷(chronicle-scroll)
|
||||||
|
data/ balance 表 + DataPackRegistry(registry.ts pack() 单源;PILL_RECIPES/FORGE_RECIPES/ERA_CONF 等)
|
||||||
|
storage/ db(MetonaSqlark 单例)/slots(槽+快照+exportEnvelope)/migrate(CURRENT_SCHEMA=2,即 normalize 即迁移)
|
||||||
|
types/domain.ts 全量领域类型(改状态结构先看它)
|
||||||
|
src/renderer/ui/ React + zustand(store.ts 主状态;面板按 panel switch 挂载;订阅 revision 刷新)
|
||||||
|
src/main/ Electron 主进程(app:// 协议 + IPC 存档导出/导入)
|
||||||
|
```
|
||||||
|
|
||||||
## 架构要点
|
## 引擎红线(0.1.14+)
|
||||||
|
|
||||||
- `src/renderer/game/`:纯 TS 游戏引擎(无 React/DOM import),可被 vitest 直接测试;`types/domain.ts` 是全量领域类型,改状态结构先看它。
|
- **统一时轮**:月度 phase(production/aging/cultivation/missions/events/diplomacy/epilogue)+ 年首钩子均注册于 `runtime/clocks.ts`。**新增系统 = 注册一行,禁止手改 `advanceMonth` 本体**(World.ts:329)。
|
||||||
- **统一时轮 `core/clock.ts`**:月度 phase(production/aging/cultivation/missions/events/diplomacy/epilogue)+ 年首钩子均注册于 `engine/clocks.ts`。**新增系统 = 注册一行,禁止手改 `advanceMonth` 本体**。
|
- **统一随机**:引擎只经 `World.rng`;UI 播种用 `RngHub.rollSeed()`、音效白噪用 `RngHub.audioNoise01()`;**不要**在逻辑里引入 Math.random()(引擎域 0 处)。渲染层 canvas 粒子散布的 Math.random 不进引擎状态,可留。
|
||||||
- **统一随机 `core/rng.ts`**:引擎只经 `World.rng`(含 `nextCount` 审计);UI 播种用 `RngHub.rollSeed()`、音效白噪用 `RngHub.audioNoise01()`,**不要**在逻辑里引入 Math.random()。
|
- **引擎单线程定论**(0.1.21 实测):`advanceMonth` 2160 月平均 **0.05ms/tick**——无 Worker 化必要;拖帧热点在 UI 渲染与存档(都已在 0.1.20/0.1.21 修)。
|
||||||
- **金钟罩 `tests/clock.test.ts`**:3 枚固定 seed × 三档月数(560/1200/2160 = 47/100/180 年)指纹常驻(0.1.11 基线:见注释 GOLDEN)。任何改动若破坏确定性立即红;**有意变更时序/数值时**指纹一并重算并在注释注明原因。
|
- **事件闸**:pending 事件唯一写入入口是 `events.ts` 的 `fire(w, id, priority)`(0=普通遇占放弃 / 1=高优 raid·渡劫·大比可顶替被占者,被顶替者入 `eventQueue` 下月兑现不丢失)。**禁止直写 `state.pendingEvent`**(渡劫/破境丹均已改走 fire)。`applyEventChoice` 前置校验 `pendingEvent===id`(陈旧 Modal 二次结算防线)。
|
||||||
- `src/renderer/ui/`:React + zustand(`ui/store.ts`)。World 的 game state 是 mutable,advance 后 `revision++` 触发重渲染;订阅 `revision` 是面板刷新惯例。
|
- **禁 eval/字符串求值(0.1.36 CSP 红线)**:打包 renderer 的 CSP 禁 unsafe-eval——**严禁 `Function()`/`eval()` 求值表达式**(业务字符串一律走 `data/arith.ts` 纯解析器:数字/L/四则/括号/幂/负号白名单,非法 NaN 不抛;bonusOf/produceExpr/MOD 表达式已全量替换)。开发模式无 CSP 常掩盖此问题——凡是新表达式求值必须用 arith。
|
||||||
- 引擎 = 种子随机数(sfc32,`core/rng.ts`)+ 不可变快照存 `state.rng`,同 seed 全程可重放(tests/world.test.ts 有确定性用例,改任何 tick 顺序都要保证仍然确定性)。
|
- **特效/音效语义出口**:`WorldEventBus.onFx?(em: FxEmit)` + `World.emitFx(kind, source?)`(零 rng 消耗);UI 的 `makeBus` 桥接 FxGate;`onLog kind` 音效映射保留作兜底。
|
||||||
- 建档开头成员 id 硬编码 `x1`~`x5`,tests 依赖。
|
- **插件架构**(0.1.8 起,路径已迁 engine/runtime|kernel):`kernel/plugin.ts` 协议、`runtime/pluginManager.ts` 安装管线(依赖校验/异常回滚/追踪型上下文——注销时钩子全摘)、`plugin-bootstrap.ts` 三核心插件(core-systems/core-data/core-events protected)。事件多池 `world.eventPools`;**findEvent 必须带 world 参数查池**。能力卡 12 张经 `viaCap` 注册;tournament/tribulation/apprentice/season 四卡各自内部检查 `w.sysEnabled`(无独立钩子)。
|
||||||
|
- **MOD 红线(0.1.26 规范)**:MOD 只含 JSON 零脚本(`docs/MOD_GUIDE.md`);**禁止新增 rng 消耗**(全局随机序列);不改核心时序(扩展走五口:事件池/模板/词库/灾因/配方);`modPreflight`+`validateMod` 先行(id 冲突用 `findEvent` 权威检测);MOD 只影响**新开档**世界(老档世界变形成档记录);MOD 状态随 `state.plugins` 持久化(对应 `worldGen.npcs` 落档保证读档永稳)。
|
||||||
|
- **门面**:`runtime/ApiFacade.ts` GameFacade(act 24 项/query 6 类/subscribe 退订协议/about);**UI 面板事实走 world 直调**(act 为测试/自动化通道),`actDirect` 与 ACT_CATALOG 语义对齐(0.1.20 补齐全 24 项);store 的 `facade` 必须在 openState/startNewGame 赋值。
|
||||||
|
- **金钟罩(双基线)**:`tests/clock.test.ts` 的 `GOLDEN`(直调冻结世界)+ `GOLDEN_RESOLVED`(自动 resolve 长跑"现实"世界),各 3 seed × 三档月数(560/1200/2160)共 18 值。当前为 **0.1.41 基线**(0.1.25/0.1.26/0.1.31 零漂;0.1.27/0.1.28/0.1.29/0.1.30/0.1.32/0.1.33 受控变更重算;0.1.39 purgeNpc 清理 worldGen.npcs/relations 僵尸定义——bell-seed-2 三档受控漂移;0.1.40 天命系统+NPC演化增强+建筑专精——金钟罩重算;0.1.41 NPC演化种子派生+pushNews确定性修复+天命flag全量接线——金钟罩重算)。指纹 `tests/fingerprint.helper.ts` 含全世界轴(era/stance/prosperity/relationsWithOthers/calamity/pendingEvent/eventQueue/worldAnnals/**worldGen 摘要/sagaAnnals/distress/declineYears 汇总**——0.1.27 生灭/世鉴路径真实锁定)。**有意变更时序/数值时**:指纹一并重算并在注释注明原因。
|
||||||
|
|
||||||
|
## 世界自演进模型(0.1.14 sim/ 层,改数值勿动流程)
|
||||||
|
|
||||||
|
- **市场实体化**:`marketPool` 是真库存——世界月供给(`worldSupplyRate` × 潮汐span × era.supplyMult)vs 常驻需求(`worldDemandRate`);NPC 按 `def.sells/buys` 逐月入池出池(姿态/区域灵势/灾年调制);玩家买卖经 `WorldSim.tradeSettle` 回写(买浅价涨/卖盈价跌);池深 < `tradeFloorPct(0.35)` 断供拒单(与 `poolFloorPct` 单常量一致)。`driftMarket` rebalance=0.025 弱锚定(让供需真实撬动价格,勿调回 0.1+)。
|
||||||
|
- **世纪弧 era**:盛世/平世/乱世/末法,`ERA_CONF`(灾年概率/供需/拍卖/tideBias)+ `ERA_DURA`(续航窗口);flow 权重和 <1(留白=延续档);**转移权重受世界温度调制**(`worldTemperature()` + `tempEraK`)——时代由天下兴衰孕育、又重塑天下。
|
||||||
|
- **NPC 定力**:`npcDyn.prosperity/stance/relationsWithOthers`——姿态(守成/扩张/隐忍/结盟)年首重估,调制 raid/互攻/贸量/外交;互攻按 power 加权 + 打残广播;玩家劫掠/结盟写关系网(蝴蝶效应)。
|
||||||
|
- **灾年**:持续 6 月(`calamityLeft`),减产乘子(CALAMITY_FAMILY)+ 疫病/兽潮体感 + NPC 贸易 ×0.7 + 群雄相噬 ×1.5;世界侧 `CALAMITY_EFFECT` 冲击池。
|
||||||
|
- **灾年** 补充:灾因注册表已聚合(`calamityNames/calamityFamilyOf/calamityEffectOf` + `addCalamity`——默认表不变,MOD/插件可注入灾因全链生效)。
|
||||||
|
- **家族生灭**:`npcDyn.declineYears`(power<58 且 景气<38 连续 8 年)→ 覆灭(最强邻分食+史官广播);家族数 < 开局目标(4~8)年首新贵补位(`greatNewbornChance`,乱世×2)。
|
||||||
|
- **世界种子**(0.1.24):`sim/worldgen.ts` `generateWorld(seed)`——NPC 4~8 家随机/初始关系网(1-2 世仇+1 盟友)/开局 era/市场 ±15% 偏移;**独立派生 rng**(`seed::worldgen`,World.rng 主序列零消耗);`state.worldGen` 摘要落档(含 npcs defs——读档幂等重注册,存档世界永稳)。
|
||||||
|
- **天下史表**:`worldSim.worldAnnals[]`(十年一鉴 80 页留档,免 newsFeed 裁剪丢失)。
|
||||||
|
- **物品六类生态纪律**(0.1.32):resource/material/pill/talisman/brew/artifact——**新资源必须四同步**(ITEMS 定义 + POOL_BASE + MARKET_IDS + worldgen marketOffset)才入世界循环(呼吸/NPC 贸易/灾年超卖);消耗类物品效果(符箓/灵酿)**零 rng 消耗**(自动消耗实现——金钟罩安全)。
|
||||||
|
- **生态闭环纪律**(0.1.33):丹方/铸器产出物**必须先在 ITEMS 立名**(幽灵物品不可见不可卖——A2 教训);灾年减产因子必须真正乘入生产(compute-and-drop 是 A3 教训);符箓坊为"消耗式"建筑(扣料产符——零 rng),新增消耗式生产勿用 produceTable 泛化(泛化只做纯产出)。
|
||||||
|
|
||||||
## MetonaSqlark(@metona-team/metona-sqlark 0.7.4)陷阱
|
## MetonaSqlark(@metona-team/metona-sqlark 0.7.4)陷阱
|
||||||
|
|
||||||
@@ -56,18 +85,30 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附
|
|||||||
- aria 引擎同库一个 tab 只能建一次连接(Web Locks → `ARIA_LOCKED`):存取必须走 `game/storage/db.ts` 的单例 `getSaveSlot(slot)` / `getSlotManager()`,**禁止**每次 `new SaveSlot/new SlotManager`。
|
- aria 引擎同库一个 tab 只能建一次连接(Web Locks → `ARIA_LOCKED`):存取必须走 `game/storage/db.ts` 的单例 `getSaveSlot(slot)` / `getSlotManager()`,**禁止**每次 `new SaveSlot/new SlotManager`。
|
||||||
- 数据在浏览器 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.35 起):**迁移链已整体铲除**——读档走 `validateLoadedState` 结构校验(seed/family/members 断言)+ World 构造器 normalize 防护(仅保留:npcs 重注册(世界永稳)/headId 修复/distress 防脏/era 合法校验);新字段初始职责归 `makeWorldSimState`(initSim 单源——改字段同步 initSim);无 schemaVersion 版本戳(SaveMeta.version 记游戏版本字符串展示用)。
|
||||||
|
|
||||||
## 数值基调(0.1.10 重建后,勿当 bug 调回去)
|
## 数值基调(勿当 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` 曲线**未参与计算**(历史摆设,勿以它反推)。
|
- **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)。
|
- 月率 base = `1.0 + perception*0.18`;65 岁起 ×0.72 衰减、8 岁以下 ×0.65(圣者护族 60+ 高境界者存在时乘 1.3)。
|
||||||
- 寿元:75/150/220/340/520/850(凡人→化神,0.1.10 放宽的梯度)。
|
- **0.1.16 修为经济化**:练气以上修行者月耗灵草(闭关2/普通1),无草速修 ×0.6~1.0 衰减——灵草是第一战略资源。
|
||||||
- 大境界晋升走**渡劫事件三选**(硬渡/护法/压制);玩家 12 月不应会**自动压制一年**(防挂机软锁,events.ts 计数 `tribPendingMonths`)。
|
- 寿元:75/150/220/340/520/850(凡人→化神)。
|
||||||
- 周期事件优先级:命运(百年/飞升)> 大比 > 传薪 > 拍卖 > 岁祷 > 回声;大比有 `once`,一年一届。
|
- 大境界晋升走**渡劫事件三选**(硬渡/护法/压制,12 月不应自动压制);破境丹在需渡劫时药力转 `tribBoost`(+12%) 入渡劫,**不可跳过**。
|
||||||
- `family.flag` 年份键(auction-/prayerDone-/echoDone-/recruitDone-)每 tick 剪 3 年前旧键。
|
- 周期事件优先级:命运(百年/飞升)> 大比 > 传薪(年锚 flag)> 拍卖(era 概率调制)> 岁祷 > 回声。
|
||||||
|
- `family.flag` 年份键(auction-/prayerDone-/echoDone-/recruitDone-/legacyDone-)每 tick 剪 3 年前旧键。
|
||||||
|
|
||||||
## 工作流约定
|
## 修改纪律(防覆盖——血泪教训,务必遵守)
|
||||||
|
|
||||||
- 提交前:`npm test` + `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。
|
**背景**:曾多次出现「大改后我之前的修复被覆盖」:① 批量 python 替换命中旧文本、② `git checkout` 回滚临时参数时误恢复了已修复文件、③ 编辑器链式 replace 生成重复属性/孤行。均靠 tsc/vitest 事后兜住,但应防于未然。
|
||||||
- 平衡数值集中在 `game/data/`(realms/pacing/buildings/events...),调平衡不改引擎流程。
|
|
||||||
|
**硬性规则**:
|
||||||
|
1. **改动前**:`git status` 确认工作区干净(或先提交当前成果,原子提交,一功一提交)。
|
||||||
|
2. **改动中**:批量替换一律用「先 assert 旧文本存在 → 替换 → 再全局 grep 断言新文本唯一」三步;禁止无检查的 sed/python 盲替。**写盘脚本严禁惰性路径**(0.1.32 教训:`open('p','w')` 撇笔把模块源码碎片写进根目录并被 git add 入库)——写文件必须用**显式路径变量**且写后立即 `git status` 检查工作区异常新文件。
|
||||||
|
3. **禁止 `git checkout -- <file>` / 整文件回滚**(这是历史事故元凶)。确需回滚时:`git show HEAD:<file> > /tmp/bak` 手动 diff 恢复,恢复后跑全量测试确认无伤。
|
||||||
|
4. **优先小步快跑**:每个功能块改完立即 `npm test`(引擎)或 `tsc`(UI 类型),绿了再动下一处——绝不在错误状态上加新修改。
|
||||||
|
5. **高危文件白名单**(world.ts / events.ts / store.ts / styles.css / cultivation.ts / **WorldSim.ts / worldsim-data.ts**)改动后必须 `git diff --stat` 核对改动面与预期一致。
|
||||||
|
6. 每次迭代收尾:`git status` 应干净 + `git log --oneline` 确认每步都有提交记录;凡 "git stash/checkout/rm" 类命令,执行前后都 `git status` 留痕。
|
||||||
|
|
||||||
|
- 提交前:`npm test`(当前 108 套件 7058 例全绿)+ `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。
|
||||||
|
- 平衡数值集中在 `game/data/` 与 `engine/sim/worldsim-data.ts`(世界循环参数唯一权威),调平衡不改引擎流程。
|
||||||
- 发布产物 `release/`、`out/` 均已 gitignore,勿入库。
|
- 发布产物 `release/`、`out/` 均已 gitignore,勿入库。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
一座庄园、一家修士、四邻势力,随年月流转:族人修炼突破、建筑产出、探秘寻宝、婚丧嫁娶;
|
一座庄园、一家修士、四邻势力,随年月流转:族人修炼突破、建筑产出、探秘寻宝、婚丧嫁娶;
|
||||||
仇家犯境则结阵应战;百年之际,望气定鼎,春秋原上写下一卷族史。
|
仇家犯境则结阵应战;百年之际,望气定鼎,春秋原上写下一卷族史。
|
||||||
|
**世界也在自演进**:市场有真库存与供需呼吸,NPC 有战略姿态与恩怨网,时代有盛世乱世的大势流转——天下兴衰与家族命运互为因果。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -11,32 +12,42 @@
|
|||||||
npm install # 依赖(含 git.metona.cn 私有 registry,token 在用户级 ~/.npmrc)
|
npm install # 依赖(含 git.metona.cn 私有 registry,token 在用户级 ~/.npmrc)
|
||||||
npm install-scripts approve electron esbuild # npm12 首装时必须(Electron 二进制)
|
npm install-scripts approve electron esbuild # npm12 首装时必须(Electron 二进制)
|
||||||
npm run dev # 开发模式(热更新)
|
npm run dev # 开发模式(热更新)
|
||||||
npm run verify # 发布验收哨兵:vitest + typecheck + build
|
npm test # 引擎测试(108 套件 / 7058 用例,纯 Node 无 GUI)
|
||||||
npm run verify:package # 附打包预览(electron-builder)
|
npm run build # 产物到 out/
|
||||||
npm run package # 直接打包(Windows 需在 Windows 侧执行)
|
npm run package # 打包(Windows 需在 Windows 侧执行)
|
||||||
npm test # 仅测试(923+ 用例)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
端到端冒烟(渲染 + OPFS + 开局推进 + 可选截图):见 `AGENTS.md`「命令」。
|
||||||
|
|
||||||
## 架构一览
|
## 架构一览
|
||||||
|
|
||||||
```
|
```
|
||||||
src/renderer/game/ 纯 TS 引擎(无 React/DOM,可被 vitest 直测)
|
src/renderer/game/ 纯 TS 引擎(无 React/DOM,vitest 直测;单线程,实测 0.05ms/tick)
|
||||||
core/ rng(RngHub) · clock(GameClock·7相位) · plugin · legacy · report · yearaxis · biography · genealogy
|
engine/GameEngine.ts 唯一门面:advance / restore / engineFromSnapshot / about / resolvePending
|
||||||
data/ balance 表 + DataPackRegistry(数据包可覆写)
|
engine/kernel/ Kernel(Clock+Rng 三合一)+ clock / rng(RngHub) / plugin / guide / urgency...
|
||||||
engine/ world · clocks(注册) · pluginManager(∃插件管线) · capabilities(12 能力卡) · api(GameFacade act/query/subscribe/about)
|
engine/runtime/ World(大对象+normalize+bus 转发)· clocks(时轮注册)· ApiFacade(GameFacade)
|
||||||
systems/(生产·寿元·修炼·任务·事件·外交·婚育·渡劫·大比·寄读)
|
Systems/(生产/修炼/任务/事件/外交/婚育/渡劫/大比/寿元/战斗)
|
||||||
storage/ db(MetonaSqlark 单例) · slots(存档槽+快照) · migrate(版本迁移链 v1→v2)
|
capabilities(12 能力卡)· pluginManager(插件管线)
|
||||||
src/renderer/ui/ React + zustand(store 持 GameFacade;面板订阅 revision 刷新)
|
engine/sim/ WorldSim(世界自进化)· worldsim-data(数值唯一权威)· Market(行情/买卖)
|
||||||
|
engine/narrative/ 族谱/传记/定鼎/年轴/百年报告
|
||||||
|
data/ balance 表 + DataPackRegistry(PILL_RECIPES / FORGE_RECIPES / ERA_CONF)
|
||||||
|
storage/ db(MetonaSqlark 单例) · slots(槽+快照) · migrate(CURRENT_SCHEMA=2,normalize 即迁移)
|
||||||
|
types/domain.ts 全量领域类型(改状态结构先看它)
|
||||||
|
src/renderer/ui/ React + zustand(store.ts 主状态;面板按 tab 挂载;LogFeed 精订阅)
|
||||||
src/main/ Electron 主进程(app:// 协议 + IPC 存档导出/导入)
|
src/main/ Electron 主进程(app:// 协议 + IPC 存档导出/导入)
|
||||||
```
|
```
|
||||||
|
|
||||||
设计文档见 `AGENTS.md`(环境坑/架构红线/方言陷阱/金钟罩约定)。
|
设计文档见 `AGENTS.md`(环境坑 / 架构红线 / 引擎纪律 / 金钟罩双基线 / 修改纪律);
|
||||||
|
**MOD 作者守则**见 `docs/MOD_GUIDE.md`(规范:命名/schema/五数据口/安全红线)。
|
||||||
|
|
||||||
## 守护机制
|
## 守护机制
|
||||||
|
|
||||||
- **金钟罩**:tests/clock.test.ts 三枚固定 seed × 560 月指纹常驻(0.1.8 基线:9af71ecb/63cede89/ebfec4a4);有意变更时序需受控更新并注明。
|
- **双金钟罩**:`tests/clock.test.ts` 的 `GOLDEN`(直调冻结世界)+ `GOLDEN_RESOLVED`(自动 resolve 长跑“现实”世界),各 3 固定 seed × 560/1200/2160 月(47/100/180 年)= 18 指纹;指纹含全世界轴(era/stance/prosperity/关系网/事件闸);**0.1.41 基线**。有意变更时序需受控更新并注明。
|
||||||
- **插件化**:万物皆是插件(系统/数据包/事件池),示例见 tests/helpers/examplePlugin.ts。
|
- **事件闸**:pending 唯一入口 `fire(w,id,priority)`——高优(raid/渡劫/大比)可顶替被占者,被顶替者入队下月兑现;`applyEventChoice` 前置校验防陈旧 Modal 二次结算。
|
||||||
- **可回放**:种子随机(sfc32)+ 闰档快照 + 回档时间轴。
|
- **插件化**:万物皆是插件(系统/数据包/事件池),三核心插件 protected 常驻;示例见 tests/helpers/examplePlugin.ts。
|
||||||
|
- **可回放**:种子随机(sfc32,引擎域零 Math.random)+ 快照回放保真(restore 重建内核+回置 rng)。
|
||||||
|
- **审计五轮**:0.1.15(引擎 P0 歼灭)/ 0.1.20(事件闸·市场黑洞·双金钟罩)/ 0.1.28(稳石:词库回滚·新贵落档·校验接生产·bundle 闭环·指纹补盲)/ 0.1.29(续命:续代断链·敌强度相对化·权重防洪)/ **0.1.35(荡涤:17 缺陷歼灭·兼容层铲除·validateLoadedState 替代迁移)**。
|
||||||
|
- **测试规模**:**108 套件 / 7058 例**(矩阵域:随机序列/世界生成/市场实体化/事件闸/数据表全量/插件 MOD 组合/省级矩阵等——语义真实,无刷水)。
|
||||||
|
|
||||||
## 打包产物
|
## 打包产物
|
||||||
|
|
||||||
@@ -44,19 +55,40 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存档导
|
|||||||
- 图标资源 resources/icon.png|ico —— electron-builder 自动使用
|
- 图标资源 resources/icon.png|ico —— electron-builder 自动使用
|
||||||
- 注意:WSL/Linux 交叉打 win 包可行但未签名,正规发布请用 Windows 侧重跑
|
- 注意:WSL/Linux 交叉打 win 包可行但未签名,正规发布请用 Windows 侧重跑
|
||||||
|
|
||||||
## 版本沿革(0.1.0 → 0.1.9)
|
## 版本沿革(0.1.0 → 0.1.41)
|
||||||
|
|
||||||
| 版本 | 主题 |
|
| 版本 | 主题 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 0.1.0 | 核心循环(修仙/家族/经营/战斗)完备可玩 |
|
| 0.1.0-0.1.11 | 核心循环 → 插件架构 → 审计三连(详见 docs/PROJECT_MEMORY.md) |
|
||||||
| 0.1.1 | 养成闭环+回档+叙事(指婚/装备/祭祖/战报匣/年度纸笺) |
|
| 0.1.12 | 金碧山河(SVG 素材 + 动效系统 + 舞美重装) |
|
||||||
| 0.1.2 | 宗族气韵(职事/悟道/谱系/碑录/庆典/飞升/合音效) |
|
| 0.1.13 | 交付与回档(工艺/界面修复 + UI 存储直连回档) |
|
||||||
| 0.1.3 | 深度审计(旧档兼容/血缘/联姻/远征修复) |
|
| 0.1.14 | 大重构:GameEngine 门面 + Kernel 三合一 + WorldSim 世界自进化(五大闭环) |
|
||||||
| 0.1.4 | 百年定局(终局/大比/寄读/春秋原) |
|
| 0.1.15 | 深度审计加固(7 P0 + 20+ P1 歼灭;restore 双注册/引擎锁死等) |
|
||||||
| 0.1.5 | 人物志(志向/渡劫/列传/契合/回声) |
|
| 0.1.16 | 资源闭环 × 世情入味(修为耗草/炼丹概率化/铸器/灵脉收益/NPC 战力/灾变落效) |
|
||||||
| 0.1.6 | 时轮与造化(GameClock/RngHub/时节/年轴/保护) |
|
| 0.1.17 | 天下为市——市场实体化(真库存/供需呼吸/NPC 上桌/玩家回写)+ 战争伤骨 + 关系网 |
|
||||||
| 0.1.7 | 仙门之钥(门面/能力注册表/数据包/开发者面板) |
|
| 0.1.18 | 时代绘卷(世纪弧 era/NPC 景气/蝴蝶效应/十年鉴)+ 特效入引擎(onFx 语义出口) |
|
||||||
| 0.1.8 | 万物皆是插件 + 全应用审计(60+ 缺陷歼灭) |
|
| 0.1.19 | 八方风雨(NPC 战略姿态/超卖潮/盟约连坐/干预天下)+ 孤立系统归并(FxGate/音效/兜底) |
|
||||||
| 0.1.9 | 百年报告 + 存档迁移 v2 + 战斗阵型 + 冬祷 + 发布哨兵 |
|
| 0.1.20 | 万世无谬(第二轮全量审计:事件闸优先级/era 延续/潮汐符号/弱锚定/双金钟罩) |
|
||||||
| 0.1.10 | 长线决断(数值重建:修炼提速/寿元放宽/渡劫修复 + 拍卖/传薪/护族) |
|
| 0.1.21 | 大势流转(线程定论 0.05ms、era↔景气双向闭环、世界史表、渲染管线修复) |
|
||||||
| 0.1.11 | 全源码审计缺陷歼灭 + UX 打磨(警讯徽章/首启引导/格式化) |
|
| 0.1.22 | 灯下春秋(Modal 全走 body Portal / 年报不打断自动速度 / 姓氏假功能修复) |
|
||||||
|
| 0.1.23 | 生灭千秋(家族覆灭/新贵补位、秘境回池、WorldSnapshot、插件持久化/双闸/自动回滚) |
|
||||||
|
| 0.1.24 | 寰宇初构(世界种子生成器:NPC 4~8 家随机/开局恩怨/era/市场偏移;插件的时轮锚点/模板注入) |
|
||||||
|
| 0.1.25 | 乾坤一统(MOD 系统首版:userData/mods 扫描/解析/安装/持久化 + worldGen 存档永稳修复) |
|
||||||
|
| 0.1.26 | 墨规清辉(MOD 规范范式:MOD_GUIDE/validateMod/灾因注册表 + 页面文本提亮可读性) |
|
||||||
|
| 0.1.27 | 活水长流(生灭可达化:180 年实测驱动——门槛/era 调制增长/顶峰衰 + 修为↔市场回环 + 世鉴 50/100 年 + 测试 2000) |
|
||||||
|
| 0.1.28 | 稳石(第三轮全量审计歼灭:词库回滚/新贵落档/校验接生产/bundle 导入/救济正道/快讯解锁 + 指纹补盲) |
|
||||||
|
| 0.1.29 | 续命(第四轮全量审计歼灭:续代断链修复/敌强度相对化/权重防垄断/启用过滤/并发存档修复) |
|
||||||
|
| 0.1.30 | 兼济(人口-世界成本闭环 + 世界关注度(匹夫无罪)+ 内置 MOD 仓库/冲突矩阵/预览 + 测试 3006) |
|
||||||
|
| 0.1.31 | 万物皆可 MOD(内容面七口:物品/功法/建筑(表达式 DSL)/职事/志向/阵型/性格 + kind 分类 + ERA_DURA/REGION_TIDE 聚合 + 测试 4006) |
|
||||||
|
| 0.1.32 | 万宝琳琅(物品六类化 + 新材料七种入市 + 符箓武备/灵酿回气 + 生产泛化 + 测试 5009) |
|
||||||
|
| 0.1.33 | 闭环·流光(物品生态闭环(符箓生效/僵尸物品归位/灾年入产)+ 引擎止血 15 项 + UI 动效打磨 + 测试 5230) |
|
||||||
|
| 0.1.34 | 万象归元(功法流派化×元素时节共鸣 + 世界自演化二层 + 插件卡 12→14 + MOD 功法效果口+patch 数值层 + 测试 6000) |
|
||||||
|
| 0.1.35 | 荡涤·向新(17 缺陷歼灭 + 兼容层整体铲除 + validateLoadedState 替代迁移 + 测试 7000) |
|
||||||
|
| 0.1.36 | CSP 根因(arith.ts 纯解析器全量替换 Function() + ErrorBoundary 防白屏 + dev:web 浏览器通道) |
|
||||||
|
| 0.1.37 | 天人感应(因果系统 karma + 飞升试炼三阶段 + 世界奇观种子派生 + 测试 7030) |
|
||||||
|
| 0.1.38 | 存档根治·石碑重刻(OPFS 探测+自动降级 IndexedDB + 回读校验 + 诊断面板 + driver.open 锁死修复) |
|
||||||
|
| 0.1.39 | 淬体·洗髓(missionIds 根治 + chronicle 智能裁剪 + worldsim 双闸守卫 + purgeNpc 僵尸定义清理 + MemberCard memo 化 + 飞升视觉强化 + 测试 7058) |
|
||||||
|
| 0.1.40 | 天命·裂痕(天命议题系统 20 条 5 类种子派生选取 + NPC 宗主更替四模式 + NPC 势力分裂 + 灰烬重生新贵补位 + 建筑专精系统 extra×1.5 + UI memo 化 4 处 + 天下大事记叙事层 saga.ts + WorldPanel/LegacyPanel 大事记展示) |
|
||||||
|
| 0.1.41 | 天命·兑现(天命 flag 全量接线 15+ 项即时兑现 + NPC 演化种子派生零主 rng 消耗 + pushNews/purgeNpc 确定性修复 + 族史卷叙事层 chronicle-scroll.ts + 时效 flag 自动清理 + UI 建筑专精加成预览 + LegacyPanel 族史卷视图) |
|
||||||
|
|
||||||
|
详见:`docs/PROJECT_MEMORY.md`(设计哲学与每个迭代的"为什么")。
|
||||||
|
|||||||
@@ -0,0 +1,992 @@
|
|||||||
|
# MOD 开发教程 · Chronicle of the Immortal Clan
|
||||||
|
|
||||||
|
> 版本 **0.1.41·《天命·兑现》** · MOD_SCHEMA_VERSION = 1 · 全口纯 JSON(零脚本)
|
||||||
|
> **本教程即全部所需**:你不需要读任何源码或类型文件——所有 MOD 能用的字段、取值、默认值、坑点、示例都写在下面。
|
||||||
|
> 照例动手即可:最快 5 分钟做出你的第一个 MOD。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [0. 先读这两句(最重要)](#0-先读这两句最重要)
|
||||||
|
- [1. 五分钟上手](#1-五分钟上手)
|
||||||
|
- [2. MOD 包结构总览](#2-mod-包结构总览)
|
||||||
|
- [3. 能力一:事件(events)](#3-能力一事件events)
|
||||||
|
- [4. 能力二:世界模板(npcs)](#4-能力二世界模板npcs)
|
||||||
|
- [5. 能力三:生成词库(worldgen)](#5-能力三生成词库worldgen)
|
||||||
|
- [6. 能力四:灾因(calamities)](#6-能力四灾因calamities)
|
||||||
|
- [7. 能力五:丹方(pills)](#7-能力五丹方pills)
|
||||||
|
- [8. 能力六:铸器配方(forges)](#8-能力六铸器配方forges)
|
||||||
|
- [9. 能力七:世界数值(worldNum)](#9-能力七世界数值worldnum)
|
||||||
|
- [9.5 内容面扩展(物品/功法/建筑/职事/志向/阵型/性格)](#95-内容面扩展物品功法建筑职事志向阵型性格)
|
||||||
|
- [9.6 数值 patch 层](#96-数值-patch-层)
|
||||||
|
- [10. 速查表:内置 id 大全](#10-速查表内置-id-大全)
|
||||||
|
- [11. 排错 FAQ](#11-排错-faq)
|
||||||
|
- [12. 进阶:依赖、冲突与导出](#12-进阶依赖冲突与导出)
|
||||||
|
- [13. 官方范例(可拆解学习)](#13-官方范例可拆解学习)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 先读这两句(最重要)
|
||||||
|
|
||||||
|
1. **MOD 是纯 JSON,零脚本**。没有代码、没有沙箱、没有 eval——永远不会"写挂"游戏本体。装错了最多报"校验失败",把文件删了就好。
|
||||||
|
2. **MOD 只影响新开档**。旧档的世界是"既成事实"——开档后世界自然演化不受 MOD 回溯;卸载只还原 MOD 造成的**机制**(词库/灾因/配方/数值/注入条目),不会回滚世界历史。想验证 MOD 效果,**务必新开档**。
|
||||||
|
|
||||||
|
### 零 B 君护航:安装即校验
|
||||||
|
|
||||||
|
每个 MOD 在安装前都会过一遍 `validateMod` 预检(0.1.28 起)——不对的地方会在安装界面提示原因(不装也不崩)。安全的数值区间:
|
||||||
|
|
||||||
|
| 项目 | 安全区间 | 违例后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `[a-z0-9-]`,长 2~32 | 解析失败(拒绝) |
|
||||||
|
| 事件 id | 全局唯一(与内置、其它 MOD 都不撞) | 安装被拒并报冲突名 |
|
||||||
|
| 模板 `initialPower` | 60~400 | warning(仍可装) |
|
||||||
|
| 事件 `weight` | 0~100 | warning |
|
||||||
|
| 丹方等级 | 1~5 | warning |
|
||||||
|
| 功法 `expBonus` | ≤ 0.5 | warning |
|
||||||
|
| 功法 `powerBonus` | ≤ 1 | warning |
|
||||||
|
| patch `mult` | 0.5 ~ 2(或 add 实数,二选一) | 拒绝 |
|
||||||
|
| 材料成本 | ≥ 0 | warning |
|
||||||
|
|
||||||
|
> **不碰任何 `rng` 字段**:MOD 里没有随机数的概念——所有随机数值(事件抽签、天气、掉落)一律由游戏引擎生成,你只挂牌子。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 五分钟上手
|
||||||
|
|
||||||
|
### 第 1 步:放文件
|
||||||
|
|
||||||
|
在你的游戏用户目录下建 `mods` 文件夹(没有就建一个),把 MOD 文件放进去:
|
||||||
|
|
||||||
|
```
|
||||||
|
Windows: %APPDATA%/Electron/mods/
|
||||||
|
Linux: ~/.config/Electron/mods/
|
||||||
|
macOS: ~/Library/Application Support/Electron/mods/
|
||||||
|
```
|
||||||
|
|
||||||
|
文件要求:
|
||||||
|
- 后缀 `.json` 或 `.cotymod`(两种都行,内容完全一样;`.cotymod` 只是"已打包分享"的惯例)
|
||||||
|
- 文件名只用 `[a-z0-9-]`(建议与 MOD `id` 同名:`hello-world.json`)——**勿用中文/大写/空格/点**(点留给后缀)
|
||||||
|
|
||||||
|
### 第 2 步:复制这个文件
|
||||||
|
|
||||||
|
最小可用 MOD(直接存为 `hello-world.json`):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"id": "hello-world", // 唯一 id([a-z0-9-],2~32 长度)
|
||||||
|
"name": "你好世界", // 显示名(随便写中文)
|
||||||
|
"version": "1.0.0", // 版本(改动必升——读档重装按 id+version 匹配)
|
||||||
|
"schema": 1, // 范式版本(当前 = 1;缺失会提示但可加载)
|
||||||
|
"author": "你的名字", // 建议填(玩家辨识)
|
||||||
|
"description": "我的第一个 MOD:家族来了一位卖豆腐的老伯。",
|
||||||
|
"kind": "content", // content|data|rules(仅设置页归类展示)
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`data` 为空 = 这个 MOD 什么都不会改**。想改世界的活往下看 §3~§9.6,往 `data` 里加键。
|
||||||
|
|
||||||
|
### 第 3 步:装上看效果
|
||||||
|
|
||||||
|
游戏内 → **设置 → MOD 层 → 扫描目录** → 列表里出现 `hello-world.json` → 点 **安装** → 提示"MOD 安装完成",插件表格里出现 `MOD·你好世界 v1.0.0 · content · 你的名字`。
|
||||||
|
|
||||||
|
**想立刻看到效果**?把这个最小模版换一个真的能动的版本(§3 完整的"卖豆腐的"事件示例),或直接装官方范例(§13),新开一局去"天下"面板找痕迹。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MOD 包结构总览
|
||||||
|
|
||||||
|
### 2.1 顶层字段
|
||||||
|
|
||||||
|
| 字段 | 必填 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | ✅ | string | 全局唯一标识(`[a-z0-9-]`,2~32 长度)——安装/卸载/读档重装全靠它 |
|
||||||
|
| `name` | ✅ | string | 中文显示名 |
|
||||||
|
| `version` | ✅ | string | 语义化版本(改内容必升——读档重装要求同 id 同版本) |
|
||||||
|
| `schema` | 建议 | number | 范式版本 = 1(写 `1` 即可;缺失会有提示,不影响安装) |
|
||||||
|
| `author` | 建议 | string | 作者名 |
|
||||||
|
| `description` | 可选 | string | 一句话介绍(显示在列表) |
|
||||||
|
| `gameVersion` | 可选 | string | 为哪个游戏版本写的(仅展示) |
|
||||||
|
| `kind` | 可选 | string | `content`(剧情/风物)\| `data`(新实体)\| `rules`(数值规则)——仅分类展示 |
|
||||||
|
| `depends` | 可选 | string[] | 依赖的 MOD id(先装依赖,未装 → 拒绝) |
|
||||||
|
| `conflicts` | 可选 | string[] | 相斥的 MOD id(对方已装 → 拒绝) |
|
||||||
|
| `data` | ✅ | object | **全部功能键的入口**(下表) |
|
||||||
|
|
||||||
|
### 2.2 `data` 十五口全景
|
||||||
|
|
||||||
|
| 分组 | 口 | 章节 | 一句话 | 卸载后 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 世界侧 | `events` | §3 | 随机剧情事件(抽签进族中日常/家国大事/乾坤造化) | 从事件池摘除 |
|
||||||
|
| 世界侧 | `npcs` | §4 | 世界模板(新档世界生成候选家) | 模板池摘除 |
|
||||||
|
| 世界侧 | `worldgen` | §5 | 词库(姓氏/地域/风格/后缀) | 词库池按来源摘除 |
|
||||||
|
| 世界侧 | `calamities` | §6 | 灾因(旱灾/涝灾/蝗灾/疫病/兽潮/寒潮 之外的自家灾年) | 灾因表按名摘除 |
|
||||||
|
| 世界侧 | `worldNum` | §9 | 世界常数覆写(供需/池深/潮汐/灾年概率……) | 复位 |
|
||||||
|
| 配方 | `pills` | §7 | 丹方(丹房等级门槛+材料+灵石) | 丹方表摘除 |
|
||||||
|
| 配方 | `forges` | §8 | 铸器配方(灵矿+兽核+灵石→兵刃) | 配方表摘除 |
|
||||||
|
| 内容面 | `items` | §9.5 | 物品表新条目(资源/材料/丹药/符箓/灵酿/兵器六类) | 从数据包摘除 |
|
||||||
|
| 内容面 | `techniques` | §9.5 | 功法(含四流派效果) | 注入条目按 id 摘除 |
|
||||||
|
| 内容面 | `buildings` | §9.5 | 建筑(产线/功能类) | 从建筑表摘除 |
|
||||||
|
| 内容面 | `posts` | §9.5 | 职事(坊市入息等加成) | 摘除 |
|
||||||
|
| 内容面 | `aspirations` | §9.5 | 志向(修为/战斗/市贸/产田/生育/声望) | 摘除 |
|
||||||
|
| 内容面 | `formations` | §9.5 | 阵型(攻击/防御/撤退伤) | 摘除 |
|
||||||
|
| 内容面 | `traits` | §9.5 | 性格(修为/突破/战力/魅力/售价加成) | 摘除 |
|
||||||
|
| 内容面 | `wonders` | §9.7 | 世界奇观类型(零 rng 种子派生选取——自定义奇观加入年首判定池) | 摘除 |
|
||||||
|
| 微调 | `patch` | §9.6 | 对**已存在**表项改数值(mult 或 add)——不改结构只改数 | 按插件归属复原 |
|
||||||
|
|
||||||
|
### 2.3 一个"五脏俱全"的示意壳(啃编排用)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"id": "shiyi-quan",
|
||||||
|
"name": "示意全包",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"schema": 1,
|
||||||
|
"author": "示例",
|
||||||
|
"kind": "data",
|
||||||
|
"data": {
|
||||||
|
"events": [ /* §3 */ ],
|
||||||
|
"npcs": [ /* §4 */ ],
|
||||||
|
"worldgen": { /* §5 */ },
|
||||||
|
"calamities": [ /* §6 */ ],
|
||||||
|
"pills": [ /* §7 */ ],
|
||||||
|
"forges": [ /* §8 */ ],
|
||||||
|
"worldNum": { /* §9 */ },
|
||||||
|
"items": [ /* §9.5 */ ],
|
||||||
|
"techniques": [ /* §9.5 */ ],
|
||||||
|
"buildings": [ /* §9.5 */ ],
|
||||||
|
"posts": [ /* §9.5 */ ],
|
||||||
|
"aspirations": [ /* §9.5 */ ],
|
||||||
|
"formations": [ /* §9.5 */ ],
|
||||||
|
"traits": [ /* §9.5 */ ],
|
||||||
|
"wonders": [ /* §9.7 */ ],
|
||||||
|
"patch": [ /* §9.6 */ ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 能力一:事件(events)
|
||||||
|
|
||||||
|
**这是什么**:剧情触发器。安装后进入游戏随机事件池,按 `category` 分成三类池子,每月抽签;命中的事件弹出一道"选择"(Modal),你为玩家准备 1~4 个选项,每个选项有不同的**效果**。
|
||||||
|
|
||||||
|
**什么时候发生**:事件池抽签(日常/大事/造化各自独立);`weight` 越大越容易被抽到;`once` 事件发生后永不再来;`cooldown` 是冷却月数。
|
||||||
|
|
||||||
|
### 3.1 事件字段表
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"id": "ev-senlin", // 唯一 id(禁止与内置/已装 MOD 撞)
|
||||||
|
"name": "森林深处", // 显示名
|
||||||
|
"category": "daily", // daily(族中日常) | major(家国大事) | fate(乾坤造化)
|
||||||
|
"weight": 10, // 池内绝对权重 0~100(建议 3~25)
|
||||||
|
"once": false, // 是否全游戏只发生一次(默认 false)
|
||||||
|
"cond": { // 触发条件(全部满足才可能被抽到;整段可选)
|
||||||
|
"minYear": 3, // 年份下限
|
||||||
|
"minRep": 20, // 声望下限
|
||||||
|
"minBuilding": { "id": "danfang", "level": 2 } // 建筑等级要求
|
||||||
|
},
|
||||||
|
"text": "……", // 正文(事件描述,写小说家一点)
|
||||||
|
"options": [ /* §3.2 */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | string | 唯一(小写字母/数字/连字符/下划线) |
|
||||||
|
| `name` | string | 标题(显示在事件框顶部) |
|
||||||
|
| `category` | string | `daily` 族中日常(常见)\| `major` 家国大事(中频)\| `fate` 乾坤造化(稀有) |
|
||||||
|
| `weight` | number | 池内权重(0~100)。同池内按比例抽签 |
|
||||||
|
| `once` | boolean | true=一生一次(之后 `completedEvents` 不再抽) |
|
||||||
|
| `cond` | object | 触发条件(见 §3.3 全表——不写=无条件) |
|
||||||
|
| `text` | string | 正文 |
|
||||||
|
| `options` | array | 选项(1~4 个) |
|
||||||
|
|
||||||
|
### 3.2 选项数组
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "换!", // 选项按钮文字
|
||||||
|
"hint": "灵草-5,聚气丹+1", // 选项小字提示(建议写人话)
|
||||||
|
"eff": { "res": { "lingcao": -5 }, "pillGain": { "pill-qiyuan": 1 } },
|
||||||
|
"note": "(可写一段结算后的话)" // 可选
|
||||||
|
},
|
||||||
|
{ "label": "送他些斋饭", "hint": "灵石-10,声望+2", "eff": { "res": { "stones": -10 }, "rep": 2 } },
|
||||||
|
{ "label": "赶走", "hint": "无", "eff": {} }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 触发条件(cond)全表
|
||||||
|
|
||||||
|
一个事件可以同时用多个键(全部满足才算"命中");也支持组合:
|
||||||
|
|
||||||
|
| 键 | 写法 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `minYear` / `maxYear` | `10` / `50` | 年份窗口(避免开局就出某些事件) |
|
||||||
|
| `minGeneration` | `3` | 代数下限 |
|
||||||
|
| `minHeadRealm` | `"core"` | 家主最低境界(取值见 §4.1 境界表) |
|
||||||
|
| `minBuilding` | `{ "id": "danfang", "level": 2 }` | 某建筑至少到某级 |
|
||||||
|
| `minRep` / `maxRep` | `20` / `80` | 声望窗口 |
|
||||||
|
| `minResource` | `{ "id": "lingcao", "n": 100 }` | 库存下限 |
|
||||||
|
| `minAdult` / `maxAdult` | `4` / `8` | 成年(16+)人数窗口 |
|
||||||
|
| `minMembers` | `10` | 总人口下限 |
|
||||||
|
| `eligibleAdult` | `3` | 可婚配(未婚成年)下限 |
|
||||||
|
| `relation` | `{ "npcId": "n-danxin", "gt": 30 }` | 与某天下家关系(gt/lt 可只用其一) |
|
||||||
|
| `flag` | `{ "key": "someKey", "eq": true }` | 家族旗标匹配(详见 §3.5 旗标) |
|
||||||
|
| `hasMeditation` | `true` | 有一人正在闭关(冥想) |
|
||||||
|
| `noWarForYears` | `2` | 未被袭击至少 N 年 |
|
||||||
|
| `minTechCount` | `3` | 已藏功法数下限 |
|
||||||
|
| `all` / `any` / `not` | `[Cond]` / `[Cond]` / `Cond` | 组合词:全满足/任一满足/取反(可嵌套) |
|
||||||
|
|
||||||
|
### 3.4 选项效果(eff)全表
|
||||||
|
|
||||||
|
| 键 | 类型 | 写法示例 | 含义 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `res` | object | `{ "stones": 50, "lingcao": -3 }` | 资源增减(§10 资源 id;数量可为负——**减不得超库存**) |
|
||||||
|
| `rep` | number | `4` | 家族声望 ± |
|
||||||
|
| `relation` | object | `{ "n-danxin": 5, "n-nulei": -8 }` | 对某几家 +关系-关系(世界家 id §10) |
|
||||||
|
| `addBuilding` | string | `"lingtian"` | 白得一座建筑(§10 建筑 id) |
|
||||||
|
| `memberBy` | object | `{ "by": "heal", "target": "youngest" }` | 对族人施加随机/指定一人的效果(§3.5 详解) |
|
||||||
|
| `pillGain` | object | `{ "pill-qiyuan": 1 }` | 直接获得丹药(§10 丹 id) |
|
||||||
|
| `mission` | string | `"m-anmoku"` | 触发该秘境任务(§10 秘境 id) |
|
||||||
|
| `raid` | object | `{ "npcId": "n-nulei" }` | 某天下家来犯(袭击事件) |
|
||||||
|
| `flag` | object | `{ "myFlag": true }` | 设家族旗标(可被 cond 读取/其它事件调用) |
|
||||||
|
| `techniqueChance` | number | `0.3` | 概率获得一部功法(0~1) |
|
||||||
|
| `artifactChance` | number | `0.3` | 概率获得一件兵器 |
|
||||||
|
| `addTech` | string | `"t-baihui"` | 直接获得指定功法(§10 功法 id) |
|
||||||
|
| `feisheng` | object | `{ "stay": false }` | 触飞升剧情(stay=false 是强者飞升/留下) |
|
||||||
|
| `tournament` | boolean | `true` | 触发五年大比 |
|
||||||
|
| `formation` | string | `"vanguard"` | 指定阵型 |
|
||||||
|
| `apprentice` | object | `{ "build": true }` | 触发求经台/寄读 |
|
||||||
|
| `trib` | object | `{ "memberId": "x1", "mode": "rash" }` | 指定成员渡劫(rash 硬渡/guard 护法/delay 压制) |
|
||||||
|
|
||||||
|
> **一个选项可同时写多个效果键**——例如 `{ "res": {...}, "rep": 4, "relation": {...} }` 三合一。无效果就 `{}`。
|
||||||
|
|
||||||
|
### 3.5 族人效果(memberBy)详解
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"memberBy": { "by": "heal", "target": "youngest", "n": 5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
| `by`(做什么) | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `exp` | 修为增加(`n` 为月量) |
|
||||||
|
| `wound` | 受伤(`n` 为伤量) |
|
||||||
|
| `heal` | 治疗(`n` 为疗量) |
|
||||||
|
| `breakthrough` | 触发一次突破判定 |
|
||||||
|
| `fatal` | 直接陨落(慎用!) |
|
||||||
|
| `repGain` | 个人修善声望 |
|
||||||
|
| `inspire` | 士气/励族 |
|
||||||
|
| `loot` | 个人得货 |
|
||||||
|
| `madness` | 心智失乱 |
|
||||||
|
| `genius` | 天骄之资(稀有待遇) |
|
||||||
|
|
||||||
|
| `target`(对谁) | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `random` | 随机一人 |
|
||||||
|
| `head` | 家主 |
|
||||||
|
| `youngest` | 最年幼 |
|
||||||
|
| `oldest` | 最年长 |
|
||||||
|
| `highestPerception` | 悟性最高 |
|
||||||
|
| `highestPower` | 战力最高 |
|
||||||
|
| `highestFortune` | 气运最高 |
|
||||||
|
| `all` | 全体族人 |
|
||||||
|
|
||||||
|
### 3.6 完整示例(可直接用)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"id": "ev-doufu",
|
||||||
|
"name": "豆腐老伯",
|
||||||
|
"category": "daily",
|
||||||
|
"weight": 12,
|
||||||
|
"once": true,
|
||||||
|
"cond": { "minYear": 2, "minRep": 10 },
|
||||||
|
"text": "天还没亮,一名挑着豆腐担的老伯在祖宅门外叫卖。他说祖上受过先辈恩惠,今日来谢——不收钱,只求族人尝尝他的手艺。",
|
||||||
|
"options": [
|
||||||
|
{ "label": "尝一碗", "hint": "灵草+3,声望+2",
|
||||||
|
"eff": { "res": { "lingcao": 3 }, "rep": 2 } },
|
||||||
|
{ "label": "请他进屋喝口茶", "hint": "灵石-20,封藏一部功法(20% 得《百草经》)",
|
||||||
|
"eff": { "res": { "stones": -20 }, "techniqueChance": 0.2 } },
|
||||||
|
{ "label": "送他几册农书", "hint": "灵草-5,信任+1",
|
||||||
|
"eff": { "res": { "lingcao": -5 }, "flag": { "doufuTrust": true } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `flag` 用法进阶:A 事件设 `flag: { "doufuTrust": true }` → B 事件条件写 `cond: { "flag": { "key": "doufuTrust", "eq": true } }`——做成"系列剧情"。
|
||||||
|
|
||||||
|
> **例行检查**:事件 id 别撞内置(内置事件在 `src/renderer/game/data/events.ts`——最省事办法是懒人前缀:`ev-mymod-`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 能力二:世界模板(npcs)
|
||||||
|
|
||||||
|
**这是什么**:新档世界生成时的**候选家族池**。worldgen 每次开档按池子凑 4~8 家(名册随机)——你的模板**有机会**被选中入世,一旦选中,它将作为 NPC 家族参与天下演化(贸易/结盟/袭扰/换代/覆灭)。
|
||||||
|
|
||||||
|
**什么时候生效**:只在新世界生成时(模板会参与)。开档后天下家已定——不会再变(世界形成为档记录)。
|
||||||
|
|
||||||
|
### 4.1 字段表
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"id": "n-cangwu", // 唯一(同样 [a-z0-9-])
|
||||||
|
"name": "苍梧氏", // 家名
|
||||||
|
"region": "南岭苍梧", // 地域(显示/影响区域风味)
|
||||||
|
"style": "剑修世家", // 风格(世界词库 styles 也可以是候选;这里写死)
|
||||||
|
"desc": "苍梧山下养剑人家,锋芒内敛而傲骨铮铮。",
|
||||||
|
"leaderRealm": "foundation", // 家主境界(见下表)
|
||||||
|
"initialPower": 180, // 起始实力 60~400
|
||||||
|
"powerGrowth": [3, 9], // 每年实力成长区间 [min,max]
|
||||||
|
"sells": ["pill-qiyuan"], // 入市售品(§10 资源/成品 id)
|
||||||
|
"buys": ["lingcao", "beastcore"] // 入市求购
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | ✅ | 唯一 |
|
||||||
|
| `name` | ✅ | 中文家名 |
|
||||||
|
| `region` | ✅ | 地域(新贵世代可用 woldgen `regions` 词库——你家取现成地域也行) |
|
||||||
|
| `style` | ✅ | 风格(标注型;剑修/丹道/商盟/兵修…) |
|
||||||
|
| `desc` | ✅ | 简介 |
|
||||||
|
| `leaderRealm` | ✅ | 家主境界:`mortal` 凡 / `qi` 炼气 / `foundation` 筑基 / `core` 金丹 / `nascent` 元婴 / `spirit` 化神 |
|
||||||
|
| `initialPower` | ✅ | 实力 60~400 |
|
||||||
|
| `powerGrowth` | ✅ | [min,max] 年成长(min ≤ max) |
|
||||||
|
| `sells` | 可选 | 售品(API:入市即贸易表) |
|
||||||
|
| `buys` | 可选 | 求购(同上) |
|
||||||
|
|
||||||
|
### 4.2 境界取值(全游戏通用 `REALMS`)
|
||||||
|
|
||||||
|
| 值 | 中文 | 年寿元 |
|
||||||
|
|---|---|---|
|
||||||
|
| `mortal` | 凡人 | 75 |
|
||||||
|
| `qi` | 炼气 | 150 |
|
||||||
|
| `foundation` | 筑基 | 220 |
|
||||||
|
| `core` | 金丹 | 340 |
|
||||||
|
| `nascent` | 元婴 | 520 |
|
||||||
|
| `spirit` | 化神 | 850 |
|
||||||
|
|
||||||
|
(对外只写大境界;小境界炼气 0~8 层随数值演化由引擎管理。)
|
||||||
|
|
||||||
|
### 4.3 完整示例
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"id": "n-juemai", "name": "绝脉氏", "region": "北原寒漠", "style": "兵修门阀",
|
||||||
|
"desc": "荒原上以猎妖为生的铁血之家,活人于死地,出手绝不留情。",
|
||||||
|
"leaderRealm": "core", "initialPower": 210, "powerGrowth": [3, 10],
|
||||||
|
"sells": ["shoupi", "beastcore"], "buys": ["lingcao", "lingkuang"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 能力三:生成词库(worldgen)
|
||||||
|
|
||||||
|
**这是什么**:四组**名字/风味池**——开档时世界从池子抽组合(家姓×地域×风格×后缀),决定"天下名卷"的自然涌现感。
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"worldgen": {
|
||||||
|
"styles": ["丹道世家", "商盟", "猎妖门"], // 家族风格词
|
||||||
|
"regions": ["江南烟雨畔", "北疆雪原"], // 地域词
|
||||||
|
"fams": ["吴", "江", "雨"], // 姓氏
|
||||||
|
"suffixes": ["宗", "门", "寨"] // 家名后缀(家族名 = 姓+后缀)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 池 | 含义 | 示例 |
|
||||||
|
|---|---|---|
|
||||||
|
| `styles` | 家族风格(影响言行/售品倾向设定) | `丹道世家`、`剑修世家`、`渔隐门` |
|
||||||
|
| `regions` | 地域(影响区域灵势) | `江南烟雨畔`、`吴淞江湾` |
|
||||||
|
| `fams` | 姓氏 | `吴`、`江` |
|
||||||
|
| `suffixes` | 家名后缀 | `宗`、`门` |
|
||||||
|
|
||||||
|
> 自己造的词会在**下一新档**出现;老档历史不变。喜欢"天下永远有"某个家族?那就用 §4 npcs 模板。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 能力四:灾因(calamities)
|
||||||
|
|
||||||
|
**这是什么**:灾年(旱灾/涝灾/蝗灾/疫病/兽潮/寒潮 是世界内置的六种)——你的 MOD 可以**追加**新的灾因,进世界灾签循环:年首抽灾 → 6 个月减产/困扰 → 散去。
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"calamities": [{
|
||||||
|
"name": "瘴雨", // 灾名(唯一——与内置灾因同名会被拒)
|
||||||
|
"brief": "瘴雨连绵,田间霉蛀——灵植减产。", // 灾年提示语
|
||||||
|
"family": { "lingcao": 0.75 }, // 家族减产乘(0.1.33 起真正计入月产——须填)
|
||||||
|
"effect": { "lingcao": -0.2 } // 天下市场冲击(世界池现货下调)
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | ✅ | 灾名(唯一;DEFAULT_CALAMITIES 里已有六种:旱灾/涝灾/蝗灾/疫病/兽潮/寒潮——同名被拒) |
|
||||||
|
| `brief` | 建议 | 灾年提示文案(历史记录/新闻引用) |
|
||||||
|
| `family` | 建议 | 家族侧减产乘:`{ 资源id: 乘数 }`(0.7=打七折;≤1 减产,>1 增产——兽潮就可以整点猎获) |
|
||||||
|
| `effect` | 建议 | 世界池侧冲击:`{ 资源id: 增减量 }`(世界市场实际库存起落) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 能力五:丹方(pills)
|
||||||
|
|
||||||
|
**这是什么**:丹房的炼丹配方。丹房等级、原料、灵石齐了之后玩家可以在"工坊"页点炼丹(0.1.30 起物品表接口已开放,背包/卖出/服丹全走物品表)。
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"pills": [{
|
||||||
|
"output": "pill-biandan", // 成品丹 id(**必须同时在 §9.5 items 立名**!)
|
||||||
|
"name": "便丹",
|
||||||
|
"danfangLevel": 1, // 丹房门槛(1~5)
|
||||||
|
"stones": 20, // 灵石成本
|
||||||
|
"lingcao": 15, // 灵草成本
|
||||||
|
"beastcore": 0, // 兽核成本
|
||||||
|
"desc": "出门在外备着。"
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `output` | ✅ | 丹 id(`pill-` 前缀惯例;且**物品表必须有同名 item**——见红牌) |
|
||||||
|
| `name` | ✅ | 丹名 |
|
||||||
|
| `danfangLevel` | ✅ | 丹房等级门槛(1~5,超出提示 warning) |
|
||||||
|
| `stones` / `lingcao` / `beastcore` | ✅ | 三原料成本(非负) |
|
||||||
|
| `desc` | ✅ | 丹说明(物品表 desc 同步显示) |
|
||||||
|
|
||||||
|
> 🔴 **0.1.33 红牌(血泪教训)**:`output` 必须同时写进 §9.5 的 `items`!产物没有物品表定义 = **幽灵物品**——炼出来在背包里看不见、卖不掉、不能用。标准的配合写法:
|
||||||
|
> ```jsonc
|
||||||
|
> "pills": [{ "output": "pill-biandan", "name": "便丹", "danfangLevel": 1, "stones": 20, "lingcao": 15, "beastcore": 0, "desc": "出门在外备着。" }],
|
||||||
|
> "items": [{ "id": "pill-biandan", "name": "便丹", "kind": "pill", "basePrice": 60, "desc": "出门在外备着。", "icon": "丹" }]
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**内置丹方参照**(照这个量级走):
|
||||||
|
|
||||||
|
| 丹方 | 丹房 | 材料 | 售价 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `pill-qiyuan` 聚气丹 | 1 级 | 灵草15 | 45 |
|
||||||
|
| `pill-ningyuan` 凝元丹 | 2 级 | 灵草25+兽核4 | 150 |
|
||||||
|
| `pill-pojing` 破境丹 | 3 级 | 灵草40+兽核10 | 480 |
|
||||||
|
| `pill-heling` 和灵丹 | 2 级 | 灵草20+兽核8 | 120 |
|
||||||
|
| `pill-xudan` 虚元丹 | 4 级 | 灵草50+兽核16 | 320 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 能力六:铸器配方(forges)
|
||||||
|
|
||||||
|
**这是什么**:自造兵器的配方(灵矿+兽核+灵石→武器),无建筑门槛——数值即门槛(成本低=品质低)。
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"forges": [{
|
||||||
|
"output": "weapon-gangchi", // 兵器 id(**同样必须先立名 items**)
|
||||||
|
"name": "罡齿刃",
|
||||||
|
"stones": 800,
|
||||||
|
"lingkuang": 16,
|
||||||
|
"beastcore": 5,
|
||||||
|
"desc": "咬罡崩铁,斩山如泥。"
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `output` | ✅ | 兵器 id;**红牌同 §7**:物品表必须有同名 item |
|
||||||
|
| `name` | ✅ | 兵名 |
|
||||||
|
| `stones` / `lingkuang` / `beastcore` | ✅ | 三成本(非负) |
|
||||||
|
|
||||||
|
> 🔴 **红牌同 §7**——范例(注意 `items` 里 `artifact` 类 + `basePrice`):
|
||||||
|
> ```jsonc
|
||||||
|
> "forges": [{ "output": "weapon-gangchi", "name": "罡齿刃", "stones": 800, "lingkuang": 16, "beastcore": 5, "desc": "咬罡崩铁。" }],
|
||||||
|
> "items": [{ "id": "weapon-gangchi", "name": "罡齿刃", "kind": "artifact", "basePrice": 900, "desc": "咬罡崩铁。", "icon": "罡" }]
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**武器强度表**(`ARTIFACT_POWER`——决定装备后的战力百分比):
|
||||||
|
|
||||||
|
| 兵器 | 战力 |
|
||||||
|
|---|---|
|
||||||
|
| `weapon-fan` 凡器 | +10% |
|
||||||
|
| `weapon-qi` 法器 | +25% |
|
||||||
|
| `weapon-yu` 玉澜刃 | +40% |
|
||||||
|
| `weapon-lingwoon` 灵纹刃(官范例) | +35% |
|
||||||
|
| `weapon-ling` 灵器 | +50% |
|
||||||
|
| `weapon-fa` 法宝 | +90% |
|
||||||
|
|
||||||
|
> 自己铸兵器的战力怎么定?**不能直接给定**(铸器表没有战力字段)——想给玩家武器设满配,请在 §11 用 `ARTIFACT_POWER` 内部映射(MOD 不可覆写内置映射;**建议在 items 定义时参考上表量级定价**:法器 450/灵器 1400/法宝 3600)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 能力七:世界数值(worldNum)
|
||||||
|
|
||||||
|
**这是什么**:对**世界常数表**(WORLDSIM)做覆写——调供需、池深、潮汐、灾年概率、战争疲劳等。白名单机制:**只认常数表内键**,乱写的键被忽略(不报错不算错)。
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"worldNum": {
|
||||||
|
"worldSupplyRate": 0.02, // 世界月供给上浮(更富饶的世界)
|
||||||
|
"calamityChance": 0.1, // 灾年概率调低(温和天下)
|
||||||
|
"newSecretChance": 0.06 // 新秘境现身率上浮
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 全键列表(取值均为"数值",单位见注释)
|
||||||
|
|
||||||
|
**市场/池**(0.1.32 起实体化——详见 README"市场实体化"):
|
||||||
|
|
||||||
|
| 键 | 默认 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `marketDriftRate` | 0.03 | 月波动幅度 |
|
||||||
|
| `marketRebalance` | 0.025 | 弱锚定强度(**勿调回 0.1+**——锚死供需失去意义) |
|
||||||
|
| `priceFloor` / `priceCeil` | 0.55 / 2.3 | 相对基准价上下限 |
|
||||||
|
| `worldSupplyRate` | 0.02 | 世界月供给 |
|
||||||
|
| `worldDemandRate` | 0.015 | 世界常驻需求 |
|
||||||
|
| `npcTradeRate` | 0.6 | NPC 贸易频率 |
|
||||||
|
| `tradeFloorPct` | 0.35 | **拒单线**(池比 < 此值玩家停手断供) |
|
||||||
|
| `poolFloorPct` | 0.25 | **池底**(市场永不清零;**低手线分离**——别改回同值,0.1.20 语义已废) |
|
||||||
|
| `tradeCeilPct` | 8 | 池上限 |
|
||||||
|
|
||||||
|
**潮汐/灵气**:
|
||||||
|
|
||||||
|
| 键 | 默认 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tideCycle` | 96 | 潮汐周期(月) |
|
||||||
|
| `tideSupplySpan` | 0.35 | 潮汐对供给的摆动 |
|
||||||
|
| `longWave` / `longWaveSpan` | 120 / 0.08 | 十年长波周期/振幅 |
|
||||||
|
| `tideMin` / `tideMax` | 0.65 / 1.35 | 潮汐上下限 |
|
||||||
|
| `secretRecover` / `secretRecoverHi` | 2 / 5 | 秘境灵气月恢复(常/潮高) |
|
||||||
|
| `secretConsume` | 6 | 单次探索消耗 |
|
||||||
|
|
||||||
|
**时代/灾**:
|
||||||
|
|
||||||
|
| 键 | 默认 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tempEraK` | 0.8 | 世界温度对 era 转移强度(下界已钳 0.05——不再零/负) |
|
||||||
|
| `greatNewbornChance` | 0.06 | 年首新贵补位率(乱世×2) |
|
||||||
|
| `allianceGriefRaid` | 0.012 | 盟约连坐的袭扰概率 |
|
||||||
|
| `calamityChance` | 0.18 | 灾年概率 |
|
||||||
|
| `calamityMonths` | 6 | 灾年持续月数 |
|
||||||
|
| `npcEventChance` | 0.05 | NPC 互攻年首诗签率 |
|
||||||
|
| `newsEvery` / `newsKeep` | 24 / 120 | 天下快讯节奏/留档条数 |
|
||||||
|
|
||||||
|
**0.1.34 新键(战争疲劳/断供/新秘境)**:
|
||||||
|
|
||||||
|
| 键 | 默认 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `warFatigueRise` | 0.008 | 每次乱象(互攻)累积的战争疲劳 |
|
||||||
|
| `warFatigueTempK` | 0.6 | 疲劳对世界温度的冷降幅度(打不动即荒年) |
|
||||||
|
| `shortageLine` | 0.7 | 断供警戒线(池/基准低于此起计) |
|
||||||
|
| `shortageMonths` | 3 | 连续月数触发断供应急(价 ×1.5 + 广播) |
|
||||||
|
| `shortagePrice` | 1.5 | 断供价乘 |
|
||||||
|
| `newSecretChance` | 0.04 | 年首新秘境现身概率(乱世×2) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9.5 内容面扩展(物品/功法/建筑/职事/志向/阵型/性格)
|
||||||
|
|
||||||
|
七口全纯数据;**内置条目不可覆盖**(同名 id 被跳过并 warn——防覆盖红线),只加新键。凡"新实体"口都自动进入游戏各面(背包/市场/建/配/装等)。
|
||||||
|
|
||||||
|
### 9.5.1 物品(items)——六类生态
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"items": [
|
||||||
|
{ "id": "pill-biandan", "name": "便丹", "kind": "pill", "basePrice": 60, "desc": "出门在外备着。", "icon": "丹" },
|
||||||
|
{ "id": "weapon-gangchi", "name": "罡齿刃", "kind": "artifact", "basePrice": 900, "desc": "咬罡崩铁。", "icon": "罡" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | ✅ | 唯一(`[a-z0-9-]`) |
|
||||||
|
| `name` | ✅ | 名字 |
|
||||||
|
| `kind` | ✅ | 六类:`resource` 资源 / `material` 材料 / `pill` 丹药 / `talisman` 符箓 / `brew` 灵酿 / `artifact` 兵器 |
|
||||||
|
| `basePrice` | ✅ | 基准价(市场行情按此波动;>0) |
|
||||||
|
| `desc` | ✅ | 描述 |
|
||||||
|
| `icon` | ✅ | **单字汉字**(印章字符——游戏内无 emoji 字体,别写 emoji/多字) |
|
||||||
|
|
||||||
|
**六类生态说明**(每类都有"出口"才闭环):
|
||||||
|
|
||||||
|
| kind | 游戏内行为 |
|
||||||
|
|---|---|
|
||||||
|
| `resource` | 基础资源(世界池/生产/市场全链) |
|
||||||
|
| `material` | 素材(入世界池参与呼吸+NPC 贸易;灵玉/灵木/兽皮等天然材料即此列) |
|
||||||
|
| `pill` | 丹药(服丹加修为——需丹方生产) |
|
||||||
|
| `talisman` | 符箓(开战胜耗:风符×1.12 / 山符×1.15+受创减半——金钟罩零 rng 结算) |
|
||||||
|
| `brew` | 灵酿(渡劫受重创后自动回气+5) |
|
||||||
|
| `artifact` | 兵器(成员可装备——战力按 ARTIFACT_POWER 表) |
|
||||||
|
|
||||||
|
> **四同步纪律**(0.1.32,想进世界循环的 *material/resource* 必须 4 件事齐):① 本表定义 → ② POOL_BASE(世界池基准)→ ③ MARKET_IDS(市场循环键位)→ ④ worldgen marketOffset(开档偏移)。只做完① = "躺仓库"(能背包不能世界呼吸)。丹药/符箓/灵酿/兵器**不需要**世界池(玩家自制/市场购)。
|
||||||
|
|
||||||
|
### 9.5.2 功法(techniques)——含四流派效果
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"techniques": [
|
||||||
|
{ "id": "t-lingwu", "name": "灵雾心法", "grade": 1, "element": "水", "path": "丹修",
|
||||||
|
"expBonus": 0.11, "powerBonus": 0.12, "desc": "雾气绕身,吐纳绵长。" },
|
||||||
|
{ "id": "t-xiashuang", "name": "霞霜剑歌", "grade": 2, "element": "水", "path": "剑修",
|
||||||
|
"expBonus": 0.14, "powerBonus": 0.17,
|
||||||
|
"critChance": 0.1, "guardBonus": 0.1, "tribBonus": 0.04, "secretBonus": 0.1,
|
||||||
|
"desc": "霞染霜锋,剑歌行处皆败。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | ✅ | 唯一(`t-` 惯例) |
|
||||||
|
| `name` | ✅ | 功法名 |
|
||||||
|
| `grade` | ✅ | 品阶 1~4(影响市价/掉率/求学经库边际) |
|
||||||
|
| `element` | ✅ | `木/金/水/火/土/雷`——**元素×时令共鸣**(春木夏火秋金冬水,季修 ×1.04;土系恒应 ×1.02 四季皆宜) |
|
||||||
|
| `path` | ✅ | 流派标签:剑修/体修/丹修/阵修/符修/御灵…(自由填) |
|
||||||
|
| `expBonus` | ✅ | 修炼加速(建议 0.08~0.28) |
|
||||||
|
| `powerBonus` | ✅ | 战力加成(建议 0.05~0.38) |
|
||||||
|
| `critChance` | 可选 | **暴击**:战斗阵亡率压制(建议 0.05~0.12) |
|
||||||
|
| `guardBonus` | 可选 | **受创减免**:战斗受伤折减(建议 0.15~0.3) |
|
||||||
|
| `tribBonus` | 可选 | **渡劫加成**:渡劫成功率 +(建议 0.03~0.09) |
|
||||||
|
| `secretBonus` | 可选 | **秘境收获**:任务掉落乘放大(建议 0.1~0.2) |
|
||||||
|
| `desc` | ✅ | 描述 |
|
||||||
|
|
||||||
|
> 新功法自动进:藏书阁可购(grade≤3 才入市)/ 秘境掉落 / 拍卖 / 求经台。
|
||||||
|
> **平衡提醒**(validateMod 检查):`expBonus ≤ 0.5`、`powerBonus ≤ 1`(超了 warning)。
|
||||||
|
> 参阅内置 23 本量级(§10 速查表功法表——照档位编,别造出"二品武功碾压天阶")。
|
||||||
|
|
||||||
|
### 9.5.3 建筑(buildings)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"buildings": [
|
||||||
|
{ "id": "b-chalu", "name": "茶园", "icon": "茗", "desc": "清茶待客,悦人悦己。", "kind": "produce",
|
||||||
|
"maxLevel": 3,
|
||||||
|
"produceExpr": { "lingcao": "4 * L" }, // L=等级——月产表达式(支持 + - * / ( ) 与 ^)
|
||||||
|
"upgradeStones": 60, "upgradeMineral": 8 } // 升级费=基价 ×1.6^(L-1) / 灵矿×L
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | ✅ | 唯一 |
|
||||||
|
| `name` / `icon` / `desc` | ✅ | 显示(icon 单字汉字符号) |
|
||||||
|
| `kind` | ✅ | `produce`(月产)\| `function`(加成——function 型建议少碰,数据面以 produceExpr 为主) |
|
||||||
|
| `maxLevel` | ✅ | 最高等级 |
|
||||||
|
| `produceExpr` | produce 必填 | 月产表达式:`{ 资源id: "公式" }`——公式中 `L`=当前等级;数学符号(+ - * / ( ) ^)——**0.1.36 起由纯解析器 arith 求值(CSP 禁 eval——表达式无脚本化能力,字母/函数名一律视为非法),引擎内置与 MOD 表达式同一求值器** |
|
||||||
|
| `upgradeStones` | ✅ | 升级灵石基价(1.6^(L-1) 递增) |
|
||||||
|
| `upgradeMineral` | ✅ | 升级灵矿(×L) |
|
||||||
|
|
||||||
|
> 建筑自动出现在领地页可建/可升。**消耗式建筑**(如符箓坊:扣符纸+灵木产符箓——0.1.33 引入)**不要**用 produceExpr 泛化——泛化只做纯产出。
|
||||||
|
|
||||||
|
### 9.5.4 职事(posts)——5 效果
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"posts": [
|
||||||
|
{ "id": "p-chashi", "name": "茶司", "effectType": "marketIncome", "value": 0.06, "max": 1, "desc": "坐市司货,坊市入息 +6%。" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | ✅ | 唯一 |
|
||||||
|
| `name` | ✅ | 职事名 |
|
||||||
|
| `effectType` | ✅ | **真实取值**:`expAll`(全体修炼)/ `marketIncome`(坊市入息)/ `battlePower`(战力)/ `meditation`(闭关效率)/ `familyRep`(家族声望) |
|
||||||
|
| `value` | ✅ | 效果强度(小数:0.06=+6%) |
|
||||||
|
| `max` | ✅ | 可设人数上限 |
|
||||||
|
| `desc` | ✅ | 描述 |
|
||||||
|
|
||||||
|
### 9.5.5 志向(aspirations)——6 效果
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"aspirations": [
|
||||||
|
{ "id": "a-chafan", "name": "茶饭闲人", "desc": "偶有茶会,心神宁和。", "effectType": "cult", "value": 0.04 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| `effectType` 真实取值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `cult` | 修炼加速 |
|
||||||
|
| `battle` | 战力(注意:是 **battle** 不是 combat——0.1.31 早期文档曾误写) |
|
||||||
|
| `market` | 市场经济(坊市/贸易) |
|
||||||
|
| `field` | 灵田产降(农务) |
|
||||||
|
| `offspring` | 生育/姻缘 |
|
||||||
|
| `rep` | 声望 |
|
||||||
|
|
||||||
|
### 9.5.6 阵型(formations)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"formations": [
|
||||||
|
{ "id": "f-qingyun", "name": "青云阵", "icon": "云", "desc": "攻守兼重。", "atk": 1.05, "def": 1.05, "retreatWound": 1 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
`atk`(攻倍)/ `def`(防倍)/ `retreatWound`(撤退伤乘):围绕 1.0 设计(0.9~1.15 区间是合理动态)。
|
||||||
|
|
||||||
|
### 9.5.7 性格(traits)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"traits": [
|
||||||
|
{ "id": "t-yaxi", "name": "雅细", "desc": "心细如发,炼丹养生。", "bonus": { "expBonus": 0.03, "charmBonus": 0.05, "danger": 0 } }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`bonus` 键(映射到引擎的 TraitDef 平铺字段):
|
||||||
|
|
||||||
|
| bonus 键 | 含义 | 建议量级 |
|
||||||
|
|---|---|---|
|
||||||
|
| `expBonus` | 修为加成 | 0.02~0.08 |
|
||||||
|
| `breakBonus` | 突破成功率 | 0.02~0.06 |
|
||||||
|
| `windBonus` | 战力 | 0.02~0.1 |
|
||||||
|
| `charmBonus` | 魅力(姻缘/招募) | 0.02~0.1 |
|
||||||
|
| `priceMult` | 售价加成 | 0.02~0.08 |
|
||||||
|
| `danger` | 风险(性情偏激/祸患 0~1) | 0~1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9.6 数值 patch 层
|
||||||
|
|
||||||
|
**这是什么**:不改表结构、不加新实体——只对**已存在**表项改数值(打包/改值/回滚全自动)。适合"风味包调平衡"(比如你的主题包想让聚气丹便宜两成)。
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"patch": [
|
||||||
|
{ "target": "technique", "id": "t-qinglian", "mult": 0.9 }, // 青莲剑诀全部数值 ×0.9
|
||||||
|
{ "target": "item", "id": "pill-qiyuan", "add": -10 } // 聚气丹基价 -10
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `target` | ✅ | `technique` / `item`(`building` 记于文——表项结构复杂,建议不碰) |
|
||||||
|
| `id` | ✅ | 表内已有条目 id |
|
||||||
|
| `mult` | 二选一 | 乘数(0.5~2,越界 → 安装拒绝) |
|
||||||
|
| `add` | 二选一 | 实数增量 |
|
||||||
|
|
||||||
|
**规则**:
|
||||||
|
1. `mult`/`add` **二选一**,同给 → 拒绝;
|
||||||
|
2. 只对**已定义数值键**生效:功法改 `expBonus/powerBonus/critChance/guardBonus/tribBonus/secretBonus`;物品改 `basePrice`(钳 ≥1);
|
||||||
|
3. **目标字段缺失 → warn 日志**(`[patch] 功法 xx 无字段 yy`)——绝不清空吞;
|
||||||
|
4. **仅新开档生效**;卸载复原(0.1.35 按插件归属——只弹本家,互不侵扰);
|
||||||
|
5. 与 `worldNum` 区别:`worldNum` 改**全局世界常数**,`patch` 改**表项数据**——可并用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 速查表:内置 id 大全
|
||||||
|
|
||||||
|
### 10.1 资源/材料 id(res / sells / buys / family / effect 通用)
|
||||||
|
|
||||||
|
- **资源**:`stones`(灵石)· `lingcao`(灵草)· `lingkuang`(灵矿)· `beastcore`(兽核)
|
||||||
|
- **材料七种**(0.1.32):`lingyu` 灵玉 · `lingmu` 灵木 · `shoupi` 兽皮 · `lingguo` 灵果 · `linglu` 灵露 · `dansha` 丹砂 · `fuzhi` 符纸
|
||||||
|
- **符箓**:`talisman-feng` 风符(开战全体 ·1.12)· `talisman-shan` 山符(开战 ·1.15 + 受创减半)
|
||||||
|
- **灵酿**:`brew-niang`(渡劫伤后回气 +5)
|
||||||
|
|
||||||
|
**材料来源速记**(与玩法真实对应):灵玉 ← 玄冰谷/古战阵掉落+市场;灵木 ← 灵木坊(建筑)+暗墨林;兽皮 ← 灵兽园/古战阵/兽潮灾年;灵果 ← 灵果园;丹砂/灵露/符纸 ← 市场购买为主。
|
||||||
|
|
||||||
|
### 10.2 丹药 id(pillGain / res 可用 / 丹方)
|
||||||
|
|
||||||
|
| id | 丹名 | 丹房 | 材料 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `pill-qiyuan` | 聚气丹 | 1 级 | 灵草15 | 炼气期主修 |
|
||||||
|
| `pill-ningyuan` | 凝元丹 | 2 级 | 灵草25+兽核4 | 筑基以上 |
|
||||||
|
| `pill-pojing` | 破境丹 | 3 级 | 灵草40+兽核10 | 渡劫药力(tribBoost) |
|
||||||
|
| `pill-heling` | 和灵丹 | 2 级 | 灵草20+兽核8 | 0.1.33 新丹方 |
|
||||||
|
| `pill-xudan` | 虚元丹 | 4 级 | 灵草50+兽核16 | 0.1.33 新丹方 |
|
||||||
|
|
||||||
|
### 10.3 兵器 id(forge output / 掉落 / 装备)
|
||||||
|
|
||||||
|
| id | 兵名 | 战力 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `weapon-fan` | 凡器 | +0.10 | 基本位 |
|
||||||
|
| `weapon-qi` | 法器 | +0.25 | 蕴灵 |
|
||||||
|
| `weapon-ling` | 灵器 | +0.50 | 有灵 |
|
||||||
|
| `weapon-fa` | 法宝 | +0.90 | 罕世 |
|
||||||
|
| `weapon-yu` | 玉澜刃 | +0.40 | 0.1.33 灵木铸器 |
|
||||||
|
| `weapon-lingwoon` | 灵纹刃 | +0.35 | 官方范例包产出(0.1.35 立名修复) |
|
||||||
|
|
||||||
|
### 10.4 功法 id(23 本全列)
|
||||||
|
|
||||||
|
| id | 名称 | 品阶 | 流派效果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `t-qinglian` | 青莲剑诀 | 1 | — |
|
||||||
|
| `t-canglei` | 苍雷炼体功 | 1 | — |
|
||||||
|
| `t-xuanyue` | 玄月斩灵诀 | 1 | — |
|
||||||
|
| `t-lieliuxin` | 离火心经 | 1 | — |
|
||||||
|
| `t-houtu` | 厚土镇岳功 | 1 | — |
|
||||||
|
| `t-baihui` | 百草经 | 1 | — |
|
||||||
|
| `t-zidian` | 紫电惊雷诀 | 1 | crit 0.10 |
|
||||||
|
| `t-xuanbing` | 玄冰凝甲功 | 1 | guard 0.20 |
|
||||||
|
| `t-zhenyu` | 阵纹天书 | 2 | — |
|
||||||
|
| `t-hanyu` | 寒玉心法 | 2 | — |
|
||||||
|
| `t-liehuo` | 烈火焚空诀 | 2 | — |
|
||||||
|
| `t-wanjian` | 万剑归一诀 | 2 | — |
|
||||||
|
| `t-yushen` | 驭兽真解 | 2 | — |
|
||||||
|
| `t-danding` | 丹鼎护道妙典 | 2 | trib 0.06 |
|
||||||
|
| `t-fengyu` | 风遁青冥诀 | 2 | secret 0.20 |
|
||||||
|
| `t-shanling` | 山灵镇岳录 | 2 | guard 0.20 + crit 0.05 |
|
||||||
|
| `t-shiling` | 时灵应候诀 | 2 | secret 0.10 |
|
||||||
|
| `t-geling` | 化灵经 | 3 | — |
|
||||||
|
| `t-jiuxiao` | 九霄龙吟诀 | 3 | — |
|
||||||
|
| `t-tianya` | 天涯孤鸿法 | 3 | — |
|
||||||
|
| `t-lanhai` | 澜海听涛经 | 3 | trib 0.09 + secret 0.15 |
|
||||||
|
| `t-qiankun` | 乾坤七星阵 | 4 | — |
|
||||||
|
| `t-xiantian` | 先天混元功 | 4 | — |
|
||||||
|
|
||||||
|
### 10.5 建筑 id(14 座)
|
||||||
|
|
||||||
|
| id | 名 | 类型 | 产物/说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `lingtian` | 灵田 | produce | 灵草(月产 L 倍) |
|
||||||
|
| `yaoyuan` | 药园 | produce | 灵草 + 兽核(3 级起) |
|
||||||
|
| `lingkuang` | 灵矿 | produce | 灵矿 |
|
||||||
|
| `fangshi` | 坊市 | produce(stones) | 灵石月入 |
|
||||||
|
| `danfang` | 丹房 | function | 炼丹(craftChance 随级) |
|
||||||
|
| `cangshu` | 藏书阁 | function | 功法购入/求经 |
|
||||||
|
| `juling` | 聚灵阵 | function | 修炼 expBonus |
|
||||||
|
| `yanwu` | 演武场 | function | 战力 + 悟位积累(0.1.34) |
|
||||||
|
| `dongfu` | 洞府 | function | 闭关 |
|
||||||
|
| `zongci` | 宗祠 | function | 祭祖 |
|
||||||
|
| `lingshou` | 灵兽园 | produce | 兽核(概率) |
|
||||||
|
| `lingguoyuan` | 灵果园 | produce | 灵果(0.1.32) |
|
||||||
|
| `lingmufang` | 灵木坊 | produce | 灵木(0.1.32) |
|
||||||
|
| `fuzhifang` | 符箓坊 | produce(消耗式) | 耗符纸×2+灵木×2/级→风符;叁级→山符(0.1.33) |
|
||||||
|
|
||||||
|
### 10.6 天下家 id(relation / raid)
|
||||||
|
|
||||||
|
`n-xuanying` 玄影沈氏 · `n-danxin` 丹心木氏 · `n-sihai` 四海王氏 · `n-nulei` 怒雷祝氏——**新档 worldgen 随机凑 4~8 家,你家不一定全有**;写不存在的也不报错,只是关系变化落空。新贵家族 id 是 `n-new-…`(世界新贵+你的模板家混出),写不得——只用上面四家作为"保底名册"。
|
||||||
|
|
||||||
|
### 10.7 秘境 id(missionId)
|
||||||
|
|
||||||
|
| id | 名 | 风险 | 掉落特征 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `m-anmoku` | 暗墨林 | 0.5 | 灵草/兽核 |
|
||||||
|
| `m-xuangu` | 玄冰谷 | 0.65 | 灵矿/灵玉/兽皮 |
|
||||||
|
| `m-guzhan` | 古战阵 | 0.75 | 灵矿/灵玉/兽皮 |
|
||||||
|
| `m-lingshan` | 灵鹫山 | 0.85 | 灵草/兽核(高难) |
|
||||||
|
| `m-tiankeng` | 天坑 | 0.9 | 兽核/高阶战利 |
|
||||||
|
|
||||||
|
> **0.1.34 起世界年首会灵机现身 `m-wonder-*` 新秘境**(随 seed/年号派生,档档不同)——引擎双向回退,你的 `sendMission` 对 wonder id 也完全可用;放心引用或观望。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 排错 FAQ
|
||||||
|
|
||||||
|
**Q1:扫描后列表里没有我的文件?**
|
||||||
|
- 文件名后缀必须是 `.json` 或 `.cotymod`;文件名 `[a-z0-9-]`(勿用中文文件名)
|
||||||
|
- 确认路径(§1 第 1 步);扫描后没有提示"已扫描 mods 目录"?——就是路径错了
|
||||||
|
|
||||||
|
**Q2:点安装提示"MOD 解析失败:缺失/非法的 MOD id"?**
|
||||||
|
- 缺 `id` 字段或 `id` 用了中文/大写/`.`;按 §2 修。**另外**:如果你把**存档导出文件**误放进了 mods 目录,也会报这个错——那是存档,请删除或移到别处(或用游戏内"导入存档")。
|
||||||
|
|
||||||
|
**Q3:提示"MOD 安装被拒绝:事件 id 冲突:xxx"?**
|
||||||
|
- 你的事件 `id` 与内置/已装 MOD 重复——改个不重名的 id 即可(用 `ev-你的前缀-` 最省心)。
|
||||||
|
|
||||||
|
**Q4:装上了但游戏里没见反应?**
|
||||||
|
- **先确认是"新开档"**:MOD 只影响新世界生成与装载;已开的档不会变。
|
||||||
|
- 事件类:检查 `category` 与 `weight`(`daily`/`major`/`fate` 各自独立抽签,weight 太小+类别太稀有会很少见)。
|
||||||
|
- 模板类:新开档后去"天下"面板看"群雄谱"。
|
||||||
|
- 物品/功法/建筑类:直接去对应页面(背包/藏书阁/领地)核对。
|
||||||
|
|
||||||
|
**Q5:卸载后世界还变着?**
|
||||||
|
- 世界状态(已发生的演化)是"既成事实"——卸载只还原 MOD 造成的**机制**(词库/灾因/配方/数值/注入条目),不会回滚开档后世界自然发生的事。这与游戏规则一致:**装 MOD 改变世界的历史**。如果希望干净世界,用没装 MOD 时的新档。
|
||||||
|
|
||||||
|
**Q6:读档提示"缺失 MOD"?**
|
||||||
|
- MOD 的文件被移动/删了,或游戏版本变化导致版本不符。把文件放回 `mods` 目录(同 id 同版本)再进档即可。
|
||||||
|
|
||||||
|
**Q7:我的 MOD 与别人冲突?**
|
||||||
|
- 检查两个 MOD 是否用了相同事件 id / 灾因名 / 丹方 output / 模板 id / 功法 id——任意撞名 → 后装者会被拒/被忽略(同 id 功法撞内置 → `[MOD:xxx]` 日志提示跳过)。用 `depends`/`conflicts` 声明关系(§2)。
|
||||||
|
|
||||||
|
**Q8:`patch` 改了没生效?**
|
||||||
|
- 查三事:① `target`/`id` 是否精确表内项;② `mult` 是否 0.5~2(越界会**拒绝**而非忽略);③ 目标字段是否存在(功法没定义某效果键 → `[patch] 功法 xx 无字段 yy` 日志。补上该效果字段再 patch 即可)。
|
||||||
|
- 注意 `patch` **只改表项**,世界常数用 `worldNum`;两者作用范围不同。
|
||||||
|
|
||||||
|
**Q9:带 `patch` / 新功法的 MOD,老档读档正常吗?**
|
||||||
|
- MOD 只作用于**新开档**;老档装载 MOD 只影响"此刻起"的机制拼接,数值 patch 不会追溯已建世界。想验证效果请新开档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 进阶:依赖、冲突与导出
|
||||||
|
|
||||||
|
### 12.1 depends / conflicts
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"depends": ["fenwu-ji"], // 必须先装「风物集」;未装 → 拒绝
|
||||||
|
"conflicts": ["old-pack"] // 与「old-pack」互斥;对方已装 → 拒绝
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 导出与分享
|
||||||
|
|
||||||
|
- 设置页 → MOD 层 → **导出当前 MOD 集**:把你已装的全部 MOD 打包成一个 `mod-bundle-export.cotymod`(合包)。
|
||||||
|
- 同伴把这个文件放进他们的 `mods` 目录,安装时**逐包**装好(0.1.28 起完全闭环)。
|
||||||
|
- 你的 `.cotymod` 文件就是打包好的 MOD——直接发文件即可,无需任何构建工具。
|
||||||
|
|
||||||
|
### 12.3 读档重装纪律
|
||||||
|
|
||||||
|
- MOD 状态随存档保存;重装要求**同 id 同版本**(版本差 → 跳过并提示"缺失")。
|
||||||
|
- 所以**改动 MOD 务必升 `version`**(如 1.0.0 → 1.0.1),否则老档可能静默跳过新包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 官方范例(可拆解学习)
|
||||||
|
|
||||||
|
游戏内置两枚官方包(设置 → MOD 层 → 内置仓库,一键安装):
|
||||||
|
|
||||||
|
| 包 | 教会你 |
|
||||||
|
|---|---|
|
||||||
|
| **风物集**(`fenwu-ji`) | 词库注入 + 灾因(含 brief 文案)——"做地域风物" |
|
||||||
|
| **战备解军**(`zhanbei-xv`) | 铸器配方 + worldNum 覆写——"做数值平衡" |
|
||||||
|
|
||||||
|
拆解方法:安装后 → 导出当前 MOD 集 → 用文本编辑器打开 `mod-bundle-export.cotymod` → 所有字段即你所见。
|
||||||
|
|
||||||
|
**风物集** data 实样:
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"worldgen": { "fams": ["吴", "江", "雨"], "regions": ["江南烟雨畔", "吴淞江湾"], "styles": ["风雅世家", "渔隐门"] },
|
||||||
|
"calamities": [{ "name": "瘴雨", "brief": "瘴雨连绵,田间霉蛀——灵植减产。", "family": { "lingcao": 0.75 }, "effect": { "lingcao": -0.2 } }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**战备解军** data 实样:
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"forges": [{ "output": "weapon-lingwoon", "name": "灵纹刃", "stones": 700, "lingkuang": 15, "beastcore": 5, "desc": "纹蕴灵光,锋锐无俦。" }],
|
||||||
|
"worldNum": { "worldSupplyRate": 0.018, "worldDemandRate": 0.014 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> 注意战备解军示范了**配方产物先在 items 立名**(灵纹刃物品表 0.1.35 修复过的正确姿势)——你说它名字里有"灵"有"纹"却不给物品表,它就真的只是"铸器单子上画出的一把虚影"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.7 世界奇观类型扩展(wonders) · 0.1.37 新增
|
||||||
|
|
||||||
|
0.1.37 引入**世界奇观**——年首种子派生判定(零 rng 消耗),4 种内置奇观:灵气涌潮 / 古遗迹现身 / 天降祥瑞 / 灵脉枯竭。
|
||||||
|
|
||||||
|
MOD 可以注册**自定义奇观类型**,加入年首种子派生选取池:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"wonders": [
|
||||||
|
{
|
||||||
|
"kind": "star_fall", // 唯一 id(不可与内置 tide_surge/ancient_ruin/heaven_bless/qi_wane 冲突)
|
||||||
|
"name": "星陨天落", // 中文名称
|
||||||
|
"desc": "天外陨星坠落——灵气激荡,宝材出世。", // 描述文本
|
||||||
|
"duration": 12, // 持续月数(0 = 一次性)
|
||||||
|
"effect": "tide_up", // 持续效果:tide_up=灵气增益 / tide_down=灵气衰减 / none=无
|
||||||
|
"oneshot": "loot" // 一次性效果:bless=灾年消弭+声望+5 / loot=随机资源获取 / none=无
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 取值 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `kind` | ✓ | 唯一字符串 | 奇观类型 id(与内置+已注册不冲突) |
|
||||||
|
| `name` | ✗ | 字符串 | 中文名(缺省"异变") |
|
||||||
|
| `desc` | ✗ | 字符串 | 描述(缺省"天地异变,灵机暗涌。") |
|
||||||
|
| `duration` | ✗ | 0~60 | 持续月数(缺省 0=一次性) |
|
||||||
|
| `effect` | ✗ | `tide_up`/`tide_down`/`none` | 持续月度效果(缺省 `none`) |
|
||||||
|
| `oneshot` | ✗ | `bless`/`loot`/`none` | 出现时一次性效果(缺省 `none`) |
|
||||||
|
|
||||||
|
> **零 rng 保证**:奇观判定使用种子哈希派生(`seed ^ year ^ npcSuccessions`),不消耗 `rng.next()` 序列——金钟罩确定性不受影响。
|
||||||
|
> **因果事件**:事件选项 `eff.karma` 可正可负——MOD 事件天然支持因果变动(无需额外口)。
|
||||||
|
> **worldNum 白名单**:`wonderChance`(0.08)/ `karmaDecayRate`(0.1)/ `karmaHighThreshold`(30)/ `karmaLowThreshold`(-20)可经 `data.worldNum` 覆写。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**一句话总结**:MOD = 一个有 `data` 字段的 JSON。事件管"剧情"、模板管"新家族"、词库管"名字概率"、灾因管"灾年种类"、丹方铸器管"炼丹造兵"、worldNum 管"世界脾性"、内容面八口管"新物品/功法/建筑/职事/志向/阵型/性格/奇观"、patch 管"已有表项微调"。装/卸/导出全在设置页,读档重装全自动。去写吧——这个世界等你的素材。
|
||||||
|
|
||||||
|
> 0.1.32 玩注:资源循环已是六类生态(资源/材料/丹药/符箓/灵酿/兵器),新物产入池即参与世界呼吸与 NPC 贸易——写材料口别怕世界吃不进去。
|
||||||
|
> 0.1.33 闭环注:符箓开战即耗(风符×1.12/山符×1.15+减伤——零 rng 结算),灵酿渡劫伤后自动回气;丹方/铸器产出物 ALL 必须先在物品表立名(否则幽灵物品不可见不可卖)。
|
||||||
|
> 0.1.34 功法注:四流派效果 crit/guard/trib/secret 全 optional——缺省零行为,金钟罩安全;元素时节共鸣 4 季同频(土系恒应)。
|
||||||
|
> 0.1.35 涤荡注:无存档迁移(游戏未发布——旧档随版本演进失效属预期);`patch` 层按插件归属回滚;validate 覆盖 §12 全部规则。
|
||||||
|
> 0.1.37 因果奇观注:奇观零 rng(种子派生选取池含 MOD 扩展);`eff.karma` 天然支持;`wonderChance`/`karmaDecayRate`/`karmaHighThreshold`/`karmaLowThreshold` 可经 worldNum 覆写;飞升事件 `ev-ascension-*` 前缀保护防劫持。
|
||||||
|
|
||||||
|
<sub>⚔️ 本文档随游戏版本更新(0.1.41)。字段若有新增,此处先行。</sub>
|
||||||
+128
-51
@@ -15,82 +15,159 @@
|
|||||||
1. 世代血脉(婚育/继嗣/谱系)——传承是主叙事;
|
1. 世代血脉(婚育/继嗣/谱系)——传承是主叙事;
|
||||||
2. 修炼纵深(境界/功法/悟道/渡劫)——成长是爽点;
|
2. 修炼纵深(境界/功法/悟道/渡劫)——成长是爽点;
|
||||||
3. 经营与外交(建筑/贸易/联姻/劫掠)——决策供给;
|
3. 经营与外交(建筑/贸易/联姻/劫掠)——决策供给;
|
||||||
4. 叙事编织(编年史/列传/百年报告/定鼎)——情绪收束。
|
4. 叙事编织(编年史/列传/百年报告/定鼎/天下史观)——情绪收束。
|
||||||
|
- **0.1.14+ 的独特命题**:世界不是背景板——市场有真库存(供需呼吸/NPC 上桌/玩家回写)、NPC 有战略姿态(守成/扩张/隐忍/结盟)与恩怨网(蝴蝶效应)、时代有大势弧(盛世/乱世/末法与景气双向闭环)。**天下兴衰与家族命运互为因果**,这是本作与"个人养成+结算"类游戏的本质区别。
|
||||||
|
|
||||||
## 二、版本时间线(每迭代的"为什么")
|
## 二、版本时间线(每迭代的"为什么")
|
||||||
|
|
||||||
| 版本 | 主题 | 当时为什么做 |
|
| 版本 | 主题 | 当时为什么做 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 0.1.0 | 核心循环 | 把"修仙+家族+经营+战斗"四系统全部立起来,形成可玩闭环 |
|
| 0.1.0-0.1.5 | 核心循环 → 养成闭环 → 审计 → 百年定局 → 人物志 | 立四系统、给成长闭环、上线前清毒、补"收束"、破成员同质化 |
|
||||||
| 0.1.1 | 养成闭环+存档安全 | 指婚/装备/祭祖/回档:管理自主度决定模拟器粘性 |
|
| 0.1.6-0.1.8 | 时轮与造化 → 仙门之钥 → 万物皆是插件 | 时间/随机统一(架构地基)→ 门面/能力卡/数据包 → 全部系统插件化 |
|
||||||
| 0.1.2 | 宗族气韵 | 职事/悟道/谱系/碑录:让"传承"有可视载体 |
|
| 0.1.9-0.1.11 | 百年报告/迁移 v2 → 长线决断 → 收官打磨 | 情感复盘 → 全灭崩溃数值重建 → 二次审计+UX 三件套 |
|
||||||
| 0.1.3 | 深度审计 | 刚做完新系统必有一批缺陷,上线前清毒 |
|
| 0.1.12 | 金碧山河 | 画面情绪价值:SVG 素材、动效系统、舞美 |
|
||||||
| 0.1.4 | 百年定局 | 模拟器缺"收束":四维评分+十二称号+春秋原 |
|
| 0.1.13 | 交付与回档 | 工艺修复 + 存储直连回档(玩家资产安全) |
|
||||||
| 0.1.5 | 人物志 | 成员同质化:志向/渡劫/列传/契合让"人"有戏 |
|
| **0.1.14** | **大重构:GameEngine 门面 + WorldSim** | 引擎/UI 边界彻底划清:世界自进化(市场/NPC/快讯)作为独立 sim 层 |
|
||||||
| 0.1.6 | 时轮与造化 | 时间与随机从"散装"走向"统一"(GameClock/RngHub)——**架构地基** |
|
| 0.1.15 | 深度审计 | 重构后必有一批缺陷;修 restore 双注册/引擎锁死等 7 P0 |
|
||||||
| 0.1.7 | 仙门之钥 | 门面/能力注册表/数据包:对外暴露与可拔插 |
|
| 0.1.16 | 资源闭环 × 世情入味 | "灵石无压力、资源无出口"——修为经济化/炼丹概率化/铸器/灵脉收益 |
|
||||||
| 0.1.8 | 万物皆是插件 | 把全部系统统一进插件协议(Manifest/依赖/卸载/追踪型钩子) |
|
| 0.1.17 | 天下为市 | 市场从"幽灵振荡器"变"真库存":玩家与 NPC 都能撼动行情——**世界对玩家单边透明死穴** |
|
||||||
| 0.1.9 | 百年报告与发布 | 情感复盘(报告)+工程收口(迁移管线/发布哨兵) |
|
| 0.1.18 | 时代绘卷 + 特效入引擎 | 世界缺大周期——世纪弧 era;特效从 UI 正则猜事件→引擎语义出口(onFx) |
|
||||||
| 0.1.10 | 长线决断 | **审计发现全灭崩溃**:修炼太慢+寿元短+渡劫误压——数值重建 |
|
| 0.1.19 | 八方风雨 + 孤立归并 | NPC 从统计物理→行为战略(stance 四姿态);超卖潮/盟约连坐/干预天下 |
|
||||||
| 0.1.11 | 收官打磨 | 二次审计(引擎8+UI11缺陷歼灭)+ UX 三件套(警讯/引导/格式化) |
|
| 0.1.20 | 万世无谬 | 第二轮全量审计:**事件闸竞态**(raid 落地率 18%)/era 无延续档/潮汐符号反向——三处"看着合理其实错" |
|
||||||
|
| 0.1.21 | 大势流转 | 线程定论(0.05ms 单线程无需 Worker);era↔景气**双向闭环**(自演进最高形态);渲染管线修 |
|
||||||
|
| 0.1.22 | 灯下春秋 | 实拍双 bug:Modal 被 tabs z 层压制(Portal 根治);年报打断自动速度;姓氏选择假功能 |
|
||||||
|
| 0.1.23 | 生灭千秋 + 插件生态 | "世界会死人也会新生"(declineYears/覆灭/新贵);插件从测试框架向真生态(持久化/双闸/回滚/公共面) |
|
||||||
|
| 0.1.24 | 寰宇初构 | **世界种子生成器**:NPC 势力数量告别定死(4~8 家随机/开局恩怨/era/市场偏移);插件时轮锚点+模板注入 |
|
||||||
|
| 0.1.25 | 乾坤一统 | **MOD 系统首版**(MOD=插件一等子类,全 JSON 外部包);修 0.1.24 遗留:worldGen 不含 npcs 落档(读档软瘫) |
|
||||||
|
| 0.1.26 | 墨规清辉 | MOD **规范范式**(MOD_GUIDE/validateMod/权威冲突检测);灾因归一化注册表(MOD 灾年入循环);页面提亮(暗因:.dim 3.4:1 对比度) |
|
||||||
|
| 0.1.27 | 活水长流 | **180 年模拟实测驱动**:生灭零触发/全图 900 打卡——门槛/era 调制/顶峰衰;修为↔市场回环;世鉴;测试规模 1054→**2000** |
|
||||||
|
| 0.1.28 | 稳石 | **第三轮全量审计**:词库池无回滚(重放漂移)/新贵不落档(读档僵尸)/校验死代码化/MOD 读档静默消失/快讯展示冻结/导出单行道——六项「书面承诺与实际不符」全歼 + 指纹补盲 |
|
||||||
|
| 0.1.29 | 续命 | **第四轮审计**(3seed×183 年实测):170 年零出生活人老人院——续代四刀(生育窗/天年 200/族外婚注入/年胎 cap 稳态 25);传薪 224 连发;敌强度 5000+ 碾压 |
|
||||||
|
| 0.1.30 | 兼济 | 人口-世界成本(人多粮少)/世界关注度(匹夫无罪);MOD 生态成型(内置仓库/冲突矩阵/预览/API 清单);**测试 2014→3006** |
|
||||||
|
| 0.1.31 | 万物皆可 MOD | 内容面总攻(物品/功法/建筑表达式 DSL/职事/志向/阵型/性格七口)+ MOD 类型(kind)+ 数值松弛(ERA_DURA/REGION_TIDE 聚合零漂);测试 3006→4006 |
|
||||||
|
| 0.1.32 | 万宝琳琅 | 物品六类化(含符箓/灵酿新用途——武备消耗/回气);新材料七种入世界循环(灵玉灵木兽皮灵果入池、新建筑灵果园灵木坊);生产泛化(produceTable 全键);**测试 4006→5009** |
|
||||||
|
| 0.1.33 | 闭环·流光 | 物品生态闭环(符箓开战生效/僵尸物品归位/灾年入产——A1-A3 高危断链修复);引擎止血 15 项(tribBoost 清零/assistAlly 先校验/回声空池守卫/版本透传/noAutoCreate 等);UI 动效打磨(Modal 淡入/DriftLayer 节点清理/季节色对月/rAF 停帧节能);**测试 5009→5230** |
|
||||||
|
| 0.1.34 | 万象归元 | 功法流派化(crit/guard/trib/secret 四效果 ×6 新功 ×元素时节共鸣)+ 世界自演化二层(warFatigue 温度冷降/换代恩义半衰/断供应急×1.5/新秘境种子派生);插件卡 12→14(worldsim 观察/tech 功法);MOD 功法效果口+patch 数值层(0.5~2 mult 校验/卸载复原);**测试 5230→6000** |
|
||||||
|
| 0.1.35 | 荡涤·向新 | 全量审计 17 缺陷歼灭(wonder 全链/boss 释放/patch 归属/拒单门/衰减/transition);**兼容层整体铲除**:migrate 迁移链 55 行+版本戳 8 处+chronicle 死表+__state/seed 三元+normalize 兼容批 55 行(未发布不向下兼容——validateLoadedState 结构校验替代)+domain 5 兼容 optional 收紧;**测试 6000→7000** |
|
||||||
|
| 0.1.36 | CSP 根因 | 三问题处置:成员弹框双排名(壳/内容双渲染已修);**推进崩溃+定制白屏同一根因——打包 renderer CSP 禁 unsafe-eval,bonusOf/produceExpr/MOD 表达式三处 Function() 求值全被拦**→ 新建 arith.ts 纯解析器(令牌化+递归下降)全量替换(零 eval);toast 根错可见+出险双保险;ErrorBoundary 防白屏;dev:web 浏览器通道;存储真链测试(MetonaSqlark memory 方言);**测试 7007→7011** |
|
||||||
|
| 0.1.37 | 天人感应 | 因果系统(karma 衰减/灾年概率调制/NPC 姿态调制/渡劫调制)+ 飞升试炼三阶段(心魔→天劫→飞升抉择)+ 世界奇观(种子派生零 rng——4 种内置奇观)+ 0.1.37 金钟罩基线重算;**测试 7011→7030** |
|
||||||
|
| 0.1.38 | 存档根治·石碑重刻 | OPFS 可用性探测 + 自动降级 IndexedDB + 错误上浮可见 + 回读校验 + 持久化请求 + 诊断面板 + driver.open 永久锁死修复 + exportSave open 补全 |
|
||||||
|
| 0.1.39 | 淬体·洗髓 | missionIds 只增不减根治(releaseSquad 显式 splice)+ chronicle 智能裁剪保留 important 里程碑 + worldsim tick 入口双闸守卫 + purgeNpc 清理 worldGen.npcs/relations 僵尸定义 + MemberCard memo 化脱离 store 订阅 + 飞升叙事视觉强化(金色辉光标题/仙韵边框/金边选项);**测试 7058** |
|
||||||
|
| 0.1.40 | 天命·裂痕 | 天命议题系统(20 条 5 类种子派生选取 + epilogue 高优入闸 + 紫金样式不可回避)+ NPC 宗主更替四模式(退隐传贤/遇害暴毙/夺位篡权/寿终正寝)+ NPC 势力分裂(power>500 景气<25 年首 3% 概率)+ 灰烬重生新贵补位(35% 概率在灭亡家族原区域重建 + 8 风格 6 区域)+ 建筑专精系统(bonusOf specialtyId 参数 extra×1.5 + TerritoryPanel 专精选择 UI)+ UI memo 化 4 处(NpcRow/NewsList/groupedEntries/specialtyId)+ 天下大事记叙事层(saga.ts buildSaga 8 类标签色系 + WorldPanel 展示区 + LegacyPanel 百年报告引用)|
|
||||||
|
| 0.1.41 | 天命·兑现 | 天命 flag 全量接线 15+ 项即时兑现(tideHarvest 灵草×1.5/mercenaryHired 战力×1.15/banditRecruit 入族/treasureExplored 古宝探索/artisanHired 丹房+10%/prodigyAdopted 天骄/envoyToAlliance 仙盟/coalitionFormed 联盟/successionElder 立长/successionWise 立贤/elderCouncil 长老会/advisoryBoard 参议等)+ NPC 演化种子派生(FNV1a 哈希 npcDerive 系列函数替代 evolveNpc/splitNpcDynasty/spawnNewbornDynasty/assessStance/pushNews 中所有 w.rng 调用——零主 rng 消耗)+ purgeNpc 确定性修复(不再 unregisterNpcDef——并行 World 实例隔离)+ 族史卷叙事层(chronicle-scroll.ts 编年史+大事记+天命决策串联)+ 时效 flag 自动清理(tideHarvestUntil/mercenaryUntil/envoyReturnUntil 到期自动删除+使者归来逻辑)+ UI 建筑专精加成预览(TerritoryPanel bonusOf 预览)+ LegacyPanel 族史卷视图(cscroll)+ 金钟罩重算(18 值全量更新)|
|
||||||
|
|
||||||
## 三、关键架构决策(及为什么)
|
## 三、关键架构决策(及为什么)
|
||||||
|
|
||||||
### 1. 纯 TS 引擎 + 确定性种子流
|
### 1. 纯 TS 引擎 + 确定性种子流
|
||||||
- `src/renderer/game/` 无 React/DOM import,vitest 直测。
|
- `src/renderer/game/` 无 React/DOM import,vitest 直测;引擎域零 `Math.random`(真实存在 0 处,唯一播种走 `RngHub.rollSeed()`)。
|
||||||
- sfc32 随机种,同 seed 全程重放;**金钟罩**=3 seed×3 档时长(47/100/180年)指纹固化。
|
- sfc32 随机种,同 seed 全程重放;**双金钟罩** = 3 seed × 3 档(47/100/180 年)× 两条长跑(冻结世界 + 自动 resolve 现实世界)= 18 指纹。
|
||||||
- **为什么**:模拟器平衡调参需要"可复现";金钟罩把"改坏了确定性"变成"一秒red"。
|
- **为什么**:平衡调参要"可复现";且 0.1.20 审计发现"直调冻结世界"的指纹锁的是事件流静止的假样本——补第二条 resolved 长跑才算真正锁住"现实"。
|
||||||
|
|
||||||
### 2. GameClock 七相位
|
### 2. GameClock 七相位 + clocks 注册表
|
||||||
- 月度 phase:production→aging→cultivation→missions→events→diplomacy→epilogue;
|
- 月度 phase:production→aging→cultivation→missions→events→diplomacy→epilogue;**新系统 = 注册一行,禁手改 `advanceMonth`**。
|
||||||
- 年首三钩子:婚配→岁贡→族簿。新系统=注册一行,禁手改 `advanceMonth`。
|
- 为什么:0.1.6 前每个 tick 体内散着调用,加系统必乱序→破坏随机→金钟罩红。
|
||||||
- **为什么**:0.1.6 前每个 tick 体内散着调用,加系统必乱序→破坏随机→金钟罩红。
|
|
||||||
|
|
||||||
### 3. 插件协议(0.1.8)
|
### 3. 事件闸(0.1.20 定稿)
|
||||||
- 万物皆是插件:系统/数据包/事件池统一 Manifest;`engine/pluginManager.ts` 安装管线(依赖校验/异常回滚/追踪型上下文)。
|
- `fire(w,id,priority)` 是 pending **唯一**写入入口;高优可顶替(被顶替者入 `eventQueue` 月月兑现);普通被占放弃。
|
||||||
- 三核心插件(Systems/Data/Events)protected 常驻。
|
- **为什么**:0.1.20 审计实证——raid 名义 0.03/月、实际落地 18%(11 命中仅 2 落地),日常事件每月占闸 70~86%,军事线被打 1/5 折扣。且传薪曾同年重复 12 次、渡劫直写会覆盖既有 pending。事件闸语义必须文档化。
|
||||||
- **为什么**:玩法内容增量的"可插拔出口";事件多池让第三方能注入事件。
|
|
||||||
|
|
||||||
### 4. 存档分层
|
### 4. WorldSim 世界自演进(0.1.14+ 的 4 层递进)
|
||||||
- metona-sqlark 在 renderer(OPFS)+ {Slots} {迁移链};**v1→v2 迁移管线**(0.1.9)。
|
- ① 市场实体化(真库存/供需/NPC/玩家回写)→ ② NPC 行为战略(姿态/景气/恩怨网/蝴蝶效应)→ ③ 世纪弧 era → ④ **era↔景气双向闭环**(世界温度反馈转移概率)。
|
||||||
- **为什么**:浏览器持久化天生平台限制;预置迁移链防未来表变炸档。
|
- **为什么**:模拟器世界"只单方向驱动玩家"会像纸片;双向因果(玩家动作→市场→时代→NPC 行为→玩家的外交与局势)才是"自进化"的真义。`worldsim-data.ts` 是数值唯一权威(含 POOL_BASE/ERO_CONF/REGION_TIDE),调平衡改它不动流程。
|
||||||
|
|
||||||
### 5. UI 惯例
|
### 5. 引擎-表现双通道
|
||||||
- zustand `revision++` 是刷新惯例;`facade` 必须 openState/startNewGame 赋值;
|
- 引擎语义事件经 `World.out`(WorldEventBus):onLog/onChronicle/onBattle/pending/gameOver/yearPaper/**onFx**(0.1.18 语义特效);UI `makeBus` 桥接(音效/日志/粒子)。特效发射零 rng(0.1.18 明训:**特效随机归渲染层,否则金钟罩崩**)。
|
||||||
- 警讯徽章(urgency)与引导任务(guide)做成 engine 侧纯函数,UI 只渲染。
|
|
||||||
|
### 6. 存档策略:normalize 即迁移
|
||||||
|
- CURRENT_SCHEMA=2,0.1.14+ 全部新字段靠 `normalizeGameState` 幂等兜底(worldSim/era/prosperity/stance/worldAnnals…)+ `loadState` 过 `migrateIfNeeded`(TOO_NEW 抛 MigrationError)。
|
||||||
|
- **为什么**:v1→v2 后才真正需要"转换"的只有一次;其余靠兜底。升 v3 无真实转换内容(每轮审计结论一致:**不升**)。
|
||||||
|
|
||||||
|
### 7. 世界种子与 MOD(0.1.24-0.1.26 新决策)
|
||||||
|
- **worldgen 独立派生 rng**:世界布局与玩法随机序列彻底分离——同 seed 世界可重放、且玩法金钟罩不被生成器移动(0.1.24 架构地基)。
|
||||||
|
- **MOD = 插件一等子类**:不发明第二套内容管线——模组 JSON→CotycPlugin→PluginManager(持久化/双闸/回滚捡现成);规范(0.1.26)落为 MOD_GUIDE + schema 校验;**零脚本**(安全+指纹双保险);**只影响新开档**(老档世界变形成档记录)。
|
||||||
|
- **"落档则永稳"纪律**:凡世界实例内容(NPC defs/关系网/era/市场偏移/ MOD 集)全进 `state.worldGen`——读过即稳;两次踩坑(0.1.24 defs 不落档→读档软瘫;0.1.25 修复)教会我们:**确定性世界必须"记其所生"**。
|
||||||
|
|
||||||
|
### 8. 人口与关注度(0.1.29-0.1.30 新平衡观)
|
||||||
|
- **续代是玩法命脉**:0.1.29 实测"170 年零出生"说明 0.1.1 以来内部通婚+固定生育窗是游戏最大暗伤——家族模拟器必须先"传得下去"再谈"传得好"。修复四刀(生育窗境界延长/天年 200/族外婚注入/年胎 cap)后人口稳态 ~25 成为设计常量。
|
||||||
|
- **规模即代价**:人口 >20 吃世界池(人多粮少)、战力超均值×2 举世瞩目(匹夫无罪)——"变大变强"天然有世界级反馈,防单边最优解。
|
||||||
|
|
||||||
|
### 8b. 物品六类生态观(0.1.32)
|
||||||
|
- **资源分层**(resource→material→成品)让"材料循环"成立:灵木→符纸→符箓(战斗消耗)、灵果→灵酿(回气)——每类物品都要有"入口(产/采/掉)→出口(用/卖/烧)",防止库存死水(同 0.1.16 灵石出口教训)。
|
||||||
|
- **新物产入池即活**:仅定义物品不够——只有进了 POOL_BASE/MARKET_IDS 才参与世界呼吸/NPC 贸易/灾年超卖(0.1.32 四同步(四新增键入池)是关键,否则"只躺仓库")。
|
||||||
|
- **消耗类物品的纪律**:符箓/灵酿效果必须**零 rng 消耗**(自动消耗实现——金钟罩安全);新增效果尽量走"已有结算点"(渡劫/战斗结束时挂钩)。
|
||||||
|
|
||||||
|
### 8e. CSP 红线(0.1.36 血案)
|
||||||
|
- **打包 renderer CSP 禁 unsafe-eval**:任何 `Function()/eval()` 字符串求值在开发模式(vite 无 CSP)可跑、打包版必炸——0.1.36 的"推进崩溃+定制白屏"双 bug 即此(bonusOf 每月月率+craft 渲染同源)。
|
||||||
|
- 军规:**新增表达式求值一律走 `data/arith.ts`**(白名单解析器——数字/L/四则/括号/幂/负号;字母/函数名→NaN 不抛);自查步骤:`rg "Function\(|eval\(" src/` 应为零。
|
||||||
|
- 教训:出 bug 先把**真实错误文字**拿到手(0.1.36 给 toast/ErrorBoundary 加了根错可见)——比猜快一个数量级。
|
||||||
|
|
||||||
|
### 8d. 未发布不兼容铁律(0.1.35)
|
||||||
|
- **游戏未正式发布 = 零存档迁移义务**:迁移链/版本戳/旧档兜底一票删除;防呆靠结构校验(保存=最新结构;读=字段断言)+ World 构造 normalize(仅防护语义)。
|
||||||
|
- **新档结构唯一权威**:新字段初始责任在 initSim/creation——normalize 只做正当监管(npcs 重注册/headId 修复/era 校验),不做「为旧档补字段」。
|
||||||
|
|
||||||
|
### 8c2. 世界生成/自演化侧红牌(0.1.34 B0 军规)
|
||||||
|
- **世界演化新增必走「世界状态 or 种子派生」**:任何 `rng.pick/int` 用于世界生成都会造成跨世界/跨跑污染(0.1.34 军规——模块级 EXTRA_MISSIONS 污染 + rng 序列漂移案);改种子哈希派生(year/npcSuccessions 异或)零 rng 零漂。
|
||||||
|
- **多世界同进程跑**:确定性测试模式 = 每世界独立 rng 快照;任何模块级可变表(SHORTAGE_WARNED/EXTRA_MISSIONS)必须世界化或派生。
|
||||||
|
- **MOD patch 层纪律**:mult 区间 0.5~2 校验先行、install 应用/uninstall 复原(patchUndo 栈)、仅新档生效(老档世界形成为档记录)。
|
||||||
|
|
||||||
|
### 8c. 闭环与动效两纪律(0.1.33)
|
||||||
|
- **闭环纪律**:物品/生产改动自检三问——①产出物有物品表定义吗?②消耗有真实入口吗?③数值真的被消费了吗?(A1 符箓不生效/A2 幽灵物品/A3 灾年算而未用=三问失败案例)
|
||||||
|
- **动效纪律**:任何常驻动画/定时器必须考虑"空态即停、事件即醒";季节/色温一律以游戏时为准(data-season 单源),严禁系统时钟入游戏表现(fx-canvas 教训)。
|
||||||
|
- **改测试先看断言源自哪层**:`as never`强转绕过校验、`require`混 TS import、模板变量未转义——都是本迭代 2 次绕路的教训;新矩阵一律顶层 import + 模板注意转义。
|
||||||
|
|
||||||
|
### 9. 线程定论(0.1.21 实测)
|
||||||
|
- 引擎 = renderer 主线程同步,`advanceMonth` 2160 月平均 **0.05ms/tick**;拖帧热点是 UI 全量渲染 + 存档 stringify(已修:精订阅/节流/截断)。
|
||||||
|
- **为什么不做 Worker**:0.05ms 换线程是负收益工程;真问题(UI 渲染、OPFS 成本)已定位修复。
|
||||||
|
|
||||||
## 四、踩过的坑(高价值教训)
|
## 四、踩过的坑(高价值教训)
|
||||||
|
|
||||||
1. **金钟罩盲区**:560 月(47 年)只能听见"中期",看不见"晚期"——0.1.10 之前全灭崩溃藏在这里。→ 教训:**确定性验证要覆盖寿命尺度**(现在三档到 180 年)。
|
1. **金钟罩盲区**:560 月(47 年)听不见 180 年;且"直调冻结世界"指纹锁的是假样本(事件闸永驻、raid 永不落地)。→ 教训:**确定性验证要覆盖寿命尺度 + 现实长跑**(0.1.20 双基线)。
|
||||||
2. **渡劫误压制**:eventRoll 一处"自动压制"让 qi8→筑基永不可达(境界峰恒炼气)。→ 教训:**自动回退/悬空逻辑必须单测"有人一直不响应"**。
|
2. **era flow 权重和=1 → 无延续档**:`roll<acc` 恒真,第一年即转移,ERA_DURA 全死码——"看着合理其实错"。→ 教训:**概率转移表永远留"延续"权重**(和<1),测试断言 sum<1。
|
||||||
3. **大比缺 once**:整年刷 12 场,收益通胀。→ 教训:周期性动态事件**生来就要考虑 once/年度封缄**。
|
3. **潮汐供给符号反向**:`1-(tide-1)*k` 在潮高 1.35 时供给 ×0.895(注释承诺 ×1.3),末法反而供给过剩。→ 教训:**每个乘子写测试断言方向**(tide>1 必丰)。
|
||||||
4. **flag 键膨胀**:auction-{year} 等永不清理,180 年挂 400 键。→ 教训:**任何按年份生成的键都要有剪枝数**(现在 3 年)。
|
4. **duplicate 时轮/双注册**(0.1.15):restore 重建世界不重建内核 → 时钟注册 12→24 跑双倍。→ 教训:单例内核 + 回档测试。
|
||||||
5. **UI 层"状态反直觉"**:死者显示"闭关/养伤"——直觉信任崩塌。→ 教训:**UI 状态必须与领域语义严格对齐**(dead 分支显式)。
|
5. **事件闸竞态**(0.1.20):单闸无条件 return → raid/军事线被日常事件饿死;被吞不写 CD 下月重试(白烧 rng 不落地)。→ 教训:**同 tick 多事件系统必须做优先级评测**。
|
||||||
6. **模块单例纪律**:MetonaSqlark 同库同 tab 一次连接(ARIA_LOCKED);spouse 竞态防了。→ 教训:**存储/稀罕资源一律单例**。
|
6. **每 tick 全量存档**(0.1.20):autosaveEvery 死代码 + saveChronicle O(N) 逐条查询 → 2160 月档每 tick 数千 OPFS。→ 教训:**autosave 节流必须真实接线;冗余表用增量写**。
|
||||||
|
6b. **确定性世界"记其所生"遗漏**(0.1.24→0.1.25):worldGen 只存摘要不存 NPC defs → 读档后动态家族(n-g-*)npcById 全 miss,贸易/外交/战斗静默降级(软性瘫痪)。→ 教训:**凡"生成"的内容必落档**(worldGen.npcs + 幂等重注册)。
|
||||||
|
6c. **聚合注册表默认值零漂**(0.1.26 验证):灾因表/词库池改造为聚合时——默认表不动、指纹不漂(金钟罩不红的唯一路径)。→ 教训:**扩展点一律"默认优先、注册追加"**。
|
||||||
|
6d. **实测优先于直觉**(0.1.27):180 年模拟推翻了"生灭千秋已实现"的直觉——零触发。→ 教训:**宣称完成的机制必须被长跑实测证伪一次**(同金钟罩盲区教训)。
|
||||||
|
6e. **承诺-实现间隙**(0.1.28):预检"只活在测试里"、worldNum"文档有字段零调用"、bundle"导出自产不能自食"、MOD 读档"静默消失"——四个"写了但没接"。→ 教训:**文档宣称的能力必须有一条真实路径测试**(审计先查"声明 vs 调用",比查"对不对"更易出真金)。
|
||||||
|
7. **修改覆盖事故**(爸爸点名):批量 python 替换命中旧文本 / `git checkout` 回滚误伤 / 链式 replace 重复属性。→ 教训:**修改纪律**(原子提交、替换三步断言、禁整文件 checkout、小步绿灯再前进)。
|
||||||
|
8. **模块单例纪律**:MetonaSqlark 同库同 tab 一次连接(ARIA_LOCKED)→ 单例存取。
|
||||||
|
|
||||||
## 五、平衡哲学(玩起来正确的普适原则)
|
## 五、平衡哲学(玩起来正确的普适原则)
|
||||||
|
|
||||||
- **修炼快感 > 真实性**:宁可让"凡人百岁筑基"变成"30岁炼气、50岁筑基、百岁金丹",不要按修真文教条拉长。
|
- **修炼快感 > 真实性**:30 岁炼气、50 岁筑基、百岁金丹,不按修真文教条拉长。
|
||||||
- **世代窗口**:关键体验"每代 25-30 年",一回合 1 月,推两百步就能看到"代际更替"。
|
- **世代窗口**:每代 25-30 年,一回合 1 月,两百步见代际更替。
|
||||||
- **失败必须有出口**:渡劫失败只跌伤不太狠(重伤/跌落为主,陨落限高阶);事件悬置自动压制不锁死。
|
- **失败必须有出口**:渡劫失败跌伤为主(陨落限高阶);事件悬置自动压制不锁死;断供拒单只到 0.35 且有世界供给回填(不造死局)。
|
||||||
- **漏斗要"花钱有去处"**:后期灵石通胀是 0.1.10 才加拍卖来回收——任何资源曲线都要有消耗侧。
|
- **资源漏斗有出口**:修为耗草(0.1.16)、兽核炼器、灵石养丹/调停/援兵——每个产出都有消耗侧;**市场超卖有反噬**(谷贱伤农)。
|
||||||
|
- **世界不是背景板**:玩家的交易/战争/结盟都会改变行情、NPC 势力与时代走向——决策有代价才有味道(0.1.17-0.1.21 一以贯之)。
|
||||||
|
|
||||||
## 六、已知技术债(诚实清单)
|
## 六、已知技术债(诚实清单)
|
||||||
|
|
||||||
1. `realms.ts` 的 `expBase/expGrowth/maxRealmExp` 曲线未参与计算(历史摆设,勿信勿用)。
|
1. `realms.ts` 的 `expBase/expGrowth/maxRealmExp` 未参与计算(历史摆设,勿信勿用)。
|
||||||
2. GameFacade `act` 目录部分 act 只走 `actDirect` fallback(少 4 项),若未来全走 facade 需补齐 `member.meditate` 等。
|
2. GameFacade act 通道与面板直调并存(**仲裁结论**:直调是事实通道、act 为测试/自动化通道——actDirect 已补齐 24 项等价映射)。
|
||||||
3. `npc.power` 目前纯展示(不参与战斗结算)。
|
3. `guide.ts`(引导任务)只做 UI 条,钩子目标点不完("提示"非"强制")。
|
||||||
4. 引导任务(guide.ts)只做 UI 条,钩子目标点不完(未联动高亮/自动转跳——是"提示"不是"强制")。
|
4. `family.flag` 长线键已有 3 年剪枝、但 `state.missions/missionIds` 只增不减(未剪枝)。
|
||||||
5. 行列 flag `family.flag` 仍在长线漂移;`state.missions`/`missionIds` 只增不减(未剪枝)。
|
5. ~~NPC 四家是固定成分(无"覆灭/新贵补位"——0.1.19 留作 0.1.22+ 候选)~~ **已解决**(0.1.23 覆灭/新贵补位 + 0.1.40 宗主更替四模式/势力分裂/灰烬重生)。附庸/吞并仅 UI"衰"标记。
|
||||||
|
6. `worldsim` 的能力卡开关无独立钩子(viaCap 恒真已注册,实际不可关)——如需可关需重构。
|
||||||
|
|
||||||
## 七、如何开始修改(给下一个智能体的路径)
|
## 七、已知技术债补充(0.1.26)
|
||||||
|
|
||||||
|
7. MOD 目录形态仅支持单文件(目录/分包/依赖多个 .cotymod 自动解析留待)。
|
||||||
|
8. CALAMITY 与 WORLDSTIM 数值表的大规模注入(多 MOD 叠加平衡)未做。
|
||||||
|
9. NPC"中期势力轮替"曲线(newborn 频度/覆灭线)仍单参数,可寻参数化。
|
||||||
|
10. **测试规模 7058**(108 套件矩阵域),后续新增机制优先在对应矩阵域追加参(seed×组合),避免重开大文件。
|
||||||
|
11. **缓做项(0.1.28 审计定案)**:declineYears 盛世误触/闪烁——重平衡归 0.1.29 独立调参(避免二次基线漂移);CALAMITY_FAMILY 独立口收益低(灾因口已覆盖);ERA_DURA/REGION_TIDE 松弛版数值口 0.1.29+(必须走"默认优先+聚合"模式)。
|
||||||
|
|
||||||
|
## 八、如何开始修改(给下一个智能体的路径)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. 读 AGENTS.md(红线)→ 2. 读本文件(意图)→ 3. 跑 npm test & typecheck 确认绿
|
1. 读 AGENTS.md(红线)→ 2. 读本文件(意图)→ 3. 跑 npm test & typecheck 确认绿
|
||||||
→ 4. 改引擎:先改数据(data/)再改逻辑,小步跑金钟罩;改结构:注册进 clocks/plugins
|
→ 4. 改引擎:先改数据(data/ 或 sim/worldsim-data.ts)再改逻辑,小步跑金钟罩;
|
||||||
→ 5. 改 UI:先改 store(revision)再改组件;纯逻辑放 game/core 可测
|
改结构:注册进 clocks/plugins;世界循环参数唯一权威 = worldsim-data.ts
|
||||||
→ 6. 加测试:金钟罩三档不动,新增用例挂 tests/audit-fix-0.1.x.test.ts
|
→ 5. 改 UI:先改 store(revision)再改组件;纯逻辑放 game/engine 可测
|
||||||
→ 7. 提交前:npm test + typecheck + (GUI 冒烟需 WSLg 在线)+ 更新 README/PROJECT_MEMORY/AGENTS
|
→ 6. 加测试:双金钟罩不动,新增用例挂 tests/xxx-0.1.x.test.ts(按主题命名)
|
||||||
|
→ 7. 提交前:npm test(108 套件 7058+)+ typecheck + (GUI 冒烟需 WSLg 在线)+ 更新三份文档 + MOD_GUIDE(触 MOD 时)
|
||||||
```
|
```
|
||||||
|
|
||||||
**一句话原则**:先复现、再小改、后金钟罩。改坏确定性比改错玩法更危险。
|
**一句话原则**:先复现、再小改、后金钟罩。改坏确定性比改错玩法更危险;改数值去 worldsim-data,改流程去 clocks,改事件去 fire()。
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronicle-of-the-immortal-clan",
|
"name": "chronicle-of-the-immortal-clan",
|
||||||
"productName": "仙途家族志",
|
"productName": "仙途家族志",
|
||||||
"version": "0.1.11",
|
"version": "0.1.41",
|
||||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "MetonaTeam",
|
"author": "MetonaTeam",
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"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": "node scripts/verify-release.mjs",
|
||||||
"verify:package": "node scripts/verify-release.mjs --package"
|
"verify:package": "node scripts/verify-release.mjs --package",
|
||||||
|
"gen:art": "node scripts/gen-art.mjs",
|
||||||
|
"dev:web": "vite --config vite.web.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@metona-team/metona-sqlark": "0.7.4"
|
"@metona-team/metona-sqlark": "0.7.4"
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* gen-art.mjs — 程序化生成 SVG 素材(金碧山水/纹样/饰件),零外部资源。
|
||||||
|
* 用法:node scripts/gen-art.mjs (产物写 src/renderer/assets/gen/*.svg)
|
||||||
|
*/
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const OUT = join(dirname(fileURLToPath(import.meta.url)), '../src/renderer/assets/gen')
|
||||||
|
mkdirSync(OUT, { recursive: true })
|
||||||
|
|
||||||
|
// ============ 1. 金碧山水全景(主背景) ============
|
||||||
|
function goldenLandscape() {
|
||||||
|
const ridge = (y, amp, seed, opacity) => {
|
||||||
|
const pts = []
|
||||||
|
let x = -40
|
||||||
|
let phase = seed
|
||||||
|
while (x <= 1280) {
|
||||||
|
const yy = y + Math.sin((x / 170) + phase) * amp + Math.sin((x / 71) + phase * 2.2) * amp * 0.3
|
||||||
|
pts.push(`${x.toFixed(0)},${yy.toFixed(0)}`)
|
||||||
|
phase += 0.35
|
||||||
|
x += 80
|
||||||
|
}
|
||||||
|
return `<polygon points="${pts.join(' ')} 1300,760 -40,760" fill="#8a6d2f" opacity="${opacity}" />`
|
||||||
|
}
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 760" preserveAspectRatio="xMidYMax slice">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#181208"/>
|
||||||
|
<stop offset="0.65" stop-color="#241a10"/>
|
||||||
|
<stop offset="1" stop-color="#38271a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#f2cf7e" stop-opacity="0.9"/>
|
||||||
|
<stop offset="0.5" stop-color="#c99a3d" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#c99a3d" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="blur2"><feGaussianBlur stdDeviation="2"/></filter>
|
||||||
|
<filter id="blur6"><feGaussianBlur stdDeviation="6"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="760" fill="url(#sky)"/>
|
||||||
|
<circle cx="905" cy="150" r="130" fill="url(#sun)"/>
|
||||||
|
<circle cx="905" cy="150" r="30" fill="#f6dd9d" opacity="0.75" filter="url(#blur2)"/>
|
||||||
|
${ridge(420, 46, 1.4, 0.10)}
|
||||||
|
${ridge(500, 52, 2.1, 0.15)}
|
||||||
|
${ridge(590, 46, 3.2, 0.22)}
|
||||||
|
<g stroke="#cbb35f" stroke-width="1.6" opacity="0.35" fill="none" filter="url(#blur2)">
|
||||||
|
<path d="M300 262 q16 -11 32 0 q16 -11 32 0"/>
|
||||||
|
<path d="M250 288 q14 -10 28 0 q14 -10 28 0"/>
|
||||||
|
<path d="M980 205 q13 -9 26 0 q13 -9 26 0"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#cbb35f" stroke-width="0.9" opacity="0.22">
|
||||||
|
<path d="M60 620 h300 M60 640 h220 M60 660 h150" />
|
||||||
|
</g>
|
||||||
|
<rect x="0" y="700" width="1200" height="60" fill="#1c1409" opacity="0.4"/>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 2. 祥云纹带(分隔/装饰) ============
|
||||||
|
function cloudBand() {
|
||||||
|
const cloud = (x, y, s, o) =>
|
||||||
|
`<g transform="translate(${x},${y}) scale(${s})" fill="#cbb35f" opacity="${o}">
|
||||||
|
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
|
||||||
|
</g>`
|
||||||
|
let out = ''
|
||||||
|
for (let i = 0; i < 6; i++) out += cloud(i * 200 - 20, ((i % 2) * 10) - 4, 0.9 + (i % 3) * 0.15, 0.5 - (i % 2) * 0.15)
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 60">${out}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 3. 回纹角饰(卡片角框) ============
|
||||||
|
function meanderCorner() {
|
||||||
|
const sq = (x, y, s) => `<path d="M${x} ${y + s} h${s} v${s} h${-s * 0} z" fill="none" stroke="#b3913e" stroke-width="2"/>`
|
||||||
|
let p = ''
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
p += `<path d="M${i * 8 + 0} 0 v${i * 8 + 0} M0 ${i * 8 + 0} h${i * 8 + 0}" />`
|
||||||
|
}
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||||
|
<g fill="none" stroke="#b3913e" stroke-width="1.6" opacity="0.7">
|
||||||
|
<path d="M4 36 L4 4 L36 4" />
|
||||||
|
<path d="M10 30 L10 10 L34 10" />
|
||||||
|
<path d="M16 24 L16 16 L30 16" />
|
||||||
|
</g>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 4. 仙鹤(点缀) ============
|
||||||
|
function crane() {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60">
|
||||||
|
<g stroke="#e0cf9b" stroke-width="2.2" fill="none" opacity="0.7" stroke-linecap="round">
|
||||||
|
<path d="M8 52 q22 -18 42 -16"/>
|
||||||
|
<path d="M50 36 q14 12 30 14"/>
|
||||||
|
<path d="M44 34 L22 22 M44 34 L70 20 M44 34 L52 14"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="42" cy="33" r="2.4" fill="#e6b84c" opacity="0.9"/>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 5. 星辰点缀层 ============
|
||||||
|
function starfield() {
|
||||||
|
let p = ''
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const x = ((i * 137) % 1200)
|
||||||
|
const y = ((i * 83) % 420)
|
||||||
|
const r = 0.6 + (i % 3) * 0.5
|
||||||
|
const o = 0.12 + ((i * 7) % 5) * 0.09
|
||||||
|
p += `<circle cx="${x}" cy="${y}" r="${r}" fill="#f4dc9a" opacity="${o}"/>`
|
||||||
|
}
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 460">${p}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
'landscape-gold.svg': goldenLandscape(),
|
||||||
|
'cloud-band.svg': cloudBand(),
|
||||||
|
'meander-corner.svg': meanderCorner(),
|
||||||
|
'crane.svg': crane(),
|
||||||
|
'starfield.svg': starfield()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, svg] of Object.entries(out)) {
|
||||||
|
writeFileSync(join(OUT, name), svg, 'utf-8')
|
||||||
|
console.log('✓', name, `${svg.length} B`)
|
||||||
|
}
|
||||||
|
console.log('gen-art done →', OUT)
|
||||||
@@ -63,6 +63,7 @@ function createWindow(): void {
|
|||||||
const title = await wc.executeJavaScript('document.title')
|
const title = await wc.executeJavaScript('document.title')
|
||||||
let bootReady = 0
|
let bootReady = 0
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await wc.executeJavaScript('window.__SMOKE__ = true; void 0')
|
||||||
bootReady = await wc.executeJavaScript(
|
bootReady = await wc.executeJavaScript(
|
||||||
"Number(window.__cotycBootReady ?? 0)"
|
"Number(window.__cotycBootReady ?? 0)"
|
||||||
)
|
)
|
||||||
@@ -208,6 +209,45 @@ ipcMain.handle('save:export', async (_ev, json: string, defaultName: string) =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('mods:scan', async () => {
|
||||||
|
const dir = join(app.getPath('userData'), 'mods')
|
||||||
|
try {
|
||||||
|
if (!existsSync(dir)) return { ok: true as const, files: [] }
|
||||||
|
const { readdirSync } = await import('fs')
|
||||||
|
const files = readdirSync(dir).filter((f) => f.endsWith('.json') || f.endsWith('.cotymod'))
|
||||||
|
return { ok: true as const, files }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false as const, error: String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('mods:write', async (_ev, name: string, text: string) => {
|
||||||
|
const dir = join(app.getPath('userData'), 'mods')
|
||||||
|
try {
|
||||||
|
const { mkdirSync } = await import('fs')
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
const safe = normalize(decodeURIComponent(name)).split(sep).slice(-1)[0]!
|
||||||
|
if (!SAFE_MOD.test(safe)) return { ok: false as const, error: '非法 MOD 文件名(需 .json/.cotymod)。' }
|
||||||
|
writeFileSync(join(dir, safe), text, 'utf-8')
|
||||||
|
return { ok: true as const }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false as const, error: String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SAFE_MOD = /^[\w.-]+\.(json|cotymod)$/i
|
||||||
|
ipcMain.handle('mods:read', async (_ev, name: string) => {
|
||||||
|
const dir = join(app.getPath('userData'), 'mods')
|
||||||
|
try {
|
||||||
|
const safe = normalize(decodeURIComponent(name)).split(sep).slice(-1)[0]!
|
||||||
|
if (!SAFE_MOD.test(safe)) return { ok: false as const, error: '非法 MOD 文件名(需 .json/.cotymod)。' }
|
||||||
|
const text = readFileSync(join(dir, safe), 'utf-8')
|
||||||
|
return { ok: true as const, text }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false as const, error: String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('save:import', async () => {
|
ipcMain.handle('save:import', async () => {
|
||||||
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
|
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
|
||||||
const result = await dialog.showOpenDialog(win!, {
|
const result = await dialog.showOpenDialog(win!, {
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ export interface SaveImportResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModsScanResult { ok: boolean; files?: string[]; error?: string }
|
||||||
|
export interface ModsReadResult { ok: boolean; text?: string; error?: string }
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
exportSave: (json: string, defaultName: string): Promise<SaveExportResult> =>
|
exportSave: (json: string, defaultName: string): Promise<SaveExportResult> =>
|
||||||
ipcRenderer.invoke('save:export', json, defaultName),
|
ipcRenderer.invoke('save:export', json, defaultName),
|
||||||
importSave: (): Promise<SaveImportResult> => ipcRenderer.invoke('save:import')
|
importSave: (): Promise<SaveImportResult> => ipcRenderer.invoke('save:import'),
|
||||||
|
scanMods: (): Promise<ModsScanResult> => ipcRenderer.invoke('mods:scan'),
|
||||||
|
readMod: (name: string): Promise<ModsReadResult> => ipcRenderer.invoke('mods:read', name),
|
||||||
|
writeMod: (name: string, text: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('mods:write', name, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('api', api)
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
|
|||||||
+46
-1
@@ -7,6 +7,33 @@ import { EventModal } from './ui/components/EventModal'
|
|||||||
import { BattleModal } from './ui/components/BattleModal'
|
import { BattleModal } from './ui/components/BattleModal'
|
||||||
import { PaperModal } from './ui/components/PaperModal'
|
import { PaperModal } from './ui/components/PaperModal'
|
||||||
import { sClick } from './ui/sound'
|
import { sClick } from './ui/sound'
|
||||||
|
import { ensureFx, fxVisibilityAutopause, applySeason } from './ui/fx'
|
||||||
|
import { startFxLayer, spawnBurst, stopFxLayer } from './ui/fx-canvas'
|
||||||
|
import { DriftLayer } from './ui/components/DriftLayer'
|
||||||
|
import { seasonOf } from './game/data/season'
|
||||||
|
|
||||||
|
import { Component, type ReactNode, type ErrorInfo } from 'react'
|
||||||
|
|
||||||
|
interface EBState { err: Error | null }
|
||||||
|
export class AppErrorBoundary extends Component<{ children: ReactNode }, EBState> {
|
||||||
|
state: EBState = { err: null }
|
||||||
|
static getDerivedStateFromError(err: Error): EBState { return { err } }
|
||||||
|
componentDidCatch(err: Error, info: ErrorInfo): void {
|
||||||
|
console.error('[ui-crash]', err, info.componentStack)
|
||||||
|
}
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.err) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, color: '#e8d9b0', fontFamily: 'serif' }}>
|
||||||
|
<h2 style={{ color: '#d99a85' }}>界面出错了(游戏数据未受影响)</h2>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', fontSize: '0.8rem', maxHeight: '50vh', overflow: 'auto' }}>{String(this.state.err?.message ?? this.state.err)}</pre>
|
||||||
|
<button className="btn" onClick={() => location.reload()}>重新加载(回档到最近存点)</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const screen = useGameStore((s) => s.screen)
|
const screen = useGameStore((s) => s.screen)
|
||||||
@@ -17,7 +44,24 @@ export default function App() {
|
|||||||
useGameStore.getState().init()
|
useGameStore.getState().init()
|
||||||
const clicker = () => sClick()
|
const clicker = () => sClick()
|
||||||
document.addEventListener('click', clicker, true)
|
document.addEventListener('click', clicker, true)
|
||||||
return () => document.removeEventListener('click', clicker, true)
|
const fx = ensureFx()
|
||||||
|
const stopVis = fxVisibilityAutopause()
|
||||||
|
const burst = (e: Event) => {
|
||||||
|
const kind = e.type.replace('fx:', '')
|
||||||
|
if (kind === 'spark' || kind === 'blade') spawnBurst('spark', 'center')
|
||||||
|
if (kind === 'ripple') spawnBurst('ripple')
|
||||||
|
if (kind === 'pulse') spawnBurst('ripple')
|
||||||
|
}
|
||||||
|
for (const k of ['fx:spark', 'fx:blade', 'fx:ripple', 'fx:pulse']) window.addEventListener(k, burst)
|
||||||
|
const seasonInit = () => applySeason(seasonOf(useGameStore.getState().world?.state.month ?? 1))
|
||||||
|
seasonInit()
|
||||||
|
startFxLayer(fx.mode === 'soft' ? 18 : fx.mode === 'full' ? 70 : 36)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', clicker, true)
|
||||||
|
stopVis()
|
||||||
|
for (const k of ['fx:spark', 'fx:blade', 'fx:ripple', 'fx:pulse']) window.removeEventListener(k, burst)
|
||||||
|
stopFxLayer()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +72,7 @@ export default function App() {
|
|||||||
{pendingEventId && <EventModal />}
|
{pendingEventId && <EventModal />}
|
||||||
{battleView && <BattleModal />}
|
{battleView && <BattleModal />}
|
||||||
<PaperModal />
|
<PaperModal />
|
||||||
|
<DriftLayer />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 60"><g transform="translate(-20,-4) scale(0.9)" fill="#cbb35f" opacity="0.5">
|
||||||
|
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
|
||||||
|
</g><g transform="translate(180,6) scale(1.05)" fill="#cbb35f" opacity="0.35">
|
||||||
|
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
|
||||||
|
</g><g transform="translate(380,-4) scale(1.2)" fill="#cbb35f" opacity="0.5">
|
||||||
|
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
|
||||||
|
</g><g transform="translate(580,6) scale(0.9)" fill="#cbb35f" opacity="0.35">
|
||||||
|
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
|
||||||
|
</g><g transform="translate(780,-4) scale(1.05)" fill="#cbb35f" opacity="0.5">
|
||||||
|
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
|
||||||
|
</g><g transform="translate(980,6) scale(1.2)" fill="#cbb35f" opacity="0.35">
|
||||||
|
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
|
||||||
|
</g></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60">
|
||||||
|
<g stroke="#e0cf9b" stroke-width="2.2" fill="none" opacity="0.7" stroke-linecap="round">
|
||||||
|
<path d="M8 52 q22 -18 42 -16"/>
|
||||||
|
<path d="M50 36 q14 12 30 14"/>
|
||||||
|
<path d="M44 34 L22 22 M44 34 L70 20 M44 34 L52 14"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="42" cy="33" r="2.4" fill="#e6b84c" opacity="0.9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 760" preserveAspectRatio="xMidYMax slice">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#181208"/>
|
||||||
|
<stop offset="0.65" stop-color="#241a10"/>
|
||||||
|
<stop offset="1" stop-color="#38271a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#f2cf7e" stop-opacity="0.9"/>
|
||||||
|
<stop offset="0.5" stop-color="#c99a3d" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#c99a3d" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="blur2"><feGaussianBlur stdDeviation="2"/></filter>
|
||||||
|
<filter id="blur6"><feGaussianBlur stdDeviation="6"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="760" fill="url(#sky)"/>
|
||||||
|
<circle cx="905" cy="150" r="130" fill="url(#sun)"/>
|
||||||
|
<circle cx="905" cy="150" r="30" fill="#f6dd9d" opacity="0.75" filter="url(#blur2)"/>
|
||||||
|
<polygon points="-40,470 40,449 120,436 200,412 280,367 360,374 440,424 520,446 600,453 680,465 760,429 840,375 920,376 1000,401 1080,414 1160,450 1240,479 1300,760 -40,760" fill="#8a6d2f" opacity="0.1" />
|
||||||
|
<polygon points="-40,537 40,518 120,497 200,447 280,440 360,497 440,529 520,535 600,549 680,521 760,455 840,445 920,476 1000,492 1080,526 1160,566 1240,536 1300,760 -40,760" fill="#8a6d2f" opacity="0.15" />
|
||||||
|
<polygon points="-40,601 40,574 120,534 200,550 280,602 360,619 440,624 520,631 600,590 680,539 760,549 840,576 920,590 1000,626 1080,648 1160,604 1240,562 1300,760 -40,760" fill="#8a6d2f" opacity="0.22" />
|
||||||
|
<g stroke="#cbb35f" stroke-width="1.6" opacity="0.35" fill="none" filter="url(#blur2)">
|
||||||
|
<path d="M300 262 q16 -11 32 0 q16 -11 32 0"/>
|
||||||
|
<path d="M250 288 q14 -10 28 0 q14 -10 28 0"/>
|
||||||
|
<path d="M980 205 q13 -9 26 0 q13 -9 26 0"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#cbb35f" stroke-width="0.9" opacity="0.22">
|
||||||
|
<path d="M60 620 h300 M60 640 h220 M60 660 h150" />
|
||||||
|
</g>
|
||||||
|
<rect x="0" y="700" width="1200" height="60" fill="#1c1409" opacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||||
|
<g fill="none" stroke="#b3913e" stroke-width="1.6" opacity="0.7">
|
||||||
|
<path d="M4 36 L4 4 L36 4" />
|
||||||
|
<path d="M10 30 L10 10 L34 10" />
|
||||||
|
<path d="M16 24 L16 16 L30 16" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 264 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 460"><circle cx="0" cy="0" r="0.6" fill="#f4dc9a" opacity="0.12"/><circle cx="137" cy="83" r="1.1" fill="#f4dc9a" opacity="0.3"/><circle cx="274" cy="166" r="1.6" fill="#f4dc9a" opacity="0.48"/><circle cx="411" cy="249" r="0.6" fill="#f4dc9a" opacity="0.21"/><circle cx="548" cy="332" r="1.1" fill="#f4dc9a" opacity="0.39"/><circle cx="685" cy="415" r="1.6" fill="#f4dc9a" opacity="0.12"/><circle cx="822" cy="78" r="0.6" fill="#f4dc9a" opacity="0.3"/><circle cx="959" cy="161" r="1.1" fill="#f4dc9a" opacity="0.48"/><circle cx="1096" cy="244" r="1.6" fill="#f4dc9a" opacity="0.21"/><circle cx="33" cy="327" r="0.6" fill="#f4dc9a" opacity="0.39"/><circle cx="170" cy="410" r="1.1" fill="#f4dc9a" opacity="0.12"/><circle cx="307" cy="73" r="1.6" fill="#f4dc9a" opacity="0.3"/><circle cx="444" cy="156" r="0.6" fill="#f4dc9a" opacity="0.48"/><circle cx="581" cy="239" r="1.1" fill="#f4dc9a" opacity="0.21"/><circle cx="718" cy="322" r="1.6" fill="#f4dc9a" opacity="0.39"/><circle cx="855" cy="405" r="0.6" fill="#f4dc9a" opacity="0.12"/><circle cx="992" cy="68" r="1.1" fill="#f4dc9a" opacity="0.3"/><circle cx="1129" cy="151" r="1.6" fill="#f4dc9a" opacity="0.48"/><circle cx="66" cy="234" r="0.6" fill="#f4dc9a" opacity="0.21"/><circle cx="203" cy="317" r="1.1" fill="#f4dc9a" opacity="0.39"/><circle cx="340" cy="400" r="1.6" fill="#f4dc9a" opacity="0.12"/><circle cx="477" cy="63" r="0.6" fill="#f4dc9a" opacity="0.3"/><circle cx="614" cy="146" r="1.1" fill="#f4dc9a" opacity="0.48"/><circle cx="751" cy="229" r="1.6" fill="#f4dc9a" opacity="0.21"/><circle cx="888" cy="312" r="0.6" fill="#f4dc9a" opacity="0.39"/><circle cx="1025" cy="395" r="1.1" fill="#f4dc9a" opacity="0.12"/><circle cx="1162" cy="58" r="1.6" fill="#f4dc9a" opacity="0.3"/><circle cx="99" cy="141" r="0.6" fill="#f4dc9a" opacity="0.48"/><circle cx="236" cy="224" r="1.1" fill="#f4dc9a" opacity="0.21"/><circle cx="373" cy="307" r="1.6" fill="#f4dc9a" opacity="0.39"/><circle cx="510" cy="390" r="0.6" fill="#f4dc9a" opacity="0.12"/><circle cx="647" cy="53" r="1.1" fill="#f4dc9a" opacity="0.3"/><circle cx="784" cy="136" r="1.6" fill="#f4dc9a" opacity="0.48"/><circle cx="921" cy="219" r="0.6" fill="#f4dc9a" opacity="0.21"/><circle cx="1058" cy="302" r="1.1" fill="#f4dc9a" opacity="0.39"/><circle cx="1195" cy="385" r="1.6" fill="#f4dc9a" opacity="0.12"/><circle cx="132" cy="48" r="0.6" fill="#f4dc9a" opacity="0.3"/><circle cx="269" cy="131" r="1.1" fill="#f4dc9a" opacity="0.48"/><circle cx="406" cy="214" r="1.6" fill="#f4dc9a" opacity="0.21"/><circle cx="543" cy="297" r="0.6" fill="#f4dc9a" opacity="0.39"/><circle cx="680" cy="380" r="1.1" fill="#f4dc9a" opacity="0.12"/><circle cx="817" cy="43" r="1.6" fill="#f4dc9a" opacity="0.3"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
Vendored
+3
@@ -2,5 +2,8 @@ interface Window {
|
|||||||
api?: {
|
api?: {
|
||||||
exportSave: (json: string, defaultName: string) => Promise<{ ok: boolean; error?: string }>
|
exportSave: (json: string, defaultName: string) => Promise<{ ok: boolean; error?: string }>
|
||||||
importSave: () => Promise<{ ok: boolean; text?: string; error?: string }>
|
importSave: () => Promise<{ ok: boolean; text?: string; error?: string }>
|
||||||
|
scanMods: () => Promise<{ ok: boolean; files?: string[]; error?: string }>
|
||||||
|
readMod: (name: string) => Promise<{ ok: boolean; text?: string; error?: string }>
|
||||||
|
writeMod: (name: string, text: string) => Promise<{ ok: boolean; error?: string }>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
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' }
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* 安全算术表达式求值(0.1.36:CSP 禁止 unsafe-eval —— Function()/eval 一律改为纯解析器)
|
||||||
|
*
|
||||||
|
* 支持:数字(整数/小数)、变量 L(等级)、+ - * / 、括号、幂 ^
|
||||||
|
* 白名单:解析器只认这些——任何字母(除 L)/函数名/属性访问都不会被解析,报 NaN 即跳过。
|
||||||
|
* 用法:arith('0.5 + 0.1*L', 3) === 0.8
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Tok { t: 'num' | 'op' | 'lparen' | 'rparen' | 'var'; v: number | string }
|
||||||
|
|
||||||
|
function tokenize(src: string): Tok[] | null {
|
||||||
|
const out: Tok[] = []
|
||||||
|
let i = 0
|
||||||
|
const s = src.replace(/\s+/g, '')
|
||||||
|
while (i < s.length) {
|
||||||
|
const c = s[i]
|
||||||
|
if (c >= '0' && c <= '9' || c === '.') {
|
||||||
|
let j = i
|
||||||
|
while (j < s.length && ((s[j] >= '0' && s[j] <= '9') || s[j] === '.')) j++
|
||||||
|
const num = Number(s.slice(i, j))
|
||||||
|
if (!Number.isFinite(num)) return null
|
||||||
|
out.push({ t: 'num', v: num })
|
||||||
|
i = j
|
||||||
|
} else if (c === 'L') {
|
||||||
|
out.push({ t: 'var', v: 'L' })
|
||||||
|
i++
|
||||||
|
} else if (c === '+' || c === '-' || c === '*' || c === '/' || c === '^') {
|
||||||
|
out.push({ t: 'op', v: c })
|
||||||
|
i++
|
||||||
|
} else if (c === '(') {
|
||||||
|
out.push({ t: 'lparen', v: '(' })
|
||||||
|
i++
|
||||||
|
} else if (c === ')') {
|
||||||
|
out.push({ t: 'rparen', v: ')' })
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归下降求值(后进先出操作符;幂右结合、乘除左结合、余运算符优先级遵循通常数学惯例) */
|
||||||
|
function evalTokens(toks: Tok[], start: number, end: number, L: number): { v: number; next: number } | null {
|
||||||
|
const peek = (p: number) => (p < toks.length ? toks[p] : null)
|
||||||
|
|
||||||
|
// 加法/减法层:最低优先级
|
||||||
|
const evalAdd = (p: number): { v: number; next: number } | null => {
|
||||||
|
let left = evalMul(p)
|
||||||
|
if (!left) return null
|
||||||
|
while (true) {
|
||||||
|
const t = peek(left.next)
|
||||||
|
if (t && t.t === 'op' && (t.v === '+' || t.v === '-')) {
|
||||||
|
const right = evalMul(left.next + 1)
|
||||||
|
if (!right) return null
|
||||||
|
left = { v: t.v === '+' ? left.v + right.v : left.v - right.v, next: right.next }
|
||||||
|
} else break
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
// 乘除层
|
||||||
|
const evalMul = (p: number): { v: number; next: number } | null => {
|
||||||
|
let left = evalPow(p)
|
||||||
|
if (!left) return null
|
||||||
|
while (true) {
|
||||||
|
const t = peek(left.next)
|
||||||
|
if (t && t.t === 'op' && (t.v === '*' || t.v === '/')) {
|
||||||
|
const right = evalPow(left.next + 1)
|
||||||
|
if (!right) return null
|
||||||
|
if (t.v === '/' && right.v === 0) return null
|
||||||
|
left = { v: t.v === '*' ? left.v * right.v : left.v / right.v, next: right.next }
|
||||||
|
} else break
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂层(右结合)
|
||||||
|
const evalPow = (p: number): { v: number; next: number } | null => {
|
||||||
|
const base = evalUnary(p)
|
||||||
|
if (!base) return null
|
||||||
|
const t = peek(base.next)
|
||||||
|
if (t && t.t === 'op' && t.v === '^') {
|
||||||
|
const exp = evalPow(base.next + 1)
|
||||||
|
if (!exp) return null
|
||||||
|
return { v: Math.pow(base.v, exp.v), next: exp.next }
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// 负号/括号层
|
||||||
|
const evalUnary = (p: number): { v: number; next: number } | null => {
|
||||||
|
const t = peek(p)
|
||||||
|
if (!t) return null
|
||||||
|
if (t.t === 'op' && t.v === '-') {
|
||||||
|
const inner = evalUnary(p + 1)
|
||||||
|
if (!inner) return null
|
||||||
|
return { v: -inner.v, next: inner.next }
|
||||||
|
}
|
||||||
|
if (t.t === 'lparen') {
|
||||||
|
const inner = evalAdd(p + 1)
|
||||||
|
if (!inner) return null
|
||||||
|
const close = peek(inner.next)
|
||||||
|
if (!close || close.t !== 'rparen') return null
|
||||||
|
return { v: inner.v, next: inner.next + 1 }
|
||||||
|
}
|
||||||
|
if (t.t === 'num') return { v: t.v as number, next: p + 1 }
|
||||||
|
if (t.t === 'var') return { v: L, next: p + 1 }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const outer = evalAdd(start)
|
||||||
|
if (!outer || outer.next !== end) return null
|
||||||
|
return outer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安全求值:非法/未消费完 → NaN(调用方 Number.isFinite 判定) */
|
||||||
|
export function arith(expr: string, L = 0): number {
|
||||||
|
const toks = tokenize(expr ?? '')
|
||||||
|
if (!toks || toks.length === 0) return Number.NaN
|
||||||
|
const r = evalTokens(toks, 0, toks.length, L)
|
||||||
|
if (!r) return Number.NaN
|
||||||
|
return r.v
|
||||||
|
}
|
||||||
@@ -65,7 +65,13 @@ export const ASPIRATIONS: Record<string, AspirationDef> = {
|
|||||||
|
|
||||||
export const ASPIRATION_IDS = Object.keys(ASPIRATIONS)
|
export const ASPIRATION_IDS = Object.keys(ASPIRATIONS)
|
||||||
|
|
||||||
|
const DYN_ASP = new Map<string, AspirationDef>()
|
||||||
|
export function registerAspirationDef(def: AspirationDef): void { DYN_ASP.set(def.id, def) }
|
||||||
export function aspirationById(id: string | undefined): AspirationDef | null {
|
export function aspirationById(id: string | undefined): AspirationDef | null {
|
||||||
|
if (id && DYN_ASP.has(id)) return DYN_ASP.get(id)!
|
||||||
|
return aspirationStatic(id)
|
||||||
|
}
|
||||||
|
function aspirationStatic(id: string | undefined): AspirationDef | null {
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
return ASPIRATIONS[id] ?? null
|
return ASPIRATIONS[id] ?? null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { arith } from './arith'
|
||||||
|
|
||||||
export interface BuildingDef {
|
export interface BuildingDef {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -5,7 +7,7 @@ export interface BuildingDef {
|
|||||||
desc: string
|
desc: string
|
||||||
kind: 'produce' | 'function'
|
kind: 'produce' | 'function'
|
||||||
maxLevel: number
|
maxLevel: number
|
||||||
produceTable?: (level: number) => { stone?: number; lingcao?: number; lingkuang?: number; beastcore?: number }
|
produceTable?: (level: number) => { stone?: number; lingcao?: number; lingkuang?: number; beastcore?: number; lingyu?: number; lingmu?: number; shoupi?: number; lingguo?: number; 'talisman-feng'?: number; 'talisman-shan'?: number }
|
||||||
upgradeCost: (level: number) => { stones: number; lingkuang: number }
|
upgradeCost: (level: number) => { stones: number; lingkuang: number }
|
||||||
extra?: Record<string, string>
|
extra?: Record<string, string>
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,26 @@ export const BUILDINGS: Record<string, BuildingDef> = {
|
|||||||
upgradeCost: (l) => ({ stones: 120 * Math.pow(1.7, l - 1), lingkuang: 8 * l }),
|
upgradeCost: (l) => ({ stones: 120 * Math.pow(1.7, l - 1), lingkuang: 8 * l }),
|
||||||
extra: { repBonus: '0.3 * L /年' }
|
extra: { repBonus: '0.3 * L /年' }
|
||||||
},
|
},
|
||||||
|
lingguoyuan: {
|
||||||
|
id: 'lingguoyuan', name: '灵果园', icon: '果',
|
||||||
|
desc: '灵果成林——月产灵果,灵酿之基。',
|
||||||
|
kind: 'produce', maxLevel: 5,
|
||||||
|
produceTable: (l) => ({ lingguo: 3 * l }),
|
||||||
|
upgradeCost: (l) => ({ stones: 100 * Math.pow(1.7, l - 1), lingkuang: 10 * l })
|
||||||
|
},
|
||||||
|
fuzhifang: {
|
||||||
|
id: 'fuzhifang', name: '符箓坊', icon: '符',
|
||||||
|
desc: '符纸灵木入炉——月耗符纸灵木各贰,出风符一枚;坊叁级另出山符。',
|
||||||
|
kind: 'produce', maxLevel: 3,
|
||||||
|
upgradeCost: (l) => ({ stones: 120 * Math.pow(1.7, l - 1), lingkuang: 10 * l })
|
||||||
|
},
|
||||||
|
lingmufang: {
|
||||||
|
id: 'lingmufang', name: '灵木坊', icon: '木',
|
||||||
|
desc: '伐百年灵木——月产灵木,符纸之基。',
|
||||||
|
kind: 'produce', maxLevel: 5,
|
||||||
|
produceTable: (l) => ({ lingmu: 4 * l }),
|
||||||
|
upgradeCost: (l) => ({ stones: 90 * Math.pow(1.7, l - 1), lingkuang: 12 * l })
|
||||||
|
},
|
||||||
lingshou: {
|
lingshou: {
|
||||||
id: 'lingshou', name: '灵兽园', icon: '兽',
|
id: 'lingshou', name: '灵兽园', icon: '兽',
|
||||||
desc: '豢养灵兽,战阵之助,偶得兽核。',
|
desc: '豢养灵兽,战阵之助,偶得兽核。',
|
||||||
@@ -92,6 +114,62 @@ export const BUILDINGS: Record<string, BuildingDef> = {
|
|||||||
|
|
||||||
export const BUILDING_IDS = Object.keys(BUILDINGS)
|
export const BUILDING_IDS = Object.keys(BUILDINGS)
|
||||||
|
|
||||||
export function buildingById(id: string): BuildingDef {
|
const DYN_BUILDINGS = new Map<string, BuildingDef>()
|
||||||
return BUILDINGS[id]
|
export function registerBuildingDef(def: BuildingDef): void {
|
||||||
|
DYN_BUILDINGS.set(def.id, def)
|
||||||
|
}
|
||||||
|
const ITEM_NAMES: Record<string, string> = { lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核', lingyu: '灵玉', lingmu: '灵木', shoupi: '兽皮', lingguo: '灵果', stone: '灵石' }
|
||||||
|
export function itemNameOf(k: string): string {
|
||||||
|
return ITEM_NAMES[k] ?? k
|
||||||
|
}
|
||||||
|
export function buildingById(id: string): BuildingDef | undefined {
|
||||||
|
return BUILDINGS[id] ?? DYN_BUILDINGS.get(id)
|
||||||
|
}
|
||||||
|
export function listBuildingDefs(): BuildingDef[] {
|
||||||
|
return [...Object.values(BUILDINGS), ...DYN_BUILDINGS.values()]
|
||||||
|
}
|
||||||
|
/** 表达式 → 月产/升级费函数('10 * L'/'80 * 1.7^(L-1)' 白名单求值——0.1.36 起纯解析器) */
|
||||||
|
export function exprToTable(expr: string): (level: number) => Record<string, number> {
|
||||||
|
const entries = Object.entries(JSON.parse(exprCfg(expr) ?? '{}') as Record<string, string>)
|
||||||
|
return (L: number) => {
|
||||||
|
const out: Record<string, number> = {}
|
||||||
|
for (const [k, v] of entries) {
|
||||||
|
const val = arith(String(v), L)
|
||||||
|
if (Number.isFinite(val)) out[k] = Math.round(val)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function exprCfg(_e: string): string | null {
|
||||||
|
void _e
|
||||||
|
return '{}' // MOD 建筑产表以 data 形式在 modManager 构造(见其 toBuildingDef)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建筑月产表达式求值(0.1.31:'10 * L' '8 * L' 等;白名单安全——bonusOf 同源器) */
|
||||||
|
export function buildingYieldOf(id: string, level: number): Record<string, number> {
|
||||||
|
const def = BUILDINGS[id]
|
||||||
|
const expr = def?.produceTable ? JSON.stringify(def.produceTable(level)) : '{}'
|
||||||
|
return JSON.parse(expr) as Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0.1.40 建筑专精乘数:专精建筑 extra 加成 ×1.5 */
|
||||||
|
export const SPECIALTY_MULT = 1.5
|
||||||
|
|
||||||
|
/** 0.1.40 检查建筑是否为当前专精建筑(flag 路径:family.flag['buildingSpecialty']) */
|
||||||
|
export function isSpecialty(id: string, specialtyId?: string): boolean {
|
||||||
|
return !!specialtyId && id === specialtyId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 extra 表达式(如 '0.5 + 0.1*L'):仅允许 数字/括号/四则/空格 与变量 L。
|
||||||
|
* 白名单校验后求值——所有数字加成以 extra 为单一权威。
|
||||||
|
* 0.1.40:专精建筑 extra 加成 ×1.5(specialtyId 传入时自动判定) */
|
||||||
|
export function bonusOf(id: string, key: string, level: number, specialtyId?: string): number {
|
||||||
|
const def = BUILDINGS[id]
|
||||||
|
const expr = def?.extra?.[key]
|
||||||
|
if (!expr) return 0
|
||||||
|
// 0.1.36 CSP safe:纯解析器——不再 Function()(unsafe-eval 被 Electron 打包版拦截)
|
||||||
|
const v = arith(expr, level)
|
||||||
|
if (!Number.isFinite(v)) return 0
|
||||||
|
// 0.1.40 专精加成
|
||||||
|
return isSpecialty(id, specialtyId) ? v * SPECIALTY_MULT : v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 内置 MOD 仓库(0.1.30 M1):开箱即用的示范包——
|
||||||
|
* 风物包(词库+灾因 brief)与战备包(配方+世界数值覆写)。
|
||||||
|
* 均为纯 JSON 语义(modSchema 同构),安装即得官方范式模版。
|
||||||
|
*/
|
||||||
|
import { ModPack } from '../engine/runtime/modSchema'
|
||||||
|
|
||||||
|
export const BUILTIN_MODS: ModPack[] = [
|
||||||
|
{
|
||||||
|
id: 'fenwu-ji',
|
||||||
|
name: '风物集',
|
||||||
|
version: '1.0.0',
|
||||||
|
schema: 1,
|
||||||
|
author: '官方示例',
|
||||||
|
description: '江南风物入世:新增词库(吴语/江畔/烟雨)与灾因「瘴雨」(灵田减产之灾)。',
|
||||||
|
data: {
|
||||||
|
worldgen: { fams: ['吴', '江', '雨'], regions: ['江南烟雨畔', '吴淞江湾'], styles: ['风雅世家', '渔隐门'] },
|
||||||
|
calamities: [{ name: '瘴雨', brief: '瘴雨连绵,田间霉蛀——灵植减产。', family: { lingcao: 0.75 }, effect: { lingcao: -0.2 } }]
|
||||||
|
}
|
||||||
|
} as ModPack,
|
||||||
|
{
|
||||||
|
id: 'zhanbei-xv',
|
||||||
|
name: '战备解军',
|
||||||
|
version: '1.0.0',
|
||||||
|
schema: 1,
|
||||||
|
author: '官方示例',
|
||||||
|
description: '战备支线:新增灵纹刃配方(灵矿+兽核),世界供需微调(供 0.018 需 0.014)。',
|
||||||
|
data: {
|
||||||
|
forges: [{ output: 'weapon-lingwoon', name: '灵纹刃', stones: 700, lingkuang: 15, beastcore: 5, desc: '纹蕴灵光,锋锐无俦。' }] as never,
|
||||||
|
worldNum: { worldSupplyRate: 0.018, worldDemandRate: 0.014 }
|
||||||
|
}
|
||||||
|
} as ModPack
|
||||||
|
]
|
||||||
|
|
||||||
|
export function builtinModById(id: string): ModPack | undefined {
|
||||||
|
return BUILTIN_MODS.find((m) => m.id === id)
|
||||||
|
}
|
||||||
@@ -45,7 +45,11 @@ export interface EffectDef {
|
|||||||
formation?: string
|
formation?: string
|
||||||
apprentice?: { build: boolean }
|
apprentice?: { build: boolean }
|
||||||
trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' }
|
trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' }
|
||||||
}
|
/** 0.1.37 功德因果值变动(正=善行,负=恶行) */
|
||||||
|
karma?: number
|
||||||
|
/** 0.1.37 飞升试炼阶段推进(心魔→天劫→飞升) */
|
||||||
|
ascension?: { memberId: string; stage: 'heart' | 'heaven' | 'ascend'; mode?: 'rash' | 'guard' | 'delay' }
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventOptionDef {
|
export interface EventOptionDef {
|
||||||
label: string
|
label: string
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export const FORMATIONS: Record<FormationId, FormationDef> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DYN_FORM = new Map<string, FormationDef>()
|
||||||
|
export function registerFormationDef(def: FormationDef): void { DYN_FORM.set(def.id, def) }
|
||||||
export function formationById(id: string | undefined): FormationDef {
|
export function formationById(id: string | undefined): FormationDef {
|
||||||
|
if (id && DYN_FORM.has(id)) return DYN_FORM.get(id)!
|
||||||
|
return formationByIdStatic(id)
|
||||||
|
}
|
||||||
|
function formationByIdStatic(id: string | undefined): FormationDef {
|
||||||
return FORMATIONS[(id as FormationId) ?? 'vanguard'] ?? FORMATIONS.vanguard
|
return FORMATIONS[(id as FormationId) ?? 'vanguard'] ?? FORMATIONS.vanguard
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
export type ItemKind = 'resource' | 'material' | 'pill' | 'talisman' | 'brew' | 'artifact'
|
||||||
|
|
||||||
export interface ItemDef {
|
export interface ItemDef {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
kind: 'resource' | 'pill' | 'artifact'
|
kind: ItemKind
|
||||||
basePrice: number
|
basePrice: number
|
||||||
desc: string
|
desc: string
|
||||||
icon: string
|
icon: string
|
||||||
@@ -17,17 +19,37 @@ export const ITEMS: Record<string, ItemDef> = {
|
|||||||
'pill-ningyuan': { id: 'pill-ningyuan', name: '凝元丹', kind: 'pill', basePrice: 150, desc: '筑基以上可效,修为增长显著。', icon: '元' },
|
'pill-ningyuan': { id: 'pill-ningyuan', name: '凝元丹', kind: 'pill', basePrice: 150, desc: '筑基以上可效,修为增长显著。', icon: '元' },
|
||||||
'pill-pojing': { id: 'pill-pojing', name: '破境丹', kind: 'pill', basePrice: 480, desc: '冲击瓶颈时的辅助神物,提升突破成功率。', icon: '破' },
|
'pill-pojing': { id: 'pill-pojing', name: '破境丹', kind: 'pill', basePrice: 480, desc: '冲击瓶颈时的辅助神物,提升突破成功率。', icon: '破' },
|
||||||
|
|
||||||
|
'lingyu': { id: 'lingyu', name: '灵玉', kind: 'material', basePrice: 40, desc: '山髓凝玉,铸器布阵之珍。', icon: '玉' },
|
||||||
|
'lingmu': { id: 'lingmu', name: '灵木', kind: 'material', basePrice: 18, desc: '百年灵木,可燃可刻,符纸之基。', icon: '木' },
|
||||||
|
'shoupi': { id: 'shoupi', name: '兽皮', kind: 'material', basePrice: 15, desc: '妖兽皮革,柔韧生辉,炼甲之材。', icon: '皮' },
|
||||||
|
'lingguo': { id: 'lingguo', name: '灵果', kind: 'material', basePrice: 22, desc: '灵田熟成的仙果,气韵清甜。', icon: '果' },
|
||||||
|
'linglu': { id: 'linglu', name: '灵露', kind: 'material', basePrice: 10, desc: '晨曦灵露,润脉养神。', icon: '露' },
|
||||||
|
'dansha': { id: 'dansha', name: '丹砂', kind: 'material', basePrice: 26, desc: '朱砂灵脉,丹炉必备。', icon: '砂' },
|
||||||
|
'fuzhi': { id: 'fuzhi', name: '符纸', kind: 'material', basePrice: 9, desc: '引灵符纸,书符所依。', icon: '符' },
|
||||||
|
|
||||||
|
'talisman-feng': { id: 'talisman-feng', name: '风符', kind: 'talisman', basePrice: 60, desc: '风灵符箓,开战时身法轻灵(战力+12%)。', icon: '风' },
|
||||||
|
'talisman-shan': { id: 'talisman-shan', name: '山符', kind: 'talisman', basePrice: 70, desc: '山岳符箓,临敌气定(战力+15%,受创减半)。', icon: '山' },
|
||||||
|
'brew-niang': { id: 'brew-niang', name: '灵酿', kind: 'brew', basePrice: 40, desc: '灵果所酿,饮之回气宁神(渡劫后调息佳品)。', icon: '酿' },
|
||||||
|
|
||||||
'weapon-fan': { id: 'weapon-fan', name: '凡器', kind: 'artifact', basePrice: 120, desc: '普通铁器,聊胜于无。', icon: '凡' },
|
'weapon-fan': { id: 'weapon-fan', name: '凡器', kind: 'artifact', basePrice: 120, desc: '普通铁器,聊胜于无。', icon: '凡' },
|
||||||
'weapon-qi': { id: 'weapon-qi', name: '法器', kind: 'artifact', basePrice: 450, desc: '蕴灵之器,可引动灵力。', icon: '法' },
|
'weapon-qi': { id: 'weapon-qi', name: '法器', kind: 'artifact', basePrice: 450, desc: '蕴灵之器,可引动灵力。', icon: '法' },
|
||||||
'weapon-ling': { id: 'weapon-ling', name: '灵器', kind: 'artifact', basePrice: 1400, desc: '有灵之器,锋芒隐露。', icon: '灵' },
|
'weapon-ling': { id: 'weapon-ling', name: '灵器', kind: 'artifact', basePrice: 1400, desc: '有灵之器,锋芒隐露。', icon: '灵' },
|
||||||
'weapon-fa': { id: 'weapon-fa', name: '法宝', kind: 'artifact', basePrice: 3600, desc: '罕世法宝,非金丹不能驾驭。', icon: '宝' }
|
'weapon-fa': { id: 'weapon-fa', name: '法宝', kind: 'artifact', basePrice: 3600, desc: '罕世法宝,非金丹不能驾驭。', icon: '宝' },
|
||||||
|
|
||||||
|
// 0.1.33 闭环:丹方/铸器产出物归位(此前仅存于配方,物品表无定义——幽灵物品)
|
||||||
|
'pill-heling': { id: 'pill-heling', name: '和灵丹', kind: 'pill', basePrice: 120, desc: '和理灵机,金丹以下均可服。', icon: '和' },
|
||||||
|
'pill-xudan': { id: 'pill-xudan', name: '虚元丹', kind: 'pill', basePrice: 320, desc: '丹中朱砂,元婴以下服之可提元。', icon: '虚' },
|
||||||
|
'weapon-yu': { id: 'weapon-yu', name: '玉澜刃', kind: 'artifact', basePrice: 950, desc: '温润如玉,锋芒内敛的灵木之刃。', icon: '澜' },
|
||||||
|
'weapon-lingwoon': { id: 'weapon-lingwoon', name: '灵纹刃', kind: 'artifact', basePrice: 700, desc: '纹蕴灵光,锋锐无俦——战备解军范文。', icon: '纹' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ARTIFACT_POWER: Record<string, number> = {
|
export const ARTIFACT_POWER: Record<string, number> = {
|
||||||
'weapon-fan': 0.1,
|
'weapon-fan': 0.1,
|
||||||
'weapon-qi': 0.25,
|
'weapon-qi': 0.25,
|
||||||
'weapon-ling': 0.5,
|
'weapon-ling': 0.5,
|
||||||
'weapon-fa': 0.9
|
'weapon-fa': 0.9,
|
||||||
|
'weapon-yu': 0.4,
|
||||||
|
'weapon-lingwoon': 0.35
|
||||||
}
|
}
|
||||||
|
|
||||||
export function itemById(id: string): ItemDef {
|
export function itemById(id: string): ItemDef {
|
||||||
@@ -41,3 +63,64 @@ export interface SimpleTradeItem {
|
|||||||
desc: string
|
desc: string
|
||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 丹方:丹房等级门槛 + 材料 + 灵石;唯一权威(craftPill 引用)。 */
|
||||||
|
export interface PillRecipe {
|
||||||
|
output: string
|
||||||
|
name: string
|
||||||
|
danfangLevel: number
|
||||||
|
stones: number
|
||||||
|
lingcao: number
|
||||||
|
beastcore: number
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
export const PILL_RECIPES: PillRecipe[] = [
|
||||||
|
{ output: 'pill-qiyuan', name: '聚气丹', danfangLevel: 1, stones: 20, lingcao: 15, beastcore: 0, desc: '炼气期修士服之,一月修为大增。' },
|
||||||
|
{ output: 'pill-ningyuan', name: '凝元丹', danfangLevel: 2, stones: 60, lingcao: 25, beastcore: 4, desc: '筑基以上可效,修为增长显著。' },
|
||||||
|
{ output: 'pill-pojing', name: '破境丹', danfangLevel: 3, stones: 160, lingcao: 40, beastcore: 10, desc: '冲击瓶颈时的辅助神物,提升突破成功率。' },
|
||||||
|
// R2 新丹(沿用基础料组合——万宝琳琅)
|
||||||
|
{ output: 'pill-heling', name: '和灵丹', danfangLevel: 2, stones: 80, lingcao: 20, beastcore: 8, desc: '和理灵机,金丹以下均可服。' },
|
||||||
|
{ output: 'pill-xudan', name: '虚元丹', danfangLevel: 4, stones: 260, lingcao: 50, beastcore: 16, desc: '丹中朱砂,元婴以下服之可提元。' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 铸器配方:灵矿+兽核+灵石 → 兵刃;无建筑限制(自建工坊念头,数值即门槛)。 */
|
||||||
|
export interface ForgeRecipe {
|
||||||
|
output: string
|
||||||
|
name: string
|
||||||
|
stones: number
|
||||||
|
lingkuang: number
|
||||||
|
beastcore: number
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
export const FORGE_RECIPES: ForgeRecipe[] = [
|
||||||
|
{ output: 'weapon-qi', name: '法器', stones: 300, lingkuang: 8, beastcore: 2, desc: '蕴灵之器,可引动灵力。' },
|
||||||
|
{ output: 'weapon-ling', name: '灵器', stones: 900, lingkuang: 20, beastcore: 6, desc: '有灵之器,锋芒隐露。' },
|
||||||
|
{ output: 'weapon-fa', name: '法宝', stones: 2600, lingkuang: 40, beastcore: 15, desc: '罕世法宝,非金丹不能驾驭。' },
|
||||||
|
// R2 新材料铸器(灵玉/灵木)
|
||||||
|
{ output: 'weapon-yu', name: '玉澜刃', stones: 800, lingkuang: 14, beastcore: 4, desc: '温润如玉,锋芒内敛。' },
|
||||||
|
{ output: 'brew-niang', name: '灵酿', stones: 30, lingkuang: 0, beastcore: 0, desc: '灵果灵露所酿——饮之回气。' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 聚合配方表(base + MOD/插件补充;确定性——注册即追加) */
|
||||||
|
const EXTENDED_PILLS: PillRecipe[] = []
|
||||||
|
const EXTENDED_FORGES: ForgeRecipe[] = []
|
||||||
|
export function addPillRecipe(r: PillRecipe): void {
|
||||||
|
if (!EXTENDED_PILLS.find((x) => x.output === r.output)) EXTENDED_PILLS.push(r)
|
||||||
|
}
|
||||||
|
export function addForgeRecipe(r: ForgeRecipe): void {
|
||||||
|
if (!EXTENDED_FORGES.find((x) => x.output === r.output)) EXTENDED_FORGES.push(r)
|
||||||
|
}
|
||||||
|
export function removePillRecipe(output: string): void {
|
||||||
|
const idx = EXTENDED_PILLS.findIndex((x) => x.output === output)
|
||||||
|
if (idx >= 0) EXTENDED_PILLS.splice(idx, 1)
|
||||||
|
}
|
||||||
|
export function removeForgeRecipe(output: string): void {
|
||||||
|
const idx = EXTENDED_FORGES.findIndex((x) => x.output === output)
|
||||||
|
if (idx >= 0) EXTENDED_FORGES.splice(idx, 1)
|
||||||
|
}
|
||||||
|
export function pillRecipeByOutput(id: string): PillRecipe | undefined {
|
||||||
|
return PILL_RECIPES.find((r) => r.output === id) ?? EXTENDED_PILLS.find((r) => r.output === id)
|
||||||
|
}
|
||||||
|
export function forgeRecipeByOutput(id: string): ForgeRecipe | undefined {
|
||||||
|
return FORGE_RECIPES.find((r) => r.output === id) ?? EXTENDED_FORGES.find((r) => r.output === id)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,669 @@
|
|||||||
|
/**
|
||||||
|
* 0.1.40 天命系统 · 数据层
|
||||||
|
*
|
||||||
|
* 天命议题池:每 3~5 年(种子派生选取,零 rng 消耗),天下大势产生一个"天命议题"。
|
||||||
|
* 玩家必须做出影响家族与天下走向的重大决策——"天命不可违"。
|
||||||
|
*
|
||||||
|
* 天命经 fire(w, id, 1) 高优入事件闸(可顶替被占日常事件)。
|
||||||
|
* 天命不可"稍后再议"(requeue)——不可回避。
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 效果复用 EffectDef 结构(零新类型)
|
||||||
|
* - 选取走种子派生(seed ^ year ^ mandateCount 哈希),零 rng 消耗
|
||||||
|
* - 每条议题含 cond 过滤(不满足条件的议题不入候选池)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Cond, EffectDef } from './events'
|
||||||
|
|
||||||
|
/** 天命类别 */
|
||||||
|
export type MandateCategory = 'calamity' | 'war' | 'fortune' | 'visitor' | 'turbulence'
|
||||||
|
|
||||||
|
/** 天命选项 */
|
||||||
|
export interface MandateOption {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
eff: EffectDef
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 天命议题定义 */
|
||||||
|
export interface MandateDef {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: MandateCategory
|
||||||
|
text: string
|
||||||
|
options: MandateOption[]
|
||||||
|
/** 触发条件(不满足的议题不出现在候选池中) */
|
||||||
|
cond?: Cond
|
||||||
|
/** 天命权重(在同类中抽签的权重,默认 10) */
|
||||||
|
weight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 天命类别中文名 */
|
||||||
|
export function mandateCategoryName(cat: MandateCategory): string {
|
||||||
|
return cat === 'calamity' ? '天灾篇'
|
||||||
|
: cat === 'war' ? '人祸篇'
|
||||||
|
: cat === 'fortune' ? '天时篇'
|
||||||
|
: cat === 'visitor' ? '人事篇'
|
||||||
|
: '乱世篇'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天命议题池(20 条,分 5 类,每类 4 条)
|
||||||
|
*
|
||||||
|
* 设计要点:
|
||||||
|
* - calamity(天灾):考验玩家的资源调度与道德抉择
|
||||||
|
* - war(人祸):考验玩家的军事与外交平衡
|
||||||
|
* - fortune(天时):考验玩家的战略取舍(短期 vs 长期)
|
||||||
|
* - visitor(人事):考验玩家的人才经营
|
||||||
|
* - turbulence(乱世):考验玩家的天下站位
|
||||||
|
*/
|
||||||
|
export const MANDATES: MandateDef[] = [
|
||||||
|
// ──────────────── 天灾篇 ────────────────
|
||||||
|
{
|
||||||
|
id: 'mandate-drought',
|
||||||
|
name: '大旱连年',
|
||||||
|
category: 'calamity',
|
||||||
|
weight: 12,
|
||||||
|
text: '天象异变,赤地千里——方圆数百里灵田干裂,灵泉枯竭。周遭散修聚于族门求一口灵水。天灾面前,族中存粮与道义如何权衡?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '开仓赈灾',
|
||||||
|
hint: '灵石-300,声望+15,功德+8',
|
||||||
|
eff: { res: { stones: -300 }, rep: 15, karma: 8 },
|
||||||
|
note: '四方散修感念恩德,族名远扬。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '筑坛祈雨',
|
||||||
|
hint: '灵草-50,全员修为微升,功德+3',
|
||||||
|
eff: { res: { lingcao: -50 }, karma: 3, memberBy: { by: 'exp', target: 'all', n: 3 } },
|
||||||
|
note: '族中修士联手做法,天际隐隐有甘霖之意。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '坐视不理',
|
||||||
|
hint: '功德-12,声望-8,可能招致难民冲击',
|
||||||
|
eff: { karma: -12, rep: -8 },
|
||||||
|
note: '散修愤而散去,日后或成祸患。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-plague',
|
||||||
|
name: '疫病横行',
|
||||||
|
category: 'calamity',
|
||||||
|
weight: 10,
|
||||||
|
text: '一场诡异疫病在周边坊市蔓延——染者灵力逆乱,三日不治则经脉尽毁。族中医者请命:是封锁山门隔绝疫源,还是以丹药济世?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '封锁山门',
|
||||||
|
hint: '声望-5,全族安全,功德-2',
|
||||||
|
eff: { rep: -5, karma: -2 },
|
||||||
|
note: '紧闭门户虽保族中平安,却失了人心。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '炼丹济世',
|
||||||
|
hint: '灵草-80,兽核-10,声望+12,功德+10',
|
||||||
|
eff: { res: { lingcao: -80, beastcore: -10 }, rep: 12, karma: 10 },
|
||||||
|
note: '丹药送出,疫病渐息,四方称颂。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '借疫敛财',
|
||||||
|
hint: '灵石+400,功德-20,声望-15',
|
||||||
|
eff: { res: { stones: 400 }, karma: -20, rep: -15 },
|
||||||
|
note: '高价售药虽得暴利,却种下恶因。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-flood',
|
||||||
|
name: '洪涝滔天',
|
||||||
|
category: 'calamity',
|
||||||
|
weight: 10,
|
||||||
|
text: '连月暴雨如注,灵河决堤——下游数处坊市被淹,灵田被冲毁者十之六七。水患当前,治水还是自救?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '组织治水',
|
||||||
|
hint: '灵石-200,灵矿-20,声望+10,功德+6',
|
||||||
|
eff: { res: { stones: -200, lingkuang: -20 }, rep: 10, karma: 6 },
|
||||||
|
note: '族中修士以术法疏导洪流,保住数处坊市。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '加固本族',
|
||||||
|
hint: '灵矿-30,仅保本族灵田',
|
||||||
|
eff: { res: { lingkuang: -30 } },
|
||||||
|
note: '护住了自家,却眼看邻家田园尽没。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '趁灾捞利',
|
||||||
|
hint: '灵石+250,功德-15,声望-10',
|
||||||
|
eff: { res: { stones: 250 }, karma: -15, rep: -10 },
|
||||||
|
note: '灾中哄抬物价,财是进了,道义却亏了。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-beasttide',
|
||||||
|
name: '兽潮汹涌',
|
||||||
|
category: 'calamity',
|
||||||
|
weight: 10,
|
||||||
|
cond: { minYear: 5 },
|
||||||
|
text: '深山老林中妖兽暴动,成群结队涌向人烟——灵兽园首当其冲,周边散修纷纷逃向族门求庇护。兽潮将至,如何应对?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '出兵抵御',
|
||||||
|
hint: '全员参战,胜则兽核+20,声望+8',
|
||||||
|
eff: { memberBy: { by: 'exp', target: 'all', n: 2 }, res: { beastcore: 20 }, rep: 8, karma: 4 },
|
||||||
|
note: '击退兽潮,满载而归。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '诱兽离境',
|
||||||
|
hint: '灵草-30,灵石-100,无损退敌',
|
||||||
|
eff: { res: { lingcao: -30, stones: -100 }, karma: 2 },
|
||||||
|
note: '以灵草为饵将兽群引向荒山,不伤一兵一卒。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '献地求安',
|
||||||
|
hint: '灵石-350,声望-6',
|
||||||
|
eff: { res: { stones: -350 }, rep: -6, karma: -3 },
|
||||||
|
note: '割让灵地赔款,暂保平安——但地盘缩了。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ──────────────── 人祸篇 ────────────────
|
||||||
|
{
|
||||||
|
id: 'mandate-bandits',
|
||||||
|
name: '流寇袭扰',
|
||||||
|
category: 'war',
|
||||||
|
weight: 12,
|
||||||
|
text: '乱世流寇盘踞山头,劫掠过往商旅——近日竟犯族门灵道,截去灵石若干。族中弟子请战:是出兵围剿,还是破财消灾?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '出兵围剿',
|
||||||
|
hint: '战力判定——胜则声望+10,灵石+150',
|
||||||
|
eff: { memberBy: { by: 'exp', target: 'highestPower', n: 5 }, rep: 10, res: { stones: 150 }, karma: 3 },
|
||||||
|
note: '遣精锐扫荡匪巢,缴获颇丰。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '招安纳降',
|
||||||
|
hint: '灵石-100,人丁+2,声望-3',
|
||||||
|
eff: { res: { stones: -100 }, rep: -3, karma: 4, flag: { banditRecruit: true } },
|
||||||
|
note: '流寇归顺入族,虽非良材,胜在充丁。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '贿赂驱离',
|
||||||
|
hint: '灵石-200',
|
||||||
|
eff: { res: { stones: -200 }, karma: -2 },
|
||||||
|
note: '花钱买平安——但流寇去了又来,何时是头?'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-borderwar',
|
||||||
|
name: '边境烽烟',
|
||||||
|
category: 'war',
|
||||||
|
weight: 10,
|
||||||
|
cond: { minYear: 8 },
|
||||||
|
text: '两方天下家开战,战场就在族门百里之外——灵脉被战火波及,坊市断商。是选边站队,还是保持中立?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '支持强者',
|
||||||
|
hint: '灵石-150,与胜方关系+25,败方-25',
|
||||||
|
eff: { res: { stones: -150 }, flag: { borderWarPicked: 'strong' } },
|
||||||
|
note: '押注强者——赢了得利,输了遭殃。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '支持弱者',
|
||||||
|
hint: '灵石-100,与弱方关系+30,强方-15,功德+5',
|
||||||
|
eff: { res: { stones: -100 }, karma: 5, flag: { borderWarPicked: 'weak' } },
|
||||||
|
note: '雪中送炭,弱者或铭记在心。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '中立观望',
|
||||||
|
hint: '声望-3,两方关系各-5',
|
||||||
|
eff: { rep: -3 },
|
||||||
|
note: '两边不得罪——也两边不讨好。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-siege',
|
||||||
|
name: '围庄之危',
|
||||||
|
category: 'war',
|
||||||
|
weight: 8,
|
||||||
|
cond: { minRep: 30, minYear: 10 },
|
||||||
|
text: '一个崛起中的 NPC 家族觊觎族门灵脉,兵临庄下——围而不攻,限三日交出灵石一千否则破庄。兵临城下,何以处之?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '誓死抵抗',
|
||||||
|
hint: '全员出战——胜则声望+20,败则大伤',
|
||||||
|
eff: { memberBy: { by: 'exp', target: 'all', n: 4 }, rep: 20, karma: 5, flag: { siegeFought: true } },
|
||||||
|
note: '族人同仇敌忾,破釜沉舟!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '献财求和',
|
||||||
|
hint: '灵石-800,声望-10',
|
||||||
|
eff: { res: { stones: -800 }, rep: -10, karma: -3 },
|
||||||
|
note: '忍辱献财,暂保门户——但被人看了笑话。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '求援盟友',
|
||||||
|
hint: '需有盟友(关系≥40的NPC),灵石-100',
|
||||||
|
eff: { res: { stones: -100 }, rep: 5, karma: 2, flag: { siegeAlliance: true } },
|
||||||
|
note: '盟友出兵来援,围敌退去,同盟之谊更深。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-mercenary',
|
||||||
|
name: '佣兵招募',
|
||||||
|
category: 'war',
|
||||||
|
weight: 8,
|
||||||
|
cond: { minYear: 6 },
|
||||||
|
text: '一支游历散修队伍慕族名而来——为首者修为不俗,愿以灵石三百为酬,为族效力三年。收还是不收?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '重金聘用',
|
||||||
|
hint: '灵石-300,战力大增,三年后离开',
|
||||||
|
eff: { res: { stones: -300 }, rep: 3, flag: { mercenaryHired: true } },
|
||||||
|
note: '佣兵入族,战力大增——但三年后便走。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '婉言谢绝',
|
||||||
|
hint: '声望+2',
|
||||||
|
eff: { rep: 2 },
|
||||||
|
note: '不收外人——族中自有良材。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '以礼相待送行',
|
||||||
|
hint: '灵石-50,功德+3',
|
||||||
|
eff: { res: { stones: -50 }, karma: 3 },
|
||||||
|
note: '赠以盘缠送走——江湖路远,或再相逢。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ──────────────── 天时篇 ────────────────
|
||||||
|
{
|
||||||
|
id: 'mandate-tidesurge',
|
||||||
|
name: '灵气大涌',
|
||||||
|
category: 'fortune',
|
||||||
|
weight: 12,
|
||||||
|
text: '天象异常——灵气潮汐骤然高涨,方圆百里灵雾弥漫。此等天赐良机,采气修炼还是广修灵田?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '闭门采气',
|
||||||
|
hint: '全员修为+8',
|
||||||
|
eff: { memberBy: { by: 'exp', target: 'all', n: 8 } },
|
||||||
|
note: '族中修士闭关采气,修为精进。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '广修灵田',
|
||||||
|
hint: '灵石-150,灵草产出 ×1.5 持续 24 月',
|
||||||
|
eff: { res: { stones: -150 }, flag: { tideHarvest: true } },
|
||||||
|
note: '趁灵气充沛广植灵草,来年丰收。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '抛售灵草',
|
||||||
|
hint: '灵草+100(可出售换取灵石)',
|
||||||
|
eff: { res: { lingcao: 100 } },
|
||||||
|
note: '灵气催生灵草疯长——趁高价出手!'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-spiritvein',
|
||||||
|
name: '灵脉显踪',
|
||||||
|
category: 'fortune',
|
||||||
|
weight: 10,
|
||||||
|
cond: { minYear: 5 },
|
||||||
|
text: '族中探子在深山发现一条隐没灵脉——尚未被任何家族占据。是立刻开采,还是报与天下共享?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '独占开采',
|
||||||
|
hint: '灵矿+80,灵石+200,功德-3',
|
||||||
|
eff: { res: { lingkuang: 80, stones: 200 }, karma: -3 },
|
||||||
|
note: '灵脉归我——但消息走漏或引来觊觎。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '报与仙盟',
|
||||||
|
hint: '声望+15,灵石+100,功德+6',
|
||||||
|
eff: { res: { stones: 100 }, rep: 15, karma: 6 },
|
||||||
|
note: '仙盟嘉奖我族高义,分润一条灵脉。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '暗中出售',
|
||||||
|
hint: '灵石+350,功德-8',
|
||||||
|
eff: { res: { stones: 350 }, karma: -8 },
|
||||||
|
note: '将灵脉位置卖给了一家——无本买卖。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-treasure',
|
||||||
|
name: '古宝出世',
|
||||||
|
category: 'fortune',
|
||||||
|
weight: 8,
|
||||||
|
cond: { minYear: 8 },
|
||||||
|
text: '一座远古洞府在地震中显露——禁制松动,宝光冲天。四方修士云集争夺。是深入探索,还是坐收渔利?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '遣队探索',
|
||||||
|
hint: '灵石-100,可能得功法/宝器/丹药',
|
||||||
|
eff: { res: { stones: -100 }, techniqueChance: 0.35, artifactChance: 0.25, flag: { treasureExplored: true } },
|
||||||
|
note: '遣精锐深入古洞——机缘与风险并存。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '设卡收税',
|
||||||
|
hint: '灵石+250,声望-5',
|
||||||
|
eff: { res: { stones: 250 }, rep: -5 },
|
||||||
|
note: '在洞府入口设卡——凡入者交灵石百枚。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '通报仙盟',
|
||||||
|
hint: '声望+8,灵石+50,功德+4',
|
||||||
|
eff: { res: { stones: 50 }, rep: 8, karma: 4 },
|
||||||
|
note: '古宝归公,仙盟论功行赏。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-heavenbless',
|
||||||
|
name: '天降祥瑞',
|
||||||
|
category: 'fortune',
|
||||||
|
weight: 8,
|
||||||
|
text: '天降异象——五彩祥云盘旋族门上空七日不散。祥瑞之下,万物滋长,族中人心振奋。这是天道的嘉许,还是考验的前奏?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '设坛祭天',
|
||||||
|
hint: '灵石-100,声望+12,功德+8,全员气血+10',
|
||||||
|
eff: { res: { stones: -100 }, rep: 12, karma: 8, memberBy: { by: 'heal', target: 'all', n: 10 } },
|
||||||
|
note: '祭天谢恩——天道降福,族运昌隆。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '趁势修炼',
|
||||||
|
hint: '全员修为+12',
|
||||||
|
eff: { memberBy: { by: 'exp', target: 'all', n: 12 } },
|
||||||
|
note: '祥瑞灵气入体——族中修士齐齐精进。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '广邀宾客',
|
||||||
|
hint: '灵石-200,声望+18,关系全面提升',
|
||||||
|
eff: { res: { stones: -200 }, rep: 18 },
|
||||||
|
note: '趁祥瑞之名广邀天下——一时风头无两。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ──────────────── 人事篇 ────────────────
|
||||||
|
{
|
||||||
|
id: 'mandate-scholar',
|
||||||
|
name: '名士来访',
|
||||||
|
category: 'visitor',
|
||||||
|
weight: 12,
|
||||||
|
cond: { minRep: 15 },
|
||||||
|
text: '一位游历天下的名士造访族门——此人参悟三教百家,论道之能名闻四海。他愿在族中盘桓数月,但需以灵石供养。是礼聘还是婉拒?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '以礼相待',
|
||||||
|
hint: '灵石-150,功法机率+30%,全员悟性微升',
|
||||||
|
eff: { res: { stones: -150 }, techniqueChance: 0.3, memberBy: { by: 'exp', target: 'all', n: 2 } },
|
||||||
|
note: '名士居族数月,或传功法一部,族中子弟眼界大开。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '切磋论道',
|
||||||
|
hint: '灵石-50,全员悟性+1(高悟性者突破概率提升)',
|
||||||
|
eff: { res: { stones: -50 }, memberBy: { by: 'inspire', target: 'all', n: 3 } },
|
||||||
|
note: '不聘为客——只做论道之交。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '婉言谢绝',
|
||||||
|
hint: '声望-2',
|
||||||
|
eff: { rep: -2 },
|
||||||
|
note: '名士拂袖而去——或传我不敬贤才。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-artisan',
|
||||||
|
name: '匠师投奔',
|
||||||
|
category: 'visitor',
|
||||||
|
weight: 10,
|
||||||
|
cond: { minYear: 4, minBuilding: { id: 'danfang', level: 1 } },
|
||||||
|
text: '一名游方匠师携炼器图谱来投——自称曾为仙盟名工,因故流落。他愿为族中效力,但需以灵矿灵石建炉开炉。收是不收?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '厚礼收留',
|
||||||
|
hint: '灵石-200,灵矿-30,丹房效率永久+10%',
|
||||||
|
eff: { res: { stones: -200, lingkuang: -30 }, flag: { artisanHired: true }, karma: 2 },
|
||||||
|
note: '匠师入族,炼丹炼器皆有所长。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '试用三月',
|
||||||
|
hint: '灵石-80,得一件随机兵器',
|
||||||
|
eff: { res: { stones: -80 }, artifactChance: 0.5 },
|
||||||
|
note: '先试后用——匠师卖力铸了一件兵器。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '荐与他人',
|
||||||
|
hint: '声望+3,灵石+50',
|
||||||
|
eff: { res: { stones: 50 }, rep: 3, karma: 1 },
|
||||||
|
note: '将匠师荐给盟友——做了一桩人情。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-disciple',
|
||||||
|
name: '天纵之资',
|
||||||
|
category: 'visitor',
|
||||||
|
weight: 8,
|
||||||
|
text: '一幼童在族门附近迷路——经探查,此童灵根奇佳,悟性超群,乃百年难遇之才。是收为族中子弟,还是送还寻亲?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '收为族中',
|
||||||
|
hint: '人丁+1(天骄幼童),功德+3',
|
||||||
|
eff: { karma: 3, flag: { prodigyAdopted: true } },
|
||||||
|
note: '幼童入族——或许将成为族中下一代栋梁。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '送还寻亲',
|
||||||
|
hint: '声望+5,功德+6',
|
||||||
|
eff: { rep: 5, karma: 6 },
|
||||||
|
note: '幼童归家——其家族或日后登门致谢。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '试其根骨',
|
||||||
|
hint: '灵石-30,有概率得天才(也有概率伤其根基)',
|
||||||
|
eff: { res: { stones: -30 }, flag: { prodigyTested: true } },
|
||||||
|
note: '以秘法探其根骨——有得有失。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-envoy',
|
||||||
|
name: '仙盟来使',
|
||||||
|
category: 'visitor',
|
||||||
|
weight: 10,
|
||||||
|
cond: { minRep: 25, minYear: 6 },
|
||||||
|
text: '太虚仙盟遣使来访——提请族中派一名弟子入仙盟修行三年。仙盟资源丰厚但规矩森严,弟子或大有长进,或因过被逐。是否应允?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '遣送入盟',
|
||||||
|
hint: '灵石-50,成员悟性+2,三年后归',
|
||||||
|
eff: { res: { stones: -50 }, flag: { envoyToAlliance: true }, memberBy: { by: 'exp', target: 'highestPerception', n: 5 } },
|
||||||
|
note: '遣最聪慧的弟子入盟——三年后归来,脱胎换骨。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '婉拒好意',
|
||||||
|
hint: '声望-5',
|
||||||
|
eff: { rep: -5 },
|
||||||
|
note: '婉拒仙盟——或被视为不识抬举。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '献银代替',
|
||||||
|
hint: '灵石-300,声望+3',
|
||||||
|
eff: { res: { stones: -300 }, rep: 3 },
|
||||||
|
note: '以灵石代遣——仙盟收了钱,但暗记一笔。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ──────────────── 乱世篇 ────────────────
|
||||||
|
{
|
||||||
|
id: 'mandate-choose-side',
|
||||||
|
name: '群雄逐鹿',
|
||||||
|
category: 'turbulence',
|
||||||
|
weight: 10,
|
||||||
|
cond: { minYear: 10 },
|
||||||
|
text: '天下大势已至分水岭——两大阵营对峙,战云密布。中立者两面受压,选边者成王败寇。家族何去何从?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '组建联盟',
|
||||||
|
hint: '需关系≥40的两家以上NPC,声望+10',
|
||||||
|
eff: { rep: 10, karma: 3, flag: { coalitionFormed: true } },
|
||||||
|
note: '牵头组建中立联盟——第三条路。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '保持中立',
|
||||||
|
hint: '声望-5,两方关系各-8',
|
||||||
|
eff: { rep: -5 },
|
||||||
|
note: '两边不沾——但也两边受气。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '择强而从',
|
||||||
|
hint: '与最强NPC关系+30,其余-20',
|
||||||
|
eff: { flag: { choseStrongSide: true }, karma: -2 },
|
||||||
|
note: '押注强者——赢了分蛋糕,输了上餐桌。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-rebellion',
|
||||||
|
name: '宗门叛逆',
|
||||||
|
category: 'turbulence',
|
||||||
|
weight: 8,
|
||||||
|
cond: { minYear: 8, minMembers: 12 },
|
||||||
|
text: '族中一名高修为弟子心生异志——暗中联络外族,意图自立门户。事发东窗,如何处置?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '大义灭亲',
|
||||||
|
hint: '声望+5,功德+4,人丁-1',
|
||||||
|
eff: { rep: 5, karma: 4, flag: { rebelPunished: true } },
|
||||||
|
note: '公审叛逆,以正族规——人心震慑。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '宽恕放逐',
|
||||||
|
hint: '功德+6,声望-3',
|
||||||
|
eff: { rep: -3, karma: 6 },
|
||||||
|
note: '念其旧功,放逐出族——或日后报恩。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '安抚留用',
|
||||||
|
hint: '灵石-100,声望-8',
|
||||||
|
eff: { res: { stones: -100 }, rep: -8, karma: -2 },
|
||||||
|
note: '好言安抚赐以灵石——但人心已散。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-elder-council',
|
||||||
|
name: '长老议政',
|
||||||
|
category: 'turbulence',
|
||||||
|
weight: 10,
|
||||||
|
cond: { minGeneration: 3, minAdult: 5 },
|
||||||
|
text: '族中已传数代,人丁渐旺——长老们提议设立长老会,分权制衡。家主独尊还是从善如流?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '设立长老会',
|
||||||
|
hint: '声望+8,全员心境+3,家主权力分散',
|
||||||
|
eff: { rep: 8, karma: 2, memberBy: { by: 'inspire', target: 'all', n: 3 }, flag: { elderCouncil: true } },
|
||||||
|
note: '长老会成立——族务共议,人心凝聚。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '独断专行',
|
||||||
|
hint: '声望-5,家主修为+5',
|
||||||
|
eff: { rep: -5, karma: -3, memberBy: { by: 'exp', target: 'head', n: 5 } },
|
||||||
|
note: '家主一言堂——权力集中,但人心微妙。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '折中设参议',
|
||||||
|
hint: '声望+3,灵石-50',
|
||||||
|
eff: { rep: 3, res: { stones: -50 }, flag: { advisoryBoard: true } },
|
||||||
|
note: '设参议之位——长老有言权无决权。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mandate-succession',
|
||||||
|
name: '储位之争',
|
||||||
|
category: 'turbulence',
|
||||||
|
weight: 10,
|
||||||
|
cond: { minGeneration: 3, minYear: 15 },
|
||||||
|
text: '家主年事渐高,继承人未定——族中分作两派,各推人选。纷争暗涌,若不决断,恐生内乱。储位归属何人?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '立长不立幼',
|
||||||
|
hint: '声望+5,族中安定',
|
||||||
|
eff: { rep: 5, karma: 2, flag: { successionElder: true } },
|
||||||
|
note: '以长幼为序——纲常有序,人心服帖。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '立贤不立长',
|
||||||
|
hint: '声望+3,悟性最高者修为+8',
|
||||||
|
eff: { rep: 3, memberBy: { by: 'exp', target: 'highestPerception', n: 8 }, flag: { successionWise: true } },
|
||||||
|
note: '以才德为先——族中或有不甘,但来日可期。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '不立储位',
|
||||||
|
hint: '声望-8,功德-4',
|
||||||
|
eff: { rep: -8, karma: -4 },
|
||||||
|
note: '拖延不决——暗流涌动,人心浮动。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 按类别分组的天命池索引 */
|
||||||
|
export const MANDATE_BY_CATEGORY: Record<MandateCategory, MandateDef[]> = {
|
||||||
|
calamity: MANDATES.filter((m) => m.category === 'calamity'),
|
||||||
|
war: MANDATES.filter((m) => m.category === 'war'),
|
||||||
|
fortune: MANDATES.filter((m) => m.category === 'fortune'),
|
||||||
|
visitor: MANDATES.filter((m) => m.category === 'visitor'),
|
||||||
|
turbulence: MANDATES.filter((m) => m.category === 'turbulence')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 天命 id 列表(用于冲突检测) */
|
||||||
|
export const MANDATE_IDS: string[] = MANDATES.map((m) => m.id)
|
||||||
|
|
||||||
|
/** 根据 id 查找天命定义 */
|
||||||
|
export function mandateById(id: string): MandateDef | undefined {
|
||||||
|
return MANDATES.find((m) => m.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 种子派生哈希(零 rng 消耗)——选取天命议题
|
||||||
|
* hash = FNV1a(seed_str + ':' + year + ':' + mandateCount) % candidate_pool.length
|
||||||
|
*/
|
||||||
|
export function mandateHash(seed: string, year: number, mandateCount: number, salt: number): number {
|
||||||
|
const str = `${seed}:${year}:${mandateCount}:${salt}`
|
||||||
|
let h = 2166136261
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i)
|
||||||
|
h = Math.imul(h, 16777619)
|
||||||
|
h = (h << 13) | (h >>> 19)
|
||||||
|
}
|
||||||
|
return h >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 天命间隔年数(3~5 年,种子派生) */
|
||||||
|
export function mandateInterval(seed: string, mandateCount: number): number {
|
||||||
|
const h = mandateHash(seed, mandateCount, 0, 0x4d41)
|
||||||
|
return 3 + (h % 3) // 3, 4, or 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 天命类别选取(种子派生,零 rng 消耗) */
|
||||||
|
export function mandateCategoryFor(seed: string, year: number, mandateCount: number): MandateCategory {
|
||||||
|
const categories: MandateCategory[] = ['calamity', 'war', 'fortune', 'visitor', 'turbulence']
|
||||||
|
const h = mandateHash(seed, year, mandateCount, 0x4d43)
|
||||||
|
return categories[h % categories.length]
|
||||||
|
}
|
||||||
@@ -36,8 +36,25 @@ export const NPCS: NpcFamilyDef[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function npcById(id: string): NpcFamilyDef {
|
/** 运行时动态 NPC 注册表(0.1.23 新贵/覆灭;插件化入口) */
|
||||||
const n = NPCS.find((x) => x.id === id)
|
const DYNAMIC_NPCS = new Map<string, NpcFamilyDef>()
|
||||||
if (!n) throw new Error(`npc not found: ${id}`)
|
|
||||||
return n
|
export function registerNpcDef(def: NpcFamilyDef): void {
|
||||||
|
DYNAMIC_NPCS.set(def.id, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterNpcDef(id: string): void {
|
||||||
|
DYNAMIC_NPCS.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 双源查询:静态 NPCS → 运行时动态表(新贵);缺失返回 undefined(调用方防御)。
|
||||||
|
* 注意:此函数拒绝 import registry(循环依赖),静态源直连 NPCS;数据包 npcs 覆写暂不走此路。 */
|
||||||
|
export function npcById(id: string): NpcFamilyDef | undefined {
|
||||||
|
const stat = NPCS.find((x) => x.id === id)
|
||||||
|
if (stat) return stat
|
||||||
|
return DYNAMIC_NPCS.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNpcDefs(): NpcFamilyDef[] {
|
||||||
|
return [...NPCS, ...DYNAMIC_NPCS.values()]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,13 @@ export const POSTS: Record<string, PostDef> = {
|
|||||||
|
|
||||||
export const POST_ORDER = ['head', 'elder', 'guardian', 'steward', 'master']
|
export const POST_ORDER = ['head', 'elder', 'guardian', 'steward', 'master']
|
||||||
|
|
||||||
|
const DYN_POSTS = new Map<string, PostDef>()
|
||||||
|
export function registerPostDef(def: PostDef): void { DYN_POSTS.set(def.id, def) }
|
||||||
export function postById(id: string | undefined): PostDef | null {
|
export function postById(id: string | undefined): PostDef | null {
|
||||||
|
if (id && DYN_POSTS.has(id)) return DYN_POSTS.get(id)!
|
||||||
|
return postStatic(id)
|
||||||
|
}
|
||||||
|
function postStatic(id: string | undefined): PostDef | null {
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
return POSTS[id] ?? null
|
return POSTS[id] ?? null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ export interface TechniqueDef {
|
|||||||
expBonus: number
|
expBonus: number
|
||||||
powerBonus: number
|
powerBonus: number
|
||||||
desc: string
|
desc: string
|
||||||
|
/** 0.1.34 流派效果(全 optional:缺省零行为——金钟罩零漂) */
|
||||||
|
critChance?: number // 暴击率:战斗获胜判定之手中暴击一击(落败时敌方反伤减半变数)
|
||||||
|
guardBonus?: number // 受创减免:战斗受伤(%)-如 0.25=受创 25%折
|
||||||
|
tribBonus?: number // 渡劫成功率加成(正值加成/负值谨防)
|
||||||
|
secretBonus?: number // 秘境掉落加成(任务完成掉落乘数)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TECHNIQUE_GRADE_NAMES = ['凡阶', '黄阶', '玄阶', '地阶', '天阶', '仙阶']
|
export const TECHNIQUE_GRADE_NAMES = ['凡阶', '黄阶', '玄阶', '地阶', '天阶', '仙阶']
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { ITEMS, ARTIFACT_POWER } from './items'
|
|||||||
import { TECHNIQUES } from './techniques'
|
import { TECHNIQUES } from './techniques'
|
||||||
import { BUILDINGS } from './buildings'
|
import { BUILDINGS } from './buildings'
|
||||||
import { MISSIONS, ENEMIES } from './secrets'
|
import { MISSIONS, ENEMIES } from './secrets'
|
||||||
import { NPCS } from './npcs'
|
// npcs 已从 DataPack 摘除(0.1.28:npcById 走静态+动态双源,包覆写是死路——与 MOD 五口宣言对齐)
|
||||||
import { EVENTS } from './events'
|
import { EVENTS } from './events'
|
||||||
import { POSTS } from './posts'
|
import { POSTS } from './posts'
|
||||||
import { TRAITS } from './traits'
|
import { TRAITS } from './traits'
|
||||||
import { ROOT_GRADES, ROOT_GRADE_NAMES, ELEMENT_LIST } from './elements'
|
import { ROOT_GRADES, ROOT_GRADE_NAMES, ELEMENT_LIST } from './elements'
|
||||||
import { MAJORS, MAJOR_ORDER } from './realms'
|
import { MAJORS, MAJOR_ORDER } from './realms'
|
||||||
|
import { ASPIRATIONS } from './aspirations'
|
||||||
|
import { FORMATIONS } from './formations'
|
||||||
|
import { SEASON } from './season'
|
||||||
|
|
||||||
export interface DataPack {
|
export interface DataPack {
|
||||||
items: typeof ITEMS
|
items: typeof ITEMS
|
||||||
@@ -16,7 +19,6 @@ export interface DataPack {
|
|||||||
buildings: typeof BUILDINGS
|
buildings: typeof BUILDINGS
|
||||||
missions: typeof MISSIONS
|
missions: typeof MISSIONS
|
||||||
enemies: typeof ENEMIES
|
enemies: typeof ENEMIES
|
||||||
npcs: typeof NPCS
|
|
||||||
events: typeof EVENTS
|
events: typeof EVENTS
|
||||||
posts: typeof POSTS
|
posts: typeof POSTS
|
||||||
traits: typeof TRAITS
|
traits: typeof TRAITS
|
||||||
@@ -25,6 +27,9 @@ export interface DataPack {
|
|||||||
elements: typeof ELEMENT_LIST
|
elements: typeof ELEMENT_LIST
|
||||||
majors: typeof MAJORS
|
majors: typeof MAJORS
|
||||||
majorOrder: typeof MAJOR_ORDER
|
majorOrder: typeof MAJOR_ORDER
|
||||||
|
aspirations: typeof ASPIRATIONS
|
||||||
|
formations: typeof FORMATIONS
|
||||||
|
season: typeof SEASON
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 默认包:与库内静态数据**同引用**(保证确定性指纹不变);未来 MOD 通过注入覆盖 */
|
/** 默认包:与库内静态数据**同引用**(保证确定性指纹不变);未来 MOD 通过注入覆盖 */
|
||||||
@@ -35,7 +40,6 @@ export const DEFAULT_PACK: DataPack = {
|
|||||||
buildings: BUILDINGS,
|
buildings: BUILDINGS,
|
||||||
missions: MISSIONS,
|
missions: MISSIONS,
|
||||||
enemies: ENEMIES,
|
enemies: ENEMIES,
|
||||||
npcs: NPCS,
|
|
||||||
events: EVENTS,
|
events: EVENTS,
|
||||||
posts: POSTS,
|
posts: POSTS,
|
||||||
traits: TRAITS,
|
traits: TRAITS,
|
||||||
@@ -43,7 +47,10 @@ export const DEFAULT_PACK: DataPack = {
|
|||||||
rootGradeNames: ROOT_GRADE_NAMES,
|
rootGradeNames: ROOT_GRADE_NAMES,
|
||||||
elements: ELEMENT_LIST,
|
elements: ELEMENT_LIST,
|
||||||
majors: MAJORS,
|
majors: MAJORS,
|
||||||
majorOrder: MAJOR_ORDER
|
majorOrder: MAJOR_ORDER,
|
||||||
|
aspirations: ASPIRATIONS,
|
||||||
|
formations: FORMATIONS,
|
||||||
|
season: SEASON
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,13 +69,40 @@ export class DataPackRegistry {
|
|||||||
return this.data
|
return this.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 数组追加(0.1.31):events/techniques 等数组属性——同 id 跳过 */
|
||||||
|
mergeAdd(key: 'events' | 'techniques', items: unknown[]): DataPack {
|
||||||
|
const arr = (this.data[key] as unknown[]) ?? []
|
||||||
|
const seen = new Set(arr.map((x) => (x as { id?: string })?.id))
|
||||||
|
for (const it of items) {
|
||||||
|
const id = (it as { id?: string })?.id
|
||||||
|
if (id && seen.has(id)) continue
|
||||||
|
seen.add(id as never)
|
||||||
|
arr.push(it)
|
||||||
|
}
|
||||||
|
return this.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 增量扩展(0.1.31):对表属性做键级 merge(新键补入、同键保留默认——MOD 不覆盖内置) */
|
||||||
|
extend<K extends keyof DataPack>(key: K, additions: Record<string, unknown>): DataPack {
|
||||||
|
const cur = (this.data[key] ?? {}) as Record<string, unknown>
|
||||||
|
const defaults = (DEFAULT_PACK[key] ?? {}) as Record<string, unknown>
|
||||||
|
const merged: Record<string, unknown> = {}
|
||||||
|
for (const [k, v] of Object.entries(cur)) merged[k] = v
|
||||||
|
for (const [k, v] of Object.entries(additions)) {
|
||||||
|
if (k in defaults || k in merged) continue // 不覆盖内置/已扩展
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
;(this.data as unknown as Record<string, unknown>)[key] = merged
|
||||||
|
return this.data
|
||||||
|
}
|
||||||
|
|
||||||
reset(): DataPack {
|
reset(): DataPack {
|
||||||
this.data = DEFAULT_PACK
|
this.data = DEFAULT_PACK
|
||||||
return this.data
|
return this.data
|
||||||
}
|
}
|
||||||
|
|
||||||
fingerprint(): string {
|
fingerprint(): string {
|
||||||
const key = ['items', 'techniques', 'buildings', 'missions', 'events', 'npcs'] as const
|
const key = ['items', 'techniques', 'buildings', 'missions', 'events'] as const
|
||||||
let h = 7
|
let h = 7
|
||||||
for (const k of key) {
|
for (const k of key) {
|
||||||
const o = this.data[k] as unknown as Record<string, unknown>
|
const o = this.data[k] as unknown as Record<string, unknown>
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export function seasonOf(month: number): Season {
|
|||||||
return 'winter'
|
return 'winter'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 0.1.34 季节元素共鸣:功法 element 与时令身合——修炼加成(万象归元) */
|
||||||
|
export const SEASON_ELEMENT: Record<Season, string> = {
|
||||||
|
spring: '木', summer: '火', autumn: '金', winter: '水'
|
||||||
|
}
|
||||||
|
|
||||||
export const SEASON: Record<Season, { name: string; field: number; cult: number; market: number; meditation: number }> = {
|
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 },
|
spring: { name: '春', field: 0.1, cult: 0, market: 0, meditation: 0 },
|
||||||
summer: { name: '夏', field: 0, cult: 0.05, market: 0, meditation: 0 },
|
summer: { name: '夏', field: 0, cult: 0.05, market: 0, meditation: 0 },
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface LootDef {
|
|||||||
artifactChance: number
|
artifactChance: number
|
||||||
techniqueChance: number
|
techniqueChance: number
|
||||||
beastcoreChance?: number
|
beastcoreChance?: number
|
||||||
|
/** 掉功法品阶上限(按秘境难度设定,防低阶秘境出四阶仙典) */
|
||||||
|
techGrades?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MissionDef {
|
export interface MissionDef {
|
||||||
@@ -60,7 +62,7 @@ export const MISSIONS: MissionDef[] = [
|
|||||||
{ kind: 'combat', months: 2, title: '遭遇袭击', enemyId: 'e-tiebei' },
|
{ kind: 'combat', months: 2, title: '遭遇袭击', enemyId: 'e-tiebei' },
|
||||||
{ kind: 'resource', months: 2, title: '寻获残洞', loot: { resources: { lingcao: [30, 80], beastcore: [1, 3] }, artifactChance: 0.05, techniqueChance: 0.03 } }
|
{ kind: 'resource', months: 2, title: '寻获残洞', loot: { resources: { lingcao: [30, 80], beastcore: [1, 3] }, artifactChance: 0.05, techniqueChance: 0.03 } }
|
||||||
],
|
],
|
||||||
completionLoot: { resources: { lingcao: [20, 60], lingkuang: [5, 15] }, artifactChance: 0.08, techniqueChance: 0.03 }
|
completionLoot: { resources: { lingcao: [20, 60], lingkuang: [5, 15], lingmu: [4, 12] }, artifactChance: 0.08, techniqueChance: 0.03, techGrades: [1, 2] }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm-xuangu', name: '玄冰谷', region: '北境寒渊', icon: '冰',
|
id: 'm-xuangu', name: '玄冰谷', region: '北境寒渊', icon: '冰',
|
||||||
@@ -71,7 +73,7 @@ export const MISSIONS: MissionDef[] = [
|
|||||||
{ kind: 'combat', months: 2, title: '泉主现身', enemyId: 'e-huiyuan' },
|
{ kind: 'combat', months: 2, title: '泉主现身', enemyId: 'e-huiyuan' },
|
||||||
{ kind: 'boss', months: 2, title: '冰潭之下', enemyId: 'e-baiqi' }
|
{ kind: 'boss', months: 2, title: '冰潭之下', enemyId: 'e-baiqi' }
|
||||||
],
|
],
|
||||||
completionLoot: { resources: { lingkuang: [30, 70], beastcore: [2, 5] }, artifactChance: 0.15, techniqueChance: 0.06 }
|
completionLoot: { resources: { lingkuang: [30, 70], beastcore: [2, 5], lingyu: [2, 6], shoupi: [3, 8] }, artifactChance: 0.15, techniqueChance: 0.06, techGrades: [2, 3] }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm-guzhan', name: '古战阵', region: '南陵荒原', icon: '战',
|
id: 'm-guzhan', name: '古战阵', region: '南陵荒原', icon: '战',
|
||||||
@@ -83,7 +85,7 @@ export const MISSIONS: MissionDef[] = [
|
|||||||
{ kind: 'combat', months: 2, title: '守阵铁骑', enemyId: 'e-huanhan' },
|
{ kind: 'combat', months: 2, title: '守阵铁骑', enemyId: 'e-huanhan' },
|
||||||
{ kind: 'resource', months: 3, title: '挖开枯井', loot: { resources: { lingkuang: [40, 90], beastcore: [2, 6] }, artifactChance: 0.2, techniqueChance: 0.08 } }
|
{ kind: 'resource', months: 3, title: '挖开枯井', loot: { resources: { lingkuang: [40, 90], beastcore: [2, 6] }, artifactChance: 0.2, techniqueChance: 0.08 } }
|
||||||
],
|
],
|
||||||
completionLoot: { resources: { lingkuang: [40, 90], beastcore: [2, 8] }, artifactChance: 0.22, techniqueChance: 0.1 }
|
completionLoot: { resources: { lingkuang: [40, 90], beastcore: [2, 8], lingyu: [4, 8], shoupi: [4, 12] }, artifactChance: 0.22, techniqueChance: 0.1, techGrades: [2, 3] }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm-lingshan', name: '灵鹫山', region: '东海之滨', icon: '鹫',
|
id: 'm-lingshan', name: '灵鹫山', region: '东海之滨', icon: '鹫',
|
||||||
@@ -110,6 +112,49 @@ export const MISSIONS: MissionDef[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** 0.1.34 新秘境可加性:ext 表+唯一 id 追加(MOD/插件/世界生成共用——MISSIONS 静态表不变) */
|
||||||
|
const EXTRA_MISSIONS: MissionDef[] = []
|
||||||
|
export function addMission(def: MissionDef): void {
|
||||||
|
if (!EXTRA_MISSIONS.find((x) => x.id === def.id)) EXTRA_MISSIONS.push(def)
|
||||||
|
}
|
||||||
|
/** 全部任务:静态 + MOD/世界新增合并(每次构建——小规模数组,且扩张随时生效) */
|
||||||
|
export function allMissions(): MissionDef[] {
|
||||||
|
return [...MISSIONS, ...EXTRA_MISSIONS]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新秘境生成模板(万象归元·天下灵机偶现)——年首世界侧 chance 生出,皆可探索 */
|
||||||
|
export const WONDER_TEMPLATES: Omit<MissionDef, 'id' | 'name'>[] = [
|
||||||
|
{
|
||||||
|
region: '云梦大泽', icon: '泽', desc: '千年碧潭,隐有灵机吞吐——是仙家典籍中的洞天妙处。',
|
||||||
|
realmHint: 'foundation', minMembers: 2, maxMembers: 4, risk: 0.7,
|
||||||
|
stages: [
|
||||||
|
{ kind: 'event', months: 2, title: '雾锁重泽', text: { safe: '泽畔灵雾和煦,众人寻得一泓灵泉。', bad: '泽中瘴气潜涌,队伍仓促退避。' } },
|
||||||
|
{ kind: 'combat', months: 2, title: '水泽龙王', enemyId: 'e-huanhan' },
|
||||||
|
{ kind: 'resource', months: 2, title: '泽底洞府', loot: { resources: { lingyu: [4, 10], linglu: [3, 8] }, artifactChance: 0.18, techniqueChance: 0.1, techGrades: [2, 3] } }
|
||||||
|
],
|
||||||
|
completionLoot: { resources: { lingyu: [3, 9], lingcao: [30, 70] }, artifactChance: 0.2, techniqueChance: 0.12, techGrades: [2, 3] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
region: '苍梧断崖', icon: '崖', desc: '断崖云雾缭绕,偶见琼光微闪——遗世洞府的门扉近在眼前。',
|
||||||
|
realmHint: 'qi', minMembers: 1, maxMembers: 3, risk: 0.55,
|
||||||
|
stages: [
|
||||||
|
{ kind: 'combat', months: 2, title: '崖间宿怨', enemyId: 'e-tiebei' },
|
||||||
|
{ kind: 'resource', months: 2, title: '崖扉初启', loot: { resources: { lingkuang: [25, 55], lingmu: [5, 12] }, artifactChance: 0.1, techniqueChance: 0.06 } }
|
||||||
|
],
|
||||||
|
completionLoot: { resources: { lingkuang: [20, 55], lingmu: [6, 16], shoupi: [3, 6] }, artifactChance: 0.12, techniqueChance: 0.08, techGrades: [1, 2] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
region: '幽都古道', icon: '幽', desc: '青石古道上阴风掠耳——上古修士埋骨之地,得之者富。',
|
||||||
|
realmHint: 'core', minMembers: 3, maxMembers: 4, risk: 0.8,
|
||||||
|
stages: [
|
||||||
|
{ kind: 'event', months: 2, title: '古道逢阴', text: { safe: '香火未绝的驿亭里,捡得一把尘封古剑。', bad: '阴煞蚀骨,众人寒战如大病初愈。' } },
|
||||||
|
{ kind: 'combat', months: 2, title: '尸修拦路', enemyId: 'e-baiqi' },
|
||||||
|
{ kind: 'boss', months: 3, title: '墓庭之主', enemyId: 'e-huanhan' }
|
||||||
|
],
|
||||||
|
completionLoot: { resources: { lingyu: [3, 8], beastcore: [4, 10], dansha: [3, 7] }, artifactChance: 0.3, techniqueChance: 0.14, techGrades: [3] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
export function missionById(id: string): MissionDef {
|
export function missionById(id: string): MissionDef {
|
||||||
const m = MISSIONS.find((x) => x.id === id)
|
const m = MISSIONS.find((x) => x.id === id)
|
||||||
if (!m) throw new Error(`mission not found: ${id}`)
|
if (!m) throw new Error(`mission not found: ${id}`)
|
||||||
|
|||||||
@@ -16,7 +16,15 @@ export const TECHNIQUES: TechniqueDef[] = [
|
|||||||
{ id: 't-jiuxiao', name: '九霄龙吟诀', grade: 3, element: '金', path: '剑修', expBonus: 0.18, powerBonus: 0.35, desc: '龙吟九霄,一剑开天。' },
|
{ id: 't-jiuxiao', name: '九霄龙吟诀', grade: 3, element: '金', path: '剑修', expBonus: 0.18, powerBonus: 0.35, desc: '龙吟九霄,一剑开天。' },
|
||||||
{ id: 't-tianya', name: '天涯孤鸿法', grade: 3, element: '火', path: '符修', expBonus: 0.21, powerBonus: 0.25, desc: '孤鸿照影,天涯为路。' },
|
{ id: 't-tianya', name: '天涯孤鸿法', grade: 3, element: '火', path: '符修', expBonus: 0.21, powerBonus: 0.25, desc: '孤鸿照影,天涯为路。' },
|
||||||
{ id: 't-qiankun', name: '乾坤七星阵', grade: 4, element: '土', path: '阵修', expBonus: 0.25, powerBonus: 0.3, desc: '七星成阵,乾坤为牢。' },
|
{ id: 't-qiankun', name: '乾坤七星阵', grade: 4, element: '土', path: '阵修', expBonus: 0.25, powerBonus: 0.3, desc: '七星成阵,乾坤为牢。' },
|
||||||
{ id: 't-xiantian', name: '先天混元功', grade: 4, element: '木', path: '体修', expBonus: 0.28, powerBonus: 0.38, desc: '混元一气,万法不侵。' }
|
{ id: 't-xiantian', name: '先天混元功', grade: 4, element: '木', path: '体修', expBonus: 0.28, powerBonus: 0.38, desc: '混元一气,万法不侵。' },
|
||||||
|
// 0.1.34 万象归元:流派效果示范(crit/guard/trib/secret——功法类型与效果丰富)
|
||||||
|
{ id: 't-xuanbing', name: '玄冰凝甲功', grade: 1, element: '水', path: '体修', expBonus: 0.11, powerBonus: 0.14, guardBonus: 0.2, desc: '寒甲覆体,攻守两全。' },
|
||||||
|
{ id: 't-zidian', name: '紫电惊雷诀', grade: 1, element: '金', path: '剑修', expBonus: 0.1, powerBonus: 0.18, critChance: 0.1, desc: '雷霆既至,一击定音。' },
|
||||||
|
{ id: 't-danding', name: '丹鼎护道妙典', grade: 2, element: '火', path: '丹修', expBonus: 0.17, powerBonus: 0.1, tribBonus: 0.06, desc: '丹香护鼎,大劫犹安。' },
|
||||||
|
{ id: 't-fengyu', name: '风遁青冥诀', grade: 2, element: '木', path: '符修', expBonus: 0.13, powerBonus: 0.2, secretBonus: 0.2, desc: '青冥风引,探幽得宝。' },
|
||||||
|
{ id: 't-shanling', name: '山灵镇岳录', grade: 2, element: '土', path: '体修', expBonus: 0.14, powerBonus: 0.22, guardBonus: 0.2, critChance: 0.05, desc: '山灵加护,崩山击岳。' },
|
||||||
|
{ id: 't-lanhai', name: '澜海听涛经', grade: 3, element: '水', path: '符修', expBonus: 0.19, powerBonus: 0.24, tribBonus: 0.09, secretBonus: 0.15, desc: '听涛悟澜,化水藏锋。' },
|
||||||
|
{ id: 't-shiling', name: '时灵应候诀', grade: 2, element: '土', path: '符修', expBonus: 0.15, powerBonus: 0.18, secretBonus: 0.1, desc: '土应时气,候至灵生。' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export function techniqueById(id: string | undefined): TechniqueDef | null {
|
export function techniqueById(id: string | undefined): TechniqueDef | null {
|
||||||
|
|||||||
@@ -28,4 +28,9 @@ export const TRAITS: Record<string, TraitDef> = {
|
|||||||
yiqi: { id: 'yiqi', name: '易喜', desc: '脾气如火,战斗极勇。', windBonus: 0.1, danger: 0.5 }
|
yiqi: { id: 'yiqi', name: '易喜', desc: '脾气如火,战斗极勇。', windBonus: 0.1, danger: 0.5 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DYN_TRAITS = new Map<string, TraitDef>()
|
||||||
|
export function registerTraitDef(def: TraitDef): void { DYN_TRAITS.set(def.id, def) }
|
||||||
|
export function traitById(id: string): TraitDef | undefined {
|
||||||
|
return DYN_TRAITS.get(id) ?? TRAITS[id] ?? (Object.values(TRAITS).find((t) => t.id === id))
|
||||||
|
}
|
||||||
export const TRAIT_POOL = Object.keys(TRAITS)
|
export const TRAIT_POOL = Object.keys(TRAITS)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Kernel, makeKernel } from './kernel/Kernel'
|
||||||
|
import { World } from './runtime/World'
|
||||||
|
import { GameFacade as ApiFacade, ActName, ActPayload, FacadeEvent } from './runtime/ApiFacade'
|
||||||
|
import { GameState } from '../types/domain'
|
||||||
|
import { GameClock } from './kernel/clock'
|
||||||
|
import { RngHub } from './kernel/rng'
|
||||||
|
import { CotycPlugin } from './kernel/plugin'
|
||||||
|
import { DataPack, PACK } from '../data/registry'
|
||||||
|
import { createWorldState } from './runtime/creation'
|
||||||
|
import './runtime/demo-plugins' // side-effect:注册示例插件工厂(持久化重装用)
|
||||||
|
import { normalizeGameState } from './runtime/World'
|
||||||
|
|
||||||
|
export interface GameEngineOptions {
|
||||||
|
seed?: string
|
||||||
|
datapack?: Partial<DataPack>
|
||||||
|
noAutoCreate?: boolean
|
||||||
|
/** 建档参数(NewGame 透传;缺省林氏兜底) */
|
||||||
|
surname?: string
|
||||||
|
familyName?: string
|
||||||
|
motto?: string
|
||||||
|
difficulty?: 'easy' | 'normal' | 'hard'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EngineStatus = 'idle' | 'running' | 'gameover'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GameEngine —— 仙途家族志统一游戏引擎(0.1.14 架构核心)。
|
||||||
|
* 一个引擎对象 = 内核(Kernel: 时钟+随机+总线) + 世界(World) + 门面(ApiFacade) + 存储接口。
|
||||||
|
* UI/测试/未来工具仅需认识 GameEngine。
|
||||||
|
*/
|
||||||
|
export class GameEngine {
|
||||||
|
kernel: Kernel
|
||||||
|
readonly datapack: typeof PACK
|
||||||
|
world: World
|
||||||
|
facade: ApiFacade
|
||||||
|
private _seed: string
|
||||||
|
autosaveEvery = 12
|
||||||
|
private tickSinceSave = 0
|
||||||
|
|
||||||
|
constructor(opts: GameEngineOptions = {}) {
|
||||||
|
this._seed = opts.seed ?? RngHub.rollSeed()
|
||||||
|
this.kernel = makeKernel(this._seed)
|
||||||
|
this.datapack = PACK
|
||||||
|
if (opts.datapack) this.datapack.override(opts.datapack)
|
||||||
|
// 组装World(内核注入→时钟/随机/总线单源;0.1.35 移除 __state 泄漏口——seed 恒用 _seed)
|
||||||
|
if (opts.noAutoCreate) {
|
||||||
|
this.world = new World(createWorldState({
|
||||||
|
seed: this._seed, surname: '占位', familyName: '占位', motto: '', difficulty: 'normal'
|
||||||
|
}), [], this.kernel)
|
||||||
|
} else {
|
||||||
|
const state = createWorldState({
|
||||||
|
seed: this._seed,
|
||||||
|
surname: opts.surname?.trim() || '林',
|
||||||
|
familyName: opts.familyName?.trim() || `${opts.surname?.trim() || '林'}氏`,
|
||||||
|
motto: opts.motto?.trim() || '耕读传家,术法继世',
|
||||||
|
difficulty: opts.difficulty ?? 'normal'
|
||||||
|
})
|
||||||
|
this.world = new World(state, [], this.kernel)
|
||||||
|
}
|
||||||
|
this.facade = new ApiFacade(this.world, 1)
|
||||||
|
// Kernel 总线 → World.out 桥接(attached 后同步 feed)
|
||||||
|
this.world.out = this.world.out.concat([])
|
||||||
|
}
|
||||||
|
|
||||||
|
view(): GameState {
|
||||||
|
return this.world.state
|
||||||
|
}
|
||||||
|
|
||||||
|
advance(): void {
|
||||||
|
if (this.world.state.gameOver) return
|
||||||
|
if (this.world.state.pendingEvent) return // 事件待决:停下(用户/自动化应 resolvePending)
|
||||||
|
this.world.advanceMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理当前待决事件(默认选第一项);无待决则 no-op */
|
||||||
|
resolvePending(idx = 0): boolean {
|
||||||
|
const pid = this.world.state.pendingEvent
|
||||||
|
if (!pid) return false
|
||||||
|
// 直接走门面 apply 通路的引擎包装(不依赖 UI)
|
||||||
|
return this.world.applyEventChoice(pid, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncRng(): void {
|
||||||
|
this.world.syncRng()
|
||||||
|
}
|
||||||
|
|
||||||
|
act(name: ActName, payload: ActPayload): boolean {
|
||||||
|
return this.facade.act(name, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
query(ref: string): Record<string, unknown> {
|
||||||
|
return this.facade.query(ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(on: (e: FacadeEvent) => void): () => void {
|
||||||
|
return this.facade.subscribe(on)
|
||||||
|
}
|
||||||
|
|
||||||
|
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
||||||
|
const a = this.facade.about()
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
|
||||||
|
return this.world.installPlugin(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 档位快照(存档 JSON) */
|
||||||
|
snapshot(): GameState {
|
||||||
|
this.world.syncRng()
|
||||||
|
return JSON.parse(JSON.stringify(this.world.state)) as GameState
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从快照恢复(可回放)——重建内核以避免时钟双注册,并回置 rng 保真 */
|
||||||
|
restore(snap: GameState): void {
|
||||||
|
normalizeGameState(snap)
|
||||||
|
// 新内核(同一 seed 派生)+ 回写存档 rng 快照 —— 回放保真
|
||||||
|
this.kernel = makeKernel(snap.seed)
|
||||||
|
this.kernel.rng.state = { ...snap.rng }
|
||||||
|
const newWorld = new World(snap, [], this.kernel)
|
||||||
|
newWorld.out = this.world.out
|
||||||
|
this.world = newWorld
|
||||||
|
this.facade = new ApiFacade(this.world, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): EngineStatus {
|
||||||
|
return this.world.state.gameOver ? 'gameover' : 'running'
|
||||||
|
}
|
||||||
|
|
||||||
|
seed(): string {
|
||||||
|
return this._seed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已用旧内核时钟注册的世界快照,反序列化时公用(测试/便捷入口——非兼容机器) */
|
||||||
|
export function engineFromSnapshot(state: GameState): GameEngine {
|
||||||
|
const eng = new GameEngine({ seed: state.seed, noAutoCreate: true })
|
||||||
|
eng.restore(state)
|
||||||
|
return eng
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { GameClock } from './clock'
|
||||||
|
import { Rng, seedToRng, RngHub } from './rng'
|
||||||
|
import type { WorldEventBus } from '../runtime/World'
|
||||||
|
|
||||||
|
/** 三合一内核:游戏时钟 + 种子随机(单一实例,由 GameEngine 持有)。
|
||||||
|
* 注:事件转发走 World.out(WorldEventBus[]);kernel 挂钩装由 attach 保留供插件层。 */
|
||||||
|
export class Kernel {
|
||||||
|
readonly clock: GameClock
|
||||||
|
readonly rng: Rng
|
||||||
|
private busOut: WorldEventBus[] = []
|
||||||
|
|
||||||
|
constructor(seed: string) {
|
||||||
|
this.clock = new GameClock()
|
||||||
|
this.rng = new Rng(seedToRng(seed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册外部订阅(每引擎一个 World 共享 bus) */
|
||||||
|
attach(out: WorldEventBus): void {
|
||||||
|
this.busOut.push(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
detachAll(): void {
|
||||||
|
this.busOut = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 随机快照回写(存档时) */
|
||||||
|
syncRngTo(state: { rng: ReturnType<Rng['getState']> }): void {
|
||||||
|
state.rng = this.rng.getState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引擎启动统一入口(含默认种子) */
|
||||||
|
export function makeKernel(seed?: string): Kernel {
|
||||||
|
return new Kernel(seed ?? RngHub.rollSeed())
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { World } from '../engine/world'
|
import type { World } from '../runtime/World'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一时轮调度:所有系统以 phase 注册,由时钟按固定顺序驱动。
|
* 统一时轮调度:所有系统以 phase 注册,由时钟按固定顺序驱动。
|
||||||
@@ -14,6 +14,7 @@ export type PhaseId =
|
|||||||
| 'missions'
|
| 'missions'
|
||||||
| 'events'
|
| 'events'
|
||||||
| 'diplomacy'
|
| 'diplomacy'
|
||||||
|
| 'worldsim'
|
||||||
| 'epilogue'
|
| 'epilogue'
|
||||||
|
|
||||||
export type SystemHook = (w: World) => void
|
export type SystemHook = (w: World) => void
|
||||||
@@ -31,12 +32,15 @@ export const PHASE_ORDER: PhaseId[] = [
|
|||||||
'missions',
|
'missions',
|
||||||
'events',
|
'events',
|
||||||
'diplomacy',
|
'diplomacy',
|
||||||
|
'worldsim',
|
||||||
'epilogue'
|
'epilogue'
|
||||||
]
|
]
|
||||||
|
|
||||||
export class GameClock {
|
export class GameClock {
|
||||||
private monthly = new Map<PhaseId, SystemHook[]>()
|
private monthly = new Map<PhaseId, SystemHook[]>()
|
||||||
private yearly: SystemHook[] = []
|
private yearly: SystemHook[] = []
|
||||||
|
private anchorsBefore: Array<{ phase: PhaseId; fn: SystemHook }> = []
|
||||||
|
private anchorsAfter: Array<{ phase: PhaseId; fn: SystemHook }> = []
|
||||||
|
|
||||||
register(phase: PhaseId, fn: SystemHook): () => void {
|
register(phase: PhaseId, fn: SystemHook): () => void {
|
||||||
const list = this.monthly.get(phase) ?? []
|
const list = this.monthly.get(phase) ?? []
|
||||||
@@ -56,6 +60,22 @@ export class GameClock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 时轮锚点(0.1.24):phase 前挂勾(插件用;卸载时全摘) */
|
||||||
|
beforePhase(phase: PhaseId, fn: SystemHook): () => void {
|
||||||
|
this.anchorsBefore.push({ phase, fn })
|
||||||
|
return () => {
|
||||||
|
this.anchorsBefore = this.anchorsBefore.filter((a) => !(a.phase === phase && a.fn === fn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时轮锚点:phase 后挂勾 */
|
||||||
|
afterPhase(phase: PhaseId, fn: SystemHook): () => void {
|
||||||
|
this.anchorsAfter.push({ phase, fn })
|
||||||
|
return () => {
|
||||||
|
this.anchorsAfter = this.anchorsAfter.filter((a) => !(a.phase === phase && a.fn === fn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fireYearStart(w: World): void {
|
fireYearStart(w: World): void {
|
||||||
for (const fn of this.yearly) fn(w)
|
for (const fn of this.yearly) fn(w)
|
||||||
}
|
}
|
||||||
@@ -64,8 +84,10 @@ export class GameClock {
|
|||||||
const report: PhaseStat[] = []
|
const report: PhaseStat[] = []
|
||||||
for (const phase of PHASE_ORDER) {
|
for (const phase of PHASE_ORDER) {
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
|
for (const a of this.anchorsBefore) if (a.phase === phase) a.fn(w)
|
||||||
const fns = [...(this.monthly.get(phase) ?? [])]
|
const fns = [...(this.monthly.get(phase) ?? [])]
|
||||||
for (const fn of fns) fn(w)
|
for (const fn of fns) fn(w)
|
||||||
|
for (const a of this.anchorsAfter) if (a.phase === phase) a.fn(w)
|
||||||
const ms = performance.now() - t0
|
const ms = performance.now() - t0
|
||||||
report.push({ phase, ms, count: fns.length })
|
report.push({ phase, ms, count: fns.length })
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { World } from '../engine/world'
|
import type { World } from '../runtime/World'
|
||||||
|
|
||||||
export interface GuideStep {
|
export interface GuideStep {
|
||||||
id: string
|
id: string
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { GameClock, SystemHook } from './clock'
|
||||||
|
import { DataPack } from '../../data/registry'
|
||||||
|
import { EventDef } from '../../data/events'
|
||||||
|
import type { World } from '../runtime/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
|
||||||
|
/** MOD 插件溯源(0.1.27:导出/显示用——原始 ModPack 引用) */
|
||||||
|
source?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginContext {
|
||||||
|
world: World
|
||||||
|
clock: GameClock
|
||||||
|
register: (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void
|
||||||
|
onYearStart: (fn: SystemHook) => () => void
|
||||||
|
/** 时轮 anchor:phase 前/后挂勾(0.1.24;卸载时全摘) */
|
||||||
|
beforePhase: (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void
|
||||||
|
afterPhase: (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void
|
||||||
|
/** 世界生成钩子:插件可注入 NPC 模板(worldgen 词库插件化) */
|
||||||
|
addNpcTemplate: (def: import('../../data/npcs').NpcFamilyDef) => void
|
||||||
|
/** 配方注册(MOD/内容包扩展丹药/铸器表) */
|
||||||
|
addPillRecipe: (r: { output: string; name: string; danfangLevel: number; stones: number; lingcao: number; beastcore: number; desc: string }) => void
|
||||||
|
addForgeRecipe: (r: { output: string; name: string; stones: number; lingkuang: number; beastcore: number; desc: string }) => void
|
||||||
|
/** 世界生成词库池注入(styles/regions/fams/suffixes) */
|
||||||
|
addWorldGenPool: (part: { styles?: string[]; regions?: string[]; fams?: string[]; suffixes?: string[] }, source?: string) => void
|
||||||
|
addCalamity: (name: string, opts: { brief?: string; family?: Partial<Record<string, number>>; effect?: Partial<Record<string, number>> }) => boolean
|
||||||
|
/** 0.1.37 奇观类型扩展(MOD 可注册自定义世界奇观——零 rng 消耗,种子派生选取) */
|
||||||
|
addWonderType: (def: { kind: string; name?: string; desc?: string; duration?: number; effect?: 'tide_up' | 'tide_down' | 'none'; oneshot?: 'bless' | 'loot' | 'none' }) => boolean
|
||||||
|
/** 0.1.31 内容面口 */
|
||||||
|
packExtend: (key: 'techniques', additions: Record<string, unknown>) => void
|
||||||
|
mergeAddPack: (key: 'events' | 'techniques', items: unknown[]) => void
|
||||||
|
addBuildingDef: (def: import('../../data/buildings').BuildingDef) => void
|
||||||
|
addPostDef: (def: import('../../data/posts').PostDef) => void
|
||||||
|
addAspirationDef: (def: import('../../data/aspirations').AspirationDef) => void
|
||||||
|
addFormationDef: (def: import('../../data/formations').FormationDef) => void
|
||||||
|
/** 0.1.34 功法定义注册(全字段——含流派效果;grade≤3 入求学/秘境池) */
|
||||||
|
addTechniqueDef: (def: import('../../data/realms').TechniqueDef) => void
|
||||||
|
/** 0.1.34 世界观察回调(每世界 tick 后——只读记录,禁止写时轮/序;MOD 数值洞察口) */
|
||||||
|
onWorldSimTick: (fn: (year: number, month: number, gameState: unknown) => void) => () => void
|
||||||
|
addTraitDef: (def: import('../../data/traits').TraitDef) => 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
|
||||||
|
author?: string
|
||||||
|
kind: PluginKind
|
||||||
|
installed: boolean
|
||||||
|
enabled: boolean
|
||||||
|
protected: boolean
|
||||||
|
description: string
|
||||||
|
dependencies?: string[]
|
||||||
|
conflicts?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginChange = { id: string; action: 'install' | 'remove' | 'enable' | 'disable' }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RngState } from '../types/domain'
|
import type { RngState } from '../../types/domain'
|
||||||
|
|
||||||
function xmur3(str: string): () => number {
|
function xmur3(str: string): () => number {
|
||||||
let h = 1779033703 ^ str.length
|
let h = 1779033703 ^ str.length
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Season } from '../../data/season'
|
||||||
|
|
||||||
|
export const TIDES: string[] = [
|
||||||
|
'立春', '雨水', '惊蛰', '春分', '清明', '谷雨',
|
||||||
|
'立夏', '小满', '芒种', '夏至', '小暑', '大暑',
|
||||||
|
'立秋', '处暑', '白露', '秋分', '寒露', '霜降',
|
||||||
|
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 年轮进度(0..1)——用于顶栏年环弧段 */
|
||||||
|
export function yearRing(month: number): number {
|
||||||
|
return (month - 1) / 12
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonTint {
|
||||||
|
accent: string
|
||||||
|
tint: string
|
||||||
|
glow: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TINTS: Record<Season, SeasonTint> = {
|
||||||
|
spring: { accent: '#9cc27c', tint: '#7a9a5c', glow: '114, 178, 106', name: '春·熏风', desc: '草木萌芽,灵田争长。' },
|
||||||
|
summer: { accent: '#d8b35f', tint: '#b48c46', glow: '216, 172, 96', name: '夏·蝉鸣', desc: '心火正旺,修炼如炉。' },
|
||||||
|
autumn: { accent: '#d0954f', tint: '#a97a3e', glow: '212, 148, 84', name: '秋·霜叶', desc: '市集兴旺,收获正忙。' },
|
||||||
|
winter: { accent: '#7f9eb5', tint: '#5c7d94', glow: '127, 158, 181', name: '冬·雪静', desc: '闭藏蓄势,闭关养气。' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seasonTint(season: Season): SeasonTint {
|
||||||
|
return TINTS[season]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS 变量包裹:data-season 驱动整页色温(4-8% 级,金碧基调内) */
|
||||||
|
export function seasonCssVars(season: Season): Record<string, string> {
|
||||||
|
const t = TINTS[season]
|
||||||
|
return {
|
||||||
|
'--season-accent': t.accent,
|
||||||
|
'--season-glow': t.glow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { World } from '../engine/world'
|
import type { World } from '../runtime/World'
|
||||||
|
|
||||||
export interface Urgency {
|
export interface Urgency {
|
||||||
blockedCount: number
|
blockedCount: number
|
||||||
+41
-5
@@ -1,8 +1,8 @@
|
|||||||
import { GameState } from '../types/domain'
|
import { GameState } from '../../types/domain'
|
||||||
import { TECHNIQUES } from '../data/techniques'
|
import { TECHNIQUES } from '../../data/techniques'
|
||||||
import { describeRealm } from '../data/realms'
|
import { describeRealm } from '../../data/realms'
|
||||||
import { aspirationById } from '../data/aspirations'
|
import { aspirationById } from '../../data/aspirations'
|
||||||
import { postById } from '../data/posts'
|
import { postById } from '../../data/posts'
|
||||||
|
|
||||||
export interface BioLine {
|
export interface BioLine {
|
||||||
at?: number
|
at?: number
|
||||||
@@ -56,6 +56,40 @@ export function buildBiography(s: GameState, memberId: string): BioLine[] {
|
|||||||
lines.push({ at: t.year, label: '大比', text: `${t.year}年,随队参加太虚大比,列第${t.rank}名。` })
|
lines.push({ at: t.year, label: '大比', text: `${t.year}年,随队参加太虚大比,列第${t.rank}名。` })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0.1.37 飞升试炼传记
|
||||||
|
if (c.deathCause === '飞升太虚') {
|
||||||
|
const ascensionChronicles = s.chronicle.filter((e) =>
|
||||||
|
e.category === 'breakthrough' && e.memberId === c.id &&
|
||||||
|
(e.text.includes('心魔') || e.text.includes('天劫') || e.text.includes('飞升'))
|
||||||
|
)
|
||||||
|
for (const e of ascensionChronicles) {
|
||||||
|
lines.push({ at: e.year, label: '飞升', text: `${e.year}年${e.month}月,${e.text.replace(new RegExp(`^${c.name}\\s*`), '')}` })
|
||||||
|
}
|
||||||
|
lines.push({
|
||||||
|
at: c.deathYear,
|
||||||
|
label: '飞升',
|
||||||
|
text: `${c.name} 历经心魔、天劫、飞升三重试炼,终成正果——飞升太虚而去。此去仙凡永隔,然道韵余泽笼罩宗族,万世蒙荫。`
|
||||||
|
})
|
||||||
|
} else if (c.deathCause === '飞升劫陨') {
|
||||||
|
lines.push({
|
||||||
|
at: c.deathYear,
|
||||||
|
label: '飞升',
|
||||||
|
text: `${c.name} 于飞升天劫中形神俱灭——仙路终成绝响。壮志未酬,英魂归天。`
|
||||||
|
})
|
||||||
|
} else if (c.traits.includes('fengxian') && c.alive) {
|
||||||
|
// 留仙坐镇者
|
||||||
|
const stayEvent = s.chronicle.find((e) =>
|
||||||
|
e.category === 'event' && e.memberId === c.id && e.text.includes('留世坐镇')
|
||||||
|
)
|
||||||
|
if (stayEvent) {
|
||||||
|
lines.push({
|
||||||
|
at: stayEvent.year,
|
||||||
|
label: '飞升',
|
||||||
|
text: `${stayEvent.year}年,${c.name} 谢绝仙门征召,留世镇守宗族——仙风入体,护佑万世。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 墓碑铭文
|
// 墓碑铭文
|
||||||
if (!c.alive) {
|
if (!c.alive) {
|
||||||
const epitaph = EPITAPH[c.deathCause ?? '寿终'] ?? '一尘一土,终归青山。'
|
const epitaph = EPITAPH[c.deathCause ?? '寿终'] ?? '一尘一土,终归青山。'
|
||||||
@@ -72,6 +106,8 @@ const EPITAPH: Record<string, string> = {
|
|||||||
'伤势不治': '壮士折戟,魂兮归来。家祠之下,与祖同列。',
|
'伤势不治': '壮士折戟,魂兮归来。家祠之下,与祖同列。',
|
||||||
'突破走火': '一念求道,九死未悔。灵前法灯,为君长明。',
|
'突破走火': '一念求道,九死未悔。灵前法灯,为君长明。',
|
||||||
'渡劫陨落': '雷霆战天,其志未竟。遗骨归山,英风尚在。',
|
'渡劫陨落': '雷霆战天,其志未竟。遗骨归山,英风尚在。',
|
||||||
|
'飞升太虚': '三劫渡尽化虹去,道韵长存荫后人。仙凡虽隔,香火永继。',
|
||||||
|
'飞升劫陨': '壮志凌云欲破天,一着未成殒飞仙。英魂不灭,长照宗门。',
|
||||||
'战殁': '马革裹尸,归葬桑梓。长剑挂壁,子孙相传。',
|
'战殁': '马革裹尸,归葬桑梓。长剑挂壁,子孙相传。',
|
||||||
'云游飞升': '此去云深不知处,魂同列宿游太虚。'
|
'云游飞升': '此去云深不知处,魂同列宿游太虚。'
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* 0.1.41 族史卷叙事层
|
||||||
|
*
|
||||||
|
* 将 GameState.chronicle(编年史原始条目)+ WorldSim 大事记 + 天命决策记录
|
||||||
|
* 串联成一个按时间轴排列的叙事性"族史卷"序列。
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 纯函数(不修改 state)
|
||||||
|
* - 去重 + 裁剪(上限 80 条——族史长卷不无限膨胀)
|
||||||
|
* - 叙事化:原始 chronicle 条目经文本润色后输出
|
||||||
|
* - 天命串联:天命决策(mandateLastId)标记在时间轴上,与同年的族中事件并列展示
|
||||||
|
*
|
||||||
|
* 数据来源:
|
||||||
|
* 1. state.chronicle — 家族编年史(生卒/突破/外交/建筑/事件/战役/死亡)
|
||||||
|
* 2. state.worldSim.newsFeed — 天下快讯中的里程碑事件
|
||||||
|
* 3. state.family.flag['mandateYear'] / mandateLastId — 天命决策记录
|
||||||
|
* 4. state.family.flag['mandateCount'] — 天命累计次数
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GameState, ChronicleEntry } from '../../types/domain'
|
||||||
|
import type { WorldSimState } from '../sim/worldsim-data'
|
||||||
|
import { mandateById, mandateCategoryName } from '../../data/mandate'
|
||||||
|
|
||||||
|
/** 族史卷条目 */
|
||||||
|
export interface ChronicleScrollEntry {
|
||||||
|
year: number
|
||||||
|
/** 事件类型 */
|
||||||
|
kind: 'mandate' | 'breakthrough' | 'death' | 'birth' | 'marriage' | 'diplomacy' | 'building' | 'event' | 'battle' | 'misc' | 'world'
|
||||||
|
/** 叙事文本 */
|
||||||
|
text: string
|
||||||
|
/** 是否重要里程碑(飞升/大境界突破/天命等) */
|
||||||
|
important: boolean
|
||||||
|
/** 相关成员 id(如有) */
|
||||||
|
memberId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 GameState + WorldSimState 中提取族史卷 */
|
||||||
|
export function buildChronicleScroll(state: GameState, ws?: WorldSimState): ChronicleScrollEntry[] {
|
||||||
|
const entries: ChronicleScrollEntry[] = []
|
||||||
|
|
||||||
|
// 1. 从 chronicle 提取全部条目
|
||||||
|
for (const e of state.chronicle) {
|
||||||
|
const entry = chronicleToScroll(e)
|
||||||
|
if (entry) entries.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从 worldSim.newsFeed 提取天下里程碑
|
||||||
|
if (ws) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const n of ws.newsFeed ?? []) {
|
||||||
|
if (n.kind === 'annal' || n.kind === 'calamity') {
|
||||||
|
const key = `${n.year}:${n.text.slice(0, 20)}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
entries.push({
|
||||||
|
year: n.year,
|
||||||
|
kind: 'world',
|
||||||
|
text: n.text,
|
||||||
|
important: n.kind === 'calamity'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 天命决策记录——从 mandateYear/mandateLastId 提取
|
||||||
|
const mandateYear = state.family.flag['mandateYear'] as number | undefined
|
||||||
|
const mandateLastId = state.family.flag['mandateLastId'] as string | undefined
|
||||||
|
if (mandateYear && mandateLastId) {
|
||||||
|
const def = mandateById(mandateLastId)
|
||||||
|
if (def) {
|
||||||
|
entries.push({
|
||||||
|
year: mandateYear,
|
||||||
|
kind: 'mandate',
|
||||||
|
text: `【天命·${mandateCategoryName(def.category)}】${def.name}:${def.text.slice(0, 60)}${def.text.length > 60 ? '……' : ''}`,
|
||||||
|
important: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按年份排序(同年按 important 优先)
|
||||||
|
entries.sort((a, b) => a.year - b.year || (b.important ? 1 : 0) - (a.important ? 1 : 0))
|
||||||
|
|
||||||
|
// 去重:同年同 kind 同文本前 30 字符只保留一条
|
||||||
|
const dedupMap = new Map<string, ChronicleScrollEntry>()
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = `${e.year}:${e.kind}:${e.text.slice(0, 30)}`
|
||||||
|
if (!dedupMap.has(key)) dedupMap.set(key, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...dedupMap.values()].sort((a, b) => b.year - a.year || (b.important ? 1 : 0) - (a.important ? 1 : 0))
|
||||||
|
|
||||||
|
// 裁剪:上限 80 条
|
||||||
|
if (result.length > 80) result.length = 80
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 ChronicleEntry 转换为族史卷条目 */
|
||||||
|
function chronicleToScroll(e: ChronicleEntry): ChronicleScrollEntry | null {
|
||||||
|
const kindMap: Record<string, ChronicleScrollEntry['kind']> = {
|
||||||
|
breakthrough: 'breakthrough',
|
||||||
|
death: 'death',
|
||||||
|
marriage: 'marriage',
|
||||||
|
diplomacy: 'diplomacy',
|
||||||
|
building: 'building',
|
||||||
|
event: 'event',
|
||||||
|
battle: 'battle',
|
||||||
|
misc: 'misc'
|
||||||
|
}
|
||||||
|
const kind = kindMap[e.category] ?? 'misc'
|
||||||
|
return {
|
||||||
|
year: e.year,
|
||||||
|
kind,
|
||||||
|
text: e.text,
|
||||||
|
important: e.important,
|
||||||
|
memberId: e.memberId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 族史卷条目类型中文名 */
|
||||||
|
export function scrollKindName(kind: ChronicleScrollEntry['kind']): string {
|
||||||
|
return kind === 'mandate' ? '天命'
|
||||||
|
: kind === 'breakthrough' ? '突破'
|
||||||
|
: kind === 'death' ? '殒落'
|
||||||
|
: kind === 'birth' ? '诞辰'
|
||||||
|
: kind === 'marriage' ? '姻缘'
|
||||||
|
: kind === 'diplomacy' ? '外交'
|
||||||
|
: kind === 'building' ? '建筑'
|
||||||
|
: kind === 'event' ? '事件'
|
||||||
|
: kind === 'battle' ? '战役'
|
||||||
|
: kind === 'world' ? '天下'
|
||||||
|
: '杂录'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 族史卷条目 CSS 类名(供 UI 着色) */
|
||||||
|
export function scrollKindClass(kind: ChronicleScrollEntry['kind']): string {
|
||||||
|
return kind === 'mandate' ? 'scroll-mandate'
|
||||||
|
: kind === 'breakthrough' ? 'scroll-breakthrough'
|
||||||
|
: kind === 'death' ? 'scroll-death'
|
||||||
|
: kind === 'birth' ? 'scroll-birth'
|
||||||
|
: kind === 'marriage' ? 'scroll-marriage'
|
||||||
|
: kind === 'diplomacy' ? 'scroll-diplomacy'
|
||||||
|
: kind === 'building' ? 'scroll-building'
|
||||||
|
: kind === 'event' ? 'scroll-event'
|
||||||
|
: kind === 'battle' ? 'scroll-battle'
|
||||||
|
: kind === 'world' ? 'scroll-world'
|
||||||
|
: 'scroll-misc'
|
||||||
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { Character } from '../types/domain'
|
import { Character } from '../../types/domain'
|
||||||
import type { World } from '../engine/world'
|
import type { World } from '../runtime/World'
|
||||||
|
|
||||||
export interface FamilyUnit {
|
export interface FamilyUnit {
|
||||||
parents: [Character | undefined, Character | undefined]
|
parents: [Character | undefined, Character | undefined]
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { GameState } from '../types/domain'
|
import { GameState } from '../../types/domain'
|
||||||
import { MAJOR_ORDER } from '../data/realms'
|
import { MAJOR_ORDER } from '../../data/realms'
|
||||||
|
|
||||||
export interface LegacyDims {
|
export interface LegacyDims {
|
||||||
renXing: number
|
renXing: number
|
||||||
daoXing: number
|
daoXing: number
|
||||||
weiMing: number
|
weiMing: number
|
||||||
xiangHuo: number
|
xiangHuo: number
|
||||||
|
/** 0.1.37 因果维度:家族 karma 值的定鼎映射 */
|
||||||
|
yinGuo: number
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +24,11 @@ export const VERDICTS: Record<string, { title: string; poem: string[] }> = {
|
|||||||
title: '飞升之资',
|
title: '飞升之资',
|
||||||
poem: ['海内升龙真气象,仙班亦录旧香名。', '遥看青山埋骨处,一脉烟霞送飞鸿。']
|
poem: ['海内升龙真气象,仙班亦录旧香名。', '遥看青山埋骨处,一脉烟霞送飞鸿。']
|
||||||
},
|
},
|
||||||
|
// 0.1.37 飞升 + 高 karma 的至高档位
|
||||||
|
daofruit: {
|
||||||
|
title: '道果圆满',
|
||||||
|
poem: ['三劫渡尽化虹去,功德如海润苍生。', '后人仰首望星处,犹见仙班旧时名。']
|
||||||
|
},
|
||||||
celestial: {
|
celestial: {
|
||||||
title: '仙朝遗脉',
|
title: '仙朝遗脉',
|
||||||
poem: ['祖庭灯火三千里,曾照中天玉斗垂。', '后世儿孙开卷处,犹闻钟鼎旧清音。']
|
poem: ['祖庭灯火三千里,曾照中天玉斗垂。', '后世儿孙开卷处,犹闻钟鼎旧清音。']
|
||||||
@@ -65,14 +72,22 @@ export function computeLegacy(s: GameState): LegacyDims {
|
|||||||
const daoXing = Math.round(st.maxRealmIdx * 26 + st.techniqueGrand * 4)
|
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 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)
|
const xiangHuo = Math.round(Math.min(s.year, 300) * 0.9 + st.feishengCount * 60)
|
||||||
return { renXing, daoXing, weiMing, xiangHuo, total: renXing + daoXing + weiMing + xiangHuo }
|
// 0.1.37 因果维度:karma 映射到 0~40 分(正 karma 加分,负 karma 扣分)
|
||||||
|
// karma=50 → +20;karma=-30 → -12;让善恶影响最终评价
|
||||||
|
const karma = s.family.karma ?? 0
|
||||||
|
const yinGuo = Math.round(Math.max(-20, Math.min(30, karma * 0.4)))
|
||||||
|
return { renXing, daoXing, weiMing, xiangHuo, yinGuo, total: renXing + daoXing + weiMing + xiangHuo + yinGuo }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLegacy(s: GameState): LegacyArch {
|
export function resolveLegacy(s: GameState): LegacyArch {
|
||||||
const dims = computeLegacy(s)
|
const dims = computeLegacy(s)
|
||||||
let key = 'dust'
|
let key = 'dust'
|
||||||
if (s.stats.feishengCount > 0) key = 'feisheng'
|
// 0.1.37 飞升 + 高 karma → 道果圆满(至高档位)
|
||||||
else {
|
if (s.stats.feishengCount > 0 && (s.family.karma ?? 0) > 30) {
|
||||||
|
key = 'daofruit'
|
||||||
|
} else if (s.stats.feishengCount > 0) {
|
||||||
|
key = 'feisheng'
|
||||||
|
} else {
|
||||||
for (const [need, k] of RANK_MAP) {
|
for (const [need, k] of RANK_MAP) {
|
||||||
if (dims.total >= need) {
|
if (dims.total >= need) {
|
||||||
key = k
|
key = k
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { GameState } from '../types/domain'
|
import { GameState } from '../../types/domain'
|
||||||
|
|
||||||
export interface LegacyReport {
|
export interface LegacyReport {
|
||||||
years: number[]
|
years: number[]
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 0.1.40 天下大事记叙事层
|
||||||
|
*
|
||||||
|
* 聚合 WorldSim 的 sagaAnnals(世鉴 50/100 年)+ worldAnnals(十年一鉴)+
|
||||||
|
* newsFeed 中的里程碑事件(宗祧更替/势力分裂/新贵崛起/灾年/世道更替),
|
||||||
|
* 输出一个按年份排序的"天下大事记"序列,供 WorldPanel 展示区和 LegacyPanel 百年报告引用。
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 纯函数(不修改 state)
|
||||||
|
* - 去重(同一年同类事件只保留最新一条)
|
||||||
|
* - 裁剪(上限 60 条——百年内的重要事件)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WorldSimState } from '../sim/worldsim-data'
|
||||||
|
|
||||||
|
/** 大事记条目 */
|
||||||
|
export interface SagaEntry {
|
||||||
|
year: number
|
||||||
|
/** 事件类型标签 */
|
||||||
|
kind: 'era' | 'calamity' | 'succession' | 'split' | 'newborn' | 'annal' | 'war' | 'wonder'
|
||||||
|
/** 叙事文本 */
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 WorldSimState 中提取天下大事记
|
||||||
|
*
|
||||||
|
* 聚合来源:
|
||||||
|
* 1. sagaAnnals — 50/100 年世鉴(宏观视角)
|
||||||
|
* 2. worldAnnals — 十年一鉴(史官综述)
|
||||||
|
* 3. newsFeed 中 kind=annal/npc/calamity 的里程碑事件
|
||||||
|
*/
|
||||||
|
export function buildSaga(ws: WorldSimState | undefined): SagaEntry[] {
|
||||||
|
if (!ws) return []
|
||||||
|
const entries: SagaEntry[] = []
|
||||||
|
|
||||||
|
// 1. 世鉴
|
||||||
|
for (const s of ws.sagaAnnals ?? []) {
|
||||||
|
entries.push({ year: s.year, kind: 'annal', text: s.text })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 十年一鉴
|
||||||
|
for (const a of ws.worldAnnals ?? []) {
|
||||||
|
entries.push({ year: a.year, kind: 'annal', text: a.text })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. newsFeed 里程碑事件(只取 annal/npc/calamity 类,去重)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const n of ws.newsFeed ?? []) {
|
||||||
|
if (n.kind === 'annal' || n.kind === 'calamity') {
|
||||||
|
const key = `${n.year}:${n.text.slice(0, 20)}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
let kind: SagaEntry['kind'] = 'annal'
|
||||||
|
if (n.kind === 'calamity') kind = 'calamity'
|
||||||
|
else if (n.text.includes('宗主') || n.text.includes('更替') || n.text.includes('退隐') || n.text.includes('遇害') || n.text.includes('篡权')) kind = 'succession'
|
||||||
|
else if (n.text.includes('分裂')) kind = 'split'
|
||||||
|
else if (n.text.includes('新贵') || n.text.includes('崛起') || n.text.includes('灰烬重生')) kind = 'newborn'
|
||||||
|
else if (n.text.includes('世道更替')) kind = 'era'
|
||||||
|
else if (n.text.includes('击破') || n.text.includes('起衅')) kind = 'war'
|
||||||
|
else if (n.text.includes('异变') || n.text.includes('出世') || n.text.includes('祥瑞')) kind = 'wonder'
|
||||||
|
entries.push({ year: n.year, kind, text: n.text })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按年份排序,同年按 kind 分组
|
||||||
|
entries.sort((a, b) => a.year - b.year)
|
||||||
|
|
||||||
|
// 去重:同年同 kind 只保留最新一条
|
||||||
|
const dedupMap = new Map<string, SagaEntry>()
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = `${e.year}:${e.kind}`
|
||||||
|
dedupMap.set(key, e) // 后来的覆盖前面的(但已排序,同年同 kind 只有一条)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...dedupMap.values()].sort((a, b) => b.year - a.year)
|
||||||
|
|
||||||
|
// 裁剪:上限 60 条
|
||||||
|
if (result.length > 60) result.length = 60
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 大事记条目类型中文名 */
|
||||||
|
export function sagaKindName(kind: SagaEntry['kind']): string {
|
||||||
|
return kind === 'era' ? '世道'
|
||||||
|
: kind === 'calamity' ? '灾年'
|
||||||
|
: kind === 'succession' ? '宗祧'
|
||||||
|
: kind === 'split' ? '分裂'
|
||||||
|
: kind === 'newborn' ? '新贵'
|
||||||
|
: kind === 'war' ? '战事'
|
||||||
|
: kind === 'wonder' ? '奇观'
|
||||||
|
: '史鉴'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 大事记条目 CSS 类名(供 UI 着色) */
|
||||||
|
export function sagaKindClass(kind: SagaEntry['kind']): string {
|
||||||
|
return kind === 'era' ? 'saga-era'
|
||||||
|
: kind === 'calamity' ? 'saga-calamity'
|
||||||
|
: kind === 'succession' ? 'saga-succession'
|
||||||
|
: kind === 'split' ? 'saga-split'
|
||||||
|
: kind === 'newborn' ? 'saga-newborn'
|
||||||
|
: kind === 'war' ? 'saga-war'
|
||||||
|
: kind === 'wonder' ? 'saga-wonder'
|
||||||
|
: 'saga-annal'
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { GameState } from '../types/domain'
|
import { GameState } from '../../types/domain'
|
||||||
|
|
||||||
export interface AxisEvent {
|
export interface AxisEvent {
|
||||||
year: number
|
year: number
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 插件公共接口(第三方唯一入口面)。
|
||||||
|
* 第三方插件:`import type { CotycPlugin, PluginContext } from '.../plugin-public'`
|
||||||
|
*
|
||||||
|
* 能力范围:
|
||||||
|
* - register(phase, fn):在时轮相位注册月度钩子(clocks 红线:phase 固定,勿自创)
|
||||||
|
* - beforePhase/afterPhase(phase, fn):phase 前后锚点(0.1.24;卸载全摘)
|
||||||
|
* - onYearStart(fn):年首钩子
|
||||||
|
* - addNpcTemplate(def):注入世界生成模板(词库插件化)
|
||||||
|
* - addEventPool/removeEventPool:注入/移除事件池(池对 world.eventPools 全量聚合)
|
||||||
|
* - addCapability/removeCapability/enableCapability:能力卡(系统开关;受旺启停联动)
|
||||||
|
* - overridePack/resetPack:数据包覆写/回滚(pack() 单源)
|
||||||
|
* 纪律:插件内不得新增 w.rng.next() 消耗(会移动全局随机序列 → 金钟罩红);
|
||||||
|
* 必须消耗时必须声明并随 0.1.x 基线重算。
|
||||||
|
*/
|
||||||
|
export type { CotycPlugin, PluginHookGuard, PluginContext, PluginKind, PluginStatus, PluginChange } from './kernel/plugin'
|
||||||
|
|
||||||
|
/** 0.1.30 API 全清单(Context 方法快照):
|
||||||
|
* register / onYearStart / beforePhase / afterPhase / addCapability / removeCapability /
|
||||||
|
* enableCapability / overridePack / resetPack / addEventPool / removeEventPool /
|
||||||
|
* addNpcTemplate / addPillRecipe / addForgeRecipe / addWorldGenPool(part, source?) /
|
||||||
|
* addCalamity(name, {brief?,family?,effect?})
|
||||||
|
* 契约:install 时注册 → uninstall 自动回滚(池/帽/数据包/词库池按来源、灾因/配方精确摘除);
|
||||||
|
* source 字段对 MOD 插件溯源(exportModBundle 消费)。 */
|
||||||
|
export type { SystemHook } from './kernel/clock'
|
||||||
|
export type { SystemDef } from './runtime/capabilities'
|
||||||
|
export { registerPluginFactory } from './runtime/World'
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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,14 +1,14 @@
|
|||||||
import type { World } from './world'
|
import type { World } from './World'
|
||||||
import { WorldEventBus } from './world'
|
import { WorldEventBus } from './World'
|
||||||
import { YearlyReport, BattleLog, ChronicleEntry, SaveMeta } from '../types/domain'
|
import { YearlyReport, BattleLog, ChronicleEntry, SaveMeta } from '../../types/domain'
|
||||||
import { marketPrice, buyItem, sellItem, buyTechnique } from './market'
|
import { marketPrice, buyItem, sellItem, buyTechnique } from '../sim/Market'
|
||||||
import { sendMission, recallAll } from './systems/missions'
|
import { sendMission, recallAll } from './Systems/missions'
|
||||||
import { giftNpc, makePeace, marryNpcFamily } from './systems/diplomacy'
|
import { giftNpc, makePeace, marryNpcFamily } from './Systems/diplomacy'
|
||||||
import { combatPowerOf } from './systems/combat'
|
import { combatPowerOf } from './Systems/combat'
|
||||||
import type { LogKind } from './world'
|
import type { LogKind } from './World'
|
||||||
import { computeLegacy, resolveLegacy, LegacyArch } from '../core/legacy'
|
import { computeLegacy, resolveLegacy, LegacyArch } from '../narrative/legacy'
|
||||||
import { yearAxis, AxisCell } from '../core/yearaxis'
|
import { yearAxis, AxisCell } from '../narrative/yearaxis'
|
||||||
import { PACK } from '../data/registry'
|
import { PACK } from '../../data/registry'
|
||||||
|
|
||||||
export type ActName =
|
export type ActName =
|
||||||
| 'head.set'
|
| 'head.set'
|
||||||
@@ -115,6 +115,7 @@ export class GameFacade {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
subscribe(on: (e: FacadeEvent) => void): () => void {
|
subscribe(on: (e: FacadeEvent) => void): () => void {
|
||||||
const bus: WorldEventBus = {
|
const bus: WorldEventBus = {
|
||||||
onLog: (kind: LogKind, text: string) => on({ type: 'log', kind, text, year: this.world.state.year, month: this.world.state.month }),
|
onLog: (kind: LogKind, text: string) => on({ type: 'log', kind, text, year: this.world.state.year, month: this.world.state.month }),
|
||||||
@@ -136,7 +137,7 @@ export class GameFacade {
|
|||||||
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
||||||
return {
|
return {
|
||||||
title: '仙途家族志',
|
title: '仙途家族志',
|
||||||
version: '0.1.11',
|
version: '0.1.38',
|
||||||
modules: this.world.systemList().length,
|
modules: this.world.systemList().length,
|
||||||
systems: this.world.systemList().filter((s) => s.enabled).length,
|
systems: this.world.systemList().filter((s) => s.enabled).length,
|
||||||
plugins: this.world.pluginList().length,
|
plugins: this.world.pluginList().length,
|
||||||
+81
-21
@@ -1,14 +1,14 @@
|
|||||||
import type { 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 { pack } from '../../data/registry'
|
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 { traitBonuses } from '../pcgen'
|
import { traitBonuses } from '../pcgen'
|
||||||
import { npcById } from '../../data/npcs'
|
import { npcById } from '../../../data/npcs'
|
||||||
import { aspirationById, fitBonusOf } from '../../data/aspirations'
|
import { aspirationById, fitBonusOf } from '../../../data/aspirations'
|
||||||
import { formationById, FormationId } from '../../data/formations'
|
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
|
||||||
@@ -23,7 +23,11 @@ export function combatPowerOf(w: World, c: Character): number {
|
|||||||
const fit = fitBonusOf(c).battle ? 1.04 : 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 * aspi * fit * health)
|
// 0.1.41 天命接线:mercenaryHired — 佣兵入族战力加成 +15%(时效 flag)
|
||||||
|
const nowMonth = w.state.year * 12 + w.state.month
|
||||||
|
const mercActive = typeof w.state.family.flag['mercenaryUntil'] === 'number' && (w.state.family.flag['mercenaryUntil'] as number) > nowMonth
|
||||||
|
const mercMult = mercActive ? 1.15 : 1
|
||||||
|
return round1(base * stat * techBonus * equip * trait * aspi * fit * health * mercMult)
|
||||||
}
|
}
|
||||||
|
|
||||||
function round1(n: number): number {
|
function round1(n: number): number {
|
||||||
@@ -59,6 +63,20 @@ const DRAW_DESC = [
|
|||||||
'僵持半晌,天入暮色,双方收阵戒备而退。'
|
'僵持半晌,天入暮色,双方收阵戒备而退。'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** 0.1.33 符箓开战(零 rng):消耗 1 张并返回其类型——风符全体战力+12%,山符+15%且受创减半 */
|
||||||
|
function talismanWarPrep(w: World): 'feng' | 'shan' | null {
|
||||||
|
const inv = w.state.family.inventory
|
||||||
|
if ((inv['talisman-feng'] ?? 0) > 0) {
|
||||||
|
inv['talisman-feng'] = inv['talisman-feng']! - 1
|
||||||
|
return 'feng'
|
||||||
|
}
|
||||||
|
if ((inv['talisman-shan'] ?? 0) > 0) {
|
||||||
|
inv['talisman-shan'] = inv['talisman-shan']! - 1
|
||||||
|
return 'shan'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveEncounter(
|
export function resolveEncounter(
|
||||||
w: World,
|
w: World,
|
||||||
opts: {
|
opts: {
|
||||||
@@ -73,8 +91,20 @@ export function resolveEncounter(
|
|||||||
}
|
}
|
||||||
): EncounterResult {
|
): EncounterResult {
|
||||||
const form = formationById(opts.formation)
|
const form = formationById(opts.formation)
|
||||||
|
const talisman = opts.kind === 'war' ? talismanWarPrep(w) : null
|
||||||
|
const talismMult = talisman === 'feng' ? 1.12 : talisman === 'shan' ? 1.15 : 1
|
||||||
let team = 0
|
let team = 0
|
||||||
for (const c of opts.team) team += combatPowerOf(w, c)
|
let guard = 0
|
||||||
|
let crit = 0
|
||||||
|
for (const c of opts.team) {
|
||||||
|
team += combatPowerOf(w, c)
|
||||||
|
const t = techniqueById(c.techniqueId)
|
||||||
|
guard += (t?.guardBonus ?? 0)
|
||||||
|
crit += (t?.critChance ?? 0)
|
||||||
|
}
|
||||||
|
guard /= Math.max(1, opts.team.length)
|
||||||
|
crit /= Math.max(1, opts.team.length)
|
||||||
|
team *= talismMult
|
||||||
const enemy = Math.round(enemyPowerOf(opts.enemy, opts.risk) * form.def)
|
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 * form.atk * jitter)
|
const teamFinal = Math.round(team * form.atk * jitter)
|
||||||
@@ -101,20 +131,21 @@ export function resolveEncounter(
|
|||||||
if (!c.alive || c.state === 'wounded') continue
|
if (!c.alive || c.state === 'wounded') continue
|
||||||
const severity = w.rng.next()
|
const severity = w.rng.next()
|
||||||
if (!win) {
|
if (!win) {
|
||||||
if (severity < 0.1 && w.rng.chance(opts.risk * 0.08 + 0.02)) {
|
if (severity < 0.1 && w.rng.chance((opts.risk * 0.08 + 0.02) * (1 - crit))) {
|
||||||
c.alive = false
|
c.alive = false
|
||||||
c.deathYear = year
|
c.deathYear = year
|
||||||
c.deathCause = `战殁于${opts.enemy.name}之手`
|
c.deathCause = `战殁于${opts.enemy.name}之手`
|
||||||
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)
|
||||||
|
w.emitFx('ripple', `death:${c.id}`)
|
||||||
} else if (severity < 0.45) {
|
} else if (severity < 0.45) {
|
||||||
const woundAmt = Math.round((40 + w.rng.int(0, 25)) * form.retreatWound)
|
const woundAmt = Math.round((40 + w.rng.int(0, 25)) * form.retreatWound * (talisman === 'shan' ? 0.5 : 1) * Math.max(0, 1 - guard))
|
||||||
c.health = Math.max(1, c.health - woundAmt)
|
c.health = Math.max(1, c.health - woundAmt)
|
||||||
c.state = 'wounded'
|
c.state = 'wounded'
|
||||||
loss.push(`${c.name} 重伤`)
|
loss.push(`${c.name} 重伤`)
|
||||||
}
|
}
|
||||||
} else if (w.rng.chance(0.12)) {
|
} else if (w.rng.chance(0.12)) {
|
||||||
c.health = Math.max(1, c.health - 20 - w.rng.int(0, 15))
|
c.health = Math.max(1, c.health - Math.round((20 + w.rng.int(0, 15)) * Math.max(0, 1 - guard)))
|
||||||
if (c.health < 35) c.state = 'wounded'
|
if (c.health < 35) c.state = 'wounded'
|
||||||
loss.push(`${c.name} 轻伤`)
|
loss.push(`${c.name} 轻伤`)
|
||||||
}
|
}
|
||||||
@@ -131,6 +162,8 @@ export function resolveEncounter(
|
|||||||
lines.push(`此战缴获:${Object.entries(loot).map(([k, v]) => `${itemName(k)} ×${v}`).join('、')}。`)
|
lines.push(`此战缴获:${Object.entries(loot).map(([k, v]) => `${itemName(k)} ×${v}`).join('、')}。`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (talisman) lines.push(`武备先声:${talisman === 'feng' ? '风符化翼' : '山符镇阵'},家士气为之振。`)
|
||||||
|
if (win && opts.kind === 'war') w.emitFx('blade', 'battle')
|
||||||
const result: EncounterResult = { win, draw, lines, loot, losses: loss }
|
const result: EncounterResult = { win, draw, lines, loot, losses: loss }
|
||||||
const log: BattleLog = {
|
const log: BattleLog = {
|
||||||
id: w.seq(),
|
id: w.seq(),
|
||||||
@@ -165,15 +198,21 @@ export function resolveRaid(
|
|||||||
): EncounterResult {
|
): EncounterResult {
|
||||||
const npc = w.state.npcFamilies[npcId]
|
const npc = w.state.npcFamilies[npcId]
|
||||||
const def = npcById(npcId)
|
const def = npcById(npcId)
|
||||||
|
if (!def) return { win: false, draw: true, lines: ['此族已散。'], loot: undefined, losses: [] }
|
||||||
|
// P1-C 敌强度随玩家队伍战力动态化(0.1.29:防 5000+ 战力零风险碾压——战争永远有戏剧性)
|
||||||
|
const teamPower = team.reduce((a, c) => a + combatPowerOf(w, c), 0)
|
||||||
|
const npcPower = npc.power ?? 60
|
||||||
|
const rel = teamPower > 0 ? npcPower / teamPower : 0
|
||||||
|
const strength = Math.min(2.6, Math.max(0.5, 0.35 + rel * 0.55))
|
||||||
const enemy: EnemyDef = {
|
const enemy: EnemyDef = {
|
||||||
id: npcId,
|
id: npcId,
|
||||||
name: `${npc.name}的劫掠队`,
|
name: `${npc.name}的劫掠队`,
|
||||||
realm: def.leaderRealm,
|
realm: def.leaderRealm,
|
||||||
strength: 0.78,
|
strength,
|
||||||
icon: '袭',
|
icon: '袭',
|
||||||
desc: def.desc
|
desc: def.desc
|
||||||
}
|
}
|
||||||
const risk = 0.55
|
const risk = Math.min(0.75, 0.5 + (npcPower / 120) * 0.1)
|
||||||
const res = resolveEncounter(w, {
|
const res = resolveEncounter(w, {
|
||||||
title: `${npc.name}来袭!`,
|
title: `${npc.name}来袭!`,
|
||||||
enemy,
|
enemy,
|
||||||
@@ -187,9 +226,24 @@ export function resolveRaid(
|
|||||||
if (res.win) {
|
if (res.win) {
|
||||||
npc.relation = Math.min(60, npc.relation + 25)
|
npc.relation = Math.min(60, npc.relation + 25)
|
||||||
w.state.family.reputation += 6
|
w.state.family.reputation += 6
|
||||||
|
// B5:战争伤骨——败方 power 重挫,次年不再来犯(warCooldownYear/raidCount 启用)
|
||||||
|
w.emitFx('blade', `raid:${npcId}`)
|
||||||
|
npc.power = Math.max(52, Math.round(npc.power * 0.82))
|
||||||
|
npc.raidCount = (npc.raidCount ?? 0) + 1
|
||||||
|
npc.warCooldownYear = w.state.year
|
||||||
|
// 1-3 蝴蝶效应:玩家重创名声——邻家对败者关系趋冷
|
||||||
|
for (const dyn of Object.values(w.state.worldSim?.npcDyn ?? {})) {
|
||||||
|
if (dyn && dyn.relationsWithOthers && npcId in dyn.relationsWithOthers) {
|
||||||
|
dyn.relationsWithOthers[npcId] = Math.max(-100, (dyn.relationsWithOthers[npcId] ?? 0) - 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
w.chronicle('battle', `击退${npc.name}的犯境,家族声威大振。`, undefined, true)
|
w.chronicle('battle', `击退${npc.name}的犯境,家族声威大振。`, undefined, true)
|
||||||
} else if (!res.draw) {
|
} else if (!res.draw) {
|
||||||
npc.relation = Math.max(-100, npc.relation - 15)
|
npc.relation = Math.max(-100, npc.relation - 15)
|
||||||
|
w.emitFx('pulse', `raid-lose:${npcId}`)
|
||||||
|
npc.raidCount = (npc.raidCount ?? 0) + 1
|
||||||
|
npc.warCooldownYear = w.state.year
|
||||||
|
npc.power = Math.min(900, Math.round(npc.power * 1.06))
|
||||||
const st = w.state.family.stones
|
const st = w.state.family.stones
|
||||||
const lostFew = Math.min(st, Math.round(st * 0.25))
|
const lostFew = Math.min(st, Math.round(st * 0.25))
|
||||||
w.state.family.stones -= lostFew
|
w.state.family.stones -= lostFew
|
||||||
@@ -198,23 +252,29 @@ export function resolveRaid(
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rollWarbooty(w: World, loot: LootDef): Record<string, number> {
|
export function rollWarbooty(w: World, loot: LootDef, qiRatio = 1): Record<string, number> {
|
||||||
const result: Record<string, number> = {}
|
const result: Record<string, number> = {}
|
||||||
for (const [k, r] of Object.entries(loot.resources)) {
|
for (const [k, r] of Object.entries(loot.resources)) {
|
||||||
const v = w.rng.int(r[0], r[1])
|
const v = Math.round(w.rng.int(r[0], r[1]) * qiRatio)
|
||||||
result[k] = v
|
result[k] = v
|
||||||
w.state.family.inventory[k] = (w.state.family.inventory[k] ?? 0) + v
|
w.state.family.inventory[k] = (w.state.family.inventory[k] ?? 0) + v
|
||||||
}
|
}
|
||||||
if (loot.artifactChance && w.rng.chance(loot.artifactChance)) {
|
const itemFactor = Math.min(1.4, Math.max(0.5, 0.6 + 0.4 * qiRatio))
|
||||||
|
if (loot.artifactChance && w.rng.chance(loot.artifactChance * itemFactor)) {
|
||||||
const pool = ['weapon-fan', 'weapon-qi', 'weapon-ling']
|
const pool = ['weapon-fan', 'weapon-qi', 'weapon-ling']
|
||||||
const a = w.rng.pick(pool)
|
const a = w.rng.pick(pool)
|
||||||
w.state.family.inventory[a] = (w.state.family.inventory[a] ?? 0) + 1
|
w.state.family.inventory[a] = (w.state.family.inventory[a] ?? 0) + 1
|
||||||
result[a] = 1
|
result[a] = 1
|
||||||
}
|
}
|
||||||
if (loot.techniqueChance && w.rng.chance(loot.techniqueChance)) {
|
if (loot.techniqueChance && w.rng.chance(loot.techniqueChance * itemFactor)) {
|
||||||
const t = w.rng.pick(pack().techniques)
|
const pool = loot.techGrades && loot.techGrades.length > 0
|
||||||
|
? pack().techniques.filter((t) => loot.techGrades!.includes(t.grade))
|
||||||
|
: pack().techniques
|
||||||
|
if (pool.length > 0) {
|
||||||
|
const t = w.rng.pick(pool)
|
||||||
w.state.family.techniques.push(t.id)
|
w.state.family.techniques.push(t.id)
|
||||||
result['tech'] = 1
|
result['tech'] = 1
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
+96
-21
@@ -1,44 +1,61 @@
|
|||||||
import type { 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 { 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 { seasonOf, SEASON_ELEMENT } from '../../../data/season'
|
||||||
|
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 '../../kernel/names'
|
||||||
import { ASPIRATION_IDS } from '../../data/aspirations'
|
import { ASPIRATION_IDS } from '../../../data/aspirations'
|
||||||
import { seasonMod } from '../../data/season'
|
import { seasonMod } from '../../../data/season'
|
||||||
import { needsTribulation, tribulationEventId } from './tribulation'
|
import { bonusOf } from '../../../data/buildings'
|
||||||
|
import { WorldSim } from '../../sim/WorldSim'
|
||||||
|
import { needsTribulation, tribulationEventId, ascensionEventId } from './tribulation'
|
||||||
|
import { fire } from './events'
|
||||||
|
|
||||||
export function monthlyRate(w: World, c: Character): number {
|
/** 化神最高 minor(化神3层 → minor=2 为圆满) */
|
||||||
|
const MAJORS_SPIRIT_MAX_MINOR = 2
|
||||||
|
|
||||||
|
export function monthlyRate(w: World, c: Character, herbFactor = 1): number {
|
||||||
const st = w.state
|
const st = w.state
|
||||||
let rate = 1
|
let rate = herbFactor
|
||||||
rate *= 1.0 + c.perception * 0.18
|
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') {
|
||||||
rate *= 1 + tech.expBonus + (c.techniqueRank ?? 0) * 0.05
|
rate *= 1 + tech.expBonus + (c.techniqueRank ?? 0) * 0.05
|
||||||
|
// 0.1.34 万象归元:功法元素与时令共鸣(春木夏火秋金冬水——身合自然)
|
||||||
|
// 0.1.35 土系应四时(长夏为农——恒微应 +2%,土不再因映射缺位而失色)
|
||||||
|
if (w.sysEnabled('season')) {
|
||||||
|
if (SEASON_ELEMENT[seasonOf(st.month)] === tech.element) rate *= 1.04
|
||||||
|
else if (tech.element === '土') rate *= 1.02
|
||||||
|
}
|
||||||
} else if (c.realm.major !== 'mortal') {
|
} else if (c.realm.major !== 'mortal') {
|
||||||
rate *= 0.65
|
rate *= 0.65
|
||||||
}
|
}
|
||||||
const buildings = st.family.buildings
|
const buildings = st.family.buildings
|
||||||
const juling = buildings['juling'] ?? 0
|
rate *= 1 + bonusOf('juling', 'expBonus', buildings['juling'] ?? 0, st.family.flag['buildingSpecialty'] as string | undefined)
|
||||||
rate *= 1 + juling * 0.05
|
|
||||||
if (w.sysEnabled('season')) rate *= 1 + seasonMod(st.month, 'cult')
|
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
|
||||||
|
// 0.1.41 天命接线:elderCouncil — 长老会设立,全族心境稳定(修炼+3%)
|
||||||
|
if (st.family.flag['elderCouncil']) rate *= 1.03
|
||||||
|
// 0.1.41 天命接线:advisoryBoard — 参议设立,微弱心境加成(修炼+1.5%)
|
||||||
|
if (st.family.flag['advisoryBoard']) rate *= 1.015
|
||||||
|
// 0.1.41 天命接线:successionWise — 立贤储君,悟性最高者额外修炼加成(+5%)
|
||||||
|
if (st.family.flag['successionWise'] && c.perception >= 7) rate *= 1.05
|
||||||
const aspiration = aspirationById(c.aspiration)
|
const aspiration = aspirationById(c.aspiration)
|
||||||
if (aspiration?.effect.type === 'cult') rate *= 1 + aspiration.effect.value
|
if (aspiration?.effect.type === 'cult') rate *= 1 + aspiration.effect.value
|
||||||
if (fitBonusOf(c).cult) rate *= 1.06
|
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') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0)
|
rate *= 1 + w.postBonus('meditation') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0)
|
||||||
const dongfu = buildings['dongfu'] ?? 0
|
rate *= 1 + bonusOf('dongfu', 'expBonus', buildings['dongfu'] ?? 0, st.family.flag['buildingSpecialty'] as string | undefined)
|
||||||
rate *= 1 + dongfu * 0.08
|
|
||||||
} else if (c.state === 'expedition') {
|
} else if (c.state === 'expedition') {
|
||||||
rate *= 0.25
|
rate *= 0.25
|
||||||
} else if (c.state === 'wounded') {
|
} else if (c.state === 'wounded') {
|
||||||
@@ -53,6 +70,9 @@ export function monthlyRate(w: World, c: Character): number {
|
|||||||
if (w.ageOf(c) < 8) rate *= 0.4
|
if (w.ageOf(c) < 8) rate *= 0.4
|
||||||
if (w.ageOf(c) > 65) rate *= 0.72
|
if (w.ageOf(c) > 65) rate *= 0.72
|
||||||
rate *= masteryRateOfMajor(c.realm.major)
|
rate *= masteryRateOfMajor(c.realm.major)
|
||||||
|
// 世界灵气潮汐
|
||||||
|
const tide = w.state.worldSim?.tide ?? 1
|
||||||
|
rate *= tide
|
||||||
return rate
|
return rate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,15 +85,48 @@ export function cultivationTick(w: World): void {
|
|||||||
c.aspiration = w.rng.pick(ASPIRATION_IDS)
|
c.aspiration = w.rng.pick(ASPIRATION_IDS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ---- A1 修为经济化:灵草是修行燃料 ----
|
||||||
|
const inv = w.state.family.inventory
|
||||||
|
let demand = 0
|
||||||
for (const c of Object.values(w.state.members)) {
|
for (const c of Object.values(w.state.members)) {
|
||||||
if (!c.alive || c.state === 'apprentice') continue
|
if (!c.alive || c.state === 'apprentice') continue
|
||||||
const rate = monthlyRate(w, c)
|
if (c.realm.major === 'mortal') continue
|
||||||
|
demand += c.state === 'meditation' ? 2 : 1
|
||||||
|
}
|
||||||
|
// P2 灵石泄压:化神者月俸 20 石(高境界吃族俸——后期灵石死水破口)
|
||||||
|
if (w.state.family.stones >= 20) {
|
||||||
|
for (const c of Object.values(w.state.members)) {
|
||||||
|
if (c.alive && c.realm.major === 'spirit') w.state.family.stones -= 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let herbFactor = 1
|
||||||
|
if (demand > 0) {
|
||||||
|
const supply = inv.lingcao ?? 0
|
||||||
|
const used = Math.min(supply, demand)
|
||||||
|
inv.lingcao = supply - used
|
||||||
|
// 0.1.27 修为↔市场回环:修行耗草同时抽走世界池(池浅→价涨→温度→era 全链)
|
||||||
|
// W1(0.1.30)人口-世界成本:人口 >20 时家族体量使世界灵草需求额外上行(人多粮少)
|
||||||
|
const pop = Object.values(w.state.members).filter((c) => c.alive).length
|
||||||
|
const crowdMult = pop > 20 ? (pop - 20) / 15 : 0
|
||||||
|
if (w.state.worldSim) new WorldSim(w).tradeSettle('lingcao', -(used + Math.max(0, Math.round(used * crowdMult))))
|
||||||
|
const ratio = supply >= demand ? 1 : supply / demand
|
||||||
|
herbFactor = 0.6 + 0.4 * ratio
|
||||||
|
if (ratio < 0.5 && w.state.family.flag['herbWarnYear'] !== w.state.year) {
|
||||||
|
w.state.family.flag['herbWarnYear'] = w.state.year
|
||||||
|
w.log('bad', '灵草告罄,族中修行渐缓——需扩灵田药园或购草补续。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const c of Object.values(w.state.members)) {
|
||||||
|
if (!c.alive || c.state === 'apprentice') continue
|
||||||
|
const rate = monthlyRate(w, c, herbFactor)
|
||||||
if (rate <= 0) continue
|
if (rate <= 0) continue
|
||||||
c.realmProgress = Math.min(100, c.realmProgress + rate)
|
c.realmProgress = Math.min(100, c.realmProgress + rate)
|
||||||
// 悟道进度:有功法且修为之外,另积一分慧根
|
// 悟道进度:有功法且修为之外,另积一分慧根
|
||||||
if (c.techniqueId && c.realm.major !== 'mortal') {
|
if (c.techniqueId && c.realm.major !== 'mortal') {
|
||||||
const rank = c.techniqueRank ?? 0
|
const rank = c.techniqueRank ?? 0
|
||||||
const gain = rate * (rank === 0 ? 0.5 : rank === 1 ? 0.3 : 0.15)
|
const wuLvl = w.state.family.buildings['yanwu'] ?? 0
|
||||||
|
const wuMult = 1 + wuLvl * 0.08 // 0.1.34 演武场联动——日日操练,悟性开慧
|
||||||
|
const gain = rate * (rank === 0 ? 0.5 : rank === 1 ? 0.3 : 0.15) * wuMult
|
||||||
c.techniqueProgress = Math.min(100, (c.techniqueProgress ?? 0) + gain)
|
c.techniqueProgress = Math.min(100, (c.techniqueProgress ?? 0) + gain)
|
||||||
if (c.techniqueProgress >= 100) {
|
if (c.techniqueProgress >= 100) {
|
||||||
if (rank === 0) {
|
if (rank === 0) {
|
||||||
@@ -90,6 +143,27 @@ export function cultivationTick(w: World): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (c.realmProgress >= 100) {
|
if (c.realmProgress >= 100) {
|
||||||
|
// 0.1.37 飞升试炼:化神圆满(major=spirit, minor=2, progress=100)时 fire 心魔试炼
|
||||||
|
if (c.realm.major === 'spirit' && c.realm.minor >= MAJORS_SPIRIT_MAX_MINOR && w.sysEnabled('tribulation')) {
|
||||||
|
// 已在飞升试炼流程中(pending 或 delay 内)不重复触发
|
||||||
|
const ascPending = w.state.pendingEvent?.startsWith('ev-ascension-') === true
|
||||||
|
if (ascPending) {
|
||||||
|
c.realmProgress = 100
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (c.tribDelayYear && w.state.year < c.tribDelayYear) {
|
||||||
|
c.realmProgress = 100
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 旧版 ev-feisheng 已完成则不再触发
|
||||||
|
if (w.state.completedEvents.includes('ev-feisheng')) {
|
||||||
|
c.realmProgress = 100
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.tribDelayYear = undefined
|
||||||
|
fire(w, ascensionEventId(c.id, 'heart'), 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
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))) {
|
||||||
if (needsTribulation(c) && w.sysEnabled('tribulation')) {
|
if (needsTribulation(c) && w.sysEnabled('tribulation')) {
|
||||||
@@ -103,8 +177,7 @@ export function cultivationTick(w: World): void {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.tribDelayYear = undefined
|
c.tribDelayYear = undefined
|
||||||
w.pendingEvent(tribulationEventId(c))
|
fire(w, tribulationEventId(c), 1)
|
||||||
w.state.pendingEvent = tribulationEventId(c)
|
|
||||||
} else {
|
} else {
|
||||||
resolveBreakthrough(w, c, 0)
|
resolveBreakthrough(w, c, 0)
|
||||||
}
|
}
|
||||||
@@ -168,7 +241,9 @@ export function resolveBreakthrough(w: World, c: Character, boost: number): void
|
|||||||
const desc = describeRealm(next)
|
const desc = describeRealm(next)
|
||||||
w.chronicle('breakthrough', `${c.name} 突破至【${desc}】。`, c.id, majorJump)
|
w.chronicle('breakthrough', `${c.name} 突破至【${desc}】。`, c.id, majorJump)
|
||||||
w.log('good', `✦ ${c.name} 突破到 ${desc}!`)
|
w.log('good', `✦ ${c.name} 突破到 ${desc}!`)
|
||||||
|
w.emitFx('spark', `break:${c.id}`)
|
||||||
if (next.major === 'spirit') {
|
if (next.major === 'spirit') {
|
||||||
|
w.emitFx('pulse', `spirit:${c.id}`)
|
||||||
w.chronicle('breakthrough', `华夏震惊:${c.name} 踏入化神之列。`, c.id, true)
|
w.chronicle('breakthrough', `华夏震惊:${c.name} 踏入化神之列。`, c.id, true)
|
||||||
if (!w.state.flags['firstSpirit']) {
|
if (!w.state.flags['firstSpirit']) {
|
||||||
w.state.flags['firstSpirit'] = w.state.year
|
w.state.flags['firstSpirit'] = w.state.year
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { World } from '../World'
|
||||||
|
import { npcById } from '../../../data/npcs'
|
||||||
|
import { findEvent, fire } from './events'
|
||||||
|
import { WORLDSIM } from '../../sim/worldsim-data'
|
||||||
|
|
||||||
|
export function diplomacyTick(w: World): void {
|
||||||
|
const s = w.state
|
||||||
|
const drift = w.rng.chance(0.15)
|
||||||
|
for (const npc of Object.values(s.npcFamilies)) {
|
||||||
|
if (drift) {
|
||||||
|
if (npc.relation > 0 && !npc.allied) npc.relation -= 1
|
||||||
|
else if (npc.relation < 0) npc.relation += 1
|
||||||
|
}
|
||||||
|
if (npc.relation < -50) {
|
||||||
|
const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0
|
||||||
|
// 姿态调制:扩张好勇、隐忍避战、结盟之势休兵
|
||||||
|
const stance = (w.state.worldSim?.npcDyn?.[npc.id] as { stance?: string } | undefined)?.stance
|
||||||
|
let raidP = 0.03 * (stance === 'expand' ? 2 : stance === 'endure' ? 0.4 : stance === 'ally' ? 0 : 1)
|
||||||
|
// W2 世界关注度(匹夫无罪):本族战力 > 邻家均值 ×2 → 强族怀璧,仇家加觊
|
||||||
|
const myPow = w.familyPower()
|
||||||
|
const npcsAll = Object.values(s.npcFamilies)
|
||||||
|
if (npcsAll.length > 0) {
|
||||||
|
const npcAvg = npcsAll.reduce((a, n) => a + n.power, 0) / npcsAll.length
|
||||||
|
if (myPow > npcAvg * 2 && myPow > 300) raidP *= 1.5
|
||||||
|
}
|
||||||
|
// 0.1.20 盟约连坐(加性):与我结盟各家的世仇,更易扣边——不再×2×盟数叠爆
|
||||||
|
const dyns = (w.state.worldSim?.npcDyn ?? {}) as Record<string, { relationsWithOthers?: Record<string, number> }>
|
||||||
|
for (const ally of Object.values(s.npcFamilies)) {
|
||||||
|
if (!ally.allied) continue
|
||||||
|
const rel = dyns[ally.id]?.relationsWithOthers?.[npc.id] ?? 0
|
||||||
|
if (rel < -60) raidP += WORLDSIM.allianceGriefRaid
|
||||||
|
}
|
||||||
|
if (s.year - last >= 2 && w.rng.chance(Math.min(0.3, raidP))) {
|
||||||
|
// 高优投放;被顶替不丢失
|
||||||
|
const ok = fire(w, `ev-raid-${npc.id}`, 1)
|
||||||
|
if (!ok) w.state.family.flag[`raidCD-${npc.id}`] = s.year
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yearGrowth(w: World): void {
|
||||||
|
const s = w.state
|
||||||
|
// era 调制(0.1.27):盛世缓增、平世正常、乱世/末法负增——遏制无脑通胀
|
||||||
|
const era = (w.state.worldSim?.era ?? 'pingshi') as 'shengshi' | 'pingshi' | 'luanshi' | 'mofa'
|
||||||
|
const eraGrowth: Record<string, number> = { shengshi: 0.7, pingshi: 1, luanshi: -0.8, mofa: -1.4 }
|
||||||
|
const gm = eraGrowth[era] ?? 1
|
||||||
|
for (const npc of Object.values(s.npcFamilies)) {
|
||||||
|
const def = npcById(npc.id)
|
||||||
|
if (!def) continue
|
||||||
|
const [a, b] = def.powerGrowth
|
||||||
|
const base = Math.round(w.rng.int(a, b))
|
||||||
|
npc.power = Math.max(35, Math.min(900, npc.power + (gm >= 0 ? Math.round(base * gm) : -Math.round(Math.max(1, base * -gm)))))
|
||||||
|
if (npc.allied) {
|
||||||
|
w.state.family.stones += 15
|
||||||
|
w.log('info', `同盟${npc.name}遣使来贺,赠灵石15(结盟年利)。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function npcRelation(w: World, npcId: string): number {
|
||||||
|
return w.state.npcFamilies[npcId]?.relation ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcGiftGain(stones: number): number {
|
||||||
|
return Math.max(1, Math.round(stones / 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GIFT_TIERS = [40, 120, 300] as const
|
||||||
|
|
||||||
|
export function giftNpc(w: World, npcId: string, stones: number): boolean {
|
||||||
|
const fam = w.state.family
|
||||||
|
const npc = w.state.npcFamilies[npcId]
|
||||||
|
if (!npc || stones <= 0 || fam.stones < stones) return false
|
||||||
|
fam.stones -= stones
|
||||||
|
let gain = calcGiftGain(stones)
|
||||||
|
// 结盟亲善势:报之以加倍情面
|
||||||
|
const stance = (w.state.worldSim?.npcDyn?.[npcId] as { stance?: string } | undefined)?.stance
|
||||||
|
if (stance === 'ally') gain = Math.round(gain * 1.3)
|
||||||
|
npc.relation = Math.min(100, npc.relation + gain)
|
||||||
|
npc.power = Math.min(900, npc.power + gain * 0.4)
|
||||||
|
// 回礼:盛情难却,或赠灵石或赠灵草
|
||||||
|
if (w.rng.chance(0.3)) {
|
||||||
|
const back = Math.round(gain * 1.5)
|
||||||
|
w.state.family.stones += back
|
||||||
|
w.log('info', `${npc.name} 回赠灵石${back},来而往之。`)
|
||||||
|
} else if (w.rng.chance(0.3)) {
|
||||||
|
const back = 2 + w.rng.int(0, 3)
|
||||||
|
w.state.family.inventory['lingcao'] = (w.state.family.inventory['lingcao'] ?? 0) + back
|
||||||
|
w.log('info', `${npc.name} 回赠灵草${back}株。`)
|
||||||
|
}
|
||||||
|
w.log('info', `厚礼送往${npc.name},两家关系 +${gain}。`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makePeace(w: World, npcId: string): boolean {
|
||||||
|
const fam = w.state.family
|
||||||
|
const npc = w.state.npcFamilies[npcId]
|
||||||
|
if (!npc || fam.stones < 200) return false
|
||||||
|
fam.stones -= 200
|
||||||
|
npc.relation = Math.max(npc.relation + 35, 30)
|
||||||
|
npc.power = Math.min(900, npc.power + 5)
|
||||||
|
w.chronicle('diplomacy', `${npc.name}立下和约,两家罢兵互市。`, undefined, true)
|
||||||
|
w.log('good', `与${npc.name}言和。`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marryNpcFamily(w: World, npcId: string): boolean {
|
||||||
|
const s = w.state
|
||||||
|
const fam = s.family
|
||||||
|
const npc = s.npcFamilies[npcId]
|
||||||
|
if (!npc || npc.relation < 25 || npc.allied) return false
|
||||||
|
const eligible = w
|
||||||
|
.aliveMembers()
|
||||||
|
.filter((c) => w.ageOf(c) >= 16 && w.ageOf(c) <= 46 && c.state !== 'expedition')
|
||||||
|
.filter((c) => !c.spouseId || w.isWidowed(c))
|
||||||
|
if (eligible.length === 0) return false
|
||||||
|
const npcDef = npcById(npcId)
|
||||||
|
const candidate = w.rng.pick(eligible)
|
||||||
|
candidate.spouseHouse = npc.name
|
||||||
|
npc.relation += 20
|
||||||
|
npc.allied = true
|
||||||
|
npc.alliedSinceYear = s.year
|
||||||
|
fam.reputation += 4
|
||||||
|
w.chronicle('marriage', `${candidate.name}与${npc.name}联姻,两家绸缪通好。`, candidate.id, true)
|
||||||
|
w.log('good', `${candidate.name} 与${npc.name}联姻成功!每年或降麟儿。`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arrangeWedding(w: World, aId: string, bId: string): boolean {
|
||||||
|
const a = w.memberById(aId)
|
||||||
|
const b = w.memberById(bId)
|
||||||
|
if (!a.alive || !b.alive) return false
|
||||||
|
if (a.spouseId && !w.isWidowed(a)) return false
|
||||||
|
if (b.spouseId && !w.isWidowed(b)) return false
|
||||||
|
if (a.gender === b.gender) return false
|
||||||
|
if (a.fatherId === b.fatherId && a.fatherId) return false
|
||||||
|
if (a.motherId === b.motherId && a.motherId) return false
|
||||||
|
a.spouseId = b.id
|
||||||
|
b.spouseId = a.id
|
||||||
|
const aAge = w.ageOf(a)
|
||||||
|
const bAge = w.ageOf(b)
|
||||||
|
if (w.state.family.flag['tenants']) {
|
||||||
|
w.state.family.reputation += 1
|
||||||
|
}
|
||||||
|
w.chronicle('marriage', `${a.name}(${aAge})与${b.name}(${bAge})拜堂成亲。`, a.id, true)
|
||||||
|
w.log('good', `${a.name} 与 ${b.name} 结为连理。`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
+479
-30
@@ -1,16 +1,22 @@
|
|||||||
import type { World } from '../world'
|
import type { World } from '../World'
|
||||||
import { Character } from '../../types/domain'
|
import { worldSimOf } from '../World'
|
||||||
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../data/events'
|
import { Character } from '../../../types/domain'
|
||||||
import { MAJOR_ORDER } from '../../data/realms'
|
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../../data/events'
|
||||||
|
import { MAJOR_ORDER } from '../../../data/realms'
|
||||||
|
|
||||||
|
|
||||||
import { npcById } from '../../data/npcs'
|
import { npcById } from '../../../data/npcs'
|
||||||
import { resolveRaid } from './combat'
|
import { resolveRaid, resolveEncounter } from './combat'
|
||||||
import { sendMission } from './missions'
|
import { sendMission } from './missions'
|
||||||
import { pack } from '../../data/registry'
|
import { pack } from '../../../data/registry'
|
||||||
import { findInheritor } from '../creation'
|
import { findInheritor } from '../creation'
|
||||||
import { resolveTribulation } from './tribulation'
|
import { resolveTribulation, resolveAscension } from './tribulation'
|
||||||
import { nextRealm, describeRealm } from '../../data/realms'
|
import { nextRealm, describeRealm } from '../../../data/realms'
|
||||||
|
import { findMandateEvent } from './mandate'
|
||||||
|
import { newCharacter } from '../pcgen'
|
||||||
|
import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names'
|
||||||
|
import type { EnemyDef } from '../../../data/secrets'
|
||||||
|
import type { FormationId } from '../../../data/formations'
|
||||||
|
|
||||||
const ALL_EVENTS: EventDef[] = [...EVENTS]
|
const ALL_EVENTS: EventDef[] = [...EVENTS]
|
||||||
|
|
||||||
@@ -28,6 +34,7 @@ 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)
|
||||||
|
if (!npc) return undefined // 该族已散(覆灭/附庸)——事件自然消亡
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: `${npc.name}来犯`,
|
name: `${npc.name}来犯`,
|
||||||
@@ -50,8 +57,8 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
|
|||||||
once: true,
|
once: true,
|
||||||
text: '百年元辰已至:这百年间,苗裔繁衍、香火未断。大宴宾客?还是告天祭祖?',
|
text: '百年元辰已至:这百年间,苗裔繁衍、香火未断。大宴宾客?还是告天祭祖?',
|
||||||
options: [
|
options: [
|
||||||
{ label: '大开宴席,张灯结彩', hint: '灵石-200,声望+15,全族心境大悦', eff: { res: { stones: -200 }, rep: 15, memberBy: { by: 'inspire', target: 'all', n: 4 } } },
|
{ label: '大开宴席,张灯结彩', hint: '灵石-200,声望+15,全族心境大悦', eff: { res: { stones: -200 }, rep: 15, karma: 3, memberBy: { by: 'inspire', target: 'all', n: 4 } } },
|
||||||
{ label: '设坛告天', hint: '灵石-100,声望+10', eff: { res: { stones: -100 }, rep: 10, flag: { centennial: 'rite' } } },
|
{ label: '设坛告天', hint: '灵石-100,声望+10,功德+6', eff: { res: { stones: -100 }, rep: 10, karma: 6, flag: { centennial: 'rite' } } },
|
||||||
{ label: '阖家简庆', hint: '声望+5', eff: { rep: 5 } }
|
{ label: '阖家简庆', hint: '声望+5', eff: { rep: 5 } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -198,6 +205,62 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ---- 0.1.37 飞升试炼三阶段(心魔→天劫→飞升) ----
|
||||||
|
if (id.startsWith('ev-ascension-heart-')) {
|
||||||
|
const memberId = id.replace('ev-ascension-heart-', '')
|
||||||
|
const member = w?.state.members[memberId]
|
||||||
|
if (!member || !member.alive) return undefined
|
||||||
|
const karma = w?.state.family.karma ?? 0
|
||||||
|
const karmaHint = karma > 30 ? '功德深厚,心魔难侵' : karma < -20 ? '因果亏缺,心魔猖獗' : '功德平平,全凭心境'
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: '心魔试炼',
|
||||||
|
category: 'fate',
|
||||||
|
weight: 0,
|
||||||
|
once: true,
|
||||||
|
text: `${member.name} 化神圆满,修为已至化境。然飞升之前,先需直面心魔——前世恩怨、今生执念化作幻象,如潮涌来。${karmaHint}。族中当如何助其破障?`,
|
||||||
|
options: [
|
||||||
|
{ label: '以心境抗衡', hint: '成败由心,心境高则破', eff: { ascension: { memberId, stage: 'heart', mode: 'rash' } } },
|
||||||
|
{ label: '以功德化解(灵石200)', hint: '消耗灵石,功德护心', eff: { ascension: { memberId, stage: 'heart', mode: 'guard' } } },
|
||||||
|
{ label: '压制一年再试', hint: '养心蓄锐,来年再渡', eff: { ascension: { memberId, stage: 'heart', mode: 'delay' } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id.startsWith('ev-ascension-heaven-')) {
|
||||||
|
const memberId = id.replace('ev-ascension-heaven-', '')
|
||||||
|
const member = w?.state.members[memberId]
|
||||||
|
if (!member || !member.alive) return undefined
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: '飞升天劫',
|
||||||
|
category: 'fate',
|
||||||
|
weight: 0,
|
||||||
|
once: true,
|
||||||
|
text: `${member.name} 破开心魔障壁,灵台清明。然飞升之劫已至——九天雷池翻涌,紫电如海。此劫远非渡劫可比,一着不慎即形神俱灭。族中当如何?`,
|
||||||
|
options: [
|
||||||
|
{ label: '硬渡天劫!', hint: '生死由天,一往无前', eff: { ascension: { memberId, stage: 'heaven', mode: 'rash' } } },
|
||||||
|
{ label: '请族中护法(灵石300)', hint: '族人护持,成算大增', eff: { ascension: { memberId, stage: 'heaven', mode: 'guard' } } },
|
||||||
|
{ label: '压制一年再渡', hint: '蓄势待发,丹力渐散', eff: { ascension: { memberId, stage: 'heaven', mode: 'delay' } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id.startsWith('ev-ascension-ascend-')) {
|
||||||
|
const memberId = id.replace('ev-ascension-ascend-', '')
|
||||||
|
const member = w?.state.members[memberId]
|
||||||
|
if (!member || !member.alive) return undefined
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: '飞升抉择',
|
||||||
|
category: 'fate',
|
||||||
|
weight: 0,
|
||||||
|
once: true,
|
||||||
|
text: `${member.name} 历经心魔天劫,灵台澄明如镜,仙门已开。此刻可选择飞升而去、云游太虚,亦可留世坐镇、护佑宗族万世。此去一别,或成永诀——宗族当如何抉择?`,
|
||||||
|
options: [
|
||||||
|
{ label: '留仙坐镇', hint: '他留世镇族:仙风入体,天赋大盛', eff: { ascension: { memberId, stage: 'ascend', mode: 'rash' } } },
|
||||||
|
{ label: '放仙飞升', hint: '他飞升太虚:宗族永蒙仙荫,声望大涨', eff: { ascension: { memberId, stage: 'ascend', mode: 'guard' } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
if (id === 'ev-feisheng') {
|
if (id === 'ev-feisheng') {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -212,6 +275,10 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 0.1.40 天命事件查找
|
||||||
|
if (id.startsWith('mandate-')) {
|
||||||
|
return findMandateEvent(id)
|
||||||
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,11 +379,18 @@ export function eventRoll(w: World): void {
|
|||||||
fire(w, 'ev-centennial')
|
fire(w, 'ev-centennial')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 0.1.37 飞升试炼:化神圆满由 cultivation.ts 直接 fire 心魔试炼
|
||||||
|
// 旧版 ev-feisheng 兼容路径:firstSpirit + 2 年 且 没有化神圆满者 → 旧版简化飞升
|
||||||
const firstSpirit = s.flags['firstSpirit'] as number | undefined
|
const firstSpirit = s.flags['firstSpirit'] as number | undefined
|
||||||
if (firstSpirit && s.year - firstSpirit >= 2 && !s.completedEvents.includes('ev-feisheng')) {
|
if (firstSpirit && s.year - firstSpirit >= 2 && !s.completedEvents.includes('ev-feisheng')) {
|
||||||
|
// 新版飞升试炼由 cultivation.ts 在化神修为满 100% 时触发
|
||||||
|
// 检查是否有化神圆满者(如果有,说明新系统已接管或即将接管)
|
||||||
|
const hasSpiritMaxed = w.aliveMembers().some((c) => c.realm.major === 'spirit' && c.realm.minor >= 2 && c.realmProgress >= 100)
|
||||||
|
if (!hasSpiritMaxed) {
|
||||||
fire(w, 'ev-feisheng')
|
fire(w, 'ev-feisheng')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
checkApprenticeExpiry(w)
|
checkApprenticeExpiry(w)
|
||||||
recruitCheck(w)
|
recruitCheck(w)
|
||||||
|
|
||||||
@@ -335,18 +409,25 @@ export function eventRoll(w: World): void {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 名宿传薪(每八年一掷)
|
// 名宿传薪(每八年一掷;年锚 flag 防同一年循环连发——曾实测 y%8==4 年 12 连发)
|
||||||
if (s.year % 8 === 4 && w.sysEnabled('apprentice') && !s.completedEvents.includes('ev-legacypass')) {
|
if (s.year % 8 === 4 && w.sysEnabled('apprentice') && !s.completedEvents.includes('ev-legacypass') && !s.family.flag[`legacyDone-${s.year}`]) {
|
||||||
const elder = w.aliveMembers().some((c) => w.ageOf(c) >= 66 && c.realm.major !== 'mortal')
|
const elder = w.aliveMembers().some((c) => w.ageOf(c) >= 66 && c.realm.major !== 'mortal')
|
||||||
if (elder) {
|
if (elder) {
|
||||||
fire(w, 'ev-legacypass')
|
const ok = fire(w, 'ev-legacypass')
|
||||||
|
if (ok) s.family.flag[`legacyDone-${s.year}`] = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 仙门拍卖(每年十月掷币)
|
// 仙门拍卖(每年十月掷币;时代调制:盛世频仍/末法难逢)
|
||||||
if (s.month === 10 && w.sysEnabled('production')) {
|
if (s.month === 10 && w.sysEnabled('production')) {
|
||||||
const key = `auction-${s.year}`
|
const key = `auction-${s.year}`
|
||||||
if (!s.family.flag[key] && w.rng.chance(0.55)) {
|
// W2 关注度:强的家族遭世价抬杠(拍卖概率 ×1.5 表示“盯上你”的通胀)——简化:仅概率上调
|
||||||
|
const famPow = w.familyPower()
|
||||||
|
const npcsAll = Object.values(s.npcFamilies)
|
||||||
|
const npcAvg = npcsAll.length > 0 ? npcsAll.reduce((a, n) => a + n.power, 0) / npcsAll.length : 1
|
||||||
|
const notoriety = famPow > npcAvg * 2 && famPow > 300 ? 1.15 : 1
|
||||||
|
const eraChance = w.state.worldSim ? Math.min(0.9, 0.55 * worldSimOf(w).eraAuctionMult() * notoriety) : 0.55
|
||||||
|
if (!s.family.flag[key] && w.rng.chance(eraChance)) {
|
||||||
s.family.flag[key] = true
|
s.family.flag[key] = true
|
||||||
fire(w, 'ev-auction')
|
fire(w, 'ev-auction')
|
||||||
return
|
return
|
||||||
@@ -368,8 +449,9 @@ export function eventRoll(w: World): void {
|
|||||||
const key = `echoDone-${s.year}`
|
const key = `echoDone-${s.year}`
|
||||||
if (!s.family.flag[key]) {
|
if (!s.family.flag[key]) {
|
||||||
s.family.flag[key] = true
|
s.family.flag[key] = true
|
||||||
if (w.rng.chance(0.7)) {
|
const npcs = Object.values(s.npcFamilies)
|
||||||
const npc = w.rng.pick(Object.values(s.npcFamilies))
|
if (npcs.length > 0 && w.rng.chance(0.7)) {
|
||||||
|
const npc = w.rng.pick(npcs)
|
||||||
fire(w, `ev-echo-${npc.id}-${s.year}`)
|
fire(w, `ev-echo-${npc.id}-${s.year}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -388,21 +470,55 @@ export function eventRoll(w: World): void {
|
|||||||
matchesCond(w, e.cond)
|
matchesCond(w, e.cond)
|
||||||
)
|
)
|
||||||
if (candidates.length === 0) return
|
if (candidates.length === 0) return
|
||||||
const total = candidates.reduce((a, e) => a + e.weight, 0)
|
// P1-N 权重洪水治理:插件池权重总额 > 核心同类 50% 时按比例压缩(MOD 不垄断世界节奏)
|
||||||
|
const coreWeight = candidates
|
||||||
|
.filter((e) => !poolTouchedByPlugin(w, e.id))
|
||||||
|
.reduce((a, e) => a + e.weight, 0)
|
||||||
|
const plugWeight = candidates
|
||||||
|
.filter((e) => poolTouchedByPlugin(w, e.id))
|
||||||
|
.reduce((a, e) => a + e.weight, 0)
|
||||||
|
const cap = Math.max(1, coreWeight * 0.5)
|
||||||
|
const compress = plugWeight > cap ? cap / plugWeight : 1
|
||||||
|
const weighted: Array<{ e: (typeof candidates)[number]; w: number }> = candidates.map((e) => ({
|
||||||
|
e,
|
||||||
|
w: poolTouchedByPlugin(w, e.id) ? e.weight * compress : e.weight
|
||||||
|
}))
|
||||||
|
const total = weighted.reduce((a, x) => a + x.w, 0)
|
||||||
let r = w.rng.next() * total
|
let r = w.rng.next() * total
|
||||||
for (const e of candidates) {
|
for (const x of weighted) {
|
||||||
r -= e.weight
|
r -= x.w
|
||||||
if (r <= 0) {
|
if (r <= 0) {
|
||||||
fire(w, e.id)
|
fire(w, x.e.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fire(w: World, id: string): void {
|
/**
|
||||||
if (w.state.pendingEvent) return
|
* 事件投放(唯一 pending 入口)。
|
||||||
w.state.pendingEvent = id
|
* priority=0 普通(被占则放弃,下次再来);priority=1 高优(raid/渡劫/大比——可顶替被占的普通事件,
|
||||||
|
* 被顶替者转存 eventQueue 下月兑现,不丢失)。
|
||||||
|
* 返回 true=已在档(pending=id);false=被占未投放(调用方应自行冷却/重试)。
|
||||||
|
*/
|
||||||
|
function poolTouchedByPlugin(w: World, eventId: string): boolean {
|
||||||
|
// 池命中判定:任一非 core 池(插件池)拥有该事件 → true
|
||||||
|
for (const p of w.eventPoolSnapshot()) {
|
||||||
|
if (p.id === 'core') continue
|
||||||
|
if (p.events.some((e) => e.id === eventId)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fire(w: World, id: string, priority: 0 | 1 = 0): boolean {
|
||||||
|
const s = w.state
|
||||||
|
if (s.pendingEvent) {
|
||||||
|
if (priority === 0) return false
|
||||||
|
const prev = s.pendingEvent
|
||||||
|
if (prev !== id && !s.eventQueue.includes(prev)) s.eventQueue.push(prev)
|
||||||
|
}
|
||||||
|
s.pendingEvent = id
|
||||||
w.pendingEvent(id)
|
w.pendingEvent(id)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyEventChoice(
|
export function applyEventChoice(
|
||||||
@@ -414,6 +530,8 @@ export function applyEventChoice(
|
|||||||
formation?: string
|
formation?: string
|
||||||
): void {
|
): void {
|
||||||
const s = w.state
|
const s = w.state
|
||||||
|
// 2 次结算防线:事件已被自动压制/取代/清空时,UI 陈旧选择不得落盘
|
||||||
|
if (s.pendingEvent && s.pendingEvent !== eventId) return
|
||||||
const def = findEvent(eventId, w)
|
const def = findEvent(eventId, w)
|
||||||
if (!def) {
|
if (!def) {
|
||||||
s.pendingEvent = undefined
|
s.pendingEvent = undefined
|
||||||
@@ -421,8 +539,7 @@ export function applyEventChoice(
|
|||||||
}
|
}
|
||||||
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, undefined, formation)
|
||||||
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)
|
||||||
}
|
}
|
||||||
s.pendingEvent = undefined
|
s.pendingEvent = undefined
|
||||||
@@ -468,7 +585,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
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
export function applyEffect(w: World, eff: EffectDef, squad?: string[], _extra?: unknown, formation?: string): void {
|
||||||
const s = w.state
|
const s = w.state
|
||||||
const fam = s.family
|
const fam = s.family
|
||||||
|
|
||||||
@@ -481,6 +598,12 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
|||||||
if (eff.pillGain) {
|
if (eff.pillGain) {
|
||||||
for (const [k, v] of Object.entries(eff.pillGain)) fam.inventory[k] = (fam.inventory[k] ?? 0) + v
|
for (const [k, v] of Object.entries(eff.pillGain)) fam.inventory[k] = (fam.inventory[k] ?? 0) + v
|
||||||
}
|
}
|
||||||
|
if (eff.karma) {
|
||||||
|
fam.karma += eff.karma
|
||||||
|
if (Math.abs(eff.karma) >= 3) {
|
||||||
|
w.log(eff.karma > 0 ? 'good' : 'bad', `功德${eff.karma > 0 ? '增加' : '损耗'}${Math.abs(eff.karma)}点。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (eff.rep) {
|
if (eff.rep) {
|
||||||
fam.reputation += eff.rep
|
fam.reputation += eff.rep
|
||||||
if (Math.abs(eff.rep) >= 4) w.log(eff.rep > 0 ? 'good' : 'bad', `家族声望${eff.rep > 0 ? '上升' : '下跌'}${Math.abs(eff.rep)}。`)
|
if (Math.abs(eff.rep) >= 4) w.log(eff.rep > 0 ? 'good' : 'bad', `家族声望${eff.rep > 0 ? '上升' : '下跌'}${Math.abs(eff.rep)}。`)
|
||||||
@@ -513,7 +636,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
|||||||
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) {
|
if (eff.tournament) {
|
||||||
runTournament(w, squad, eff.formation as never)
|
runTournament(w, squad, formation as never)
|
||||||
}
|
}
|
||||||
if (eff.apprentice?.build) {
|
if (eff.apprentice?.build) {
|
||||||
buildApprentice(w)
|
buildApprentice(w)
|
||||||
@@ -558,6 +681,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
|||||||
w.log('info', '拍卖会灵器已溢价转卖,回款150灵石。')
|
w.log('info', '拍卖会灵器已溢价转卖,回款150灵石。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
w.emitFx('spark', 'auction')
|
||||||
delete fam.flag['auctionWin']
|
delete fam.flag['auctionWin']
|
||||||
}
|
}
|
||||||
if (eff.trib) {
|
if (eff.trib) {
|
||||||
@@ -593,6 +717,15 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 0.1.37 飞升试炼三阶段
|
||||||
|
if (eff.ascension) {
|
||||||
|
const c = w.state.members[eff.ascension.memberId]
|
||||||
|
if (c?.alive) {
|
||||||
|
c.tribPendingMonths = 0
|
||||||
|
resolveAscension(w, c, eff.ascension.stage, eff.ascension.mode ?? 'rash')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (eff.memberBy) {
|
if (eff.memberBy) {
|
||||||
const targets = pickMember(w, eff.memberBy)
|
const targets = pickMember(w, eff.memberBy)
|
||||||
const by = eff.memberBy.by
|
const by = eff.memberBy.by
|
||||||
@@ -671,7 +804,9 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
|||||||
.sort((a, b) => rankPower(w, b) - rankPower(w, a))
|
.sort((a, b) => rankPower(w, b) - rankPower(w, a))
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
if (squad && squad.length > 0) {
|
if (squad && squad.length > 0) {
|
||||||
team = squad.map((id) => w.memberById(id)).filter((c) => c.alive && c.state !== 'expedition' && w.ageOf(c) >= 16)
|
team = squad
|
||||||
|
.map((id) => w.state.members[id] ? w.memberById(id) : null)
|
||||||
|
.filter((c): c is Character => !!c && c.alive && c.state !== 'expedition' && w.ageOf(c) >= 16)
|
||||||
}
|
}
|
||||||
if (team.length > 0) {
|
if (team.length > 0) {
|
||||||
resolveRaid(w, eff.raid.npcId, team)
|
resolveRaid(w, eff.raid.npcId, team)
|
||||||
@@ -679,4 +814,318 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────── 0.1.41 天命 flag 全量接线 ────────────────
|
||||||
|
// 即时兑现:flag 设置后立即在引擎层产生真实效果
|
||||||
|
// 持续效果:设置时效 flag(以到期绝对月数存储),由各系统每月读取
|
||||||
|
|
||||||
|
// tideHarvest — 灵草产出 ×1.5 持续 24 月
|
||||||
|
if (eff.flag?.['tideHarvest'] !== undefined) {
|
||||||
|
const expireMonth = s.year * 12 + s.month + 24
|
||||||
|
fam.flag['tideHarvestUntil'] = expireMonth
|
||||||
|
delete fam.flag['tideHarvest']
|
||||||
|
w.log('good', '趁灵气充沛广植灵草,来年丰收——灵草产出将提升 24 月。')
|
||||||
|
}
|
||||||
|
|
||||||
|
// mercenaryHired — 佣兵入族,战力加成 3 年(36 月)
|
||||||
|
if (eff.flag?.['mercenaryHired'] !== undefined) {
|
||||||
|
const expireMonth = s.year * 12 + s.month + 36
|
||||||
|
fam.flag['mercenaryUntil'] = expireMonth
|
||||||
|
delete fam.flag['mercenaryHired']
|
||||||
|
w.log('good', '佣兵入族效力——家族战力大增,三年后离去。')
|
||||||
|
}
|
||||||
|
|
||||||
|
// banditRecruit — 流寇归顺入族(人丁+2)
|
||||||
|
if (eff.flag?.['banditRecruit'] !== undefined) {
|
||||||
|
const surname = fam.surname
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const isMale = w.rng.chance(0.6) // 流寇多为男性
|
||||||
|
const name = `${surname}${w.rng.pick(isMale ? MALE_GIVEN : FEMALE_GIVEN)}`
|
||||||
|
const recruit = newCharacter(w.rng, {
|
||||||
|
name,
|
||||||
|
gender: isMale ? 'male' : 'female',
|
||||||
|
generation: fam.generation,
|
||||||
|
bornYear: s.year - (20 + w.rng.int(0, 15)),
|
||||||
|
age: 0,
|
||||||
|
realm: { major: 'qi', minor: w.rng.int(0, 2) },
|
||||||
|
})
|
||||||
|
recruit.health = 70 + w.rng.int(0, 20) // 流寇体质尚可
|
||||||
|
recruit.state = 'idle'
|
||||||
|
w.addMember(recruit)
|
||||||
|
w.log('info', `流寇归顺——${name} 入族充丁。`)
|
||||||
|
}
|
||||||
|
delete fam.flag['banditRecruit']
|
||||||
|
}
|
||||||
|
|
||||||
|
// borderWarPicked — 选边站队:根据选择调整 NPC 关系
|
||||||
|
if (eff.flag?.['borderWarPicked'] !== undefined) {
|
||||||
|
const side = eff.flag['borderWarPicked'] as string
|
||||||
|
const npcs = Object.entries(s.npcFamilies).sort(([, a], [, b]) => b.power - a.power)
|
||||||
|
if (npcs.length >= 2) {
|
||||||
|
if (side === 'strong') {
|
||||||
|
const strongNpc = npcs[0]![1]
|
||||||
|
strongNpc.relation = Math.min(100, strongNpc.relation + 25)
|
||||||
|
for (let i = 1; i < npcs.length; i++) {
|
||||||
|
npcs[i]![1].relation = Math.max(-100, npcs[i]![1].relation - 25)
|
||||||
|
}
|
||||||
|
w.log('info', `支持${strongNpc.name}——胜方关系提升,败方关系恶化。`)
|
||||||
|
} else if (side === 'weak') {
|
||||||
|
const [strongId] = npcs[0]!
|
||||||
|
const weakNpc = npcs[npcs.length - 1]![1]
|
||||||
|
weakNpc.relation = Math.min(100, weakNpc.relation + 30)
|
||||||
|
s.npcFamilies[strongId]!.relation = Math.max(-100, s.npcFamilies[strongId]!.relation - 15)
|
||||||
|
w.log('info', `支持${weakNpc.name}——雪中送炭,弱方铭记在心。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete fam.flag['borderWarPicked']
|
||||||
|
}
|
||||||
|
|
||||||
|
// siegeFought — 誓死抵抗:执行围庄战斗判定
|
||||||
|
if (eff.flag?.['siegeFought'] !== undefined) {
|
||||||
|
const team = w.aliveMembers()
|
||||||
|
.filter((c) => w.ageOf(c) >= 16 && c.state !== 'expedition')
|
||||||
|
.sort((a, b) => rankPower(w, b) - rankPower(w, a))
|
||||||
|
.slice(0, 5)
|
||||||
|
if (team.length > 0) {
|
||||||
|
const enemy: EnemyDef = {
|
||||||
|
id: 'siege-attacker',
|
||||||
|
name: '围庄敌军',
|
||||||
|
realm: 'foundation',
|
||||||
|
strength: 1.8,
|
||||||
|
icon: '围',
|
||||||
|
desc: '来犯之敌兵临城下。'
|
||||||
|
}
|
||||||
|
const res = resolveEncounter(w, {
|
||||||
|
title: '围庄之战',
|
||||||
|
enemy,
|
||||||
|
risk: 0.6,
|
||||||
|
team,
|
||||||
|
kind: 'war',
|
||||||
|
year: s.year,
|
||||||
|
month: s.month,
|
||||||
|
formation: (formation as FormationId) ?? undefined
|
||||||
|
})
|
||||||
|
if (res.win) {
|
||||||
|
w.log('good', '族人同仇敌忾,击退围敌!声威大振。')
|
||||||
|
} else if (res.draw) {
|
||||||
|
w.log('info', '围敌暂退,但伤亡不小。')
|
||||||
|
} else {
|
||||||
|
w.log('bad', '围庄之战惨败——族中损失惨重。')
|
||||||
|
fam.stones = Math.max(0, fam.stones - 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete fam.flag['siegeFought']
|
||||||
|
}
|
||||||
|
|
||||||
|
// siegeAlliance — 求援盟友:检查是否有盟友(关系≥40 的 NPC)
|
||||||
|
if (eff.flag?.['siegeAlliance'] !== undefined) {
|
||||||
|
const allies = Object.values(s.npcFamilies).filter((n) => n.relation >= 40 || n.allied)
|
||||||
|
if (allies.length > 0) {
|
||||||
|
const ally = allies[0]!
|
||||||
|
ally.relation = Math.min(100, ally.relation + 15)
|
||||||
|
w.log('good', `${ally.name} 出兵来援,围敌退去——同盟之谊更深。`)
|
||||||
|
w.chronicle('diplomacy', `${ally.name} 遣兵驰援围庄之危,同盟益固。`, undefined, true)
|
||||||
|
} else {
|
||||||
|
// 无盟友——效果落空,补偿灵石损失
|
||||||
|
w.log('bad', '求援无门——四方无一肯来,只得独自面对。')
|
||||||
|
fam.stones = Math.max(0, fam.stones - 100) // 额外赔偿
|
||||||
|
}
|
||||||
|
delete fam.flag['siegeAlliance']
|
||||||
|
}
|
||||||
|
|
||||||
|
// treasureExplored — 古宝探索:灵石已扣,按概率获得功法/宝器/丹药
|
||||||
|
if (eff.flag?.['treasureExplored'] !== undefined) {
|
||||||
|
const roll = w.rng.next()
|
||||||
|
if (roll < 0.30) {
|
||||||
|
const t = w.rng.pick(pack().techniques.filter((t) => t.grade >= 2))
|
||||||
|
if (t && !fam.techniques.includes(t.id)) {
|
||||||
|
fam.techniques.push(t.id)
|
||||||
|
w.log('good', `古洞探索——寻得《${t.name}》残篇!`)
|
||||||
|
w.chronicle('event', `遣队深入古洞,寻得《${t.name}》残篇。`, undefined, true)
|
||||||
|
} else {
|
||||||
|
fam.inventory['pill-pojing'] = (fam.inventory['pill-pojing'] ?? 0) + 1
|
||||||
|
w.log('good', '古洞探索——寻得一枚破境丹!')
|
||||||
|
}
|
||||||
|
} else if (roll < 0.55) {
|
||||||
|
const a = w.rng.pick(['weapon-fan', 'weapon-qi', 'weapon-ling'])
|
||||||
|
fam.inventory[a] = (fam.inventory[a] ?? 0) + 1
|
||||||
|
w.log('good', '古洞探索——寻得一件法器!')
|
||||||
|
} else if (roll < 0.75) {
|
||||||
|
fam.inventory['pill-pojing'] = (fam.inventory['pill-pojing'] ?? 0) + 1
|
||||||
|
w.log('good', '古洞探索——寻得一枚破境丹!')
|
||||||
|
} else {
|
||||||
|
// 受伤但无所得
|
||||||
|
for (const c of w.aliveMembers().slice(0, 3)) {
|
||||||
|
c.health = Math.max(1, c.health - w.rng.int(10, 30))
|
||||||
|
if (c.health < 35) c.state = 'wounded'
|
||||||
|
}
|
||||||
|
w.log('bad', '古洞探索——禁制反噬,队员负伤而归。')
|
||||||
|
}
|
||||||
|
delete fam.flag['treasureExplored']
|
||||||
|
}
|
||||||
|
|
||||||
|
// artisanHired — 匠师入族,丹房效率永久+10%
|
||||||
|
if (eff.flag?.['artisanHired'] !== undefined) {
|
||||||
|
fam.flag['artisanBonus'] = true
|
||||||
|
delete fam.flag['artisanHired']
|
||||||
|
w.log('good', '匠师入族——炼丹炼器效率永久提升。')
|
||||||
|
}
|
||||||
|
|
||||||
|
// prodigyAdopted — 收养天骄幼童
|
||||||
|
if (eff.flag?.['prodigyAdopted'] !== undefined) {
|
||||||
|
const surname = fam.surname
|
||||||
|
const isMale = w.rng.chance(0.5)
|
||||||
|
const name = `${surname}${w.rng.pick(isMale ? MALE_GIVEN : FEMALE_GIVEN)}`
|
||||||
|
const child = newCharacter(w.rng, {
|
||||||
|
name,
|
||||||
|
gender: isMale ? 'male' : 'female',
|
||||||
|
generation: fam.generation + 1,
|
||||||
|
bornYear: s.year - 3, // 3 岁幼童
|
||||||
|
age: 0,
|
||||||
|
realm: { major: 'mortal', minor: 0 },
|
||||||
|
})
|
||||||
|
// 天骄:高悟性+高根骨
|
||||||
|
child.perception = Math.min(10, 7 + w.rng.int(0, 3))
|
||||||
|
child.mind = Math.min(10, 7 + w.rng.int(0, 3))
|
||||||
|
child.fortune = Math.min(12, 8 + w.rng.int(0, 4))
|
||||||
|
w.addMember(child)
|
||||||
|
w.log('good', `天纵之资——${name} 入族,灵根奇佳,悟性超群。`)
|
||||||
|
w.chronicle('event', `族中收养一名天骄幼童${name},灵根奇佳。`, child.id, true)
|
||||||
|
delete fam.flag['prodigyAdopted']
|
||||||
|
}
|
||||||
|
|
||||||
|
// prodigyTested — 试根骨:概率天才或伤根基
|
||||||
|
if (eff.flag?.['prodigyTested'] !== undefined) {
|
||||||
|
const roll = w.rng.next()
|
||||||
|
const surname = fam.surname
|
||||||
|
const isMale = w.rng.chance(0.5)
|
||||||
|
const name = `${surname}${w.rng.pick(isMale ? MALE_GIVEN : FEMALE_GIVEN)}`
|
||||||
|
if (roll < 0.45) {
|
||||||
|
// 天才
|
||||||
|
const child = newCharacter(w.rng, {
|
||||||
|
name,
|
||||||
|
gender: isMale ? 'male' : 'female',
|
||||||
|
generation: fam.generation + 1,
|
||||||
|
bornYear: s.year - 3,
|
||||||
|
age: 0,
|
||||||
|
realm: { major: 'mortal', minor: 0 },
|
||||||
|
})
|
||||||
|
child.perception = Math.min(10, 8 + w.rng.int(0, 2))
|
||||||
|
child.mind = Math.min(10, 8 + w.rng.int(0, 2))
|
||||||
|
child.fortune = Math.min(12, 10)
|
||||||
|
// 但秘法伤了一分根基
|
||||||
|
child.health = 70
|
||||||
|
w.addMember(child)
|
||||||
|
w.log('good', `秘法试根骨——${name} 确为天才,但根基微损。`)
|
||||||
|
w.chronicle('event', `以秘法探得${name}乃天才,然根基微损。`, child.id, true)
|
||||||
|
} else if (roll < 0.75) {
|
||||||
|
// 普通幼童入族
|
||||||
|
const child = newCharacter(w.rng, {
|
||||||
|
name,
|
||||||
|
gender: isMale ? 'male' : 'female',
|
||||||
|
generation: fam.generation + 1,
|
||||||
|
bornYear: s.year - 3,
|
||||||
|
age: 0,
|
||||||
|
realm: { major: 'mortal', minor: 0 },
|
||||||
|
})
|
||||||
|
w.addMember(child)
|
||||||
|
w.log('info', `秘法试根骨——${name} 灵根尚可,收入族中。`)
|
||||||
|
} else {
|
||||||
|
// 伤其根基——幼童未能入族
|
||||||
|
w.log('bad', '秘法反噬——幼童根基受损,其家含泪带走,再不来投。')
|
||||||
|
fam.karma -= 2
|
||||||
|
}
|
||||||
|
delete fam.flag['prodigyTested']
|
||||||
|
}
|
||||||
|
|
||||||
|
// envoyToAlliance — 遣送入盟,三年后归
|
||||||
|
if (eff.flag?.['envoyToAlliance'] !== undefined) {
|
||||||
|
const best = w.aliveMembers()
|
||||||
|
.filter((c) => c.perception >= 6 && w.ageOf(c) >= 16)
|
||||||
|
.sort((a, b) => b.perception - a.perception)[0]
|
||||||
|
if (best) {
|
||||||
|
const expireMonth = s.year * 12 + s.month + 36
|
||||||
|
fam.flag['envoyReturnUntil'] = expireMonth
|
||||||
|
fam.flag['envoyMemberId'] = best.id
|
||||||
|
best.state = 'apprentice'
|
||||||
|
best.apprentice = { sect: '太虚仙盟', untilYear: s.year + 3, quiet: false }
|
||||||
|
w.log('info', `${best.name} 入太虚仙盟修行三年——悟性大开。`)
|
||||||
|
w.chronicle('event', `${best.name} 遣入太虚仙盟修行三年。`, best.id, true)
|
||||||
|
}
|
||||||
|
delete fam.flag['envoyToAlliance']
|
||||||
|
}
|
||||||
|
|
||||||
|
// coalitionFormed — 组建中立联盟:与关系≥40 的 NPC 建立同盟
|
||||||
|
if (eff.flag?.['coalitionFormed'] !== undefined) {
|
||||||
|
let count = 0
|
||||||
|
for (const npc of Object.values(s.npcFamilies)) {
|
||||||
|
if (npc.relation >= 40 && !npc.allied) {
|
||||||
|
npc.allied = true
|
||||||
|
npc.alliedSinceYear = s.year
|
||||||
|
npc.relation = Math.min(100, npc.relation + 10)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count >= 2) {
|
||||||
|
w.log('good', `中立联盟成立——${count} 家响应,第三条路已开。`)
|
||||||
|
w.chronicle('event', `牵头组建中立联盟,${count} 家响应。`, undefined, true)
|
||||||
|
} else if (count === 1) {
|
||||||
|
w.log('info', '仅有一家响应联盟——势单力薄,但聊胜于无。')
|
||||||
|
} else {
|
||||||
|
w.log('bad', '无人响应联盟——中立之路难行。')
|
||||||
|
}
|
||||||
|
delete fam.flag['coalitionFormed']
|
||||||
|
}
|
||||||
|
|
||||||
|
// choseStrongSide — 择强而从:与最强 NPC 关系+30,其余-20
|
||||||
|
if (eff.flag?.['choseStrongSide'] !== undefined) {
|
||||||
|
const npcs = Object.values(s.npcFamilies).sort((a, b) => b.power - a.power)
|
||||||
|
if (npcs.length > 0) {
|
||||||
|
npcs[0]!.relation = Math.min(100, npcs[0]!.relation + 30)
|
||||||
|
for (let i = 1; i < npcs.length; i++) {
|
||||||
|
npcs[i]!.relation = Math.max(-100, npcs[i]!.relation - 20)
|
||||||
|
}
|
||||||
|
w.log('info', `择强而从——与${npcs[0]!.name}关系大增,其余家族转冷。`)
|
||||||
|
}
|
||||||
|
delete fam.flag['choseStrongSide']
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebelPunished — 大义灭亲:移除一名最高修为的叛逆成员
|
||||||
|
if (eff.flag?.['rebelPunished'] !== undefined) {
|
||||||
|
const rebel = w.aliveMembers()
|
||||||
|
.filter((c) => c.id !== fam.headId && c.realm.major !== 'mortal')
|
||||||
|
.sort((a, b) => rankPower(w, b) - rankPower(w, a))[0]
|
||||||
|
if (rebel) {
|
||||||
|
rebel.alive = false
|
||||||
|
rebel.deathYear = s.year
|
||||||
|
rebel.deathCause = '族规处决'
|
||||||
|
w.log('bad', `${rebel.name} 以叛逆之罪处决——人心震慑。`)
|
||||||
|
w.chronicle('death', `${rebel.name} 因通敌叛逆被公审处决。`, rebel.id, true)
|
||||||
|
}
|
||||||
|
delete fam.flag['rebelPunished']
|
||||||
|
}
|
||||||
|
|
||||||
|
// elderCouncil — 长老会设立(永久 flag,不删除)
|
||||||
|
if (eff.flag?.['elderCouncil'] !== undefined) {
|
||||||
|
// elderCouncil 作为永久 flag 保留——cultivation.ts 持续读取
|
||||||
|
w.log('good', '长老会成立——族务共议,人心凝聚。')
|
||||||
|
}
|
||||||
|
|
||||||
|
// advisoryBoard — 参议设立(永久 flag,不删除)
|
||||||
|
if (eff.flag?.['advisoryBoard'] !== undefined) {
|
||||||
|
// advisoryBoard 作为永久 flag 保留——cultivation.ts 持续读取
|
||||||
|
w.log('info', '参议之位设立——长老有言权无决权。')
|
||||||
|
}
|
||||||
|
|
||||||
|
// successionElder — 立长(永久 flag,影响 findInheritor)
|
||||||
|
if (eff.flag?.['successionElder'] !== undefined) {
|
||||||
|
// successionElder 作为永久 flag 保留——creation.ts findInheritor 读取
|
||||||
|
w.log('good', '储位已定——以长幼为序,纲常有序。')
|
||||||
|
}
|
||||||
|
|
||||||
|
// successionWise — 立贤(永久 flag,影响 findInheritor + 修炼加成)
|
||||||
|
if (eff.flag?.['successionWise'] !== undefined) {
|
||||||
|
// successionWise 作为永久 flag 保留
|
||||||
|
w.log('good', '储位已定——以才德为先,来日可期。')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+7
-5
@@ -1,5 +1,5 @@
|
|||||||
import type { 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'
|
||||||
|
|
||||||
export function lifespanOf(w: World, c: { realm: { major: keyof typeof MAJORS }; physique: number }): number {
|
export function lifespanOf(w: World, c: { realm: { major: keyof typeof MAJORS }; physique: number }): number {
|
||||||
@@ -11,11 +11,13 @@ export function deathTick(w: World): void {
|
|||||||
if (!c.alive) continue
|
if (!c.alive) continue
|
||||||
const age = w.ageOf(c)
|
const age = w.ageOf(c)
|
||||||
const span = lifespanOf(w, c)
|
const span = lifespanOf(w, c)
|
||||||
const softCap = span * 0.85
|
// P0-A 天年:仙家 200 年为限(span 供凡人/低境界;高境界同样 240 内退场——世界人口会换血)
|
||||||
|
const cap = Math.min(span, 200)
|
||||||
|
const softCap = cap * 0.85
|
||||||
let p = 0
|
let p = 0
|
||||||
if (age >= span) p = 0.35
|
if (age >= cap) p = 0.35
|
||||||
else if (age >= softCap) {
|
else if (age >= softCap) {
|
||||||
const t = (age - softCap) / (span - softCap)
|
const t = (age - softCap) / (cap - softCap)
|
||||||
p = Math.min(0.3, Math.pow(t, 5) * 0.9)
|
p = Math.min(0.3, Math.pow(t, 5) * 0.9)
|
||||||
}
|
}
|
||||||
if (c.health < 30) p += 0.18
|
if (c.health < 30) p += 0.18
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 0.1.40 天命系统 · 引擎层
|
||||||
|
*
|
||||||
|
* 天命系统核心逻辑:
|
||||||
|
* - checkMandate(w): 检查是否到期 + 选取议题 + fire 高优入闸
|
||||||
|
* - resolveMandate(w, idx): 玩家选择结算(复用 applyEventChoice 路径)
|
||||||
|
* - 动态事件生成:天命 id = `mandate-<defId>`,经 dynamicEventFor 路径返回 EventDef
|
||||||
|
*
|
||||||
|
* 确定性保证:
|
||||||
|
* - 选取走种子派生(mandateHash),零 rng 消耗
|
||||||
|
* - 效果结算复用 applyEventChoice 路径(已验证的确定性通道)
|
||||||
|
* - 天命间隔 3~5 年(种子派生)
|
||||||
|
* - 天命经 fire(w, id, 1) 高优入闸——可顶替被占日常事件
|
||||||
|
* - 天命不可"稍后再议"——不可回避(UI 层隐藏 dorm 按钮)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { World } from '../World'
|
||||||
|
import { fire } from './events'
|
||||||
|
import { matchesCond } from './events'
|
||||||
|
import type { EventDef } from '../../../data/events'
|
||||||
|
import {
|
||||||
|
MANDATES,
|
||||||
|
MANDATE_BY_CATEGORY,
|
||||||
|
mandateById,
|
||||||
|
mandateCategoryFor,
|
||||||
|
mandateHash,
|
||||||
|
mandateInterval,
|
||||||
|
mandateCategoryName,
|
||||||
|
type MandateDef,
|
||||||
|
type MandateCategory
|
||||||
|
} from '../../../data/mandate'
|
||||||
|
|
||||||
|
/** 天命事件 id 前缀 */
|
||||||
|
export const MANDATE_PREFIX = 'mandate-'
|
||||||
|
|
||||||
|
/** 天命事件 id 构造 */
|
||||||
|
export function mandateEventId(defId: string): string {
|
||||||
|
return `${MANDATE_PREFIX}${defId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断事件 id 是否为天命 */
|
||||||
|
export function isMandateEvent(eventId: string): boolean {
|
||||||
|
return eventId.startsWith(MANDATE_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从天命事件 id 提取天命 def id */
|
||||||
|
export function mandateDefIdFromEvent(eventId: string): string {
|
||||||
|
return eventId.slice(MANDATE_PREFIX.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查天命是否到期——如到期则选取议题并 fire 高优入闸
|
||||||
|
*
|
||||||
|
* 在 epilogue 相位末调用(clocks.ts 注册)。
|
||||||
|
* 检查逻辑:
|
||||||
|
* 1. 首次天命:开局第 5 年(给玩家前期适应时间)
|
||||||
|
* 2. 后续天命:上次天命年 + mandateInterval(seed, count)(3~5 年)
|
||||||
|
* 3. 如果有待决事件——不触发(避免覆盖渡劫/飞升等关键事件)
|
||||||
|
* 4. 如果 game over——不触发
|
||||||
|
*
|
||||||
|
* 返回 true 表示已触发天命(调用了 fire)
|
||||||
|
*/
|
||||||
|
export function checkMandate(w: World): boolean {
|
||||||
|
const s = w.state
|
||||||
|
if (s.gameOver) return false
|
||||||
|
if (s.pendingEvent) return false // 有待决事件——不覆盖
|
||||||
|
|
||||||
|
const seed = s.seed
|
||||||
|
const mandateYear = (s.family.flag['mandateYear'] as number | undefined) ?? 0
|
||||||
|
const mandateCount = (s.family.flag['mandateCount'] as number | undefined) ?? 0
|
||||||
|
|
||||||
|
// 首次天命:第 5 年
|
||||||
|
if (mandateYear === 0) {
|
||||||
|
if (s.year < 5) return false
|
||||||
|
} else {
|
||||||
|
// 后续天命:上次天命年 + 间隔
|
||||||
|
const interval = mandateInterval(seed, mandateCount)
|
||||||
|
if (s.year < mandateYear + interval) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选取天命议题
|
||||||
|
const def = selectMandate(w)
|
||||||
|
if (!def) return false
|
||||||
|
|
||||||
|
// 记录天命年/次数
|
||||||
|
s.family.flag['mandateYear'] = s.year
|
||||||
|
s.family.flag['mandateCount'] = mandateCount + 1
|
||||||
|
s.family.flag['mandateLastId'] = def.id
|
||||||
|
|
||||||
|
// 高优入闸
|
||||||
|
const eventId = mandateEventId(def.id)
|
||||||
|
return fire(w, eventId, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选取天命议题(种子派生,零 rng 消耗)
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 种子派生选取类别
|
||||||
|
* 2. 在该类别的议题池中过滤 cond
|
||||||
|
* 3. 在符合条件的议题中按权重+种子派生选取
|
||||||
|
* 4. 如果该类别无符合条件议题——回退到全池(按 cond 过滤后种子派生)
|
||||||
|
*/
|
||||||
|
function selectMandate(w: World): MandateDef | undefined {
|
||||||
|
const s = w.state
|
||||||
|
const seed = s.seed
|
||||||
|
const mandateCount = (s.family.flag['mandateCount'] as number | undefined) ?? 0
|
||||||
|
|
||||||
|
// 种子派生选取类别
|
||||||
|
const cat = mandateCategoryFor(seed, s.year, mandateCount)
|
||||||
|
|
||||||
|
// 在该类别中过滤 cond
|
||||||
|
const pool = MANDATE_BY_CATEGORY[cat].filter((m) => matchesCond(w, m.cond))
|
||||||
|
|
||||||
|
if (pool.length > 0) {
|
||||||
|
return weightedPick(pool, seed, s.year, mandateCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 该类别无符合条件议题——回退到全池
|
||||||
|
const fullPool = MANDATES.filter((m) => matchesCond(w, m.cond))
|
||||||
|
if (fullPool.length === 0) return undefined
|
||||||
|
return weightedPick(fullPool, seed, s.year, mandateCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 种子派生加权选取(零 rng 消耗) */
|
||||||
|
function weightedPick(pool: MandateDef[], seed: string, year: number, mandateCount: number): MandateDef {
|
||||||
|
const weights = pool.map((m) => m.weight ?? 10)
|
||||||
|
const total = weights.reduce((a, b) => a + b, 0)
|
||||||
|
const h = mandateHash(seed, year, mandateCount, 0x4d50)
|
||||||
|
const r = h % total
|
||||||
|
let acc = 0
|
||||||
|
for (let i = 0; i < pool.length; i++) {
|
||||||
|
acc += weights[i]
|
||||||
|
if (r < acc) return pool[i]
|
||||||
|
}
|
||||||
|
return pool[pool.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天命事件动态生成——将 MandateDef 转为 EventDef
|
||||||
|
* 经 events.ts 的 dynamicEventFor 路径调用
|
||||||
|
*/
|
||||||
|
export function mandateAsEvent(def: MandateDef): EventDef {
|
||||||
|
return {
|
||||||
|
id: mandateEventId(def.id),
|
||||||
|
name: `【天命】${def.name}`,
|
||||||
|
category: 'fate',
|
||||||
|
weight: 0,
|
||||||
|
once: false, // 天命可重复(不同年可选到不同议题)
|
||||||
|
text: def.text,
|
||||||
|
options: def.options.map((o) => ({
|
||||||
|
label: o.label,
|
||||||
|
hint: o.hint,
|
||||||
|
eff: o.eff,
|
||||||
|
note: o.note
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 events.ts 的 dynamicEventFor 中注册天命事件查找
|
||||||
|
*
|
||||||
|
* 当事件 id 以 `mandate-` 开头时,查找天命定义并转为 EventDef
|
||||||
|
*/
|
||||||
|
export function findMandateEvent(eventId: string): EventDef | undefined {
|
||||||
|
if (!isMandateEvent(eventId)) return undefined
|
||||||
|
const defId = mandateDefIdFromEvent(eventId)
|
||||||
|
const def = mandateById(defId)
|
||||||
|
if (!def) return undefined
|
||||||
|
return mandateAsEvent(def)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 天命类别名(供 UI 展示) */
|
||||||
|
export function mandateEventCategoryName(eventId: string): string | undefined {
|
||||||
|
if (!isMandateEvent(eventId)) return undefined
|
||||||
|
const defId = mandateDefIdFromEvent(eventId)
|
||||||
|
const def = mandateById(defId)
|
||||||
|
if (!def) return undefined
|
||||||
|
return mandateCategoryName(def.category)
|
||||||
|
}
|
||||||
+44
-7
@@ -1,9 +1,10 @@
|
|||||||
import type { 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 '../../kernel/names'
|
||||||
import { aspirationById } from '../../data/aspirations'
|
import { aspirationById } from '../../../data/aspirations'
|
||||||
|
import { lifespanOf } from './lifecycle'
|
||||||
|
|
||||||
export function yearStartMarriage(w: World): void {
|
export function yearStartMarriage(w: World): void {
|
||||||
yearGrowth(w)
|
yearGrowth(w)
|
||||||
@@ -15,18 +16,30 @@ export function yearStartMarriage(w: World): void {
|
|||||||
const couples = buildCouples(w)
|
const couples = buildCouples(w)
|
||||||
|
|
||||||
// 族内夫妇
|
// 族内夫妇
|
||||||
|
let yearBirths = 0
|
||||||
|
const pop = Object.values(s.members).filter((m) => m.alive).length
|
||||||
for (const couple of couples.internal) {
|
for (const couple of couples.internal) {
|
||||||
const [a, b] = couple
|
const [a, b] = couple
|
||||||
const father = a.gender === 'male' ? a : b
|
const father = a.gender === 'male' ? a : b
|
||||||
const mother = a.gender === 'male' ? b : a
|
const mother = a.gender === 'male' ? b : a
|
||||||
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
|
// P0-A 续代:生育窗随境界延长(高境界者容颜常驻、生育期延)
|
||||||
|
const fatherLife = lifespanOf(w, father)
|
||||||
|
const motherLife = lifespanOf(w, mother)
|
||||||
|
const fatherMax = Math.min(520, 52 + (fatherLife - 75) * 0.35)
|
||||||
|
const motherMax = Math.min(520, 46 + (motherLife - 75) * 0.35)
|
||||||
|
if (fatherAge < 18 || fatherAge > fatherMax || motherAge < 16 || motherAge > motherMax) continue
|
||||||
const offspringBonus = Object.values(s.members)
|
const offspringBonus = Object.values(s.members)
|
||||||
.filter((m) => m.alive && aspirationById(m.aspiration)?.effect.type === 'offspring')
|
.filter((m) => m.alive && aspirationById(m.aspiration)?.effect.type === 'offspring')
|
||||||
.length * 0.05
|
.length * 0.05
|
||||||
const p = birthBase * (0.75 + mother.physique * 0.05) + offspringBonus
|
// P0-A 人口卫生:生育率稳定在 0.2~0.35/对/年;家族进入大龄期再衰减(防指数爆炸)
|
||||||
|
// P0-A 人口卫生(终版):全局年胎 cap 按人口档——小族多生、大族停育(稳态 ~20 人防爆炸)
|
||||||
|
if (yearBirths >= 3 || pop > 24) continue
|
||||||
|
if (pop > 18 && yearBirths >= 1) continue
|
||||||
|
const p = Math.min(0.6, birthBase * 0.35 * (0.75 + mother.physique * 0.05)) + offspringBonus
|
||||||
if (!w.rng.chance(p)) continue
|
if (!w.rng.chance(p)) continue
|
||||||
|
yearBirths++
|
||||||
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)
|
||||||
@@ -42,11 +55,12 @@ export function yearStartMarriage(w: World): void {
|
|||||||
w.log('good', `${fam.surname}家诞下新丁:${first.name}。`)
|
w.log('good', `${fam.surname}家诞下新丁:${first.name}。`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 联姻外孙来投(男方娶亲:妻室携子来归;或女儿携子回门)
|
// 联姻外孙来投(男方娶亲:妻室携子来归;或女儿携子回门)——与族内育共用人口 cap
|
||||||
for (const member of Object.values(s.members)) {
|
for (const member of Object.values(s.members)) {
|
||||||
if (!member.alive || !member.spouseHouse) continue
|
if (!member.alive || !member.spouseHouse) continue
|
||||||
const age = w.ageOf(member)
|
const age = w.ageOf(member)
|
||||||
if (age < 18 || age > 44) continue
|
if (age < 18 || age > 44) continue
|
||||||
|
if (yearBirths >= 2 || pop > 24) continue
|
||||||
if (!w.rng.chance(0.15)) continue
|
if (!w.rng.chance(0.15)) continue
|
||||||
const gen = member.generation + 1
|
const gen = member.generation + 1
|
||||||
const house = member.spouseHouse
|
const house = member.spouseHouse
|
||||||
@@ -78,6 +92,29 @@ export function yearStartMarriage(w: World): void {
|
|||||||
const age = w.ageOf(c)
|
const age = w.ageOf(c)
|
||||||
return age >= 16 && age <= 42
|
return age >= 16 && age <= 42
|
||||||
})
|
})
|
||||||
|
// P0-A 族外婚注入:仅当真实缺口(适龄异性为 0)+ 人口上限(确定性判定=长度)
|
||||||
|
const aliveM = w.aliveMembers().length
|
||||||
|
const inlawNeeded =
|
||||||
|
aliveM < 24 &&
|
||||||
|
men.length > 0 && women.length === 0 && w.rng.chance(0.8)
|
||||||
|
const inlawFemaleNeeded =
|
||||||
|
aliveM < 24 &&
|
||||||
|
women.length > 0 && men.length === 0 && w.rng.chance(0.8)
|
||||||
|
if (inlawNeeded || inlawFemaleNeeded) {
|
||||||
|
const gender = inlawNeeded ? 'female' : 'male'
|
||||||
|
const inlaw = produceOffspring(w, {
|
||||||
|
father: null,
|
||||||
|
mother: null,
|
||||||
|
generation: 1,
|
||||||
|
bornYear: s.year - (18 + w.rng.int(0, 10)),
|
||||||
|
surname: '远' as never
|
||||||
|
})
|
||||||
|
inlaw.spouseHouse = '外姓'
|
||||||
|
w.addMember(inlaw)
|
||||||
|
if (gender === 'male') men.push(inlaw)
|
||||||
|
else women.push(inlaw)
|
||||||
|
w.log('info', `媒人引介一位${gender === 'male' ? '外姓男' : '外姓女'}士入族相亲。`)
|
||||||
|
}
|
||||||
for (const m of men) {
|
for (const m of men) {
|
||||||
if (w.rng.chance(0.55) && women.length > 0) {
|
if (w.rng.chance(0.55) && women.length > 0) {
|
||||||
const candidate = women.filter(
|
const candidate = women.filter(
|
||||||
+56
-13
@@ -1,10 +1,11 @@
|
|||||||
import type { World } from '../world'
|
import type { World } from '../World'
|
||||||
import { MissionState } from '../../types/domain'
|
import { MissionState } from '../../../types/domain'
|
||||||
import { FormationId } from '../../data/formations'
|
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 { WorldSim } from '../../sim/WorldSim'
|
||||||
import { describeRealm } from '../../data/realms'
|
import { techniqueById } from '../../../data/techniques'
|
||||||
|
import { describeRealm } from '../../../data/realms'
|
||||||
|
|
||||||
export function missionTick(w: World): void {
|
export function missionTick(w: World): void {
|
||||||
const active = w.state.missions.filter((m) => !m.done)
|
const active = w.state.missions.filter((m) => !m.done)
|
||||||
@@ -14,7 +15,8 @@ export function missionTick(w: World): void {
|
|||||||
m.stageMonth++
|
m.stageMonth++
|
||||||
|
|
||||||
if (m.stageMonth < 2) continue
|
if (m.stageMonth < 2) continue
|
||||||
const def = missionById(m.defId)
|
const wonderDef = w.state.worldSim?.wonders?.[m.defId] as MissionDef | undefined
|
||||||
|
const def = wonderDef ?? missionById(m.defId)
|
||||||
const stage = def.stages[m.stage]
|
const stage = def.stages[m.stage]
|
||||||
if (!stage || m.stageMonth < stage.months) continue
|
if (!stage || m.stageMonth < stage.months) continue
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ export function missionTick(w: World): void {
|
|||||||
if (victim.health < 35) victim.state = 'wounded'
|
if (victim.health < 35) victim.state = 'wounded'
|
||||||
}
|
}
|
||||||
} else if (stage.kind === 'resource') {
|
} else if (stage.kind === 'resource') {
|
||||||
const loot = rollWarbooty(w, stage.loot ?? def.completionLoot)
|
const loot = scaleBySecret(w, m, rollWarbooty(w, stage.loot ?? def.completionLoot, qiRatioOf(w, def.id)))
|
||||||
m.log.push(`${stage.title}:收获 ${lootText(loot)}。`)
|
m.log.push(`${stage.title}:收获 ${lootText(loot)}。`)
|
||||||
} else if (stage.kind === 'combat' || stage.kind === 'boss') {
|
} else if (stage.kind === 'combat' || stage.kind === 'boss') {
|
||||||
const enemy = ENEMIES.find((e) => e.id === stage.enemyId) ?? ENEMIES[0]
|
const enemy = ENEMIES.find((e) => e.id === stage.enemyId) ?? ENEMIES[0]
|
||||||
@@ -56,7 +58,8 @@ export function missionTick(w: World): void {
|
|||||||
if (stage.kind === 'boss') {
|
if (stage.kind === 'boss') {
|
||||||
m.done = true
|
m.done = true
|
||||||
m.result = res.draw ? 'stalemate' : 'retreat'
|
m.result = res.draw ? 'stalemate' : 'retreat'
|
||||||
m.log.join(' ')
|
m.log = [m.log.join(' ')]
|
||||||
|
releaseSquad(w, m) // 0.1.35 A-3:不释放则整队永困 expedition
|
||||||
w.chronicle('exploration', `${def.name}探路不遂,${resultText(m)}。`, undefined, false)
|
w.chronicle('exploration', `${def.name}探路不遂,${resultText(m)}。`, undefined, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +71,16 @@ export function missionTick(w: World): void {
|
|||||||
m.done = true
|
m.done = true
|
||||||
m.result = 'success'
|
m.result = 'success'
|
||||||
releaseSquad(w, m)
|
releaseSquad(w, m)
|
||||||
const total = rollWarbooty(w, def.completionLoot)
|
const total = scaleBySecret(w, m, rollWarbooty(w, def.completionLoot, qiRatioOf(w, def.id)))
|
||||||
|
// 0.1.23 秘境回池:探得之物近 2 成回流人间(负反馈世界侧供给)
|
||||||
|
if (w.state.worldSim) {
|
||||||
|
const sim = new WorldSim(w)
|
||||||
|
for (const [k, v] of Object.entries(total)) {
|
||||||
|
if (k === 'tech') continue
|
||||||
|
const bleed = Math.max(1, Math.round((v as number) * 0.2))
|
||||||
|
sim.tradeSettle(k === 'stones' ? 'lingkuang' : k, bleed)
|
||||||
|
}
|
||||||
|
}
|
||||||
m.log.push(`凯旋而归,清点战利:${lootText(total)}。`)
|
m.log.push(`凯旋而归,清点战利:${lootText(total)}。`)
|
||||||
const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、')
|
const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、')
|
||||||
w.chronicle(
|
w.chronicle(
|
||||||
@@ -82,6 +94,19 @@ export function missionTick(w: World): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 0.1.34 功法秘境加成:结队平均 secretBonus → 战利乘数(零 rng——值域确定) */
|
||||||
|
function scaleBySecret(w: World, m: MissionState, loot: Record<string, number>): Record<string, number> {
|
||||||
|
const squad = squadOf(w, m)
|
||||||
|
if (squad.length === 0) return loot
|
||||||
|
let bonus = 0
|
||||||
|
for (const c of squad) bonus += techniqueById(c.techniqueId)?.secretBonus ?? 0
|
||||||
|
bonus /= squad.length
|
||||||
|
if (bonus <= 0) return loot
|
||||||
|
const out: Record<string, number> = {}
|
||||||
|
for (const [k, v] of Object.entries(loot)) out[k] = typeof v === 'number' ? Math.max(1, Math.round(v * (1 + bonus))) : v
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
function squadOf(w: World, m: MissionState) {
|
function squadOf(w: World, m: MissionState) {
|
||||||
return m.memberIds.map((id) => w.memberById(id)).filter((c) => c.alive)
|
return m.memberIds.map((id) => w.memberById(id)).filter((c) => c.alive)
|
||||||
}
|
}
|
||||||
@@ -119,8 +144,17 @@ export function canSendMission(w: World, def: MissionDef, members: string[]): bo
|
|||||||
return w.state.missions.filter((m) => !m.done).length < 3
|
return w.state.missions.filter((m) => !m.done).length < 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function qiRatioOf(w: World, missionId: string): number {
|
||||||
|
const qi = new WorldSim(w).secretQiOf(missionId)
|
||||||
|
return Math.min(1.5, Math.max(0.5, 0.5 + qi / 125))
|
||||||
|
}
|
||||||
|
|
||||||
|
function defOf(w: World, defId: string): MissionDef {
|
||||||
|
return (w.state.worldSim?.wonders?.[defId] as MissionDef | undefined) ?? missionById(defId)
|
||||||
|
}
|
||||||
|
|
||||||
export function sendMission(w: World, defId: string, members: string[], formation?: FormationId): boolean {
|
export function sendMission(w: World, defId: string, members: string[], formation?: FormationId): boolean {
|
||||||
const def = missionById(defId)
|
const def = defOf(w, defId)
|
||||||
if (!canSendMission(w, def, members)) return false
|
if (!canSendMission(w, def, members)) return false
|
||||||
const m: MissionState = {
|
const m: MissionState = {
|
||||||
id: w.seq(),
|
id: w.seq(),
|
||||||
@@ -138,6 +172,10 @@ export function sendMission(w: World, defId: string, members: string[], formatio
|
|||||||
const c = w.memberById(id)
|
const c = w.memberById(id)
|
||||||
c.state = 'expedition'
|
c.state = 'expedition'
|
||||||
})
|
})
|
||||||
|
// 世界秘境灵气消耗(统一走 WorldSim 入口)
|
||||||
|
if (w.state.worldSim) {
|
||||||
|
new WorldSim(w).consumeSecretQi(def.id)
|
||||||
|
}
|
||||||
w.state.missions.push(m)
|
w.state.missions.push(m)
|
||||||
w.state.family.missionIds.push(m.id)
|
w.state.family.missionIds.push(m.id)
|
||||||
w.log('info', `队伍出发探索【${def.name}】。`)
|
w.log('info', `队伍出发探索【${def.name}】。`)
|
||||||
@@ -153,7 +191,9 @@ export function recallAll(w: World, missionId: string): void {
|
|||||||
w.log('info', '探索队伍奉命返家。')
|
w.log('info', '探索队伍奉命返家。')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 队伍成员状态归位:伤者保持养伤,其余恢复闲居 */
|
/** 队伍成员状态归位:伤者保持养伤,其余恢复闲居。
|
||||||
|
* 0.1.39 根治:完成后从 missionIds 移除——missionIds 只跟踪活跃任务 id,
|
||||||
|
* 避免长跑 180 年后死引用堆积百条。state.missions 全量保留用于史书回看。 */
|
||||||
function releaseSquad(w: World, m: MissionState): void {
|
function releaseSquad(w: World, m: MissionState): void {
|
||||||
for (const id of m.memberIds) {
|
for (const id of m.memberIds) {
|
||||||
const c = w.memberById(id)
|
const c = w.memberById(id)
|
||||||
@@ -161,11 +201,14 @@ function releaseSquad(w: World, m: MissionState): void {
|
|||||||
if (c.state === 'wounded') continue
|
if (c.state === 'wounded') continue
|
||||||
c.state = 'idle'
|
c.state = 'idle'
|
||||||
}
|
}
|
||||||
|
// 0.1.39:从 family.missionIds 移除已完成任务的 id
|
||||||
|
const idx = w.state.family.missionIds.indexOf(m.id)
|
||||||
|
if (idx >= 0) w.state.family.missionIds.splice(idx, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 月度巡查:亡者除名、重伤者离队疗伤;人数不足则提前收队 */
|
/** 月度巡查:亡者除名、重伤者离队疗伤;人数不足则提前收队 */
|
||||||
function settleSquad(w: World, m: MissionState): void {
|
function settleSquad(w: World, m: MissionState): void {
|
||||||
const def = missionById(m.defId)
|
const def = defOf(w, m.defId)
|
||||||
let removed = 0
|
let removed = 0
|
||||||
for (let i = m.memberIds.length - 1; i >= 0; i--) {
|
for (let i = m.memberIds.length - 1; i >= 0; i--) {
|
||||||
const c = w.memberById(m.memberIds[i])
|
const c = w.memberById(m.memberIds[i])
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { World } from '../World'
|
||||||
|
import { aspirationById } from '../../../data/aspirations'
|
||||||
|
import { buildingById, itemNameOf } from '../../../data/buildings'
|
||||||
|
import { seasonMod } from '../../../data/season'
|
||||||
|
import { calamityFamilyOf } from '../../sim/worldsim-data'
|
||||||
|
|
||||||
|
export function productionTick(w: World): void {
|
||||||
|
const fam = w.state.family
|
||||||
|
const inv = fam.inventory
|
||||||
|
const parts: string[] = []
|
||||||
|
const lvl = (b: string) => fam.buildings[b] ?? 0
|
||||||
|
|
||||||
|
// 0.1.31:MOD 建筑经 buildingById 双源注册即被 production 消费(默认表不变)
|
||||||
|
const lingtian = lvl('lingtian')
|
||||||
|
const yaoyuan = lvl('yaoyuan')
|
||||||
|
const lingkuang = lvl('lingkuang')
|
||||||
|
const fangshi = lvl('fangshi')
|
||||||
|
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
|
||||||
|
// 灾年减产(CALAMITY_FAMILY 乘量)
|
||||||
|
const cl = w.state.worldSim?.calamity as string | undefined
|
||||||
|
const farmFactor = cl ? (calamityFamilyOf(cl).lingcao ?? 1) : 1
|
||||||
|
const mineFactor = cl ? (calamityFamilyOf(cl).lingkuang ?? 1) : 1
|
||||||
|
// W1 生产泛化:逐建筑读 produceTable(含新建筑产物——默认表行保持不变)
|
||||||
|
const produceAll = (bid: string, lvl: number, factor: number): void => {
|
||||||
|
const def = buildingById(bid)
|
||||||
|
const table = def?.produceTable?.(lvl)
|
||||||
|
if (!table) return
|
||||||
|
for (const [k, v] of Object.entries(table)) {
|
||||||
|
if (typeof v !== 'number' || v <= 0) continue
|
||||||
|
const prod = Math.max(1, Math.round(v * factor))
|
||||||
|
inv[k] = (inv[k] ?? 0) + prod
|
||||||
|
parts.push(`${def!.name}+${prod}${itemNameOf(k)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 0.1.41 天命接线:tideHarvest — 灵草产出 ×1.5 持续 24 月
|
||||||
|
const nowMonth = w.state.year * 12 + w.state.month
|
||||||
|
const tideHarvestActive = typeof fam.flag['tideHarvestUntil'] === 'number' && (fam.flag['tideHarvestUntil'] as number) > nowMonth
|
||||||
|
const tideHarvestMult = tideHarvestActive ? 1.5 : 1
|
||||||
|
// 0.1.41 天命接线:artisanHired — 丹房/炼器效率永久+10%(体现在灵田药园产出微增)
|
||||||
|
const artisanMult = fam.flag['artisanBonus'] ? 1.1 : 1
|
||||||
|
// 0.1.33 灾年真正生效(此前 farmFactor/mineFactor 算而未用——生产零体感)
|
||||||
|
produceAll('lingtian', lingtian, (1 + fielders * 0.05 + springMod) * farmFactor * tideHarvestMult)
|
||||||
|
produceAll('yaoyuan', yaoyuan, farmFactor * tideHarvestMult * artisanMult)
|
||||||
|
produceAll('lingkuang', lingkuang, mineFactor)
|
||||||
|
produceAll('lingguoyuan', fam.buildings['lingguoyuan'] ?? 0, farmFactor)
|
||||||
|
produceAll('lingmufang', fam.buildings['lingmufang'] ?? 0, farmFactor)
|
||||||
|
const autumnMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'market') : 0
|
||||||
|
if (fangshi > 0) {
|
||||||
|
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome') + merchants * 0.03 + autumnMod))
|
||||||
|
fam.stones += v
|
||||||
|
parts.push(`坊市+${v}灵石`)
|
||||||
|
}
|
||||||
|
if (lingshou > 0 && w.rng.chance(0.35)) {
|
||||||
|
inv.beastcore = (inv.beastcore ?? 0) + 1
|
||||||
|
parts.push('灵兽园+1兽核')
|
||||||
|
}
|
||||||
|
// 0.1.33 符箓坊(消耗式:符纸×2+灵木×2/级 → 风符;叁级追加山符)
|
||||||
|
const fuzhiLvl = lvl('fuzhifang')
|
||||||
|
if (fuzhiLvl > 0) {
|
||||||
|
const need = 2 * fuzhiLvl
|
||||||
|
if ((inv['fuzhi'] ?? 0) >= need && (inv['lingmu'] ?? 0) >= need) {
|
||||||
|
inv['fuzhi'] -= need
|
||||||
|
inv['lingmu'] -= need
|
||||||
|
inv['talisman-feng'] = (inv['talisman-feng'] ?? 0) + 1
|
||||||
|
parts.push(`符箓坊-符纸${need}灵木${need}+风符1`)
|
||||||
|
if (fuzhiLvl >= 3) {
|
||||||
|
inv['talisman-shan'] = (inv['talisman-shan'] ?? 0) + 1
|
||||||
|
parts.push('符箓坊+山符1')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void parts
|
||||||
|
|
||||||
|
if (w.state.month % 3 === 0) {
|
||||||
|
const drift = w.rng.between(-0.04, 0.04)
|
||||||
|
const cur = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1
|
||||||
|
fam.flag['priceMult'] = Math.max(0.78, Math.min(1.25, cur + drift))
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-4
@@ -1,10 +1,10 @@
|
|||||||
import type { World } from '../world'
|
import type { World } from '../World'
|
||||||
import { Character } from '../../types/domain'
|
import { Character } from '../../../types/domain'
|
||||||
import { ENEMIES, EnemyDef } from '../../data/secrets'
|
import { ENEMIES, EnemyDef } from '../../../data/secrets'
|
||||||
import { resolveEncounter } from './combat'
|
import { resolveEncounter } from './combat'
|
||||||
import { rankPower } from './events'
|
import { rankPower } from './events'
|
||||||
import { resolveBreakthrough } from './cultivation'
|
import { resolveBreakthrough } from './cultivation'
|
||||||
import { FormationId } from '../../data/formations'
|
import { FormationId } from '../../../data/formations'
|
||||||
|
|
||||||
export const SECTS = ['云栖宗', '太谷书院', '玄微剑阁', '丹霞洞天']
|
export const SECTS = ['云栖宗', '太谷书院', '玄微剑阁', '丹霞洞天']
|
||||||
|
|
||||||
@@ -79,6 +79,7 @@ export function runTournament(w: World, squad?: string[], formation?: FormationI
|
|||||||
const teamNames = team.map((c) => c.name).join('、')
|
const teamNames = team.map((c) => c.name).join('、')
|
||||||
w.chronicle('battle', `太虚大比:${teamNames} 最终位列第${rank}名,赏灵石${rewards.stones}、声望+${rewards.rep}。`, undefined, true)
|
w.chronicle('battle', `太虚大比:${teamNames} 最终位列第${rank}名,赏灵石${rewards.stones}、声望+${rewards.rep}。`, undefined, true)
|
||||||
w.log('good', `太虚大比落下帷幕,本族第${rank}名。`)
|
w.log('good', `太虚大比落下帷幕,本族第${rank}名。`)
|
||||||
|
w.emitFx(rank <= 3 ? 'spark' : 'ripple', 'tournament')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function apprenticeCandidates(w: World): Character[] {
|
export function apprenticeCandidates(w: World): Character[] {
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import type { World } from '../World'
|
||||||
|
import { Character } from '../../../types/domain'
|
||||||
|
import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../../data/realms'
|
||||||
|
import { techniqueById } from '../../../data/techniques'
|
||||||
|
import { fire } from './events'
|
||||||
|
import { findInheritor } from '../creation'
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (mode !== 'delay' && (w.state.family.inventory['brew-niang'] ?? 0) > 0 && c.health < 40) {
|
||||||
|
// R4 灵酿:受创回气(每渡劫一次至多一瓶——疗伤佳酿)
|
||||||
|
w.state.family.inventory['brew-niang'] = w.state.family.inventory['brew-niang']! - 1
|
||||||
|
c.health = Math.min(100, c.health + 5)
|
||||||
|
}
|
||||||
|
const next = nextRealm(c.realm)
|
||||||
|
if (!next) return 'peak'
|
||||||
|
|
||||||
|
if (mode === 'delay') {
|
||||||
|
c.tribDelayYear = w.state.year + 1
|
||||||
|
c.tribBoost = 0
|
||||||
|
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
|
||||||
|
w.emitFx('spark', `trib:${c.id}`)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
c.tribBoost = 0
|
||||||
|
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} 渡劫陨落。`)
|
||||||
|
c.tribBoost = 0
|
||||||
|
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)
|
||||||
|
c.tribBoost = 0
|
||||||
|
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
|
||||||
|
const tb = techniqueById(c.techniqueId)?.tribBonus ?? 0
|
||||||
|
// 0.1.37 因果调制:功德深厚者天劫顺遂,因果亏缺者天道加码
|
||||||
|
// karma > 30 → +5% 渡劫成功率(积善有余庆,天道暗佑)
|
||||||
|
// karma < -20 → -5% 渡劫成功率(因果缠身,劫罚加重)
|
||||||
|
const karma = w.state.family.karma ?? 0
|
||||||
|
const karmaTribMod = karma > 30 ? 0.05 : karma < -20 ? -0.05 : 0
|
||||||
|
return Math.min(0.92, base + c.mind * 0.01 + c.health / 400 + (c.tribBoost ?? 0) + tb + karmaTribMod)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 0.1.37 飞升试炼 ====================
|
||||||
|
|
||||||
|
/** 飞升试炼事件 ID 生成 */
|
||||||
|
export function ascensionEventId(memberId: string, stage: 'heart' | 'heaven' | 'ascend'): string {
|
||||||
|
return `ev-ascension-${stage}-${memberId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 飞升试炼三阶段结算(0.1.37 天人感应·因果织锦)
|
||||||
|
*
|
||||||
|
* 阶段一 · 心魔试炼:心境 + 功德 vs 心魔强度
|
||||||
|
* - rash:心境硬抗(base 40% + mind×2% + karma 调制)
|
||||||
|
* - guard:消耗灵石200,功德护心(base 55% + mind×2% + karma 调制 +10%)
|
||||||
|
* - delay:压制一年再试
|
||||||
|
* 通过 → fire 天劫试炼
|
||||||
|
*
|
||||||
|
* 阶段二 · 天劫试炼:飞升级天劫(远难于渡劫)
|
||||||
|
* - rash:硬渡(base 20% + mind×1.5% + health/500 + karma 调制)
|
||||||
|
* - guard:消耗灵石300,护法加持(base 35% + mind×1.5% + health/500 + karma 调制 +15%)
|
||||||
|
* - delay:压制一年再试
|
||||||
|
* 通过 → fire 飞升抉择
|
||||||
|
* 失败可能陨落(danger = 0.08)
|
||||||
|
*
|
||||||
|
* 阶段三 · 飞升抉择:留仙 / 飞升
|
||||||
|
* - rash(留仙):获得 fengxian 特质,修为 ×1.3,声望+15
|
||||||
|
* - guard(飞升):飞升者离族,feishengCount+1,声望+30,karma+10,全族修为提振
|
||||||
|
*/
|
||||||
|
export function resolveAscension(
|
||||||
|
w: World,
|
||||||
|
c: Character,
|
||||||
|
stage: 'heart' | 'heaven' | 'ascend',
|
||||||
|
mode: 'rash' | 'guard' | 'delay'
|
||||||
|
): string {
|
||||||
|
const s = w.state
|
||||||
|
|
||||||
|
// ---- 阶段一:心魔试炼 ----
|
||||||
|
if (stage === 'heart') {
|
||||||
|
if (mode === 'delay') {
|
||||||
|
c.tribDelayYear = w.state.year + 1
|
||||||
|
w.log('info', `${c.name} 收束心神,暂压心魔,待来年再试。`)
|
||||||
|
return 'delayed'
|
||||||
|
}
|
||||||
|
|
||||||
|
const karma = s.family.karma ?? 0
|
||||||
|
const karmaMod = karma > 30 ? 0.12 : karma < -20 ? -0.12 : 0
|
||||||
|
|
||||||
|
let chance: number
|
||||||
|
if (mode === 'guard') {
|
||||||
|
if (s.family.stones < 200) {
|
||||||
|
w.log('bad', '功德法事之资不足,只得强渡心魔。')
|
||||||
|
chance = 0.40 + c.mind * 0.02 + karmaMod
|
||||||
|
} else {
|
||||||
|
s.family.stones -= 200
|
||||||
|
chance = 0.55 + c.mind * 0.02 + karmaMod + 0.10
|
||||||
|
w.log('info', `设坛焚香,以功德化解心魔——${c.name} 心境渐稳。`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chance = 0.40 + c.mind * 0.02 + karmaMod
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = Math.min(0.88, Math.max(0.05, chance))
|
||||||
|
if (w.rng.chance(p)) {
|
||||||
|
w.emitFx('spark', `heart:${c.id}`)
|
||||||
|
w.chronicle('breakthrough', `${c.name} 破开心魔障壁,灵台清明如镜。`, c.id, true)
|
||||||
|
w.log('good', `✦ ${c.name} 斩碎心魔,灵台澄明——飞升之门渐开。`)
|
||||||
|
// 心魔通过 → fire 天劫试炼
|
||||||
|
fire(w, ascensionEventId(c.id, 'heaven'), 1)
|
||||||
|
return 'success'
|
||||||
|
} else {
|
||||||
|
c.health = Math.max(1, c.health - 15 - w.rng.int(0, 10))
|
||||||
|
if (c.health < 25) c.state = 'wounded'
|
||||||
|
c.realmProgress = Math.max(0, 90 - w.rng.int(0, 15))
|
||||||
|
w.log('bad', `${c.name} 心魔反噬,神识受创——修为跌落。`)
|
||||||
|
w.chronicle('event', `${c.name} 心魔试炼受挫,神识震荡。`, c.id, false)
|
||||||
|
return 'fail'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 阶段二:天劫试炼 ----
|
||||||
|
if (stage === 'heaven') {
|
||||||
|
if (mode === 'delay') {
|
||||||
|
c.tribDelayYear = w.state.year + 1
|
||||||
|
c.tribBoost = 0
|
||||||
|
w.log('info', `${c.name} 引气蓄势,按兵不动——来年再渡飞升之劫。`)
|
||||||
|
return 'delayed'
|
||||||
|
}
|
||||||
|
|
||||||
|
const karma = s.family.karma ?? 0
|
||||||
|
const karmaMod = karma > 30 ? 0.06 : karma < -20 ? -0.06 : 0
|
||||||
|
|
||||||
|
let chance: number
|
||||||
|
let guardian: Character | null = null
|
||||||
|
if (mode === 'guard') {
|
||||||
|
if (s.family.stones < 300) {
|
||||||
|
w.log('bad', '护法之资不足,只得独渡天劫。')
|
||||||
|
chance = 0.20 + c.mind * 0.015 + c.health / 500 + karmaMod
|
||||||
|
} else {
|
||||||
|
s.family.stones -= 300
|
||||||
|
chance = 0.35 + c.mind * 0.015 + c.health / 500 + karmaMod + 0.15
|
||||||
|
guardian = w
|
||||||
|
.aliveMembers()
|
||||||
|
.filter((m) => m.id !== c.id && MAJOR_ORDER.indexOf(m.realm.major) >= MAJOR_ORDER.indexOf('spirit'))
|
||||||
|
.sort((a, b) => MAJOR_ORDER.indexOf(b.realm.major) - MAJOR_ORDER.indexOf(a.realm.major))[0] ?? null
|
||||||
|
if (guardian) {
|
||||||
|
w.log('info', `${guardian.name} 护法加持,天劫成算大增。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chance = 0.20 + c.mind * 0.015 + c.health / 500 + karmaMod
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = Math.min(0.80, Math.max(0.03, chance))
|
||||||
|
if (w.rng.chance(p)) {
|
||||||
|
w.emitFx('pulse', `heaven:${c.id}`)
|
||||||
|
c.health = Math.min(100, c.health + 20)
|
||||||
|
w.chronicle('breakthrough', `${c.name} 飞升天劫功成,九天雷池尽散——仙路已通!`, c.id, true)
|
||||||
|
w.log('good', `✦ 紫电消散,${c.name} 渡过飞升天劫——仙门洞开!`)
|
||||||
|
// 天劫通过 → fire 飞升抉择
|
||||||
|
fire(w, ascensionEventId(c.id, 'ascend'), 1)
|
||||||
|
return 'success'
|
||||||
|
} else {
|
||||||
|
c.health = Math.max(1, c.health - 30 - w.rng.int(0, 20))
|
||||||
|
if (c.health < 20) c.state = 'wounded'
|
||||||
|
const danger = 0.08
|
||||||
|
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} 飞升天劫陨落,形神俱灭。`)
|
||||||
|
c.tribBoost = 0
|
||||||
|
return 'dead'
|
||||||
|
}
|
||||||
|
c.realmProgress = Math.max(0, 70 - w.rng.int(0, 20))
|
||||||
|
w.log('bad', `${c.name} 飞升天劫失败,灵力逆行——修为大跌。`)
|
||||||
|
w.chronicle('event', `${c.name} 飞升天劫受挫,灵力逆行。`, c.id, false)
|
||||||
|
if (guardian) {
|
||||||
|
const g = guardian
|
||||||
|
if (w.rng.chance(0.4)) {
|
||||||
|
g.health = Math.max(1, g.health - 20 - w.rng.int(0, 15))
|
||||||
|
if (g.health < 25) g.state = 'wounded'
|
||||||
|
w.log('bad', `${g.name} 护法受牵连,灵力受损。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.tribBoost = 0
|
||||||
|
return 'fail'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 阶段三:飞升抉择 ----
|
||||||
|
if (stage === 'ascend') {
|
||||||
|
if (mode === 'rash') {
|
||||||
|
// 留仙坐镇
|
||||||
|
if (!c.traits.includes('fengxian')) c.traits.push('fengxian')
|
||||||
|
s.family.reputation += 15
|
||||||
|
s.family.karma += 5
|
||||||
|
w.chronicle('event', `${c.name} 谢绝仙门征召,愿镇守此世,护佑宗族万世——仙风入体,道行精进。`, c.id, true)
|
||||||
|
w.log('good', `${c.name} 留世坐镇(仙风入体),声望大涨。`)
|
||||||
|
w.emitFx('ripple', `stay:${c.id}`)
|
||||||
|
return 'stay'
|
||||||
|
} else {
|
||||||
|
// 放仙飞升
|
||||||
|
c.alive = false
|
||||||
|
c.deathYear = s.year
|
||||||
|
c.deathCause = '飞升太虚'
|
||||||
|
s.stats.feishengCount = (s.stats.feishengCount ?? 0) + 1
|
||||||
|
s.family.flag['fengFeiBless'] = true
|
||||||
|
s.family.reputation += 30
|
||||||
|
s.family.karma += 10
|
||||||
|
// 全族修为提振(飞升者的道韵余泽)
|
||||||
|
for (const m of w.aliveMembers()) {
|
||||||
|
m.realmProgress = Math.min(100, m.realmProgress + 8)
|
||||||
|
}
|
||||||
|
w.chronicle('event', `${c.name} 历经三劫,终成正果——飞升太虚而去。仙韵余泽笼罩宗族,万世蒙荫。`, c.id, true)
|
||||||
|
w.log('good', `✦ ${c.name} 飞升太虚!宗族永蒙仙荫,声望大涨,全族修为提振。`)
|
||||||
|
w.emitFx('pulse', `ascend:${c.id}`)
|
||||||
|
if (s.family.headId === c.id) {
|
||||||
|
const heir = findInheritor(w)
|
||||||
|
if (heir) w.assignHead(heir.id, true)
|
||||||
|
}
|
||||||
|
return 'ascended'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -1,4 +1,4 @@
|
|||||||
import type { PhaseId } from '../core/clock'
|
import type { PhaseId } from '../kernel/clock'
|
||||||
|
|
||||||
export interface SystemDef {
|
export interface SystemDef {
|
||||||
id: string
|
id: string
|
||||||
@@ -21,5 +21,7 @@ export const SYSTEM_DEFS: SystemDef[] = [
|
|||||||
{ id: 'tournament', name: '太虚大比', version: '1.0', desc: '五年一会的太虚仙盟争锋。' },
|
{ id: 'tournament', name: '太虚大比', version: '1.0', desc: '五年一会的太虚仙盟争锋。' },
|
||||||
{ id: 'tribulation', name: '渡劫', version: '1.0', desc: '大境界天劫与护法之仪。' },
|
{ id: 'tribulation', name: '渡劫', version: '1.0', desc: '大境界天劫与护法之仪。' },
|
||||||
{ id: 'apprentice', name: '寄读', version: '1.0', desc: '宗门子弟外修与求经台。' },
|
{ id: 'apprentice', name: '寄读', version: '1.0', desc: '宗门子弟外修与求经台。' },
|
||||||
{ id: 'season', name: '时节', version: '1.0', desc: '春夏秋冬之乘气(春耕夏修秋市冬闭)。' }
|
{ id: 'season', name: '时节', version: '1.0', desc: '春夏秋冬之乘气(春耕夏修秋市冬闭)。' },
|
||||||
|
{ id: 'worldsim', name: '世界演衍', version: '1.0', desc: '世界自演化观察/响应(0.1.34:战争疲劳/断供应急/新秘境灵机)。' },
|
||||||
|
{ id: 'tech', name: '功法百汇', version: '1.0', desc: '功法流派效果(0.1.34:暴击/受创减免/渡劫/秘境——万象归元)。' }
|
||||||
]
|
]
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { GameClock } from '../core/clock'
|
import { GameClock } from '../kernel/clock'
|
||||||
import { PluginContext } from '../core/plugin'
|
import { PluginContext } from '../kernel/plugin'
|
||||||
import type { World } from './world'
|
import type { World } from './World'
|
||||||
import { productionTick } from './systems/production'
|
import { productionTick } from './Systems/production'
|
||||||
import { deathTick, woundHealTick } from './systems/lifecycle'
|
import { deathTick, woundHealTick } from './Systems/lifecycle'
|
||||||
import { cultivationTick } from './systems/cultivation'
|
import { cultivationTick } from './Systems/cultivation'
|
||||||
import { missionTick } from './systems/missions'
|
import { missionTick } from './Systems/missions'
|
||||||
import { eventRoll } from './systems/events'
|
import { eventRoll } from './Systems/events'
|
||||||
import { diplomacyTick } from './systems/diplomacy'
|
import { diplomacyTick } from './Systems/diplomacy'
|
||||||
import { yearStartMarriage } from './systems/marriage'
|
import { yearStartMarriage } from './Systems/marriage'
|
||||||
|
import { WorldSim } from '../sim/WorldSim'
|
||||||
|
import { checkMandate } from './Systems/mandate'
|
||||||
|
|
||||||
/** 原语义保留:满门凋零后仅存生产/寿元与收尾 */
|
/** 原语义保留:满门凋零后仅存生产/寿元与收尾 */
|
||||||
function ifAlive(fn: (w: World) => void): (w: World) => void {
|
function ifAlive(fn: (w: World) => void): (w: World) => void {
|
||||||
@@ -55,7 +57,14 @@ export function installCoreSystems(ctx: PluginContext): Array<() => void> {
|
|||||||
unsubs.push(clock.register('missions', viaCap('missions', ifAlive((w: World) => missionTick(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('events', viaCap('events', ifAlive((w: World) => eventRoll(w)))))
|
||||||
unsubs.push(clock.register('diplomacy', viaCap('diplomacy', ifAlive((w: World) => diplomacyTick(w)))))
|
unsubs.push(clock.register('diplomacy', viaCap('diplomacy', ifAlive((w: World) => diplomacyTick(w)))))
|
||||||
|
unsubs.push(clock.register('worldsim', (w: World) => {
|
||||||
|
if (w.sysEnabled('worldsim')) {
|
||||||
|
new WorldSim(w).tick()
|
||||||
|
}
|
||||||
|
}))
|
||||||
unsubs.push(clock.register('epilogue', (w: World) => w.epilogueTick()))
|
unsubs.push(clock.register('epilogue', (w: World) => w.epilogueTick()))
|
||||||
|
// 0.1.40 天命系统:epilogue 末检查天命是否到期——种子派生选取,零 rng 消耗
|
||||||
|
unsubs.push(clock.register('epilogue', viaCap('events', ifAlive((w: World) => { void checkMandate(w) }))))
|
||||||
|
|
||||||
return unsubs
|
return unsubs
|
||||||
}
|
}
|
||||||
+47
-9
@@ -1,9 +1,9 @@
|
|||||||
import { Character, GameState, NpcFamilyState, RealmMajor } from '../types/domain'
|
import { Character, GameState, NpcFamilyState, RealmMajor } from '../../types/domain'
|
||||||
import { Rng, seedToRng } from '../core/rng'
|
import { Rng, seedToRng } from '../kernel/rng'
|
||||||
import { randomSurname, MALE_GIVEN, FEMALE_GIVEN } from '../core/names'
|
import { randomSurname, MALE_GIVEN, FEMALE_GIVEN } from '../kernel/names'
|
||||||
import { newCharacter } from './pcgen'
|
import { newCharacter } from './pcgen'
|
||||||
import { NPCS } from '../data/npcs'
|
import { generateWorld } from '../sim/worldgen'
|
||||||
import { World } from './world'
|
import { World } from './World'
|
||||||
|
|
||||||
export interface NewGameOptions {
|
export interface NewGameOptions {
|
||||||
seed: string
|
seed: string
|
||||||
@@ -21,9 +21,24 @@ export function createWorldState(opts: NewGameOptions): GameState {
|
|||||||
const stones = diff === 'easy' ? 1200 : diff === 'normal' ? 800 : 550
|
const stones = diff === 'easy' ? 1200 : diff === 'normal' ? 800 : 550
|
||||||
const npcStrength = diff === 'easy' ? 0.9 : diff === 'normal' ? 1 : 1.15
|
const npcStrength = diff === 'easy' ? 0.9 : diff === 'normal' ? 1 : 1.15
|
||||||
|
|
||||||
|
const worldGen = generateWorld(opts.seed)
|
||||||
const state: GameState = {
|
const state: GameState = {
|
||||||
schemaVersion: 1,
|
|
||||||
seed: opts.seed,
|
seed: opts.seed,
|
||||||
|
worldGen: {
|
||||||
|
relations: worldGen.relations,
|
||||||
|
eras: worldGen.eras,
|
||||||
|
marketOffset: worldGen.marketOffset,
|
||||||
|
regionFlavor: worldGen.regionFlavor,
|
||||||
|
alliances: worldGen.alliances,
|
||||||
|
feuds: worldGen.feuds,
|
||||||
|
npcCount: worldGen.npcs.length,
|
||||||
|
worldGenMods: [],
|
||||||
|
npcs: worldGen.npcs.map((n) => ({
|
||||||
|
id: n.id, name: n.name, region: n.region, style: n.style,
|
||||||
|
desc: n.desc, leaderRealm: n.leaderRealm, initialPower: n.initialPower,
|
||||||
|
powerGrowth: n.powerGrowth, sells: n.sells, buys: n.buys
|
||||||
|
}))
|
||||||
|
},
|
||||||
rng: rng.getState(),
|
rng: rng.getState(),
|
||||||
year: 1,
|
year: 1,
|
||||||
month: 1,
|
month: 1,
|
||||||
@@ -50,11 +65,12 @@ export function createWorldState(opts: NewGameOptions): GameState {
|
|||||||
missionIds: [],
|
missionIds: [],
|
||||||
headId: '',
|
headId: '',
|
||||||
difficulty: diff,
|
difficulty: diff,
|
||||||
flag: { priceMult: 1, tenants: 0, headBless: 0 }
|
flag: { priceMult: 1, tenants: 0, headBless: 0 },
|
||||||
|
karma: 0
|
||||||
},
|
},
|
||||||
members: {},
|
members: {},
|
||||||
npcFamilies: Object.fromEntries(
|
npcFamilies: Object.fromEntries(
|
||||||
NPCS.map((n) => [
|
worldGen.npcs.map((n) => [
|
||||||
n.id,
|
n.id,
|
||||||
{
|
{
|
||||||
id: n.id,
|
id: n.id,
|
||||||
@@ -63,6 +79,7 @@ export function createWorldState(opts: NewGameOptions): GameState {
|
|||||||
power: Math.round(n.initialPower * npcStrength),
|
power: Math.round(n.initialPower * npcStrength),
|
||||||
relation: 0,
|
relation: 0,
|
||||||
allied: false,
|
allied: false,
|
||||||
|
declineYears: 0,
|
||||||
raidCount: 0
|
raidCount: 0
|
||||||
} as NpcFamilyState
|
} as NpcFamilyState
|
||||||
])
|
])
|
||||||
@@ -80,7 +97,7 @@ export function createWorldState(opts: NewGameOptions): GameState {
|
|||||||
stats: {
|
stats: {
|
||||||
repPeak: 5,
|
repPeak: 5,
|
||||||
popPeak: 5,
|
popPeak: 5,
|
||||||
maxRealmIdx: 25,
|
maxRealmIdx: 0, // normalize(World) 时按开局成员重算真实峰值,此处仅为占位
|
||||||
techniqueGrand: 0,
|
techniqueGrand: 0,
|
||||||
tourneyHistory: [],
|
tourneyHistory: [],
|
||||||
feishengCount: 0
|
feishengCount: 0
|
||||||
@@ -185,6 +202,27 @@ export function findInheritor(world: World): Character | undefined {
|
|||||||
const head = world.state.members[world.state.family.headId]
|
const head = world.state.members[world.state.family.headId]
|
||||||
const candidates = alive.filter((c) => c.id !== head?.id)
|
const candidates = alive.filter((c) => c.id !== head?.id)
|
||||||
if (candidates.length === 0) return undefined
|
if (candidates.length === 0) return undefined
|
||||||
|
// 0.1.41 天命接线:successionElder — 立长不立幼(优先年长者)
|
||||||
|
const flag = world.state.family.flag
|
||||||
|
if (flag['successionElder']) {
|
||||||
|
const byBlood = candidates
|
||||||
|
.filter((c) => (head && head.children.includes(c.id)) || (c.fatherId === head?.id))
|
||||||
|
.sort((a, b) => world.ageOf(b) - world.ageOf(a))
|
||||||
|
if (byBlood.length > 0) return byBlood[0]
|
||||||
|
// 无血亲时也优先年长
|
||||||
|
const byAge = [...candidates].sort((a, b) => world.ageOf(b) - world.ageOf(a))
|
||||||
|
return byAge[0]
|
||||||
|
}
|
||||||
|
// 0.1.41 天命接线:successionWise — 立贤不立长(优先悟性最高者)
|
||||||
|
if (flag['successionWise']) {
|
||||||
|
const byBlood = candidates
|
||||||
|
.filter((c) => (head && head.children.includes(c.id)) || (c.fatherId === head?.id))
|
||||||
|
.sort((a, b) => b.perception - a.perception || realmRank(b.realm) - realmRank(a.realm))
|
||||||
|
if (byBlood.length > 0) return byBlood[0]
|
||||||
|
const byWisdom = [...candidates].sort((a, b) => b.perception - a.perception || realmRank(b.realm) - realmRank(a.realm))
|
||||||
|
return byWisdom[0]
|
||||||
|
}
|
||||||
|
// 默认继承:血亲优先(年长),无血亲按境界
|
||||||
const byBlood = candidates
|
const byBlood = candidates
|
||||||
.filter((c) => (head && head.children.includes(c.id)) || (c.fatherId === head?.id))
|
.filter((c) => (head && head.children.includes(c.id)) || (c.fatherId === head?.id))
|
||||||
.sort((a, b) => world.ageOf(b) - world.ageOf(a))
|
.sort((a, b) => world.ageOf(b) - world.ageOf(a))
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { CotycPlugin } from '../kernel/plugin'
|
||||||
|
import { registerPluginFactory } from './World'
|
||||||
|
|
||||||
|
/** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */
|
||||||
|
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 } }]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
|
// 0.1.23 契约:清理由 PluginManager 自动回滚(池/能力卡/数据包/时轮钩子全摘);uninstall 留作扩展点
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依赖缺失的坏插件:应被拒绝安装 */
|
||||||
|
export const brokenPlugin: CotycPlugin = {
|
||||||
|
id: 'demo-broken',
|
||||||
|
name: '依赖断链的插件',
|
||||||
|
version: '0.1.0',
|
||||||
|
kind: 'events',
|
||||||
|
description: '故意缺少依赖的示例插件(安装应被拒绝)。',
|
||||||
|
dependencies: ['demo-not-exists'],
|
||||||
|
install() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPluginFactory('demo-peaks', () => examplePlugin)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* MOD 管理器:ModPack → CotycPlugin 构造(MOD=插件一等子类)。
|
||||||
|
* 全量复用 PluginManager 管线:持久化/启停/双闸/自动回滚。
|
||||||
|
*/
|
||||||
|
import { CotycPlugin } from '../kernel/plugin'
|
||||||
|
import { ModPack, validateMod } from './modSchema'
|
||||||
|
import { EventDef } from '../../data/events'
|
||||||
|
import { findEvent } from './Systems/events'
|
||||||
|
import { World } from './World'
|
||||||
|
import { WorldGenPools } from '../sim/worldgen'
|
||||||
|
import { arith } from '../../data/arith'
|
||||||
|
import { pack as dataPack } from '../../data/registry'
|
||||||
|
import { removeCalamityByName, overrideWorldNum, resetWorldNum, addWonderType, removeWonderType } from '../sim/worldsim-data'
|
||||||
|
import { removePillRecipe, removeForgeRecipe } from '../../data/items'
|
||||||
|
import { registerBuildingDef } from '../../data/buildings'
|
||||||
|
import { registerPostDef } from '../../data/posts'
|
||||||
|
import { registerAspirationDef } from '../../data/aspirations'
|
||||||
|
import { registerFormationDef } from '../../data/formations'
|
||||||
|
import { registerTraitDef } from '../../data/traits'
|
||||||
|
|
||||||
|
const DEFAULT_ITEMS = { lingcao: { id: 'lingcao', name: '灵草', kind: 'resource', basePrice: 8, desc: '最常见的修炼辅材,亦是炼丹基础。', icon: '草' },
|
||||||
|
lingkuang: { id: 'lingkuang', name: '灵矿', kind: 'resource', basePrice: 12, desc: '铸器与布阵常用矿物。', icon: '矿' },
|
||||||
|
beastcore: { id: 'beastcore', name: '兽核', kind: 'resource', basePrice: 30, desc: '妖兽一身的精华,可炼丹炼器。', icon: '核' },
|
||||||
|
'pill-qiyuan': { id: 'pill-qiyuan', name: '聚气丹', kind: 'pill', basePrice: 45, desc: '炼气期修士服之,一月修为大增。', icon: '气' },
|
||||||
|
'pill-ningyuan': { id: 'pill-ningyuan', name: '凝元丹', kind: 'pill', basePrice: 150, desc: '筑基以上可效,修为增长显著。', icon: '元' },
|
||||||
|
'pill-pojing': { id: 'pill-pojing', name: '破境丹', kind: 'pill', basePrice: 480, desc: '冲击瓶颈时的辅助神物,提升突破成功率。', icon: '破' },
|
||||||
|
'weapon-fan': { id: 'weapon-fan', name: '凡器', kind: 'artifact', basePrice: 120, desc: '普通铁器,聊胜于无。', icon: '凡' },
|
||||||
|
'weapon-qi': { id: 'weapon-qi', name: '法器', kind: 'artifact', basePrice: 450, desc: '蕴灵之器,可引动灵力。', icon: '法' },
|
||||||
|
'weapon-ling': { id: 'weapon-ling', name: '灵器', kind: 'artifact', basePrice: 1400, desc: '有灵之器,锋芒隐露。', icon: '灵' },
|
||||||
|
'weapon-fa': { id: 'weapon-fa', name: '法宝', kind: 'artifact', basePrice: 3600, desc: '罕世法宝,非金丹不能驾驭。', icon: '宝' } }
|
||||||
|
|
||||||
|
function itemsBasePack(): Record<string, unknown> {
|
||||||
|
return { ...DEFAULT_ITEMS }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBuildingDef(b: { id: string; name: string; icon: string; desc: string; kind: 'produce' | 'function'; maxLevel: number; produceExpr?: string | Record<string, string>; upgradeStones?: number; upgradeMineral?: number }) {
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
icon: b.icon,
|
||||||
|
desc: b.desc,
|
||||||
|
kind: b.kind,
|
||||||
|
maxLevel: Math.min(6, Math.max(1, b.maxLevel ?? 3)),
|
||||||
|
produceTable: b.produceExpr ? booleanExprTable(b.produceExpr) : undefined,
|
||||||
|
upgradeCost: (L: number) => ({ stones: (b.upgradeStones ?? 80) * Math.pow(1.6, L - 1), lingkuang: Math.round((b.upgradeMineral ?? 8) * L) })
|
||||||
|
} as never
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 白名单表达式:'10 * L'(单资源)或 '{"lingcao":"10 * L"}'(多资源对象) */
|
||||||
|
function booleanExprTable(expr: string | Record<string, string>): (level: number) => Record<string, number> {
|
||||||
|
const parsed = typeof expr === 'string'
|
||||||
|
? expr.trim().startsWith('{') ? JSON.parse(expr) : { lingcao: expr }
|
||||||
|
: expr
|
||||||
|
const entries = Object.entries(parsed as Record<string, unknown>)
|
||||||
|
return (L: number) => {
|
||||||
|
const out: Record<string, number> = {}
|
||||||
|
for (const [k, v] of entries) {
|
||||||
|
const val = arith(String(v), L)
|
||||||
|
if (Number.isFinite(val) && val >= 0) out[k] = Math.round(val)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安装前检查:事件 id 与核心/已装池冲突 + 范式校验警告 */
|
||||||
|
const DYNAMIC_PREFIXES = ['ev-raid-', 'ev-trib-', 'ev-tournament-', 'ev-echo-', 'ev-auction', 'ev-centennial', 'ev-feisheng', 'ev-legacypass', 'ev-winterprayer', 'ev-jobai', 'ev-ascension-']
|
||||||
|
|
||||||
|
export function modPreflight(w: World, pack: ModPack): { ok: boolean; reason?: string; warnings: string[] } {
|
||||||
|
const v = validateMod(pack)
|
||||||
|
// 权威冲突检测:findEvent(池+动态分支)+ 动态前缀黑名单(防御构造缺口)
|
||||||
|
for (const e of pack.data.events ?? []) {
|
||||||
|
if (findEvent(e.id, w) || DYNAMIC_PREFIXES.some((p) => e.id.startsWith(p))) {
|
||||||
|
return { ok: false, reason: `事件 id 冲突:${e.id}`, warnings: v.warnings }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, warnings: v.warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modToPlugin(pack: ModPack): CotycPlugin {
|
||||||
|
const plugin: CotycPlugin = {
|
||||||
|
id: `mod-${pack.id}`,
|
||||||
|
name: `MOD·${pack.name}`,
|
||||||
|
version: pack.version,
|
||||||
|
author: pack.author ?? 'mod',
|
||||||
|
description: pack.description ?? '',
|
||||||
|
kind: 'content',
|
||||||
|
dependencies: (pack.depends ?? []).map((d) => `mod-${d}`),
|
||||||
|
conflicts: (pack.conflicts ?? []).map((c) => `mod-${c}`),
|
||||||
|
install(ctx) {
|
||||||
|
// 事件池(与核心事件同池聚合;findEvent 查池)
|
||||||
|
if (pack.data.events?.length) ctx.addEventPool(`mod-${pack.id}`, pack.data.events)
|
||||||
|
// 世界模板(worldgen 池候选——新档参与生成)
|
||||||
|
for (const def of pack.data.npcs ?? []) ctx.addNpcTemplate(def)
|
||||||
|
// 词库注入(styles/regions/fams/suffixes;带 source 便于精确回滚)
|
||||||
|
if (pack.data.worldgen) ctx.addWorldGenPool(pack.data.worldgen, `mod-${pack.id}`)
|
||||||
|
// 配方补充
|
||||||
|
for (const r of pack.data.pills ?? []) ctx.addPillRecipe(r)
|
||||||
|
for (const r of pack.data.forges ?? []) ctx.addForgeRecipe(r)
|
||||||
|
// 灾因补充(0.1.26:MOD 灾因进入世界循环)
|
||||||
|
for (const c of pack.data.calamities ?? []) {
|
||||||
|
ctx.addCalamity(c.name, { brief: c.brief, family: c.family, effect: c.effect })
|
||||||
|
}
|
||||||
|
// 0.1.37 奇观类型扩展(MOD 奇观进入种子派生选取池——零 rng 消耗)
|
||||||
|
for (const w of pack.data.wonders ?? []) {
|
||||||
|
ctx.addWonderType(w)
|
||||||
|
}
|
||||||
|
// P1-11 worldNum 白名单覆写(0.1.28 接线:仅 WORLDSIM 内键生效,卸载复位)
|
||||||
|
for (const [k, v] of Object.entries(pack.data.worldNum ?? {})) {
|
||||||
|
overrideWorldNum(k, v)
|
||||||
|
}
|
||||||
|
// M1-M4(0.1.31)内容面总攻:物品/功法进 Pack(键级合入);建筑/职事/志向/阵型/性格走双源注册
|
||||||
|
if (pack.data.items?.length) {
|
||||||
|
const base = itemsBasePack()
|
||||||
|
for (const it of pack.data.items) base[it.id] = it
|
||||||
|
ctx.overridePack({ items: base as never })
|
||||||
|
}
|
||||||
|
if (pack.data.techniques?.length) {
|
||||||
|
// 语义冲突日志:同 id 功法(mergeAdd 跳过内置——提示玩家谁改了什么)
|
||||||
|
const seenT = new Set((dataPack().techniques as Array<{ id: string }>).map((x) => x.id))
|
||||||
|
for (const t of pack.data.techniques) if (seenT.has(t.id)) ctx.world.log('info', `[MOD:${pack.id}] 功法 ${t.id} 同 id 已存在(内置优先,新增跳过)。`)
|
||||||
|
// 0.1.35 A-6:注入即追踪(与插件绑拆——卸载按 id 摘除注入行)
|
||||||
|
const injected = pack.data.techniques.filter((t) => !seenT.has(t.id) && !(pack as unknown as { trackedTech?: Array<string> }).trackedTech?.includes(t.id))
|
||||||
|
;(pack as unknown as { trackedTech: string[] }).trackedTech = [...((pack as unknown as { trackedTech?: string[] }).trackedTech ?? []), ...injected.map((t) => t.id)]
|
||||||
|
ctx.mergeAddPack('techniques', pack.data.techniques as never)
|
||||||
|
}
|
||||||
|
for (const b of pack.data.buildings ?? []) ctx.addBuildingDef(toBuildingDef(b))
|
||||||
|
for (const po of pack.data.posts ?? []) ctx.addPostDef({
|
||||||
|
id: po.id, name: po.id, effect: { type: po.effectType, value: po.value }, max: po.max, desc: po.desc
|
||||||
|
} as never)
|
||||||
|
for (const a of pack.data.aspirations ?? []) ctx.addAspirationDef(a as never)
|
||||||
|
for (const f of pack.data.formations ?? []) ctx.addFormationDef({ ...f, retreatWound: f.retreatWound ?? 1 } as never)
|
||||||
|
for (const t of pack.data.traits ?? []) ctx.addTraitDef({ ...t, danger: t.bonus?.danger ?? 0 } as never)
|
||||||
|
// 0.1.34 MOD 数值 patch 层(仅新档生效;卸载复原——mult 0.5~2 / add 实数)
|
||||||
|
ctx.world.applyModPatch(pack.data.patch ?? [], `mod-${pack.id}`)
|
||||||
|
},
|
||||||
|
uninstall(ctx2) {
|
||||||
|
ctx2.world.rollbackPatch(`mod-${pack.id}`)
|
||||||
|
const td = (pack as unknown as { trackedTech?: string[] }).trackedTech ?? []
|
||||||
|
if (td.length) ctx2.world.removeInjectedTechniques(td)
|
||||||
|
// 0.1.28 精确回滚:词库池/灾因/配方/世界数值按来源摘除(其余 MOD 共享词条保留)
|
||||||
|
WorldGenPools.removePool(`mod-${pack.id}`)
|
||||||
|
for (const c of pack.data.calamities ?? []) removeCalamityByName(c.name)
|
||||||
|
for (const w of pack.data.wonders ?? []) removeWonderType(w.kind)
|
||||||
|
for (const r of pack.data.pills ?? []) removePillRecipe(r.output)
|
||||||
|
for (const r of pack.data.forges ?? []) removeForgeRecipe(r.output)
|
||||||
|
if (pack.data.worldNum) for (const k of Object.keys(pack.data.worldNum)) resetWorldNum(k)
|
||||||
|
},
|
||||||
|
// M1:挂 pack 源——导出/显示用(插件实例可溯源到原始 MOD 包)
|
||||||
|
source: pack
|
||||||
|
}
|
||||||
|
return plugin
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* MOD 包 schema(0.1.25《乾坤一统》)
|
||||||
|
* MOD = 可分发的外部内容包(JSON 单文件/文件)——Manifest + 纯数据。
|
||||||
|
* MOD 经 ModManager 转换为 CotycPlugin 进标准插件管线(持久化/双闸/回滚免费获得)。
|
||||||
|
*/
|
||||||
|
import { EventDef } from '../../data/events'
|
||||||
|
import { NpcFamilyDef } from '../../data/npcs'
|
||||||
|
|
||||||
|
export type ModKind = 'content' | 'data' | 'rules'
|
||||||
|
|
||||||
|
export interface ModPackData {
|
||||||
|
/** 追加事件(需与既有 id 无冲突) */
|
||||||
|
events?: EventDef[]
|
||||||
|
/** 世界模板(worldgen 池候选——新档生成时参与) */
|
||||||
|
npcs?: NpcFamilyDef[]
|
||||||
|
/** 世界生成词库注入 */
|
||||||
|
worldgen?: {
|
||||||
|
styles?: string[]
|
||||||
|
regions?: string[]
|
||||||
|
fams?: string[]
|
||||||
|
suffixes?: string[]
|
||||||
|
}
|
||||||
|
/** 灾因扩展(0.1.26:进入世界灾签循环) */
|
||||||
|
calamities?: Array<{ name: string; brief?: string; family?: Record<string, number>; effect?: Record<string, number> }>
|
||||||
|
/** 世界数值白名单覆写(0.1.27 声明口径,0.1.28 接线;卸载复位) */
|
||||||
|
worldNum?: Record<string, number>
|
||||||
|
/** 丹方/铸器配方 */
|
||||||
|
pills?: Array<{ output: string; name: string; danfangLevel: number; stones: number; lingcao: number; beastcore: number; desc: string }>
|
||||||
|
forges?: Array<{ output: string; name: string; stones: number; lingkuang: number; beastcore: number; desc: string }>
|
||||||
|
/** 0.1.31 内容面总攻:物品/功法(PACK 合入)、建筑/职事/志向/阵型/性格(双源注册) */
|
||||||
|
items?: Array<{ id: string; name: string; kind: 'resource' | 'material' | 'pill' | 'talisman' | 'brew' | 'artifact'; basePrice: number; desc: string; icon: string }>
|
||||||
|
techniques?: Array<{ id: string; name: string; grade: number; element: string; path: string; expBonus: number; powerBonus: number; desc: string; critChance?: number; guardBonus?: number; tribBonus?: number; secretBonus?: number }>
|
||||||
|
buildings?: Array<{ id: string; name: string; icon: string; desc: string; kind: 'produce' | 'function'; maxLevel: number; produceExpr?: string; upgradeStones?: number; upgradeMineral?: number }>
|
||||||
|
/** 0.1.34 数值 patch 层(mult 0.5~2 / add 实数——仅新开档生效;老档世界形成为档记录) */
|
||||||
|
patch?: Array<{ target: 'technique' | 'item' | 'building'; id: string; mult?: number; add?: number }>
|
||||||
|
posts?: Array<{ id: string; name: string; effectType: string; value: number; max: number; desc: string }>
|
||||||
|
aspirations?: Array<{ id: string; name: string; desc: string; effectType: string; value: number }>
|
||||||
|
formations?: Array<{ id: string; name: string; icon: string; desc: string; atk: number; def: number; retreatWound: number }>
|
||||||
|
traits?: Array<{ id: string; name: string; desc: string; bonus?: Record<string, number> }>
|
||||||
|
/** 0.1.37 奇观类型扩展(MOD 可注册自定义世界奇观——零 rng 消耗,种子派生选取) */
|
||||||
|
wonders?: Array<{ kind: string; name: string; desc: string; duration?: number; effect?: 'tide_up' | 'tide_down' | 'none'; oneshot?: 'bless' | 'loot' | 'none' }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModPack {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
/** MOD 类型(0.1.31):content=内容包 / data=实体包 / rules=规则包 */
|
||||||
|
kind?: ModKind
|
||||||
|
version: string
|
||||||
|
gameVersion?: string
|
||||||
|
author?: string
|
||||||
|
description?: string
|
||||||
|
/** 模式范式版本(0.1.26 起建议声明 = 1) */
|
||||||
|
schema?: number
|
||||||
|
depends?: string[]
|
||||||
|
conflicts?: string[]
|
||||||
|
data: ModPackData
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModParseResult = { ok: true; pack: ModPack } | { ok: false; error: string }
|
||||||
|
|
||||||
|
const ID_RE = /^[a-z0-9][a-z0-9-_]{1,31}$/i
|
||||||
|
|
||||||
|
/** 纯解析(任何文本 → ModPack 或错误);校验层供 vitest 直测 */
|
||||||
|
export function modParse(text: string): ModParseResult {
|
||||||
|
let raw: unknown
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'MOD 文件不是合法 JSON。' }
|
||||||
|
}
|
||||||
|
// P0-7 bundle 分支:游戏内导出的合并包({app:'cotymod',bundle:[...]})→ 逐包递归(首包解析失败即报)
|
||||||
|
const asRec = raw as { app?: string; bundle?: unknown[] }
|
||||||
|
if (asRec?.app === 'cotymod' && Array.isArray(asRec.bundle)) {
|
||||||
|
if (asRec.bundle.length === 0) return { ok: false, error: 'bundle 为空。' }
|
||||||
|
for (const b of asRec.bundle) {
|
||||||
|
const r = modParse(JSON.stringify(b))
|
||||||
|
if (!r.ok) return { ok: false, error: `bundle 内包非法:${r.error}` }
|
||||||
|
}
|
||||||
|
return modParse(JSON.stringify(asRec.bundle[0]))
|
||||||
|
}
|
||||||
|
if (!raw || typeof raw !== 'object') return { ok: false, error: 'MOD 内容为空。' }
|
||||||
|
const p = raw as Record<string, unknown>
|
||||||
|
if (typeof p.id !== 'string' || !ID_RE.test(p.id)) return { ok: false, error: '缺失/非法的 MOD id。' }
|
||||||
|
if (typeof p.name !== 'string' || !p.name.trim()) return { ok: false, error: '缺失 MOD 名称。' }
|
||||||
|
if (typeof p.version !== 'string' || !p.version.trim()) return { ok: false, error: '缺失 MOD 版本号。' }
|
||||||
|
const data = (p.data ?? {}) as Record<string, unknown>
|
||||||
|
if (!data || typeof data !== 'object') return { ok: false, error: 'MOD data 段缺失。' }
|
||||||
|
if (Array.isArray(data.events)) {
|
||||||
|
for (const e of data.events) {
|
||||||
|
if (!e || typeof e !== 'object') return { ok: false, error: 'MOD 事件表含非法行。' }
|
||||||
|
const ev = e as Record<string, unknown>
|
||||||
|
if (typeof ev.id !== 'string' || typeof ev.name !== 'string' || !Array.isArray(ev.options)) {
|
||||||
|
return { ok: false, error: `MOD 事件 ${String(ev.id ?? '?')} 缺 id/name/options。` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.npcs)) {
|
||||||
|
for (const n of data.npcs) {
|
||||||
|
const nn = n as Record<string, unknown>
|
||||||
|
if (typeof nn.id !== 'string' || typeof nn.name !== 'string' || typeof nn.region !== 'string') {
|
||||||
|
return { ok: false, error: 'MOD 模板表含非法行。' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, pack: raw as ModPack }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P1-O 全量解析:bundle → 全部包;单包 → [包];非法 → [] */
|
||||||
|
export function modParseAll(text: string): ModPack[] {
|
||||||
|
let raw: unknown
|
||||||
|
try { raw = JSON.parse(text) } catch { return [] }
|
||||||
|
const asRec = raw as { app?: string; bundle?: unknown[] }
|
||||||
|
if (asRec?.app === 'cotymod' && Array.isArray(asRec.bundle)) {
|
||||||
|
const out: ModPack[] = []
|
||||||
|
for (const b of asRec.bundle) {
|
||||||
|
const r = modParse(JSON.stringify(b))
|
||||||
|
if (r.ok) out.push(r.pack)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const r = modParse(text)
|
||||||
|
return r.ok ? [r.pack] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOD_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
export interface ModValidation {
|
||||||
|
ok: boolean
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES = ['daily', 'major', 'fate'] as const
|
||||||
|
const REALMS = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit'] as const
|
||||||
|
|
||||||
|
/** 范式校验(0.1.26 规范):深度字段检查——event 形状/模板境界/配方非负 */
|
||||||
|
export function validateMod(pack: ModPack): ModValidation {
|
||||||
|
const warnings: string[] = []
|
||||||
|
if (pack.schema !== undefined && pack.schema !== MOD_SCHEMA_VERSION) {
|
||||||
|
warnings.push(`schema 版本 ${pack.schema} ≠ 当前支持 ${MOD_SCHEMA_VERSION}(提示 MOD 作者更新)。`)
|
||||||
|
}
|
||||||
|
if (!pack.author) warnings.push('缺 author(建议填,供玩家辨识)。')
|
||||||
|
for (const e of pack.data.events ?? []) {
|
||||||
|
if (!CATEGORIES.includes(e.category as never)) warnings.push(`事件 ${e.id} category 非法(daily|major|fate)。`)
|
||||||
|
if (!(e.weight >= 0 && e.weight <= 100)) warnings.push(`事件 ${e.id} weight 超界(0~100)。`)
|
||||||
|
if (!e.text || !e.text.trim()) warnings.push(`事件 ${e.id} 缺正文。`)
|
||||||
|
if (!e.options || e.options.length === 0) warnings.push(`事件 ${e.id} 缺选项。`)
|
||||||
|
for (const o of e.options ?? []) {
|
||||||
|
if (!o.label) warnings.push(`事件 ${e.id} 某选项缺 label。`)
|
||||||
|
if (!o.eff || typeof o.eff !== 'object') warnings.push(`事件 ${e.id} 某选项缺 eff(可空对象)。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const n of pack.data.npcs ?? []) {
|
||||||
|
if (!REALMS.includes(n.leaderRealm)) warnings.push(`模板 ${n.id} leaderRealm 非法(${REALMS.join('|')})。`)
|
||||||
|
if (!(n.initialPower >= 60 && n.initialPower <= 400)) warnings.push(`模板 ${n.id} initialPower 建议 60~400。`)
|
||||||
|
}
|
||||||
|
for (const r of pack.data.pills ?? []) {
|
||||||
|
if ([r.stones, r.lingcao, r.beastcore].some((v) => v < 0)) warnings.push(`丹方 ${r.output} 含负成本。`)
|
||||||
|
if (!(r.danfangLevel >= 1 && r.danfangLevel <= 5)) warnings.push(`丹方 ${r.output} danfangLevel 建议 1~5。`)
|
||||||
|
}
|
||||||
|
for (const r of pack.data.forges ?? []) {
|
||||||
|
if ([r.stones, r.lingkuang, r.beastcore].some((v) => v < 0)) warnings.push(`配方 ${r.output} 含负成本。`)
|
||||||
|
}
|
||||||
|
for (const t of pack.data.techniques ?? []) {
|
||||||
|
if (!t.grade || t.grade < 1 || t.grade > 4) warnings.push(`功法 ${t.id} grade 建议 1~4。`)
|
||||||
|
if (Math.abs(t.expBonus) > 0.5 || Math.abs(t.powerBonus) > 1) warnings.push(`功法 ${t.id} 数值强度提醒(expBonus≤0.5/powerBonus≤1)。`)
|
||||||
|
}
|
||||||
|
for (const p of pack.data.patch ?? []) {
|
||||||
|
if (!(p.mult === undefined || (p.mult >= 0.5 && p.mult <= 2))) warnings.push(`patch ${p.target}:${p.id} mult 限 0.5~2。`)
|
||||||
|
if (p.mult !== undefined && p.add !== undefined) warnings.push(`patch ${p.target}:${p.id} mult/add 二选一(勿同给)。`)
|
||||||
|
}
|
||||||
|
// 0.1.37 奇观类型校验
|
||||||
|
for (const w of pack.data.wonders ?? []) {
|
||||||
|
if (!w.kind || typeof w.kind !== 'string') warnings.push('奇观缺 kind(唯一 id)。')
|
||||||
|
if (['tide_surge', 'ancient_ruin', 'heaven_bless', 'qi_wane'].includes(w.kind)) warnings.push(`奇观 ${w.kind} 与内置冲突。`)
|
||||||
|
if (w.duration !== undefined && (w.duration < 0 || w.duration > 60)) warnings.push(`奇观 ${w.kind} duration 建议 0~60。`)
|
||||||
|
}
|
||||||
|
return { ok: warnings.length === 0, warnings }
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Character, Element, Gender, Realm, RealmMajor } from '../types/domain'
|
import { Character, Element, Gender, Realm, RealmMajor } from '../../types/domain'
|
||||||
import { Rng } from '../core/rng'
|
import { Rng } from '../kernel/rng'
|
||||||
import { ELEMENT_LIST, ROOT_GRADES } from '../data/elements'
|
import { ELEMENT_LIST, ROOT_GRADES } from '../../data/elements'
|
||||||
import { MAJORS } from '../data/realms'
|
import { MAJORS } from '../../data/realms'
|
||||||
import { TRAIT_POOL, TRAITS } from '../data/traits'
|
import { TRAIT_POOL, TRAITS, traitById } from '../../data/traits'
|
||||||
|
|
||||||
export function rollRoots(rng: Rng, parents?: { m?: Character; f?: Character }): { grade: number; primary: Element; secondary: Element[] } {
|
export function rollRoots(rng: Rng, parents?: { m?: Character; f?: Character }): { grade: number; primary: Element; secondary: Element[] } {
|
||||||
let grade: number
|
let grade: number
|
||||||
@@ -95,6 +95,9 @@ export function newCharacter(
|
|||||||
state: 'idle',
|
state: 'idle',
|
||||||
health: 100,
|
health: 100,
|
||||||
alive: true,
|
alive: true,
|
||||||
|
techniqueRank: 0,
|
||||||
|
techniqueProgress: 0,
|
||||||
|
tribBoost: 0,
|
||||||
isHead: opts.isHead,
|
isHead: opts.isHead,
|
||||||
isFounder: opts.isFounder
|
isFounder: opts.isFounder
|
||||||
}
|
}
|
||||||
@@ -107,7 +110,7 @@ export function traitBonuses(character: Character): { exp: number; breakBonus: n
|
|||||||
let charmBonus = 0
|
let charmBonus = 0
|
||||||
let priceMult = 0
|
let priceMult = 0
|
||||||
for (const t of character.traits) {
|
for (const t of character.traits) {
|
||||||
const def = TRAITS[t]
|
const def = traitById(t)
|
||||||
if (!def) continue
|
if (!def) continue
|
||||||
exp += def.expBonus ?? 0
|
exp += def.expBonus ?? 0
|
||||||
breakBonus += def.breakBonus ?? 0
|
breakBonus += def.breakBonus ?? 0
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { CotycPlugin } from '../core/plugin'
|
import { CotycPlugin } from '../kernel/plugin'
|
||||||
import { installCoreSystems } from './clocks'
|
import { installCoreSystems } from './clocks'
|
||||||
import { EVENTS } from '../data/events'
|
import { EVENTS } from '../../data/events'
|
||||||
import { DEFAULT_PACK } from '../data/registry'
|
import { DEFAULT_PACK } from '../../data/registry'
|
||||||
|
|
||||||
/** 内置插件一:镇族基石(12 能力卡与时轮钩子) */
|
/** 内置插件一:镇族基石(12 能力卡与时轮钩子) */
|
||||||
export function makeCoreSystemsPlugin(): CotycPlugin {
|
export function makeCoreSystemsPlugin(): CotycPlugin {
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../kernel/plugin'
|
||||||
|
import { PLUGIN_REGISTRY } from './World'
|
||||||
|
|
||||||
|
function pluginRegistryOf(id: string): (() => CotycPlugin) | undefined {
|
||||||
|
return PLUGIN_REGISTRY.get(id)
|
||||||
|
}
|
||||||
|
import { EventDef } from '../../data/events'
|
||||||
|
import { PACK } from '../../data/registry'
|
||||||
|
import { SystemDef, SYSTEM_DEFS } from './capabilities'
|
||||||
|
import type { World } from './World'
|
||||||
|
import { SystemHook } from '../kernel/clock'
|
||||||
|
import { GameClock } from '../kernel/clock'
|
||||||
|
|
||||||
|
interface RuntimePlugin {
|
||||||
|
plugin: CotycPlugin
|
||||||
|
status: { installed: boolean; enabled: boolean }
|
||||||
|
unsubscribers: Array<() => void>
|
||||||
|
/** 追踪清单:插件注册的资源(池/能力卡/数据包覆写),卸载/禁用时自动回滚 */
|
||||||
|
tracked: {
|
||||||
|
eventPools: string[]
|
||||||
|
caps: string[]
|
||||||
|
packOverride?: Partial<import('../../data/registry').DataPack>
|
||||||
|
packWasOverridden: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件管理器:统一安装/卸载/启停管线。
|
||||||
|
* 安装序即确定性序;依赖缺失或冲突 → 拒绝安装。
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 禁用插件的池集(P0-M:allEvents 过滤) */
|
||||||
|
disabledPools(): Set<string> {
|
||||||
|
const out = new Set<string>()
|
||||||
|
for (const rt of this.runtime.values()) {
|
||||||
|
if (rt.status.enabled) continue
|
||||||
|
for (const pid of rt.tracked.eventPools) out.add(pid)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
listChanges(): PluginChange[] {
|
||||||
|
return this.changes.slice()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearChanges(): void {
|
||||||
|
this.changes = []
|
||||||
|
}
|
||||||
|
|
||||||
|
install(plugin: CotycPlugin, depth = 0): { ok: boolean; reason?: string } {
|
||||||
|
if (this.runtime.has(plugin.id)) return { ok: false, reason: `插件已存在:${plugin.id}` }
|
||||||
|
// 0.1.27 拓扑自动安装:依赖缺失时先查注册表工厂装齐(栈深 ≤ 3)
|
||||||
|
for (const dep of plugin.dependencies ?? []) {
|
||||||
|
if (this.runtime.has(dep)) continue
|
||||||
|
if (depth < 3) {
|
||||||
|
const mk = pluginRegistryOf(dep)
|
||||||
|
if (mk) {
|
||||||
|
const sub = this.install(mk(), depth + 1)
|
||||||
|
if (sub.ok) continue
|
||||||
|
return { ok: false, reason: `依赖 ${dep} 安装失败:${sub.reason ?? ''}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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: [], tracked: { eventPools: [], caps: [], packWasOverridden: false } }
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prop === 'onWorldSimTick') {
|
||||||
|
return (fn: Parameters<PluginContext['onWorldSimTick']>[0]) => {
|
||||||
|
const unsub = (target.onWorldSimTick as PluginContext['onWorldSimTick'])(fn)
|
||||||
|
rt.unsubscribers.push(unsub)
|
||||||
|
return unsub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prop === 'addEventPool') {
|
||||||
|
return (pid: string, events: EventDef[]) => {
|
||||||
|
;(target.addEventPool as (pid: string, ev: EventDef[]) => void)(pid, events)
|
||||||
|
rt.tracked.eventPools.push(pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prop === 'addCapability') {
|
||||||
|
return (cap: Parameters<PluginContext['addCapability']>[0]) => {
|
||||||
|
;(target.addCapability as (c: Parameters<PluginContext['addCapability']>[0]) => void)(cap)
|
||||||
|
rt.tracked.caps.push(cap.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prop === 'overridePack') {
|
||||||
|
return (partial: Parameters<PluginContext['overridePack']>[0]) => {
|
||||||
|
if (!rt.tracked.packWasOverridden) {
|
||||||
|
rt.tracked.packOverride = partial
|
||||||
|
rt.tracked.packWasOverridden = true
|
||||||
|
}
|
||||||
|
;(target.overridePack as (p: Parameters<PluginContext['overridePack']>[0]) => void)(partial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (target as unknown as Record<string, unknown>)[key as string]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
plugin.install(tracked as PluginContext)
|
||||||
|
plugin.hooks?.onLoad?.()
|
||||||
|
} catch (e) {
|
||||||
|
// P1-V 异常回滚:钩子+池+能力卡+数据包全摘(与 remove 同一序)
|
||||||
|
rt.unsubscribers.forEach((u) => u())
|
||||||
|
for (const pid of rt.tracked.eventPools) this.ctx.removeEventPool(pid)
|
||||||
|
for (const cid of rt.tracked.caps) this.ctx.enableCapability(cid, false)
|
||||||
|
if (rt.tracked.packWasOverridden) this.ctx.resetPack()
|
||||||
|
rt.plugin.hooks?.onUninstall?.()
|
||||||
|
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())
|
||||||
|
for (const pid of rt.tracked.eventPools) this.ctx.removeEventPool(pid)
|
||||||
|
for (const cid of rt.tracked.caps) this.ctx.enableCapability(cid, false) // 停用而非删除(sysEnabled ?? true 兜底会“归真”)
|
||||||
|
if (rt.tracked.packWasOverridden) this.ctx.resetPack()
|
||||||
|
rt.plugin.uninstall?.(this.ctx)
|
||||||
|
rt.plugin.hooks?.onUninstall?.()
|
||||||
|
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?.()
|
||||||
|
// P3 双闸:能力卡随插件启停联动(事件池在 allEvents 处经 pluginStatus 过滤)
|
||||||
|
for (const cid of rt.tracked.caps) this.ctx.enableCapability(cid, enabled)
|
||||||
|
void PACK
|
||||||
|
this.onChange({ id: pluginId, action: enabled ? 'enable' : 'disable' })
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MOD 源包列表(0.1.27 导出) */
|
||||||
|
modPlugins(): Array<{ id: string; source: unknown }> {
|
||||||
|
return [...this.runtime.values()]
|
||||||
|
.filter((r) => r.plugin.id.startsWith('mod-') && r.plugin.source)
|
||||||
|
.map((r) => ({ id: r.plugin.id, source: r.plugin.source }))
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
author: p.author,
|
||||||
|
kind: p.kind,
|
||||||
|
installed: rt.status.installed,
|
||||||
|
enabled: rt.status.enabled,
|
||||||
|
protected: !!p.protected,
|
||||||
|
description: p.description,
|
||||||
|
dependencies: p.dependencies,
|
||||||
|
conflicts: p.conflicts
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: string): boolean {
|
||||||
|
return this.runtime.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): CotycPlugin | null {
|
||||||
|
return this.runtime.get(id)?.plugin ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { World } from './world'
|
import type { World } from '../runtime/World'
|
||||||
import { pack } from '../data/registry'
|
import { pack } from '../../data/registry'
|
||||||
import { traitBonuses } from './pcgen'
|
import { traitBonuses } from '../runtime/pcgen'
|
||||||
|
|
||||||
|
import { WorldSim } from './WorldSim'
|
||||||
|
|
||||||
export function marketPrice(w: World, itemId: string): number {
|
export function marketPrice(w: World, itemId: string): number {
|
||||||
const item = pack().items[itemId]
|
const item = pack().items[itemId]
|
||||||
@@ -9,19 +11,28 @@ export function marketPrice(w: World, itemId: string): number {
|
|||||||
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
|
||||||
|
// 世界行情乘子(唯一读口:worldSim.marketMultFor;无 sim 时 1)
|
||||||
|
const simMult = w.state.worldSim ? new WorldSim(w).marketMultFor(itemId) : 1
|
||||||
|
// 0.1.34 断供应急:货源连续告急(shortage ≥ 触发月数)→ 价格上浮(恐慌:供不应求者贵)
|
||||||
|
const shortN = w.state.worldSim?.shortage?.[itemId]
|
||||||
|
const shortMult = shortN && shortN >= worldNum('shortageMonths') ? worldNum('shortagePrice') : 1
|
||||||
// 利己(priceMult)与信誉修正
|
// 利己(priceMult)与信誉修正
|
||||||
const sellers = w.aliveMembers().filter((c) => traitBonuses(c).priceMult > 0).length
|
const sellers = w.aliveMembers().filter((c) => traitBonuses(c).priceMult > 0).length
|
||||||
const liarPct = Math.min(0.2, sellers * 0.04)
|
const liarPct = Math.min(0.2, sellers * 0.04)
|
||||||
return Math.max(1, Math.round(base * mult * mood * (1 - liarPct)))
|
return Math.max(1, Math.round(base * mult * mood * simMult * shortMult * (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
|
if (!pack().items[itemId]) return false
|
||||||
|
const sim = w.state.worldSim ? new WorldSim(w) : null
|
||||||
|
// 断供告急:池深低于下限时拒单(零库存市场无货可买;世界生产与 NPC 供给会回填)
|
||||||
|
if (sim && sim.poolDepthOf(itemId) < worldNum('tradeFloorPct') && w.state.totalTicks > 12) 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
|
||||||
fam.inventory[itemId] = (fam.inventory[itemId] ?? 0) + count
|
fam.inventory[itemId] = (fam.inventory[itemId] ?? 0) + count
|
||||||
|
sim?.tradeSettle(itemId, -count)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +43,7 @@ export function sellItem(w: World, itemId: string, count: number): boolean {
|
|||||||
if (have < count) return false
|
if (have < count) return false
|
||||||
fam.inventory[itemId] = have - count
|
fam.inventory[itemId] = have - count
|
||||||
fam.stones += marketPrice(w, itemId) * count
|
fam.stones += marketPrice(w, itemId) * count
|
||||||
|
if (w.state.worldSim) new WorldSim(w).tradeSettle(itemId, count)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +57,13 @@ export function buyTechnique(w: World, techId: string, price: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function techniquePrice(techId: string): number {
|
export function techniquePrice(techId: string): number {
|
||||||
|
const t = pack().techniques.find((x) => x.id === techId)
|
||||||
|
if (t) return TECHNIQUE_GRADE_PRICE[t.grade] ?? 300
|
||||||
return TECH_GRADE_BASE[techId] ?? 200
|
return TECH_GRADE_BASE[techId] ?? 200
|
||||||
}
|
}
|
||||||
|
|
||||||
import { TECHNIQUES } from '../data/techniques'
|
import { TECHNIQUES } from '../../data/techniques'
|
||||||
|
import { WORLDSIM, worldNum } from './worldsim-data'
|
||||||
|
|
||||||
const TECHNIQUE_GRADE_PRICE: Record<number, number> = { 1: 120, 2: 300, 3: 700, 4: 1600 }
|
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(
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* 世界种子生成器(0.1.24《寰宇初构》)
|
||||||
|
* 游戏种子 → 确定性世界布局:NPC 势力(4~8 家)/初始关系网/开局时代/区域风味/市场偏移。
|
||||||
|
* 关键纪律:使用独立派生 rng(seed + '::worldgen')——World.rng 主序列零消耗,
|
||||||
|
* 玩家玩法随机序列不被生成器移动(金钟罩玩法序保护);同 seed 世界可重放。
|
||||||
|
*/
|
||||||
|
import { Rng, seedToRng } from '../kernel/rng'
|
||||||
|
import { NpcFamilyDef } from '../../data/npcs'
|
||||||
|
import { registerNpcDef } from '../../data/npcs'
|
||||||
|
|
||||||
|
/** 老牌世家模板(创世第一轮种子池核心;worldgen 决定/谁出现) */
|
||||||
|
export const LEGACY_TEMPLATES: NpcFamilyDef[] = [
|
||||||
|
{ id: 'n-xuanying', name: '玄影沈氏', region: '北岳玄影峰', style: '剑修世家', desc: '隐于北岳的剑修沈氏,剑意凛冽,最为孤傲。', leaderRealm: 'foundation', initialPower: 240, powerGrowth: [4, 10], sells: ['weapon-qi', 'weapon-ling'], buys: ['lingcao', 'lingkuang'] },
|
||||||
|
{ id: 'n-danxin', name: '丹心木氏', region: '西川药谷', style: '丹道世家', desc: '悬壶济世的丹道世家,人脉广博,风格和缓。', leaderRealm: 'foundation', initialPower: 200, powerGrowth: [3, 9], sells: ['pill-qiyuan', 'pill-ningyuan', 'pill-pojing'], buys: ['lingcao', 'beastcore'] },
|
||||||
|
{ id: 'n-sihai', name: '四海王氏', region: '南都连港', style: '商盟世族', desc: '商通四海,富可敌国,只认灵石不认人。', leaderRealm: 'foundation', initialPower: 160, powerGrowth: [5, 12], sells: ['lingcao', 'lingkuang'], buys: ['beastcore', 'lingkuang'] },
|
||||||
|
{ id: 'n-nulei', name: '怒雷祝氏', region: '东丘雷泽', style: '兵修蛮门', desc: '雷泽蛮族的世仇,性情火爆,最易生衅。', leaderRealm: 'core', initialPower: 300, powerGrowth: [5, 13], sells: [], buys: ['lingkuang', 'beastcore'] }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 新贵词库(默认池——MOD/插件可注入扩展;默认不变 → 基准世界不变) */
|
||||||
|
const DEFAULT_GEN_STYLES = ['剑修世家', '丹道世家', '商盟世族', '兵修蛮门', '符箓仙门', '灵植谷户', '阵道门阀', '散修聚落']
|
||||||
|
const DEFAULT_GEN_REGIONS = ['北岳玄影峰', '西川药谷', '南都连港', '东丘雷泽', '南麓青泽', '西山雾谷', '东溪云汉', '北原古井']
|
||||||
|
const DEFAULT_GEN_SUFFIX = ['氏', '氏', '宗', '寨', '门']
|
||||||
|
const DEFAULT_GEN_FAM = ['玄', '墨', '楚', '白', '萧', '洛', '燕', '秦', '顾', '周', '华', '苏']
|
||||||
|
|
||||||
|
/** 聚合词库池(MOD/插件注入端;读取端 worldgen 消费)
|
||||||
|
* 0.1.28 追踪回滚:add 记录来源(source key),removePool 精确退——卸 MOD 后同 seed 重放零漂移。 */
|
||||||
|
type PoolPart = { styles?: string[]; regions?: string[]; suffixes?: string[]; fams?: string[] }
|
||||||
|
const TRACKER = new Map<string, PoolPart>()
|
||||||
|
|
||||||
|
export const WorldGenPools = {
|
||||||
|
styles: [...DEFAULT_GEN_STYLES],
|
||||||
|
regions: [...DEFAULT_GEN_REGIONS],
|
||||||
|
suffixes: [...DEFAULT_GEN_SUFFIX],
|
||||||
|
fams: [...DEFAULT_GEN_FAM],
|
||||||
|
add(part: PoolPart, source = 'unnamed'): void {
|
||||||
|
TRACKER.set(source, part)
|
||||||
|
if (part.styles) for (const s of part.styles) if (!this.styles.includes(s)) this.styles.push(s)
|
||||||
|
if (part.regions) for (const r of part.regions) if (!this.regions.includes(r)) this.regions.push(r)
|
||||||
|
if (part.suffixes) for (const s of part.suffixes) if (!this.suffixes.includes(s)) this.suffixes.push(s)
|
||||||
|
if (part.fams) for (const f of part.fams) if (!this.fams.includes(f)) this.fams.push(f)
|
||||||
|
},
|
||||||
|
/** 精确回滚:移除某来源注入的词条(其他来源仍拥有的保留) */
|
||||||
|
removePool(source: string): void {
|
||||||
|
const part = TRACKER.get(source)
|
||||||
|
if (!part) return
|
||||||
|
TRACKER.delete(source)
|
||||||
|
const ownedByOthers = new Set(
|
||||||
|
[...TRACKER.values()].flatMap((p) => [
|
||||||
|
...(p.styles ?? []), ...(p.regions ?? []), ...(p.suffixes ?? []), ...(p.fams ?? [])
|
||||||
|
])
|
||||||
|
)
|
||||||
|
const removeIfUnshared = (arr: string[], items?: string[]) => {
|
||||||
|
if (!items) return
|
||||||
|
for (const s of items) {
|
||||||
|
if (ownedByOthers.has(s)) continue
|
||||||
|
const idx = arr.indexOf(s)
|
||||||
|
if (idx >= 0) arr.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removeIfUnshared(this.styles, part.styles)
|
||||||
|
removeIfUnshared(this.regions, part.regions)
|
||||||
|
removeIfUnshared(this.suffixes, part.suffixes)
|
||||||
|
removeIfUnshared(this.fams, part.fams)
|
||||||
|
},
|
||||||
|
reset(): void {
|
||||||
|
this.styles = [...DEFAULT_GEN_STYLES]
|
||||||
|
this.regions = [...DEFAULT_GEN_REGIONS]
|
||||||
|
this.suffixes = [...DEFAULT_GEN_SUFFIX]
|
||||||
|
this.fams = [...DEFAULT_GEN_FAM]
|
||||||
|
TRACKER.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldGenResult {
|
||||||
|
seed: string
|
||||||
|
npcs: NpcFamilyDef[]
|
||||||
|
relations: Record<string, Record<string, number>>
|
||||||
|
eras: 'shengshi' | 'pingshi' | 'luanshi'
|
||||||
|
regionFlavor: Record<string, number>
|
||||||
|
marketOffset: Record<string, number>
|
||||||
|
alliances: Array<[string, string]>
|
||||||
|
feuds: Array<[string, string]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 世界生成(确定性;独立 rng) */
|
||||||
|
export function generateWorld(seed: string): WorldGenResult {
|
||||||
|
const rng = new Rng(seedToRng(`${seed}::worldgen`))
|
||||||
|
// 家数 4~8(乱世开局偏少);老牌世家参与率 60~100%
|
||||||
|
const count = 4 + rng.int(0, 4)
|
||||||
|
const legacyPick = 1 + rng.int(0, 4) // 1~4 家老牌
|
||||||
|
const legacy: NpcFamilyDef[] = []
|
||||||
|
const pool = [...LEGACY_TEMPLATES]
|
||||||
|
while (legacy.length < legacyPick && pool.length > 0) {
|
||||||
|
const i = rng.int(0, pool.length - 1)
|
||||||
|
legacy.push(pool[i]!)
|
||||||
|
pool.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedRegions = new Set(legacy.map((l) => l.region))
|
||||||
|
const usedNames = new Set(legacy.map((l) => l.name))
|
||||||
|
const npcs: NpcFamilyDef[] = [...legacy]
|
||||||
|
const frontier = count - legacy.length
|
||||||
|
for (let k = 0; k < frontier; k++) {
|
||||||
|
const region = WorldGenPools.regions.filter((r) => !usedRegions.has(r))
|
||||||
|
const regionPick = region.length > 0 ? region[rng.int(0, region.length - 1)]! : WorldGenPools.regions[rng.int(0, WorldGenPools.regions.length - 1)]!
|
||||||
|
const stylePick = WorldGenPools.styles[rng.int(0, WorldGenPools.styles.length - 1)]!
|
||||||
|
usedRegions.add(regionPick)
|
||||||
|
// 去重:家名组合回溯(同 seed 确定性;至多 8 次重抽)
|
||||||
|
let fam = WorldGenPools.fams[rng.int(0, WorldGenPools.fams.length - 1)]!
|
||||||
|
let suffix = WorldGenPools.suffixes[rng.int(0, WorldGenPools.suffixes.length - 1)]!
|
||||||
|
let name = `${fam}${suffix}`
|
||||||
|
for (let t = 0; t < 8 && usedNames.has(name); t++) {
|
||||||
|
fam = WorldGenPools.fams[rng.int(0, WorldGenPools.fams.length - 1)]!
|
||||||
|
suffix = WorldGenPools.suffixes[rng.int(0, WorldGenPools.suffixes.length - 1)]!
|
||||||
|
name = `${fam}${suffix}`
|
||||||
|
}
|
||||||
|
usedNames.add(name)
|
||||||
|
const power = 90 + rng.int(0, 130)
|
||||||
|
npcs.push({
|
||||||
|
id: `n-g${k + 1}-${fam}`,
|
||||||
|
name: `${fam}${suffix}`,
|
||||||
|
region: regionPick,
|
||||||
|
style: stylePick,
|
||||||
|
desc: `${stylePick}新立足${regionPick},渐成气候。`,
|
||||||
|
leaderRealm: power > 180 ? 'core' : 'foundation',
|
||||||
|
initialPower: power,
|
||||||
|
powerGrowth: [3, 10],
|
||||||
|
sells: rng.chance(0.5) ? ['lingcao'] : [],
|
||||||
|
buys: ['lingcao', 'lingkuang']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 注册动态 def(npcById 双源命中)
|
||||||
|
for (const n of npcs) registerNpcDef(n)
|
||||||
|
|
||||||
|
// 初始关系网:随机世仇 1-2 对 + 盟友 1 对 + 其余 ±20 漂移
|
||||||
|
const relations: Record<string, Record<string, number>> = {}
|
||||||
|
for (const a of npcs) relations[a.id] = {}
|
||||||
|
const pairAll = (a: string, b: string, v: number) => {
|
||||||
|
relations[a]![b] = v
|
||||||
|
relations[b]![a] = v
|
||||||
|
}
|
||||||
|
const shuffle = [...npcs]
|
||||||
|
for (let i = shuffle.length - 1; i > 0; i--) {
|
||||||
|
const j = rng.int(0, i)
|
||||||
|
;[shuffle[i], shuffle[j]] = [shuffle[j]!, shuffle[i]!]
|
||||||
|
}
|
||||||
|
const feuds: Array<[string, string]> = []
|
||||||
|
const alliances: Array<[string, string]> = []
|
||||||
|
if (shuffle.length >= 2) {
|
||||||
|
pairAll(shuffle[0]!.id, shuffle[1]!.id, -60 - rng.int(0, 20))
|
||||||
|
feuds.push([shuffle[0]!.id, shuffle[1]!.id])
|
||||||
|
if (shuffle.length >= 3) {
|
||||||
|
pairAll(shuffle[2]!.id, shuffle[3]!.id, 45 + rng.int(0, 20))
|
||||||
|
alliances.push([shuffle[2]!.id, shuffle[3]!.id])
|
||||||
|
} else {
|
||||||
|
pairAll(shuffle[0]!.id, shuffle[1]!.id, -60 - rng.int(0, 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < npcs.length; i++) {
|
||||||
|
for (let j = i + 1; j < npcs.length; j++) {
|
||||||
|
const a = npcs[i]!.id
|
||||||
|
const b = npcs[j]!.id
|
||||||
|
if (relations[a]?.[b] === undefined) {
|
||||||
|
pairAll(a, b, rng.int(-25, 25))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开局 era:盛世/平世/乱世(乱世世界凶兆与繁华初现的分野)
|
||||||
|
const eras = ['shengshi', 'pingshi', 'luanshi'] as const
|
||||||
|
const eraPick = eras[rng.int(0, eras.length < 2 ? 0 : 2)]!
|
||||||
|
|
||||||
|
// 区域风味(每 region 商品偏移 ±15%对称)
|
||||||
|
const regionFlavor: Record<string, number> = {}
|
||||||
|
for (const n of npcs) {
|
||||||
|
regionFlavor[n.id] = (rng.next() - 0.5) * 0.3
|
||||||
|
}
|
||||||
|
const marketOffset: Record<string, number> = {
|
||||||
|
lingcao: (rng.next() - 0.5) * 0.24,
|
||||||
|
lingkuang: (rng.next() - 0.5) * 0.24,
|
||||||
|
beastcore: (rng.next() - 0.5) * 0.24,
|
||||||
|
lingyu: (rng.next() - 0.5) * 0.24,
|
||||||
|
lingmu: (rng.next() - 0.5) * 0.24,
|
||||||
|
shoupi: (rng.next() - 0.5) * 0.24,
|
||||||
|
lingguo: (rng.next() - 0.5) * 0.24,
|
||||||
|
'pill-qiyuan': (rng.next() - 0.5) * 0.18,
|
||||||
|
'pill-ningyuan': (rng.next() - 0.5) * 0.18
|
||||||
|
}
|
||||||
|
|
||||||
|
return { seed, npcs, relations, eras: eraPick, regionFlavor, marketOffset, alliances, feuds }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { World } from '../runtime/World'
|
||||||
|
import { WONDER_NAMES } from './worldsim-data'
|
||||||
|
|
||||||
|
export function worldSimBrief(w: World): { tideLabel: string; priceLabel: string; latest: string; wonderLabel?: string } | null {
|
||||||
|
const ws = w.state.worldSim
|
||||||
|
if (!ws) return null
|
||||||
|
const tide = ws.tide ?? 0.5
|
||||||
|
const tideLabel = tide > 0.9 ? '灵涨' : tide < 0.7 ? '灵衰' : '汐平'
|
||||||
|
const pool = ws.marketPool ?? {}
|
||||||
|
const ratio = (pool['lingcao'] ?? 600) / 600
|
||||||
|
const priceLabel = ratio > 1.15 ? '物腾' : ratio < 0.85 ? '物贱' : '平稳'
|
||||||
|
const latest = ws.newsFeed?.[ws.newsFeed.length - 1]?.text ?? ''
|
||||||
|
// 0.1.37 世界奇观标签
|
||||||
|
const wonder = (ws as { wonder?: { kind: string } }).wonder
|
||||||
|
const wonderLabel = wonder ? WONDER_NAMES[wonder.kind as keyof typeof WONDER_NAMES] : undefined
|
||||||
|
return { tideLabel, priceLabel, latest, wonderLabel }
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
export type NpcStance = 'guardian' | 'expand' | 'endure' | 'ally'
|
||||||
|
|
||||||
|
export interface NpcDynamics {
|
||||||
|
/** 景气指数(0~100):月结余/断供受挫/灾年支出;驱动 power 微调 */
|
||||||
|
prosperity: number
|
||||||
|
/** 战略姿态:守成/扩张/隐忍/结盟(年首评估) */
|
||||||
|
stance: NpcStance
|
||||||
|
stanceSinceYear: number
|
||||||
|
/** 衰微连续年数(power<58 起计) */
|
||||||
|
declineYears?: number
|
||||||
|
/** 互攻败仗累计(0.1.27 残衰减判定) */
|
||||||
|
warLosses?: number
|
||||||
|
/** 宗主姓名快照(换代时更新) */
|
||||||
|
leaderName: string
|
||||||
|
leaderRealmIdx: number
|
||||||
|
leaderAge: number
|
||||||
|
/** 演化旗标 */
|
||||||
|
lastEvent: string
|
||||||
|
lastEventYear: number
|
||||||
|
relationsWithOthers: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretQi {
|
||||||
|
qi: number
|
||||||
|
peak: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldSimState {
|
||||||
|
/** 世界库存池(资源循环) */
|
||||||
|
marketPool: Record<string, number>
|
||||||
|
/** NPC 动力学 */
|
||||||
|
npcDyn: Record<string, NpcDynamics>
|
||||||
|
/** 秘境灵气条 */
|
||||||
|
secretQi: Record<string, number>
|
||||||
|
/** 灵气潮汐(0-100 全局系数) */
|
||||||
|
tide: number
|
||||||
|
tideTicks: number
|
||||||
|
/** 灾年(当前灾因 id;持续 calamityMonths 个月) */
|
||||||
|
calamity?: string
|
||||||
|
calamityYear: number
|
||||||
|
/** 灾年剩余月数(递减,归 0 时散去) */
|
||||||
|
calamityLeft?: number
|
||||||
|
/** 天下快讯(滚动 N=120;itemId/kind 供 UI 响应按钮与区分) */
|
||||||
|
newsFeed: { year: number; month: number; src: string; text: string; itemId?: string; kind?: 'quote' | 'npc' | 'calamity' | 'annal' }[]
|
||||||
|
lastNewsMonth: number
|
||||||
|
/** NPC 换代计数 */
|
||||||
|
npcSuccessions: number
|
||||||
|
/** 世纪弧(时代状态机) */
|
||||||
|
era?: 'shengshi' | 'pingshi' | 'luanshi' | 'mofa'
|
||||||
|
eraStartYear?: number
|
||||||
|
/** 超卖潮播报年份(谷贱伤农,一年一次) */
|
||||||
|
overfluxYear?: number
|
||||||
|
/** 盟友求援(最近一次:互攻失利波及盟我之族) */
|
||||||
|
distress?: { id: string; year: number; month: number }
|
||||||
|
/** 战争疲劳(0~1):NPC 互攻/玩家 raid 逐年累积——世界温度上浮,乱世加速 */
|
||||||
|
warFatigue?: number
|
||||||
|
/** 断供监测:货源id→连续低于警戒线月数(≥3 触发断供告警×1.5 价) */
|
||||||
|
shortage?: Record<string, number>
|
||||||
|
/** 断供已播年(id→年——仅始发广播,持续断供不刷屏) */
|
||||||
|
shortageWarned?: Record<string, number>
|
||||||
|
/** 0.1.34 新秘境定义(世界状态内——每档独立,绝无全局跨世界污染) */
|
||||||
|
wonders?: Record<string, unknown>
|
||||||
|
/** 天下史书(十年一鉴独立留档,免 newsFeed 裁剪;上限 80 页) */
|
||||||
|
worldAnnals?: { year: number; text: string }[]
|
||||||
|
/** 世鉴(50/100 年宏观:开局 vs 现在/era 路径/最强家更替——独立数组零漂) */
|
||||||
|
sagaAnnals?: { year: number; kind: 'quinquagenary' | 'centennial'; text: string }[]
|
||||||
|
/** 0.1.37 世界奇观(当前活跃奇观;年首种子派生判定——零 rng 消耗) */
|
||||||
|
wonder?: WorldWonder
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeWorldSimState(): WorldSimState {
|
||||||
|
return {
|
||||||
|
marketPool: { lingcao: 600, lingkuang: 300, beastcore: 80, 'pill-qiyuan': 90, 'pill-ningyuan': 40 },
|
||||||
|
npcDyn: {},
|
||||||
|
secretQi: {},
|
||||||
|
tide: 0.5,
|
||||||
|
tideTicks: 0,
|
||||||
|
calamityYear: -99,
|
||||||
|
calamityLeft: 0,
|
||||||
|
newsFeed: [],
|
||||||
|
lastNewsMonth: -99,
|
||||||
|
npcSuccessions: 0,
|
||||||
|
era: 'pingshi',
|
||||||
|
eraStartYear: 1,
|
||||||
|
overfluxYear: -99,
|
||||||
|
worldAnnals: [],
|
||||||
|
warFatigue: 0,
|
||||||
|
shortage: {},
|
||||||
|
shortageWarned: {},
|
||||||
|
wonders: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 市场基准库存(单一权威:WorldSim 再平衡 / Market 行情 / brief 图标 三方共用) */
|
||||||
|
export const POOL_BASE: Record<string, number> = {
|
||||||
|
lingcao: 600,
|
||||||
|
lingkuang: 300,
|
||||||
|
beastcore: 80,
|
||||||
|
lingyu: 120,
|
||||||
|
lingmu: 220,
|
||||||
|
shoupi: 160,
|
||||||
|
lingguo: 260,
|
||||||
|
'pill-qiyuan': 90,
|
||||||
|
'pill-ningyuan': 40
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 世纪弧:时代 → 世界调制量(0.1.18 大周期) */
|
||||||
|
export const ERA_CONF: Record<'shengshi' | 'pingshi' | 'luanshi' | 'mofa', {
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
calamityMult: number
|
||||||
|
supplyMult: number
|
||||||
|
demandMult: number
|
||||||
|
auctionMult: number
|
||||||
|
tideBias: number
|
||||||
|
/** 转移概势表:[下一态, 权重] */
|
||||||
|
flow: Array<['shengshi' | 'pingshi' | 'luanshi' | 'mofa', number]>
|
||||||
|
}> = {
|
||||||
|
shengshi: { name: '盛世', desc: '灵气鼎盛,百业兴旺,天下升平。', calamityMult: 0.5, supplyMult: 1.25, demandMult: 1.15, auctionMult: 1.3, tideBias: 0.08, flow: [['pingshi', 0.45], ['luanshi', 0.15]] },
|
||||||
|
pingshi: { name: '平世', desc: '四海无波,仙凡相安。', calamityMult: 1.0, supplyMult: 1.0, demandMult: 1.0, auctionMult: 1.0, tideBias: 0, flow: [['shengshi', 0.22], ['luanshi', 0.28], ['mofa', 0.1]] },
|
||||||
|
luanshi: { name: '乱世', desc: '群雄相逐,烽烟四起,灾祸连年。', calamityMult: 1.6, supplyMult: 0.85, demandMult: 1.05, auctionMult: 0.75, tideBias: -0.04, flow: [['pingshi', 0.25], ['mofa', 0.35]] },
|
||||||
|
mofa: { name: '末法', desc: '灵机衰微,大能隐迹,仙路将绝。', calamityMult: 1.2, supplyMult: 0.7, demandMult: 0.85, auctionMult: 0.6, tideBias: -0.09, flow: [['pingshi', 0.4], ['shengshi', 0.2]] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERA_DURA_OVERRIDES = new Map<string, [number, number]>()
|
||||||
|
export function overrideEraDura(era: string, dur: [number, number]): void {
|
||||||
|
if (era in ERA_DURA) ERA_DURA_OVERRIDES.set(era, dur)
|
||||||
|
}
|
||||||
|
export function resetEraDura(era?: string): void {
|
||||||
|
if (era) ERA_DURA_OVERRIDES.delete(era)
|
||||||
|
else ERA_DURA_OVERRIDES.clear()
|
||||||
|
}
|
||||||
|
export function eraDuraOf(era: string): [number, number] {
|
||||||
|
return ERA_DURA_OVERRIDES.get(era) ?? ERA_DURA[era as keyof typeof ERA_DURA] ?? [20, 40]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** era 持续年数区间(转移时机) */
|
||||||
|
export const ERA_DURA: Record<'shengshi' | 'pingshi' | 'luanshi' | 'mofa', [number, number]> = {
|
||||||
|
shengshi: [25, 45],
|
||||||
|
pingshi: [20, 40],
|
||||||
|
luanshi: [15, 30],
|
||||||
|
mofa: [20, 40]
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGION_TIDE_OVERRIDES = new Map<string, Record<string, number>>()
|
||||||
|
export function overrideRegionTide(npcId: string, byEra: Record<string, number>): void {
|
||||||
|
REGION_TIDE_OVERRIDES.set(npcId, byEra)
|
||||||
|
}
|
||||||
|
export function resetRegionTide(npcId?: string): void {
|
||||||
|
if (npcId) REGION_TIDE_OVERRIDES.delete(npcId)
|
||||||
|
else REGION_TIDE_OVERRIDES.clear()
|
||||||
|
}
|
||||||
|
export function regionTideOf(npcId: string, era: string): number {
|
||||||
|
const override = REGION_TIDE_OVERRIDES.get(npcId)?.[era]
|
||||||
|
if (override !== undefined) return override
|
||||||
|
const base = REGION_TIDE[npcId]
|
||||||
|
if (!base) return 0
|
||||||
|
return base[era as keyof typeof base] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 区域灵势(NPC id × era → 修正量 +-0.06~0.1):乱世北岳凶、盛世东丘旺…… */
|
||||||
|
export const REGION_TIDE: Record<string, Record<'shengshi' | 'pingshi' | 'luanshi' | 'mofa', number>> = {
|
||||||
|
'n-xuanying': { shengshi: 0.06, pingshi: 0, luanshi: -0.08, mofa: -0.06 },
|
||||||
|
'n-danxin': { shengshi: 0.04, pingshi: 0, luanshi: -0.04, mofa: -0.02 },
|
||||||
|
'n-sihai': { shengshi: 0.05, pingshi: 0.01, luanshi: -0.06, mofa: -0.04 },
|
||||||
|
'n-nulei': { shengshi: 0.02, pingshi: 0, luanshi: 0.05, mofa: -0.03 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0.1.27 世界数值 MOD 口:允许子集覆写(默认表不变→零漂;任意时刻可恢复) */
|
||||||
|
const WORLD_OVERRIDES: Record<string, number> = {}
|
||||||
|
export function overrideWorldNum(key: string, value: number): boolean {
|
||||||
|
if (key in WORLDSIM) {
|
||||||
|
WORLD_OVERRIDES[key] = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
export function resetWorldNum(key?: string): void {
|
||||||
|
if (key) delete WORLD_OVERRIDES[key]
|
||||||
|
else for (const k of Object.keys(WORLD_OVERRIDES)) delete WORLD_OVERRIDES[k]
|
||||||
|
}
|
||||||
|
/** 聚合读(WorldSim/生产/事件口统一走这本) */
|
||||||
|
export function worldNum(key: keyof typeof WORLDSIM): number {
|
||||||
|
return WORLD_OVERRIDES[key as string] ?? ((WORLDSIM as unknown as Record<string, number>)[key as string] ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 世界演化参数表(全部可调) */
|
||||||
|
export const WORLDSIM = {
|
||||||
|
marketDriftRate: 0.03,
|
||||||
|
marketRebalance: 0.025, // 0.1.20 弱锚定:让供需/era/灾年真实驱动价格(0.1.19 为 0.1 硬锚死)
|
||||||
|
priceFloor: 0.55,
|
||||||
|
priceCeil: 2.3,
|
||||||
|
// —— 0.1.17 真库存循环 ——
|
||||||
|
worldSupplyRate: 0.02, // 世界侧月供给(base 比例;潮涨时 ×1.3)
|
||||||
|
worldDemandRate: 0.015, // 世界侧月需求(坊市/宗门常驻消耗)
|
||||||
|
npcTradeRate: 0.008, // 每 NPC 月贸易量(base 比例;上桌食量)
|
||||||
|
tradeFloorPct: 0.35, // 拒单线:池低于此比例时玩家买入拒单(断供告急)
|
||||||
|
poolFloorPct: 0.25, // driftMarket 池底(0.1.35 调低——地板与拒单线分离,断供期间真买不到料)
|
||||||
|
tradeCeilPct: 2.4, // 池上限(玩家大量卖出后价格封顶)
|
||||||
|
tideSupplySpan: 0.3, // 潮汐对供给的浮动幅度(tide±0.35 时 ×(1∓0.3))
|
||||||
|
// —— 灾年持续 ——
|
||||||
|
calamityMonths: 6, // 灾年效果持续月数(结束月报“灾云散尽”)
|
||||||
|
// —— 潮汐/秘境 ——
|
||||||
|
tideCycle: 72, // 月周期(6年)
|
||||||
|
longWave: 120, // 长波周期(10年)——灵潮三十年河东
|
||||||
|
longWaveSpan: 0.08, // 长波振幅(±0.08 叠加)
|
||||||
|
tideMin: 0.65,
|
||||||
|
tideMax: 1.35,
|
||||||
|
secretRecover: 2, // 灵气月恢复(潮高 ×2.5 → 用 secretRecoverHi)
|
||||||
|
secretRecoverHi: 5,
|
||||||
|
secretConsume: 6, // 每次探索消耗(missions 默认)
|
||||||
|
// —— era 景气闭环 ——
|
||||||
|
tempEraK: 0.8, // 世界温度对 era 转移权重的调制强度(±0.8/单位温差)
|
||||||
|
// —— 新贵补位 ——
|
||||||
|
greatNewbornChance: 0.06, // 年首新贵出现率(乱世 ×2;上限 4 家)
|
||||||
|
// —— 盟约连坐(加性) ——
|
||||||
|
allianceGriefRaid: 0.012, // 每 世仇盟连加性 raid 概率
|
||||||
|
// —— 天下事件 ——
|
||||||
|
calamityChance: 0.18,
|
||||||
|
calamities: ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const,
|
||||||
|
npcEventChance: 0.05, // NPC 互攻年首诗签概率
|
||||||
|
newsEvery: 24, // 月
|
||||||
|
newsKeep: 120,
|
||||||
|
// —— 0.1.34 万象归元(战争疲劳/断供/新秘境) ——
|
||||||
|
warFatigueRise: 0.008, // 每次乱象(NPC 互攻)累积的战争疲劳
|
||||||
|
warFatigueTempK: 0.6, // 疲劳对世界温度的冷降幅度(打不动即荒年)
|
||||||
|
shortageLine: 0.7, // 断供警戒线:池/基准 < 此值起计连续月
|
||||||
|
shortageMonths: 3,
|
||||||
|
shortagePrice: 1.5, // 断供价乘
|
||||||
|
newSecretChance: 0.04, // 年首新秘境现身概率(乱世 ×2)
|
||||||
|
// —— 0.1.37 因果/奇观 ——
|
||||||
|
wonderChance: 0.08, // 世界奇观年首现身概率(种子派生——零 rng 消耗)
|
||||||
|
karmaDecayRate: 0.1, // 功德年首衰减率(向中性靠拢——善行需持续)
|
||||||
|
karmaHighThreshold: 30, // 功德深厚阈值(触发灾年 -15%/渡劫 +5%)
|
||||||
|
karmaLowThreshold: -20 // 功德亏缺阈值(触发灾年 +15%/渡劫 -5%)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CalamityName = string
|
||||||
|
|
||||||
|
// ==================== 0.1.37 世界奇观 ====================
|
||||||
|
|
||||||
|
/** 世界奇观类型(年首种子派生判定——零 rng 消耗) */
|
||||||
|
export type WorldWonderKind = 'tide_surge' | 'ancient_ruin' | 'heaven_bless' | 'qi_wane' | string
|
||||||
|
|
||||||
|
export interface WorldWonder {
|
||||||
|
kind: WorldWonderKind
|
||||||
|
year: number
|
||||||
|
/** 持续月数(灵气涌潮/灵脉枯竭有持续效果;古遗迹/天降祥瑞一次性) */
|
||||||
|
duration?: number
|
||||||
|
/** 已过月数 */
|
||||||
|
elapsed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 世界奇观年首现身概率(聚合读口——MOD 可经 worldNum('wonderChance') 覆写) */
|
||||||
|
export const WONDER_CHANCE = 0.08
|
||||||
|
|
||||||
|
/** 奇观名称映射 */
|
||||||
|
export const WONDER_NAMES: Record<WorldWonderKind, string> = {
|
||||||
|
tide_surge: '灵气涌潮',
|
||||||
|
ancient_ruin: '古遗迹现身',
|
||||||
|
heaven_bless: '天降祥瑞',
|
||||||
|
qi_wane: '灵脉枯竭'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 奇观描述 */
|
||||||
|
export const WONDER_DESCS: Record<WorldWonderKind, string> = {
|
||||||
|
tide_surge: '天地灵气如潮涌至,万灵共鸣——修炼事半功倍。',
|
||||||
|
ancient_ruin: '远古修士洞府现世——其中或藏功法丹材之珍。',
|
||||||
|
heaven_bless: '祥瑞降世——风调雨顺,灾厄消弭。',
|
||||||
|
qi_wane: '灵脉逆行——天地灵气骤衰,修行维艰。'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 奇观持续月数(灵气涌潮/灵脉枯竭持续 12 月;古遗迹/天降祥瑞一次性) */
|
||||||
|
export const WONDER_DURATION: Record<WorldWonderKind, number> = {
|
||||||
|
tide_surge: 12,
|
||||||
|
ancient_ruin: 0,
|
||||||
|
heaven_bless: 0,
|
||||||
|
qi_wane: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认灾因集(聚合读口:默认+MOD/插件扩展——默认表和不变 → 基准世界指纹不漂) */
|
||||||
|
export const DEFAULT_CALAMITIES = ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const
|
||||||
|
|
||||||
|
/** 灾因 → 家族实际生产乘量(1 = 正常) */
|
||||||
|
export const CALAMITY_FAMILY: Record<string, Partial<Record<string, number>>> = {
|
||||||
|
旱灾: { lingcao: 0.7 },
|
||||||
|
涝灾: { lingcao: 0.8, lingkuang: 0.95 },
|
||||||
|
蝗灾: { lingcao: 0.55 },
|
||||||
|
疫病: {},
|
||||||
|
兽潮: { beastcore: 1.4 },
|
||||||
|
寒潮: { lingcao: 0.85, lingkuang: 0.75 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 灾因 → 资源方向 */
|
||||||
|
export const CALAMITY_EFFECT: Record<string, Partial<Record<string, number>>> = {
|
||||||
|
旱灾: { lingcao: -0.3 },
|
||||||
|
涝灾: { lingcao: -0.2, lingkuang: -0.1 },
|
||||||
|
蝗灾: { lingcao: -0.45 },
|
||||||
|
疫病: { beastcore: -0.25 },
|
||||||
|
兽潮: { beastcore: 0.3 },
|
||||||
|
寒潮: { lingcao: -0.2, beastcore: -0.15 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扩展灾因注册表(MOD/插件 addCalamity)——名字唯一;与默认集聚合 */
|
||||||
|
const EXTENDED_CALAMITIES: string[] = []
|
||||||
|
const EXTENDED_FAMILY: Record<string, Partial<Record<string, number>>> = {}
|
||||||
|
const EXTENDED_EFFECT: Record<string, Partial<Record<string, number>>> = {}
|
||||||
|
|
||||||
|
const EXTENDED_BRIEF: Record<string, string> = {}
|
||||||
|
export function addCalamity(
|
||||||
|
name: string,
|
||||||
|
opts: { brief?: string; family?: Partial<Record<string, number>>; effect?: Partial<Record<string, number>> }
|
||||||
|
): boolean {
|
||||||
|
if (DEFAULT_CALAMITIES.includes(name as never) || EXTENDED_CALAMITIES.includes(name)) return false
|
||||||
|
EXTENDED_CALAMITIES.push(name)
|
||||||
|
if (opts.brief) EXTENDED_BRIEF[name] = opts.brief
|
||||||
|
if (opts.family) EXTENDED_FAMILY[name] = opts.family
|
||||||
|
if (opts.effect) EXTENDED_EFFECT[name] = opts.effect
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCalamityByName(name: string): void {
|
||||||
|
const idx = EXTENDED_CALAMITIES.indexOf(name)
|
||||||
|
if (idx >= 0) EXTENDED_CALAMITIES.splice(idx, 1)
|
||||||
|
delete EXTENDED_FAMILY[name]
|
||||||
|
delete EXTENDED_EFFECT[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚合读口(WorldSim 灾签/体感/生产/Market 读取) */
|
||||||
|
export function calamityNames(): string[] {
|
||||||
|
return [...DEFAULT_CALAMITIES, ...EXTENDED_CALAMITIES]
|
||||||
|
}
|
||||||
|
export function calamityFamilyOf(name: string): Partial<Record<string, number>> {
|
||||||
|
return EXTENDED_FAMILY[name] ?? CALAMITY_FAMILY[name] ?? {}
|
||||||
|
}
|
||||||
|
export function calamityEffectOf(name: string): Partial<Record<string, number>> {
|
||||||
|
return EXTENDED_EFFECT[name] ?? CALAMITY_EFFECT[name] ?? {}
|
||||||
|
}
|
||||||
|
/** 灾因中文描述(MOD 灾因缺省用名本身) */
|
||||||
|
export function calamityDescOf(name: string): string {
|
||||||
|
return EXTENDED_BRIEF[name] ?? `${name}——灵植减产,市价将行。`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 0.1.37 奇观类型扩展注册表(MOD 口) ====================
|
||||||
|
|
||||||
|
/** 扩展奇观类型定义(MOD 可注入自定义奇观——零 rng 消耗,种子派生选取) */
|
||||||
|
export interface ExtendedWonderDef {
|
||||||
|
/** 奇观类型 id(唯一——与内置不冲突) */
|
||||||
|
kind: string
|
||||||
|
/** 中文名称 */
|
||||||
|
name: string
|
||||||
|
/** 描述文本 */
|
||||||
|
desc: string
|
||||||
|
/** 持续月数(0 = 一次性) */
|
||||||
|
duration: number
|
||||||
|
/** 持续效果类型:tide_up = 灵气增益 / tide_down = 灵气衰减 / none = 无持续效果 */
|
||||||
|
effect: 'tide_up' | 'tide_down' | 'none'
|
||||||
|
/** 一次性效果:bless = 灾年消弭+声望 / loot = 资源获取 / none = 无 */
|
||||||
|
oneshot: 'bless' | 'loot' | 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXTENDED_WONDERS: ExtendedWonderDef[] = []
|
||||||
|
|
||||||
|
/** 内置奇观定义(供聚合读口统一访问) */
|
||||||
|
const BUILTIN_WONDERS: ExtendedWonderDef[] = [
|
||||||
|
{ kind: 'tide_surge', name: '灵气涌潮', desc: '天地灵气如潮涌至,万灵共鸣——修炼事半功倍。', duration: 12, effect: 'tide_up', oneshot: 'none' },
|
||||||
|
{ kind: 'ancient_ruin', name: '古遗迹现身', desc: '远古修士洞府现世——其中或藏功法丹材之珍。', duration: 0, effect: 'none', oneshot: 'loot' },
|
||||||
|
{ kind: 'heaven_bless', name: '天降祥瑞', desc: '祥瑞降世——风调雨顺,灾厄消弭。', duration: 0, effect: 'none', oneshot: 'bless' },
|
||||||
|
{ kind: 'qi_wane', name: '灵脉枯竭', desc: '灵脉逆行——天地灵气骤衰,修行维艰。', duration: 12, effect: 'tide_down', oneshot: 'none' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** MOD/插件注册奇观类型(kind 唯一——与内置+已注册不冲突) */
|
||||||
|
export function addWonderType(def: Omit<ExtendedWonderDef, 'name' | 'desc' | 'duration' | 'effect' | 'oneshot'> & Partial<Omit<ExtendedWonderDef, 'kind'>>): boolean {
|
||||||
|
if (!def.kind || BUILTIN_WONDERS.some((w) => w.kind === def.kind) || EXTENDED_WONDERS.some((w) => w.kind === def.kind)) return false
|
||||||
|
EXTENDED_WONDERS.push({
|
||||||
|
kind: def.kind,
|
||||||
|
name: def.name ?? '异变',
|
||||||
|
desc: def.desc ?? '天地异变,灵机暗涌。',
|
||||||
|
duration: def.duration ?? 0,
|
||||||
|
effect: def.effect ?? 'none',
|
||||||
|
oneshot: def.oneshot ?? 'none'
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卸载时按 kind 摘除扩展奇观 */
|
||||||
|
export function removeWonderType(kind: string): void {
|
||||||
|
const idx = EXTENDED_WONDERS.findIndex((w) => w.kind === kind)
|
||||||
|
if (idx >= 0) EXTENDED_WONDERS.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚合读口:全部奇观类型(内置 + 扩展) */
|
||||||
|
export function allWonderKinds(): ExtendedWonderDef[] {
|
||||||
|
return [...BUILTIN_WONDERS, ...EXTENDED_WONDERS]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚合读口:按 kind 查奇观定义 */
|
||||||
|
export function wonderDefOf(kind: string): ExtendedWonderDef | undefined {
|
||||||
|
return BUILTIN_WONDERS.find((w) => w.kind === kind) ?? EXTENDED_WONDERS.find((w) => w.kind === kind)
|
||||||
|
}
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import type { World } from '../world'
|
|
||||||
import { npcById } from '../../data/npcs'
|
|
||||||
import { findEvent, fire } from './events'
|
|
||||||
|
|
||||||
export function diplomacyTick(w: World): void {
|
|
||||||
const s = w.state
|
|
||||||
const drift = w.rng.chance(0.15)
|
|
||||||
for (const npc of Object.values(s.npcFamilies)) {
|
|
||||||
if (drift) {
|
|
||||||
if (npc.relation > 0) npc.relation -= 1
|
|
||||||
else if (npc.relation < 0) npc.relation += 1
|
|
||||||
}
|
|
||||||
if (npc.relation < -50) {
|
|
||||||
const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0
|
|
||||||
if (s.year - last >= 2 && w.rng.chance(0.03)) {
|
|
||||||
fire(w, `ev-raid-${npc.id}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function yearGrowth(w: World): void {
|
|
||||||
const s = w.state
|
|
||||||
for (const npc of Object.values(s.npcFamilies)) {
|
|
||||||
const def = npcById(npc.id)
|
|
||||||
const [a, b] = def.powerGrowth
|
|
||||||
npc.power += w.rng.int(a, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function npcRelation(w: World, npcId: string): number {
|
|
||||||
return w.state.npcFamilies[npcId]?.relation ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function giftNpc(w: World, npcId: string, stones: number): boolean {
|
|
||||||
const fam = w.state.family
|
|
||||||
if (stones <= 0 || fam.stones < stones) return false
|
|
||||||
fam.stones -= stones
|
|
||||||
const npc = w.state.npcFamilies[npcId]
|
|
||||||
const gain = Math.max(1, Math.round(stones / 12))
|
|
||||||
npc.relation = Math.min(100, npc.relation + gain)
|
|
||||||
w.log('info', `厚礼送往${npc.name},两家关系 +${gain}。`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makePeace(w: World, npcId: string): boolean {
|
|
||||||
const fam = w.state.family
|
|
||||||
const npc = w.state.npcFamilies[npcId]
|
|
||||||
if (fam.stones < 200) return false
|
|
||||||
fam.stones -= 200
|
|
||||||
npc.relation = Math.max(npc.relation + 35, 30)
|
|
||||||
w.chronicle('diplomacy', `${npc.name}立下和约,两家罢兵互市。`, undefined, true)
|
|
||||||
w.log('good', `与${npc.name}言和。`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function marryNpcFamily(w: World, npcId: string): boolean {
|
|
||||||
const s = w.state
|
|
||||||
const fam = s.family
|
|
||||||
const npc = s.npcFamilies[npcId]
|
|
||||||
if (!npc || npc.relation < 25 || npc.allied) return false
|
|
||||||
const eligible = w
|
|
||||||
.aliveMembers()
|
|
||||||
.filter((c) => w.ageOf(c) >= 16 && w.ageOf(c) <= 46 && c.state !== 'expedition')
|
|
||||||
.filter((c) => !c.spouseId || w.isWidowed(c))
|
|
||||||
if (eligible.length === 0) return false
|
|
||||||
const npcDef = npcById(npcId)
|
|
||||||
const candidate = w.rng.pick(eligible)
|
|
||||||
candidate.spouseHouse = npc.name
|
|
||||||
npc.relation += 20
|
|
||||||
npc.allied = true
|
|
||||||
npc.alliedSinceYear = s.year
|
|
||||||
fam.reputation += 4
|
|
||||||
w.chronicle('marriage', `${candidate.name}与${npc.name}联姻,两家绸缪通好。`, candidate.id, true)
|
|
||||||
w.log('good', `${candidate.name} 与${npc.name}联姻成功!每年或降麟儿。`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function arrangeWedding(w: World, aId: string, bId: string): boolean {
|
|
||||||
const a = w.memberById(aId)
|
|
||||||
const b = w.memberById(bId)
|
|
||||||
if (!a.alive || !b.alive) return false
|
|
||||||
if (a.spouseId && !w.isWidowed(a)) return false
|
|
||||||
if (b.spouseId && !w.isWidowed(b)) return false
|
|
||||||
if (a.gender === b.gender) return false
|
|
||||||
if (a.fatherId === b.fatherId && a.fatherId) return false
|
|
||||||
if (a.motherId === b.motherId && a.motherId) return false
|
|
||||||
a.spouseId = b.id
|
|
||||||
b.spouseId = a.id
|
|
||||||
const aAge = w.ageOf(a)
|
|
||||||
const bAge = w.ageOf(b)
|
|
||||||
if (w.state.family.flag['tenants']) {
|
|
||||||
w.state.family.reputation += 1
|
|
||||||
}
|
|
||||||
w.chronicle('marriage', `${a.name}(${aAge})与${b.name}(${bAge})拜堂成亲。`, a.id, true)
|
|
||||||
w.log('good', `${a.name} 与 ${b.name} 结为连理。`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import type { World } from '../world'
|
|
||||||
import { aspirationById } from '../../data/aspirations'
|
|
||||||
import { seasonMod } from '../../data/season'
|
|
||||||
|
|
||||||
export function productionTick(w: World): void {
|
|
||||||
const fam = w.state.family
|
|
||||||
const inv = fam.inventory
|
|
||||||
const parts: string[] = []
|
|
||||||
const lvl = (b: string) => fam.buildings[b] ?? 0
|
|
||||||
|
|
||||||
const lingtian = lvl('lingtian')
|
|
||||||
const yaoyuan = lvl('yaoyuan')
|
|
||||||
const lingkuang = lvl('lingkuang')
|
|
||||||
const fangshi = lvl('fangshi')
|
|
||||||
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) {
|
|
||||||
const v = Math.round(10 * lingtian * (1 + fielders * 0.05 + springMod))
|
|
||||||
inv.lingcao = (inv.lingcao ?? 0) + v
|
|
||||||
parts.push(`灵田+${v}灵草`)
|
|
||||||
}
|
|
||||||
if (yaoyuan > 0) {
|
|
||||||
const v = 5 * yaoyuan
|
|
||||||
inv.lingcao = (inv.lingcao ?? 0) + v
|
|
||||||
parts.push(`药园+${v}药草`)
|
|
||||||
if (yaoyuan >= 3) {
|
|
||||||
inv.beastcore = (inv.beastcore ?? 0) + 1
|
|
||||||
parts.push('药园+1兽核')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lingkuang > 0) {
|
|
||||||
const v = 8 * lingkuang
|
|
||||||
inv.lingkuang = (inv.lingkuang ?? 0) + v
|
|
||||||
parts.push(`灵矿+${v}灵矿`)
|
|
||||||
}
|
|
||||||
const autumnMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'market') : 0
|
|
||||||
if (fangshi > 0) {
|
|
||||||
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome') + merchants * 0.03 + autumnMod))
|
|
||||||
fam.stones += v
|
|
||||||
parts.push(`坊市+${v}灵石`)
|
|
||||||
}
|
|
||||||
if (lingshou > 0 && w.rng.chance(0.35)) {
|
|
||||||
inv.beastcore = (inv.beastcore ?? 0) + 1
|
|
||||||
parts.push('灵兽园+1兽核')
|
|
||||||
}
|
|
||||||
|
|
||||||
void parts
|
|
||||||
|
|
||||||
if (w.state.month % 3 === 0) {
|
|
||||||
const drift = w.rng.between(-0.04, 0.04)
|
|
||||||
const cur = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1
|
|
||||||
fam.flag['priceMult'] = Math.max(0.78, Math.min(1.25, cur + drift))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,713 +0,0 @@
|
|||||||
import {
|
|
||||||
BattleLog,
|
|
||||||
Character,
|
|
||||||
ChronicleEntry,
|
|
||||||
GameState,
|
|
||||||
Id,
|
|
||||||
LogItem,
|
|
||||||
Realm,
|
|
||||||
YearlyReport
|
|
||||||
} from '../types/domain'
|
|
||||||
import { Rng } from '../core/rng'
|
|
||||||
import { BUILDINGS } from '../data/buildings'
|
|
||||||
import { POSTS } from '../data/posts'
|
|
||||||
import { aspirationById as aspirationOf } from '../data/aspirations'
|
|
||||||
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
|
|
||||||
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'
|
|
||||||
|
|
||||||
export type LogKind = LogItem['kind']
|
|
||||||
|
|
||||||
export interface WorldEventBus {
|
|
||||||
onLog(kind: LogKind, text: string): void
|
|
||||||
onChronicle(entry: ChronicleEntry, important: boolean): void
|
|
||||||
onBattle(log: BattleLog): void
|
|
||||||
onPendingEvent(id: string): void
|
|
||||||
onGameOver(reason: string, year: number): void
|
|
||||||
onYearPaper?(entry: YearlyReport): void
|
|
||||||
onSystemChange?(id: string, enabled: boolean): void
|
|
||||||
onPluginChange?(id: string, action: string): void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeGameState(state: GameState): GameState {
|
|
||||||
// 老版本存档(<0.1.1)缺少新增字段,加载时补齐,避免运行期 undefined 崩溃
|
|
||||||
if (!state.finance) state.finance = { accum: 0 }
|
|
||||||
if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 }
|
|
||||||
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.seq !== 'number') state.seq = 10
|
|
||||||
if (!state.battles) state.battles = []
|
|
||||||
if (!state.eventQueue) state.eventQueue = []
|
|
||||||
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)) {
|
|
||||||
if (typeof c.techniqueRank !== 'number') c.techniqueRank = 0
|
|
||||||
if (typeof c.techniqueProgress !== 'number') c.techniqueProgress = 0
|
|
||||||
if (typeof c.health !== 'number') c.health = 100
|
|
||||||
}
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
export class World {
|
|
||||||
state: GameState
|
|
||||||
rng: Rng
|
|
||||||
out: WorldEventBus[]
|
|
||||||
clock: GameClock
|
|
||||||
systems: Record<string, { enabled: boolean }>
|
|
||||||
plugins: PluginManager
|
|
||||||
private eventPools = new Map<string, EventDef[]>()
|
|
||||||
|
|
||||||
constructor(state: GameState, out: WorldEventBus[] = []) {
|
|
||||||
normalizeGameState(state)
|
|
||||||
this.state = state
|
|
||||||
this.rng = new Rng(state.rng)
|
|
||||||
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 {
|
|
||||||
this.state.seq++
|
|
||||||
return `x${this.state.seq.toString(36)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
syncRng(): void {
|
|
||||||
this.state.rng = this.rng.getState()
|
|
||||||
}
|
|
||||||
|
|
||||||
log(kind: LogKind, text: string): void {
|
|
||||||
this.out.forEach((o) => o.onLog(kind, text))
|
|
||||||
}
|
|
||||||
|
|
||||||
chronicle(cat: ChronicleEntry['category'], text: string, memberId?: Id, important = false): void {
|
|
||||||
const entry: ChronicleEntry = {
|
|
||||||
id: this.seq(),
|
|
||||||
year: this.state.year,
|
|
||||||
month: this.state.month,
|
|
||||||
category: cat,
|
|
||||||
text,
|
|
||||||
memberId,
|
|
||||||
important
|
|
||||||
}
|
|
||||||
this.state.chronicle.push(entry)
|
|
||||||
this.out.forEach((o) => o.onChronicle(entry, important))
|
|
||||||
}
|
|
||||||
|
|
||||||
battle(log: BattleLog): void {
|
|
||||||
this.state.battles.push(log)
|
|
||||||
this.out.forEach((o) => o.onBattle(log))
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingEvent(id: string): void {
|
|
||||||
this.out.forEach((o) => o.onPendingEvent(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
gameOver(reason: string, year: number): void {
|
|
||||||
this.state.gameOver = { year, reason }
|
|
||||||
this.out.forEach((o) => o.onGameOver(reason, year))
|
|
||||||
}
|
|
||||||
|
|
||||||
gameOver___placeholder(): void {
|
|
||||||
void 0
|
|
||||||
}
|
|
||||||
|
|
||||||
memberById(id: Id): Character {
|
|
||||||
const c = this.state.members[id]
|
|
||||||
if (!c) throw new Error(`member not found ${id}`)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
aliveMembers(): Character[] {
|
|
||||||
return Object.values(this.state.members).filter((c) => c.alive)
|
|
||||||
}
|
|
||||||
|
|
||||||
ageOf(c: Character): number {
|
|
||||||
return this.state.year - c.bornYear
|
|
||||||
}
|
|
||||||
|
|
||||||
head(): Character {
|
|
||||||
return this.memberById(this.state.family.headId)
|
|
||||||
}
|
|
||||||
|
|
||||||
advanceMonth(): void {
|
|
||||||
const s = this.state
|
|
||||||
const stonesStart = s.family.stones
|
|
||||||
s.month++
|
|
||||||
if (s.month > 12) {
|
|
||||||
s.month = 1
|
|
||||||
s.year++
|
|
||||||
this.clock.fireYearStart(this)
|
|
||||||
}
|
|
||||||
s.totalTicks++
|
|
||||||
|
|
||||||
this.clock.stepMonthly(this)
|
|
||||||
const stonesEnd = s.family.stones
|
|
||||||
s.finance.accum += stonesEnd - stonesStart
|
|
||||||
this.trackStats()
|
|
||||||
this.clampState()
|
|
||||||
this.pruneYearFlags()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 年度清理:剔除 3 年以前的年份前缀 flag 键(防长线膨胀) */
|
|
||||||
private pruneYearFlags(): void {
|
|
||||||
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 power = this.familyPower()
|
|
||||||
const report: YearlyReport = {
|
|
||||||
year: this.state.year - 1,
|
|
||||||
nets: this.state.finance.accum,
|
|
||||||
births: this.state.yearStats.births,
|
|
||||||
deaths: this.state.yearStats.deaths,
|
|
||||||
rep,
|
|
||||||
power
|
|
||||||
}
|
|
||||||
this.state.yearlyReports.push(report)
|
|
||||||
if (this.state.yearlyReports.length > 80) {
|
|
||||||
this.state.yearlyReports.shift()
|
|
||||||
}
|
|
||||||
this.state.finance.accum = 0
|
|
||||||
this.state.yearStats = { births: 0, deaths: 0 }
|
|
||||||
this.out.forEach((o) => o.onYearPaper?.(report))
|
|
||||||
}
|
|
||||||
|
|
||||||
epilogueTick(): void {
|
|
||||||
if (this.state.gameOver) return
|
|
||||||
this.checkHead()
|
|
||||||
}
|
|
||||||
|
|
||||||
reputationDrift(): void {
|
|
||||||
const cur = this.state.family.reputation
|
|
||||||
const drift = cur > 0 ? -1.5 : cur < 0 ? 1.2 : 0
|
|
||||||
if (drift !== 0) this.state.family.reputation = Math.round(cur + drift)
|
|
||||||
}
|
|
||||||
|
|
||||||
totalFamilyReputation(): number {
|
|
||||||
return this.state.family.reputation
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkHead(): void {
|
|
||||||
const s = this.state
|
|
||||||
if (s.gameOver) return
|
|
||||||
const headId = s.family.headId
|
|
||||||
if (!headId) return
|
|
||||||
const head = this.memberById(headId)
|
|
||||||
if (head.alive) return
|
|
||||||
// 功德碑:宗主薨,勒石纪功
|
|
||||||
const reignStart = (s.family.flag['reignStart'] as number | undefined) ?? 1
|
|
||||||
const reignYears = Math.max(1, s.year - reignStart)
|
|
||||||
const peak = s.family.reputation
|
|
||||||
const top = this.aliveMembers().length
|
|
||||||
this.chronicle(
|
|
||||||
'misc',
|
|
||||||
`功德碑:先主${head.name}承宗${reignYears}载,宗族声望达「${peak}」、丁口${top}。族人勒石铭功,立于宗祠。`,
|
|
||||||
head.id,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
const heir = findInheritor(this)
|
|
||||||
if (heir) {
|
|
||||||
this.assignHead(heir.id, true)
|
|
||||||
s.family.flag['reignStart'] = s.year
|
|
||||||
} else if (this.aliveMembers().length === 0) {
|
|
||||||
this.gameOver('满门凋零,香火断绝', s.year)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== player actions ====================
|
|
||||||
|
|
||||||
assignHead(id: Id, silent = false): void {
|
|
||||||
const c = this.memberById(id)
|
|
||||||
if (!c.alive) return
|
|
||||||
if (this.state.family.headId && !silent) {
|
|
||||||
const old = this.memberById(this.state.family.headId)
|
|
||||||
old.isHead = false
|
|
||||||
} else {
|
|
||||||
const oldId = this.state.family.headId
|
|
||||||
if (oldId && this.state.members[oldId]) this.state.members[oldId].isHead = false
|
|
||||||
}
|
|
||||||
c.isHead = true
|
|
||||||
this.state.family.headId = id
|
|
||||||
this.state.family.flag['reignStart'] = this.state.year
|
|
||||||
if (!silent) {
|
|
||||||
this.chronicle('misc', `${c.name} 继任为家主。`, c.id, true)
|
|
||||||
this.log('info', `${c.name} 继任为家主。`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setMeditation(id: Id, on: boolean): void {
|
|
||||||
const c = this.memberById(id)
|
|
||||||
if (!c.alive || c.state === 'expedition') return
|
|
||||||
c.state = on ? 'meditation' : 'idle'
|
|
||||||
}
|
|
||||||
|
|
||||||
giveTechnique(memberId: Id, techId: string): void {
|
|
||||||
const c = this.memberById(memberId)
|
|
||||||
c.techniqueId = techId
|
|
||||||
}
|
|
||||||
|
|
||||||
teachTechnique(techId: string, cost: number): boolean {
|
|
||||||
const fam = this.state.family
|
|
||||||
if (fam.techniques.includes(techId)) return false
|
|
||||||
if (fam.stones < cost) return false
|
|
||||||
fam.stones -= cost
|
|
||||||
fam.techniques.push(techId)
|
|
||||||
this.log('info', `藏书阁续得《${techId}》,译作一名。`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
equip(memberId: Id, artifact: string): void {
|
|
||||||
const c = this.memberById(memberId)
|
|
||||||
if (!c.alive) return
|
|
||||||
c.equipment = artifact
|
|
||||||
}
|
|
||||||
|
|
||||||
takePill(memberId: Id, pill: string): void {
|
|
||||||
const c = this.memberById(memberId)
|
|
||||||
const inv = this.state.family.inventory
|
|
||||||
if (!c.alive || (inv[pill] ?? 0) <= 0) return
|
|
||||||
inv[pill] = inv[pill]! - 1
|
|
||||||
if (pill === 'pill-pojing') {
|
|
||||||
if (c.realmProgress >= 100) {
|
|
||||||
this.resolveBottleneck(c, 0.22)
|
|
||||||
} else {
|
|
||||||
this.memberById(memberId).realmProgress = Math.min(100, c.realmProgress + 20)
|
|
||||||
this.log('info', `${c.name} 服下破境丹,灵力充盈。`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const pct = pill === 'pill-qiyuan' ? 18 : 30
|
|
||||||
c.realmProgress = Math.min(100, c.realmProgress + pct)
|
|
||||||
this.log('info', `${c.name} 服下丹药,修为精进。`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assistedBreakthrough(id: Id): void {
|
|
||||||
const c = this.memberById(id)
|
|
||||||
if (!c.alive || c.realmProgress < 100) return
|
|
||||||
this.resolveBottleneck(c, 0.06 + this.head().mind * 0.005)
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveBottleneck(c: Character, boost: number): void {
|
|
||||||
resolveBreakthrough(this, c, boost)
|
|
||||||
}
|
|
||||||
|
|
||||||
build(id: string): boolean {
|
|
||||||
const fam = this.state.family
|
|
||||||
const def = BUILDINGS[id]
|
|
||||||
if (!def) return false
|
|
||||||
if (fam.buildings[id]) return false
|
|
||||||
const cost = def.upgradeCost(1)
|
|
||||||
if (fam.stones < cost.stones) return false
|
|
||||||
fam.stones -= cost.stones
|
|
||||||
fam.buildings[id] = 1
|
|
||||||
this.chronicle('building', `建成「${def.name}」。`, undefined, true)
|
|
||||||
this.log('info', `建成「${def.name}」。`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
upgrade(id: string): boolean {
|
|
||||||
const fam = this.state.family
|
|
||||||
const def = BUILDINGS[id]
|
|
||||||
const lvl = fam.buildings[id]
|
|
||||||
if (!def || !lvl || lvl >= def.maxLevel) return false
|
|
||||||
const cost = def.upgradeCost(lvl + 1)
|
|
||||||
if (fam.stones < cost.stones || (fam.inventory['lingkuang'] ?? 0) < cost.lingkuang) return false
|
|
||||||
fam.stones -= cost.stones
|
|
||||||
fam.inventory['lingkuang'] -= cost.lingkuang
|
|
||||||
fam.buildings[id] = lvl + 1
|
|
||||||
this.log('info', `「${def.name}」升至 ${lvl + 1} 级。`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
craftPill(kind: 'qiyuan' | 'ningyuan'): boolean {
|
|
||||||
const fam = this.state.family
|
|
||||||
const lvl = fam.buildings['danfang']
|
|
||||||
if (!lvl) return false
|
|
||||||
const cost = kind === 'qiyuan'
|
|
||||||
? { lingcao: 15, beastcore: 0, stones: 20 }
|
|
||||||
: { lingcao: 25, beastcore: 4, stones: 60 }
|
|
||||||
if ((fam.inventory['lingcao'] ?? 0) < cost.lingcao) return false
|
|
||||||
if ((fam.inventory['beastcore'] ?? 0) < cost.beastcore) return false
|
|
||||||
if (fam.stones < cost.stones) return false
|
|
||||||
fam.inventory['lingcao'] -= cost.lingcao
|
|
||||||
fam.inventory['beastcore'] -= cost.beastcore
|
|
||||||
fam.stones -= cost.stones
|
|
||||||
fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] =
|
|
||||||
(fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] ?? 0) + 1
|
|
||||||
this.log('info', `丹房炼成一枚${kind === 'qiyuan' ? '聚气丹' : '凝元丹'}。`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
addMember(c: Character, father?: Character, mother?: Character): void {
|
|
||||||
const s = this.state
|
|
||||||
if (father || mother) {
|
|
||||||
if (father) {
|
|
||||||
c.fatherId = father.id
|
|
||||||
father.children.push(c.id)
|
|
||||||
}
|
|
||||||
if (mother) mother.children.push(c.id)
|
|
||||||
}
|
|
||||||
c.id = c.id || this.seq()
|
|
||||||
s.members[c.id] = c
|
|
||||||
}
|
|
||||||
|
|
||||||
postCount(def: string): number {
|
|
||||||
return this.aliveMembers().filter((c) => c.post === def && c.state !== 'apprentice').length
|
|
||||||
}
|
|
||||||
|
|
||||||
assignPost(memberId: Id, postId: string | undefined): boolean {
|
|
||||||
const c = this.memberById(memberId)
|
|
||||||
if (!c.alive || c.state === 'apprentice') return false
|
|
||||||
if (postId === undefined || postId === '') {
|
|
||||||
c.post = undefined
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const def = POSTS[postId]
|
|
||||||
if (!def || def.id === 'head') return false
|
|
||||||
if (this.postCount(postId) >= def.max) return false
|
|
||||||
c.post = postId
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
postBonus(type: string): number {
|
|
||||||
let sum = 0
|
|
||||||
for (const c of this.aliveMembers()) {
|
|
||||||
const def = POSTS[c.post ?? '']
|
|
||||||
if (def && def.effect.type === type) sum += def.effect.value
|
|
||||||
}
|
|
||||||
return sum
|
|
||||||
}
|
|
||||||
|
|
||||||
familyPower(): number {
|
|
||||||
const fam = this.state.family
|
|
||||||
const bonus =
|
|
||||||
1 +
|
|
||||||
(fam.buildings['yanwu'] ?? 0) * 0.04 +
|
|
||||||
(fam.buildings['lingshou'] ?? 0) * 0.05 +
|
|
||||||
this.postBonus('battlePower')
|
|
||||||
const top = this.aliveMembers()
|
|
||||||
.map((c) => combatPowerOf(this, c))
|
|
||||||
.sort((a, b) => b - a)
|
|
||||||
.slice(0, 4)
|
|
||||||
.reduce((a, b) => a + b, 0)
|
|
||||||
return Math.round(top * bonus)
|
|
||||||
}
|
|
||||||
|
|
||||||
ancestralRite(): boolean {
|
|
||||||
const fam = this.state.family
|
|
||||||
const last = (fam.flag['lastRiteYear'] as number | undefined) ?? -999
|
|
||||||
if (this.state.year - last < 2) return false
|
|
||||||
if (fam.stones < 150) return false
|
|
||||||
fam.stones -= 150
|
|
||||||
fam.flag['lastRiteYear'] = this.state.year
|
|
||||||
fam.reputation += 6
|
|
||||||
for (const c of this.aliveMembers()) {
|
|
||||||
c.realmProgress = Math.min(100, c.realmProgress + 3)
|
|
||||||
c.health = Math.min(100, c.health + 5)
|
|
||||||
}
|
|
||||||
const headName = this.head()?.name ?? '家主'
|
|
||||||
this.chronicle('event', `${headName} 斋戒三日后开祠祭祖,先祖显灵赐福。`, undefined, true)
|
|
||||||
this.log('good', `祭祖!族中众人灵力温润,族人受益。`)
|
|
||||||
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 {
|
|
||||||
const fam = this.state.family
|
|
||||||
const npc = this.state.npcFamilies[npcId]
|
|
||||||
if (!npc) return false
|
|
||||||
const last = (fam.flag[`tauntCD-${npcId}`] as number | undefined) ?? 0
|
|
||||||
if (this.state.year - last < 1) return false
|
|
||||||
fam.flag[`tauntCD-${npcId}`] = this.state.year
|
|
||||||
npc.relation = Math.max(-100, npc.relation - 20)
|
|
||||||
this.log('bad', `指桑骂槐,${npc.name}记恨于心。`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
isWidowed(member: Character | Id): boolean {
|
|
||||||
const m = typeof member === 'string' ? this.state.members[member] : member
|
|
||||||
if (!m || !m.spouseId) return false
|
|
||||||
const sp = this.state.members[m.spouseId]
|
|
||||||
return !!sp && !sp.alive
|
|
||||||
}
|
|
||||||
|
|
||||||
marriageCandidatesOf(id: Id): Character[] {
|
|
||||||
const me = this.memberById(id)
|
|
||||||
const meG = me.gender
|
|
||||||
return this.aliveMembers()
|
|
||||||
.filter((c) => c.gender !== meG && c.state !== 'expedition' && c.state !== 'apprentice')
|
|
||||||
.filter((c) => w2age(this, c) >= 16 && w2age(this, c) <= 46)
|
|
||||||
.filter((c) => !c.spouseId || this.isWidowed(c))
|
|
||||||
.filter(
|
|
||||||
(c) =>
|
|
||||||
!(me.fatherId && me.fatherId === c.fatherId) &&
|
|
||||||
!(me.motherId && me.motherId === c.motherId) &&
|
|
||||||
!me.children.includes(c.id) &&
|
|
||||||
!c.children.includes(me.id) &&
|
|
||||||
me.id !== c.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
canMarry(memberId: Id): boolean {
|
|
||||||
const me = this.memberById(memberId)
|
|
||||||
if (!me.alive) return false
|
|
||||||
return !me.spouseId || this.isWidowed(me)
|
|
||||||
}
|
|
||||||
|
|
||||||
marryTo(aId: Id, bId: Id): boolean {
|
|
||||||
return arrangeWeddingBridge(this, aId, bId)
|
|
||||||
}
|
|
||||||
|
|
||||||
static create(opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }): World {
|
|
||||||
const state = createWorldState(opts)
|
|
||||||
return new World(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeWorldFromSave(state: GameState): World {
|
|
||||||
return new World(state, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
function w2age(w: World, c: Character): number {
|
|
||||||
return w.ageOf(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrangeWeddingBridge(w: World, aId: Id, bId: Id): boolean {
|
|
||||||
const a = w.state.members[aId]
|
|
||||||
const b = w.state.members[bId]
|
|
||||||
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
|
|
||||||
if (b.spouseId && !w.isWidowed(b)) return false
|
|
||||||
if (a.gender === b.gender) return false
|
|
||||||
if (a.fatherId && a.fatherId === b.fatherId) return false
|
|
||||||
if (a.motherId && a.motherId === b.motherId) return false
|
|
||||||
a.spouseId = b.id
|
|
||||||
b.spouseId = a.id
|
|
||||||
const aAge = w.ageOf(a)
|
|
||||||
const bAge = w.ageOf(b)
|
|
||||||
w.chronicle('marriage', `${a.name}(${aAge})与${b.name}(${bAge})拜堂成亲。`, a.id, true)
|
|
||||||
w.log('good', `${a.name} 与 ${b.name} 结为连理。`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyChoice(world: World, eventId: string, optionIdx: number): void {
|
|
||||||
applyEventChoice(world, eventId, optionIdx)
|
|
||||||
}
|
|
||||||
+304
-16
@@ -1,6 +1,24 @@
|
|||||||
import { SaveSlot } from './slots'
|
import { SaveSlot } from './slots'
|
||||||
import { GameState, SaveMeta, SnapshotMeta } from '../types/domain'
|
import { GameState, SaveMeta, SnapshotMeta } from '../types/domain'
|
||||||
import type { SaveDbDriver } from './slots'
|
import type { SaveDbDriver } from './slots'
|
||||||
|
import { IndexedDBBackend } from './idb-backend'
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 0.1.38 存档根治(最终版):IndexedDB 持久化后端注入
|
||||||
|
//
|
||||||
|
// 根因分析(上一版修复遗留问题):
|
||||||
|
// 0.1.38 初版探测 OPFS → 不可用时降级 diskEngine='kv' → AriaEngine 内部
|
||||||
|
// new KVStoreBackend() 不传 medium → KVStore.defaultMedium() 在 OPFS 不可
|
||||||
|
// 用时返回 SharedMemoryBackend(纯内存!)→ 数据写入内存 → 进程关闭全丢。
|
||||||
|
// 即使 OPFS "假可用"(getDirectory 不抛错但数据不持久化),也有同样问题。
|
||||||
|
//
|
||||||
|
// 最终方案:
|
||||||
|
// · 自建 IndexedDBBackend(idb-backend.ts),用原生 IndexedDB API 持久化
|
||||||
|
// · MetonaSqlark.create 成功后,通过反射链替换底层 KVStore.medium
|
||||||
|
// · 替换后 reload() 重新从 IndexedDB 加载数据
|
||||||
|
// · 无论 OPFS 是否可用,数据始终写入 IndexedDB(跨进程持久化)
|
||||||
|
// · 保留 OPFS 探测和诊断能力供 UI 展示
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Adapter for @metona-team/metona-sqlark
|
// Adapter for @metona-team/metona-sqlark
|
||||||
// The driver interface keeps the engine swappable (testable in node too).
|
// The driver interface keeps the engine swappable (testable in node too).
|
||||||
@@ -14,30 +32,205 @@ interface SqlarkLike {
|
|||||||
|
|
||||||
export function setSqlarkBackend(impl: AnyFactory): void {
|
export function setSqlarkBackend(impl: AnyFactory): void {
|
||||||
MetonaSqlark = impl
|
MetonaSqlark = impl
|
||||||
|
_backendType = 'mock'
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function makeDriver(): Promise<SaveDbDriver> {
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
const factory = await getSqlarkFactory()
|
// OPFS 可用性探测(一次性,进程级缓存)
|
||||||
return new SqlarkDriver(factory)
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type StorageBackendType = 'opfs' | 'kv' | 'memory' | 'mock' | 'unknown'
|
||||||
|
|
||||||
|
let _backendType: StorageBackendType = 'unknown'
|
||||||
|
let _opfsProbed = false
|
||||||
|
let _opfsAvailable = false
|
||||||
|
|
||||||
|
/** 探测 OPFS 是否可用(navigator.storage.getDirectory 不抛错即视为可用) */
|
||||||
|
export async function probeOpfs(): Promise<boolean> {
|
||||||
|
if (_opfsProbed) return _opfsAvailable
|
||||||
|
_opfsProbed = true
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.storage && typeof navigator.storage.getDirectory === 'function') {
|
||||||
|
const root = await navigator.storage.getDirectory()
|
||||||
|
_opfsAvailable = !!root
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
_opfsAvailable = false
|
||||||
|
}
|
||||||
|
return _opfsAvailable
|
||||||
}
|
}
|
||||||
|
|
||||||
class SqlarkDriver implements SaveDbDriver {
|
/** 获取当前存储后端类型(供 UI 诊断面板) */
|
||||||
|
export function getStorageBackendType(): StorageBackendType {
|
||||||
|
return _backendType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置探测状态(测试用) */
|
||||||
|
export function _resetStorageProbe(): void {
|
||||||
|
_opfsProbed = false
|
||||||
|
_opfsAvailable = false
|
||||||
|
_backendType = 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 数据库创建配置——根据 OPFS 可用性自动选择 diskEngine
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 构建数据库配置——始终用 hybrid 模式(KVStore 后端),后续注入 IndexedDB medium */
|
||||||
|
async function buildDbConfig(name: string, _withCompression: boolean): Promise<{
|
||||||
|
name: string
|
||||||
|
mode: string
|
||||||
|
diskEngine: string
|
||||||
|
}> {
|
||||||
|
// 探测 OPFS 供诊断面板展示(不影响实际后端选择——始终注入 IndexedDB)
|
||||||
|
await probeOpfs()
|
||||||
|
_backendType = 'kv'
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
mode: 'hybrid',
|
||||||
|
diskEngine: 'kv',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// IndexedDB 后端注入——通过反射链替换底层 KVStore.medium
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type Reflective = Record<string, any>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过反射链找到并替换 MetonaSqlark 实例底层 KVStore 的 medium。
|
||||||
|
*
|
||||||
|
* 反射路径(两种模式):
|
||||||
|
* · mode='aria': instance.engine.backend → KVStoreBackend.kv.medium
|
||||||
|
* instance.engine.backend → OPFSBackend (直接替换 this.backend)
|
||||||
|
* · mode='hybrid': instance.engine.diskEngine.kv.medium
|
||||||
|
*
|
||||||
|
* 替换后重新 open KVStore 并 reload HybridEngine 内存缓存。
|
||||||
|
*/
|
||||||
|
async function injectIndexedDBBackend(instance: SqlarkLike & { engine?: unknown }, dbName: string): Promise<void> {
|
||||||
|
// 测试环境用 mock backend(setSqlarkBackend),没有 engine 属性——跳过
|
||||||
|
const engine = (instance as Reflective).engine as Reflective | undefined
|
||||||
|
if (!engine) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试 mode='hybrid' 路径:engine.diskEngine.kv.medium
|
||||||
|
const diskEngine = engine.diskEngine as Reflective | undefined
|
||||||
|
if (diskEngine?.kv) {
|
||||||
|
const kv = diskEngine.kv as Reflective
|
||||||
|
const oldMedium = kv.medium as Reflective | undefined
|
||||||
|
const oldName = oldMedium?.constructor?.name ?? ''
|
||||||
|
|
||||||
|
// 无论 OPFS 还是 SharedMemory,都替换为 IndexedDB
|
||||||
|
// OPFS 在 Electron app:// 下可能"假可用"(不抛错但不持久化)
|
||||||
|
if (oldName !== 'IndexedDBBackend') {
|
||||||
|
const idbBackend = new IndexedDBBackend()
|
||||||
|
// 先打开新的 IndexedDB backend(KVStore.reload 会再次调 open,幂等)
|
||||||
|
await idbBackend.open(dbName)
|
||||||
|
// 直接替换 medium(不先关闭旧的——kv.reload 内部会先排空 opQueue 再 open 新 medium)
|
||||||
|
kv.medium = idbBackend
|
||||||
|
// 重新加载 KVStore 从新 medium 读取数据
|
||||||
|
if (typeof kv.reload === 'function') {
|
||||||
|
await kv.reload()
|
||||||
|
}
|
||||||
|
// 让 HybridEngine 从磁盘重新加载到内存
|
||||||
|
if (typeof engine.reloadMemoryFromDisk === 'function') {
|
||||||
|
await engine.reloadMemoryFromDisk()
|
||||||
|
}
|
||||||
|
_backendType = 'kv'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试 mode='aria' 路径:engine.backend
|
||||||
|
const ariaBackend = engine.backend as Reflective | undefined
|
||||||
|
if (ariaBackend) {
|
||||||
|
const backendName = ariaBackend.constructor?.name ?? ''
|
||||||
|
// 如果是 KVStoreBackend,走 kv.medium 路径
|
||||||
|
if (backendName === 'KVStoreBackend' && ariaBackend.kv) {
|
||||||
|
const kv = ariaBackend.kv as Reflective
|
||||||
|
const oldMedium = kv.medium as Reflective | undefined
|
||||||
|
const oldName = oldMedium?.constructor?.name ?? ''
|
||||||
|
if (oldName !== 'IndexedDBBackend') {
|
||||||
|
const idbBackend = new IndexedDBBackend()
|
||||||
|
await idbBackend.open(dbName)
|
||||||
|
try { await oldMedium?.close?.() } catch { /* ignore */ }
|
||||||
|
kv.medium = idbBackend
|
||||||
|
if (typeof kv.reload === 'function') {
|
||||||
|
await kv.reload()
|
||||||
|
}
|
||||||
|
_backendType = 'kv'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 如果是 OPFSBackend,直接替换 engine.backend
|
||||||
|
// 但 AriaEngine 内部有大量组件引用 backend(FileManager 等),直接替换不安全
|
||||||
|
// → 跳过,让 OPFS 自己工作(如果 OPFS 确实持久化就没问题)
|
||||||
|
// 如果 OPFS 不持久化,应该走 buildDbConfig 的 kv 降级路径
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 反射失败(可能是 mock/test 环境)——静默跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SqlarkDriver——根治 open 失败后永久锁死问题
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class SqlarkDriver implements SaveDbDriver {
|
||||||
private db: SqlarkLike | null = null
|
private db: SqlarkLike | null = null
|
||||||
|
private opening: Promise<void> | null = null
|
||||||
|
private lastError: string | null = null
|
||||||
|
|
||||||
constructor(private factory: AnyFactory) {}
|
constructor(private factory: AnyFactory) {}
|
||||||
|
|
||||||
async open(name: string): Promise<void> {
|
async open(name: string): Promise<void> {
|
||||||
if (this.db) return
|
if (this.db) return
|
||||||
this.db = (await this.factory.create({
|
// 0.1.38 修复:如果上次 opening 失败(rejected),清除它允许重试
|
||||||
name,
|
if (this.opening) {
|
||||||
mode: 'aria',
|
try {
|
||||||
diskEngine: 'opfs',
|
return await this.opening
|
||||||
aria: { walSyncMode: 'full', compression: true }
|
} catch {
|
||||||
})) as SqlarkLike
|
// 上次失败,清除后走下方重试逻辑
|
||||||
|
this.opening = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.opening = (async () => {
|
||||||
|
try {
|
||||||
|
const config = await buildDbConfig(name, true)
|
||||||
|
const instance = (await this.factory.create(config)) as SqlarkLike & {
|
||||||
|
engine?: unknown
|
||||||
|
}
|
||||||
|
// 0.1.38 最终方案:注入 IndexedDB 持久化后端
|
||||||
|
// 无论 OPFS 是否可用,都替换为 IndexedDBBackend 确保数据跨进程持久化
|
||||||
|
await injectIndexedDBBackend(instance, name)
|
||||||
|
this.db = instance
|
||||||
|
this.lastError = null
|
||||||
|
} catch (e) {
|
||||||
|
this.lastError = String(e)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
// 无论成功失败都清除 opening,允许后续重试
|
||||||
|
this.opening = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return this.opening
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
this.db = null
|
this.db = null
|
||||||
|
this.opening = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取上次错误信息(供诊断) */
|
||||||
|
getLastError(): string | null {
|
||||||
|
return this.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断数据库是否已成功打开 */
|
||||||
|
isOpen(): boolean {
|
||||||
|
return this.db !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(sql: string, params: unknown[] = []): Promise<unknown> {
|
async run(sql: string, params: unknown[] = []): Promise<unknown> {
|
||||||
@@ -59,6 +252,11 @@ class SqlarkDriver implements SaveDbDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function makeDriver(): Promise<SaveDbDriver> {
|
||||||
|
const factory = await getSqlarkFactory()
|
||||||
|
return new SqlarkDriver(factory)
|
||||||
|
}
|
||||||
|
|
||||||
const META_DB = 'cotyc-appmeta'
|
const META_DB = 'cotyc-appmeta'
|
||||||
|
|
||||||
let singletonManager: SlotManager | null = null
|
let singletonManager: SlotManager | null = null
|
||||||
@@ -77,6 +275,12 @@ export async function getSqlarkFactory(): Promise<AnyFactory> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slotCache = new Map<number, SaveSlot>()
|
const slotCache = new Map<number, SaveSlot>()
|
||||||
|
|
||||||
|
/** 0.1.38:重置 slot 缓存(测试用——避免旧 driver 残留) */
|
||||||
|
export function _resetSlotCache(): void {
|
||||||
|
slotCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSaveSlot(slot: number): Promise<SaveSlot> {
|
export async function getSaveSlot(slot: number): Promise<SaveSlot> {
|
||||||
let s = slotCache.get(slot)
|
let s = slotCache.get(slot)
|
||||||
if (!s) {
|
if (!s) {
|
||||||
@@ -87,18 +291,48 @@ export async function getSaveSlot(slot: number): Promise<SaveSlot> {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SlotManager——0.1.38 同样走降级策略 + 错误恢复
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class SlotManager {
|
export class SlotManager {
|
||||||
private metaDb: SqlarkLike | null = null
|
private metaDb: SqlarkLike | null = null
|
||||||
|
private metaOpening: Promise<void> | null = null
|
||||||
|
private metaError: string | null = null
|
||||||
|
|
||||||
async ensureMeta(): Promise<void> {
|
async ensureMeta(): Promise<void> {
|
||||||
if (this.metaDb) return
|
if (this.metaDb) return
|
||||||
this.metaDb = (await (await getSqlarkFactory()).create({
|
if (this.metaOpening) {
|
||||||
name: META_DB,
|
try {
|
||||||
mode: 'aria',
|
return await this.metaOpening
|
||||||
diskEngine: 'opfs',
|
} catch {
|
||||||
aria: { walSyncMode: 'full' }
|
this.metaOpening = null
|
||||||
})) as SqlarkLike
|
}
|
||||||
|
}
|
||||||
|
this.metaOpening = (async () => {
|
||||||
|
try {
|
||||||
|
const config = await buildDbConfig(META_DB, false)
|
||||||
|
const instance = (await (await getSqlarkFactory()).create(config)) as SqlarkLike & {
|
||||||
|
engine?: unknown
|
||||||
|
}
|
||||||
|
// 注入 IndexedDB 持久化后端
|
||||||
|
await injectIndexedDBBackend(instance, META_DB)
|
||||||
|
this.metaDb = instance
|
||||||
await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`)
|
await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`)
|
||||||
|
this.metaError = null
|
||||||
|
} catch (e) {
|
||||||
|
this.metaError = String(e)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
this.metaOpening = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return this.metaOpening
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 meta DB 的最后错误(供诊断) */
|
||||||
|
getMetaError(): string | null {
|
||||||
|
return this.metaError
|
||||||
}
|
}
|
||||||
|
|
||||||
async listSlotMetas(): Promise<(SaveMeta | null)[]> {
|
async listSlotMetas(): Promise<(SaveMeta | null)[]> {
|
||||||
@@ -136,5 +370,59 @@ export class SlotManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 存储健康诊断——供 UI 诊断面板调用
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface StorageHealthReport {
|
||||||
|
backendType: StorageBackendType
|
||||||
|
opfsAvailable: boolean
|
||||||
|
persistGranted: boolean | null
|
||||||
|
driverOpen: boolean
|
||||||
|
driverError: string | null
|
||||||
|
metaError: string | null
|
||||||
|
slotCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全面诊断存储状态——供 SettingsPanel 健康面板调用 */
|
||||||
|
export async function diagnoseStorage(): Promise<StorageHealthReport> {
|
||||||
|
const opfsAvailable = await probeOpfs()
|
||||||
|
const persistGranted = typeof navigator !== 'undefined' && navigator.storage
|
||||||
|
? (await navigator.storage.persisted?.().catch(() => null) ?? null)
|
||||||
|
: null
|
||||||
|
|
||||||
|
let driverOpen = false
|
||||||
|
let driverError: string | null = null
|
||||||
|
|
||||||
|
// 尝试打开 slot 1 做探测
|
||||||
|
try {
|
||||||
|
const slot = await getSaveSlot(1)
|
||||||
|
await slot.open()
|
||||||
|
driverOpen = true
|
||||||
|
} catch (e) {
|
||||||
|
driverError = String(e).slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = getSlotManager()
|
||||||
|
const metaError = manager.getMetaError()
|
||||||
|
|
||||||
|
let slotCount = 0
|
||||||
|
try {
|
||||||
|
const metas = await manager.listSlotMetas()
|
||||||
|
slotCount = metas.filter((m) => m !== null).length
|
||||||
|
} catch {
|
||||||
|
// meta DB 不可用
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
backendType: _backendType,
|
||||||
|
opfsAvailable,
|
||||||
|
persistGranted,
|
||||||
|
driverOpen,
|
||||||
|
driverError,
|
||||||
|
metaError,
|
||||||
|
slotCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type { GameState, SaveMeta, SnapshotMeta }
|
export type { GameState, SaveMeta, SnapshotMeta }
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// IndexedDB Storage Backend — 自建 IStorageBackend 实现
|
||||||
|
//
|
||||||
|
// 0.1.38 根治方案:
|
||||||
|
// MetonaSqlark 的 KVStore 在 OPFS 不可用时 fallback 到 SharedMemoryBackend(纯内存),
|
||||||
|
// 进程关闭后数据全部丢失。此模块用原生 IndexedDB API 实现 IStorageBackend 接口,
|
||||||
|
// 确保数据真正持久化到浏览器/Electron 的 IndexedDB 中。
|
||||||
|
//
|
||||||
|
// 设计要点:
|
||||||
|
// · 每个 key 对应一条 IndexedDB 记录(value 为 ArrayBuffer)
|
||||||
|
// · open 时创建 object store(如不存在)
|
||||||
|
// · 所有写操作通过单条 IDB 事务保证原子性
|
||||||
|
// · writeMany 用单事务多 put 保证原子批量写入
|
||||||
|
// · append 用读-改-写(单事务内 read+write 保证原子性)
|
||||||
|
// · close 不删数据(持久化语义——重开同名库可读到上次数据)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** IStorageBackend 最小接口(与 metona-sqlark 的 IStorageBackend 对齐) */
|
||||||
|
export interface IStorageBackendLike {
|
||||||
|
open(name: string): Promise<void>
|
||||||
|
close(): Promise<void>
|
||||||
|
isOpen(): boolean
|
||||||
|
read(key: string): Promise<ArrayBuffer | null>
|
||||||
|
write(key: string, data: ArrayBuffer): Promise<void>
|
||||||
|
append?(key: string, data: ArrayBuffer): Promise<void>
|
||||||
|
writeMany(entries: Record<string, ArrayBuffer>): Promise<void>
|
||||||
|
delete(key: string): Promise<void>
|
||||||
|
deleteMany(keys: string[]): Promise<void>
|
||||||
|
listKeys(): Promise<string[]>
|
||||||
|
exists(key: string): Promise<boolean>
|
||||||
|
clear(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE_NAME = 'kv'
|
||||||
|
const DB_PREFIX = 'cotyc-idb-'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 IndexedDB 的持久化存储后端。
|
||||||
|
*
|
||||||
|
* 用法:传入 KVStoreBackend 或 KVStore 构造函数作为 medium 参数,
|
||||||
|
* 替代 OPFSBackend / SharedMemoryBackend。
|
||||||
|
*/
|
||||||
|
export class IndexedDBBackend implements IStorageBackendLike {
|
||||||
|
private dbName = ''
|
||||||
|
private db: IDBDatabase | null = null
|
||||||
|
private writeQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
|
async open(name: string): Promise<void> {
|
||||||
|
// 幂等:如果已打开同名库,直接返回(KVStore.reload 会二次调 open)
|
||||||
|
if (this.db && this.dbName === name) return
|
||||||
|
// 如果打开了不同名的库,先关闭旧的
|
||||||
|
if (this.db) {
|
||||||
|
this.db.close()
|
||||||
|
this.db = null
|
||||||
|
}
|
||||||
|
this.dbName = name
|
||||||
|
const fullDbName = DB_PREFIX + name
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(fullDbName, 1)
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.onsuccess = () => {
|
||||||
|
this.db = req.result
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
req.onerror = () => {
|
||||||
|
reject(new Error(`IndexedDB open failed: ${req.error?.message ?? 'unknown'}`))
|
||||||
|
}
|
||||||
|
req.onblocked = () => {
|
||||||
|
reject(new Error('IndexedDB open blocked (another tab holding connection)'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
// 等待写队列排空
|
||||||
|
try { await this.writeQueue } catch { /* ignore */ }
|
||||||
|
if (this.db) {
|
||||||
|
this.db.close()
|
||||||
|
this.db = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen(): boolean {
|
||||||
|
return this.db !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(key: string): Promise<ArrayBuffer | null> {
|
||||||
|
if (!this.db) return null
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db!.transaction(STORE_NAME, 'readonly')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const req = store.get(key)
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const result = req.result
|
||||||
|
if (result === undefined) {
|
||||||
|
resolve(null)
|
||||||
|
} else if (result instanceof ArrayBuffer) {
|
||||||
|
resolve(result)
|
||||||
|
} else if (result instanceof Uint8Array) {
|
||||||
|
// 某些浏览器可能返回 Uint8Array
|
||||||
|
resolve(result.buffer as ArrayBuffer)
|
||||||
|
} else {
|
||||||
|
// 兜底:尝试作为 ArrayBuffer 处理
|
||||||
|
resolve(result as ArrayBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.onerror = () => reject(req.error ?? new Error(`IDB read failed: ${key}`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(key: string, data: ArrayBuffer): Promise<void> {
|
||||||
|
const run = this.writeQueue.then(() => this._writeRaw(key, data))
|
||||||
|
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
private _writeRaw(key: string, data: ArrayBuffer): Promise<void> {
|
||||||
|
if (!this.db) return Promise.resolve()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
store.put(data, key)
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error ?? new Error(`IDB write failed: ${key}`))
|
||||||
|
tx.onabort = () => reject(tx.error ?? new Error(`IDB write aborted: ${key}`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async append(key: string, data: ArrayBuffer): Promise<void> {
|
||||||
|
if (!this.db) return
|
||||||
|
const run = this.writeQueue.then(() => this._appendRaw(key, data))
|
||||||
|
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
private _appendRaw(key: string, data: ArrayBuffer): Promise<void> {
|
||||||
|
if (!this.db) return Promise.resolve()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const getReq = store.get(key)
|
||||||
|
getReq.onsuccess = () => {
|
||||||
|
const existing = getReq.result
|
||||||
|
let combined: ArrayBuffer
|
||||||
|
if (existing instanceof ArrayBuffer) {
|
||||||
|
const merged = new Uint8Array(existing.byteLength + data.byteLength)
|
||||||
|
merged.set(new Uint8Array(existing), 0)
|
||||||
|
merged.set(new Uint8Array(data), existing.byteLength)
|
||||||
|
combined = merged.buffer as ArrayBuffer
|
||||||
|
} else if (existing instanceof Uint8Array) {
|
||||||
|
const merged = new Uint8Array(existing.byteLength + data.byteLength)
|
||||||
|
merged.set(existing, 0)
|
||||||
|
merged.set(new Uint8Array(data), existing.byteLength)
|
||||||
|
combined = merged.buffer as ArrayBuffer
|
||||||
|
} else if (existing !== undefined) {
|
||||||
|
combined = existing as ArrayBuffer
|
||||||
|
} else {
|
||||||
|
combined = data
|
||||||
|
}
|
||||||
|
store.put(combined, key)
|
||||||
|
}
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error ?? new Error(`IDB append failed: ${key}`))
|
||||||
|
tx.onabort = () => reject(tx.error ?? new Error(`IDB append aborted: ${key}`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
|
||||||
|
if (!this.db || Object.keys(entries).length === 0) return
|
||||||
|
const run = this.writeQueue.then(() => this._writeManyRaw(entries))
|
||||||
|
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
private _writeManyRaw(entries: Record<string, ArrayBuffer>): Promise<void> {
|
||||||
|
if (!this.db) return Promise.resolve()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
for (const [key, data] of Object.entries(entries)) {
|
||||||
|
store.put(data, key)
|
||||||
|
}
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error ?? new Error('IDB writeMany failed'))
|
||||||
|
tx.onabort = () => reject(tx.error ?? new Error('IDB writeMany aborted'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
if (!this.db) return
|
||||||
|
const run = this.writeQueue.then(() => this._deleteRaw(key))
|
||||||
|
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
private _deleteRaw(key: string): Promise<void> {
|
||||||
|
if (!this.db) return Promise.resolve()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
store.delete(key)
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error ?? new Error(`IDB delete failed: ${key}`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMany(keys: string[]): Promise<void> {
|
||||||
|
if (!this.db || keys.length === 0) return
|
||||||
|
const run = this.writeQueue.then(() => this._deleteManyRaw(keys))
|
||||||
|
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
private _deleteManyRaw(keys: string[]): Promise<void> {
|
||||||
|
if (!this.db) return Promise.resolve()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
for (const key of keys) {
|
||||||
|
store.delete(key)
|
||||||
|
}
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error ?? new Error('IDB deleteMany failed'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async listKeys(): Promise<string[]> {
|
||||||
|
if (!this.db) return []
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db!.transaction(STORE_NAME, 'readonly')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const req = store.getAllKeys()
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const keys = req.result as IDBValidKey[]
|
||||||
|
resolve(keys.map((k) => String(k)))
|
||||||
|
}
|
||||||
|
req.onerror = () => reject(req.error ?? new Error('IDB listKeys failed'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
if (!this.db) return false
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db!.transaction(STORE_NAME, 'readonly')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const req = store.count(key)
|
||||||
|
req.onsuccess = () => resolve(req.result > 0)
|
||||||
|
req.onerror = () => reject(req.error ?? new Error(`IDB exists failed: ${key}`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
if (!this.db) return
|
||||||
|
const run = this.writeQueue.then(() => this._clearRaw())
|
||||||
|
this.writeQueue = run.then(() => undefined, () => undefined)
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clearRaw(): Promise<void> {
|
||||||
|
if (!this.db) return Promise.resolve()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
store.clear()
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error ?? new Error('IDB clear failed'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +1,32 @@
|
|||||||
import { GameState } from '../types/domain'
|
import { GameState } from '../types/domain'
|
||||||
import { normalizeGameState } from '../engine/world'
|
|
||||||
|
|
||||||
export const CURRENT_SCHEMA = 2
|
|
||||||
export const APP_ID = 'cotyc'
|
export const APP_ID = 'cotyc'
|
||||||
|
|
||||||
export interface MigrationError {
|
|
||||||
code: 'TOO_NEW' | 'UNKNOWN_VERSION'
|
|
||||||
version: number
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 迁移链:obj.from 升到 obj.to 逐级执行(任何一步失败即中断抛出,原状态不被写入)。
|
* 0.1.35 荡涤:迁移链(CURRENT_SCHEMA/MIGRATIONS/migrateIfNeeded)整体铲除。
|
||||||
* v1(≤0.1.8)→ v2(0.1.9):补 stats/finance/annals 字段并打上 schema 版本戳。
|
* 未发布游戏不做版本迁移——读档只需结构粗检 + World 构造器 normalize 兜底/防护。
|
||||||
*/
|
*/
|
||||||
const MIGRATIONS: Array<{ from: number; to: number; fn: (s: GameState) => GameState }> = [
|
export function validateLoadedState(state: unknown): { ok: true; state: GameState } | { ok: false; message: string } {
|
||||||
{
|
const s = state as GameState | null
|
||||||
from: 1,
|
if (!s || typeof s !== 'object') return { ok: false, message: '存档内容为空或损坏。' }
|
||||||
to: 2,
|
if (!s.family || !s.members || typeof s.seed !== 'string') {
|
||||||
fn: (s) => {
|
return { ok: false, message: '存档结构缺失(family/members/seed)——本作未发布变更频繁,旧档不可读。' }
|
||||||
normalizeGameState(s)
|
|
||||||
s.schemaVersion = 2
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
}
|
return { ok: true, state: 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 {
|
export function exportEnvelope(state: GameState): string {
|
||||||
return JSON.stringify({ app: APP_ID, schemaVersion: state.schemaVersion ?? CURRENT_SCHEMA, payload: state })
|
return JSON.stringify({ app: APP_ID, payload: state })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseImportEnvelope(text: string): { ok: true; state: GameState } | { ok: false; error: string } {
|
export function parseImportEnvelope(text: string): { ok: true; state: GameState } | { ok: false; error: string } {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(text) as { app?: string; schemaVersion?: number; payload?: GameState; state?: GameState }
|
const raw = JSON.parse(text) as { app?: string; payload?: GameState; state?: GameState }
|
||||||
if (raw.app !== undefined && raw.app !== APP_ID) return { ok: false, error: '非本作存档(app 标识不符)。' }
|
if (raw.app !== undefined && raw.app !== APP_ID) return { ok: false, error: '非本作存档(app 标识不符)。' }
|
||||||
const state = raw.payload ?? raw.state
|
const state = raw.payload ?? raw.state
|
||||||
if (!state || typeof state !== 'object') return { ok: false, error: '存档内容为空或损坏。' }
|
const checked = validateLoadedState(state)
|
||||||
const migrated = migrateIfNeeded(state as GameState)
|
if (checked.ok) return { ok: true, state: checked.state }
|
||||||
if (migrated.ok) return { ok: true, state: migrated.state }
|
return { ok: false, error: checked.message }
|
||||||
return { ok: false, error: migrated.error.message }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: `解析失败:${String(e)}` }
|
return { ok: false, error: `解析失败:${String(e)}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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'
|
import { validateLoadedState, exportEnvelope, parseImportEnvelope } from './migrate'
|
||||||
|
|
||||||
|
const GAME_VERSION = '0.1.41'
|
||||||
|
|
||||||
const DB_PREFIX = 'cotyc-save-'
|
const DB_PREFIX = 'cotyc-save-'
|
||||||
let seqCounter = 0
|
let seqCounter = 0
|
||||||
@@ -36,17 +38,32 @@ export class SaveSlot {
|
|||||||
const driver = this.driver
|
const driver = this.driver
|
||||||
await driver.exec(`CREATE TABLE IF NOT EXISTS meta (key string PRIMARY KEY, value string)`)
|
await driver.exec(`CREATE TABLE IF NOT EXISTS meta (key string PRIMARY KEY, value string)`)
|
||||||
await driver.exec(`CREATE TABLE IF NOT EXISTS snapshot (id string PRIMARY KEY, year number, month number, savedAt string, label string, data string)`)
|
await driver.exec(`CREATE TABLE IF NOT EXISTS snapshot (id string PRIMARY KEY, year number, month number, savedAt string, label string, data string)`)
|
||||||
await driver.exec(`CREATE TABLE IF NOT EXISTS chronicle (id string PRIMARY KEY, year number, month number, category string, important number, memberId string, text string)`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)}-${(seqCounter++ % 1296).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 (?, ?, ?, ?, ?, ?)`,
|
||||||
[id, state.year, state.month, new Date().toISOString(), label, json]
|
[id, state.year, state.month, new Date().toISOString(), label, json]
|
||||||
)
|
)
|
||||||
|
// 0.1.38 P1-F:回读校验——确认数据真正落盘
|
||||||
|
const verify = await this.driver.all<{ data: string }>(
|
||||||
|
`SELECT data FROM snapshot WHERE id = ?`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
if (!verify.length || !verify[0]!.data) {
|
||||||
|
throw new Error(`存档写入校验失败:数据未落盘(slot=${this.slot}, id=${id})`)
|
||||||
|
}
|
||||||
|
// 校验 JSON 可解析且关键字段在
|
||||||
|
try {
|
||||||
|
const check = JSON.parse(verify[0]!.data) as GameState
|
||||||
|
if (!check.family || !check.members || typeof check.seed !== 'string') {
|
||||||
|
throw new Error('存档回读校验失败:关键字段缺失')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`存档回读校验失败:${String(e).slice(0, 80)}`)
|
||||||
|
}
|
||||||
const keep = 12
|
const keep = 12
|
||||||
const rows = await this.driver.all<{ id: string }>(
|
const rows = await this.driver.all<{ id: string }>(
|
||||||
`SELECT id FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC`
|
`SELECT id FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC`
|
||||||
@@ -60,22 +77,6 @@ export class SaveSlot {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveChronicle(chronicle: GameState['chronicle']): Promise<void> {
|
|
||||||
const driver = this.driver
|
|
||||||
for (const e of chronicle) {
|
|
||||||
try {
|
|
||||||
const exists = await driver.all<{ id: string }>(`SELECT id FROM chronicle WHERE id = ?`, [e.id])
|
|
||||||
if (exists.length > 0) continue
|
|
||||||
await driver.run(
|
|
||||||
`INSERT INTO chronicle (id, year, month, category, important, memberId, text) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[e.id, e.year, e.month, e.category, e.important ? 1 : 0, e.memberId ?? null, e.text]
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// chronicle table redundancy is best-effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async listSnapshots(): Promise<SnapshotMeta[]> {
|
async listSnapshots(): Promise<SnapshotMeta[]> {
|
||||||
const rows = await this.driver.all<SnapshotMeta & { data?: string }>(`SELECT id, year, month, savedAt, label FROM snapshot ORDER BY year DESC, month DESC`)
|
const rows = await this.driver.all<SnapshotMeta & { data?: string }>(`SELECT id, year, month, savedAt, label FROM snapshot ORDER BY year DESC, month DESC`)
|
||||||
return rows.map((r) => ({ id: r.id, year: r.year, month: r.month, savedAt: r.savedAt, label: r.label }))
|
return rows.map((r) => ({ id: r.id, year: r.year, month: r.month, savedAt: r.savedAt, label: r.label }))
|
||||||
@@ -89,7 +90,10 @@ export class SaveSlot {
|
|||||||
id ? [id] : []
|
id ? [id] : []
|
||||||
)
|
)
|
||||||
if (!rows || rows.length === 0) return null
|
if (!rows || rows.length === 0) return null
|
||||||
return JSON.parse(rows[0]!.data) as GameState
|
const state = JSON.parse(rows[0]!.data) as GameState
|
||||||
|
const r = validateLoadedState(state)
|
||||||
|
if (!r.ok) throw new Error(r.message)
|
||||||
|
return r.state
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSnapshot(id: string): Promise<void> {
|
async deleteSnapshot(id: string): Promise<void> {
|
||||||
@@ -116,8 +120,8 @@ export class SaveSlot {
|
|||||||
const state = await this.loadState()
|
const state = await this.loadState()
|
||||||
if (!state) throw new Error('无可导出存档')
|
if (!state) throw new Error('无可导出存档')
|
||||||
const meta = await this.getMeta()
|
const meta = await this.getMeta()
|
||||||
const envelope = JSON.parse(exportEnvelope(state)) as { schemaVersion: number; payload: GameState }
|
const envelope = JSON.parse(exportEnvelope(state)) as { payload: GameState }
|
||||||
return JSON.stringify({ app: APP_ID, schemaVersion: envelope.schemaVersion, meta, state: envelope.payload })
|
return JSON.stringify({ app: 'cotyc', meta, payload: envelope.payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
async importAll(data: string): Promise<boolean> {
|
async importAll(data: string): Promise<boolean> {
|
||||||
@@ -125,7 +129,6 @@ export class SaveSlot {
|
|||||||
const parsed = parseImportEnvelope(data)
|
const parsed = parseImportEnvelope(data)
|
||||||
if (!parsed.ok) return false
|
if (!parsed.ok) return false
|
||||||
const state = parsed.state
|
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(state, 'import')
|
await this.saveState(state, 'import')
|
||||||
@@ -156,7 +159,7 @@ export function buildSimpleMeta(state: GameState, slot: number, memberCount: num
|
|||||||
members: memberCount,
|
members: memberCount,
|
||||||
reputation: state.family.reputation,
|
reputation: state.family.reputation,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
version: state.schemaVersion
|
version: GAME_VERSION
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export interface Character {
|
|||||||
fortune: number
|
fortune: number
|
||||||
traits: string[]
|
traits: string[]
|
||||||
techniqueId?: string
|
techniqueId?: string
|
||||||
techniqueRank?: number
|
techniqueRank: number // 0.1.35 必填(pcgen 必写——归一化兼容批已删)
|
||||||
techniqueProgress?: number
|
techniqueProgress: number
|
||||||
post?: string
|
post?: string
|
||||||
equipment?: string
|
equipment?: string
|
||||||
state: CharState
|
state: CharState
|
||||||
@@ -57,6 +57,8 @@ export interface Character {
|
|||||||
aspiration?: string
|
aspiration?: string
|
||||||
tribDelayYear?: number
|
tribDelayYear?: number
|
||||||
tribPendingMonths?: number
|
tribPendingMonths?: number
|
||||||
|
/** 渡劫药力加成(破境丹等),渡劫结算后清零 */
|
||||||
|
tribBoost: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FamilyState {
|
export interface FamilyState {
|
||||||
@@ -76,6 +78,8 @@ export interface FamilyState {
|
|||||||
headId: Id
|
headId: Id
|
||||||
difficulty: 'easy' | 'normal' | 'hard'
|
difficulty: 'easy' | 'normal' | 'hard'
|
||||||
flag: Record<string, number | boolean | string>
|
flag: Record<string, number | boolean | string>
|
||||||
|
/** 0.1.37 功德因果值:善行+恶行-,年首衰减10%向中性回归;影响灾年概率/NPC姿态/渡劫成功率 */
|
||||||
|
karma: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NpcFamilyState {
|
export interface NpcFamilyState {
|
||||||
@@ -88,6 +92,8 @@ export interface NpcFamilyState {
|
|||||||
alliedSinceYear?: number
|
alliedSinceYear?: number
|
||||||
warCooldownYear?: number
|
warCooldownYear?: number
|
||||||
raidCount: number
|
raidCount: number
|
||||||
|
/** 衰微连续年数(power<45 起计;达 8 年覆灭/附庸) */
|
||||||
|
declineYears: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MissionState {
|
export interface MissionState {
|
||||||
@@ -160,7 +166,6 @@ export interface YearlyReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
schemaVersion: number
|
|
||||||
seed: string
|
seed: string
|
||||||
rng: RngState
|
rng: RngState
|
||||||
year: number
|
year: number
|
||||||
@@ -181,7 +186,58 @@ export interface GameState {
|
|||||||
finance: { accum: number }
|
finance: { accum: number }
|
||||||
yearStats: { births: number; deaths: number }
|
yearStats: { births: number; deaths: number }
|
||||||
yearlyReports: YearlyReport[]
|
yearlyReports: YearlyReport[]
|
||||||
|
/** 已装插件(id/version/enabled)——存档持久化,加载时按注册表重装 */
|
||||||
|
plugins?: Array<{ id: string; version: string; enabled: boolean }>
|
||||||
|
/** 读档时缺失的插件(工厂未注册/版本不符)——UI 提示:MOD 文件可能需重新放入 mods 目录 */
|
||||||
|
pluginsMissing?: string[]
|
||||||
|
/** 天下轮廓(种子生成器产物:关系网/开局 era/市场偏移/世仇盟约) */
|
||||||
|
worldGen: {
|
||||||
|
relations: Record<string, Record<string, number>>
|
||||||
|
eras: 'shengshi' | 'pingshi' | 'luanshi'
|
||||||
|
marketOffset: Record<string, number>
|
||||||
|
regionFlavor: Record<string, number>
|
||||||
|
alliances: Array<[string, string]>
|
||||||
|
feuds: Array<[string, string]>
|
||||||
|
npcCount?: number
|
||||||
|
/** 世界实例 NPC 定义存档(纯数据)——读档后幂等重注册(P0:动态家族永不为 undefined) */
|
||||||
|
npcs?: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
region: string
|
||||||
|
style: string
|
||||||
|
desc: string
|
||||||
|
leaderRealm: string
|
||||||
|
initialPower: number
|
||||||
|
powerGrowth: [number, number]
|
||||||
|
sells?: string[]
|
||||||
|
buys?: string[]
|
||||||
|
}>
|
||||||
|
/** 开档时生效的 MOD 集(id@version)——读档校验一致性 */
|
||||||
|
worldGenMods?: string[]
|
||||||
|
}
|
||||||
stats: FamilyStats
|
stats: FamilyStats
|
||||||
|
worldSim?: {
|
||||||
|
marketPool?: Record<string, number>
|
||||||
|
npcDyn?: Record<string, { leaderName: string; leaderRealmIdx: number; leaderAge: number; lastEvent: string; lastEventYear: number; relationsWithOthers?: Record<string, number>; prosperity: number; stance?: string; stanceSinceYear?: number }>
|
||||||
|
secretQi?: Record<string, number>
|
||||||
|
tide?: number
|
||||||
|
tideTicks?: number
|
||||||
|
calamity?: string
|
||||||
|
calamityYear?: number
|
||||||
|
calamityLeft?: number
|
||||||
|
era?: string
|
||||||
|
eraStartYear?: number
|
||||||
|
overfluxYear?: number
|
||||||
|
warFatigue?: number
|
||||||
|
shortage?: Record<string, number>
|
||||||
|
shortageWarned?: Record<string, number>
|
||||||
|
wonders?: Record<string, unknown>
|
||||||
|
distress?: { id: string; year: number; month: number }
|
||||||
|
worldAnnals?: { year: number; text: string }[]
|
||||||
|
newsFeed?: { year: number; month: number; src: string; text: string; itemId?: string; kind?: string }[]
|
||||||
|
lastNewsMonth?: number
|
||||||
|
npcSuccessions?: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogItem {
|
export interface LogItem {
|
||||||
@@ -203,7 +259,7 @@ export interface SaveMeta {
|
|||||||
members: number
|
members: number
|
||||||
reputation: number
|
reputation: number
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
version: number
|
version: string // 0.1.35 记录游戏版本字符串(非 schema 戳)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnapshotMeta {
|
export interface SnapshotMeta {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from './App'
|
import App, { AppErrorBoundary } from './App'
|
||||||
import './ui/styles.css'
|
import './ui/styles.css'
|
||||||
|
|
||||||
const root = document.getElementById('root')
|
const root = document.getElementById('root')
|
||||||
if (root) {
|
if (root) {
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<AppErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
</AppErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useGameStore } from '../store'
|
import { useGameStore } from '../store'
|
||||||
|
import { ModalShell } from './ModalShell'
|
||||||
|
|
||||||
export function BattleModal() {
|
export function BattleModal() {
|
||||||
const battleView = useGameStore((s) => s.battleView)
|
const battleView = useGameStore((s) => s.battleView)
|
||||||
@@ -7,13 +8,15 @@ export function BattleModal() {
|
|||||||
const winnerLabel =
|
const winnerLabel =
|
||||||
battleView.winner === 'player' ? '✦ 我方得胜 ✦' : battleView.winner === 'enemy' ? '✕ 我方败北 ✕' : '◌ 平分秋色 ◌'
|
battleView.winner === 'player' ? '✦ 我方得胜 ✦' : battleView.winner === 'enemy' ? '✕ 我方败北 ✕' : '◌ 平分秋色 ◌'
|
||||||
|
|
||||||
|
const close = () => useGameStore.setState({ battleView: undefined })
|
||||||
return (
|
return (
|
||||||
<div className="modal-outer">
|
<ModalShell
|
||||||
<div className="modal battle-modal">
|
title={battleView.title}
|
||||||
<div className="modal-title" style={{ fontSize: '1.3rem' }}>{battleView.title}</div>
|
category={`${battleView.year}年${battleView.month}月 · ${winnerLabel}`}
|
||||||
<div className="modal-cat">
|
width={720}
|
||||||
{battleView.year}年{battleView.month}月 · <span className={battleView.winner === 'player' ? 'good' : battleView.winner === 'enemy' ? 'bad' : 'warn'}>{winnerLabel}</span>
|
onClose={close}
|
||||||
</div>
|
footer={<button className="btn btn-primary" onClick={close}>合上战报</button>}
|
||||||
|
>
|
||||||
<div className="battle-lines">
|
<div className="battle-lines">
|
||||||
{battleView.lines.map((l, i) => (
|
{battleView.lines.map((l, i) => (
|
||||||
<div key={i}>{l}</div>
|
<div key={i}>{l}</div>
|
||||||
@@ -24,11 +27,7 @@ export function BattleModal() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
</ModalShell>
|
||||||
<button className="btn btn-primary" onClick={() => useGameStore.setState({ battleView: undefined })}>合上战报</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface DriftItem { id: number; text: string; cls: string; at: number }
|
||||||
|
|
||||||
|
/** 资源漂字层:每次 fx:drift 事件追加独立节点(互不覆盖),超 8 条自动修剪尾部 */
|
||||||
|
export function DriftLayer() {
|
||||||
|
const [items, setItems] = useState<DriftItem[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
const onDrift = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail as Record<string, number>
|
||||||
|
const now = Date.now()
|
||||||
|
const next: DriftItem[] = []
|
||||||
|
for (const [k, v] of Object.entries(detail)) {
|
||||||
|
if (v === 0 || !Number.isFinite(v)) continue
|
||||||
|
next.push({ id: now + Math.random() + k.length, text: `${v > 0 ? '+' : ''}${v}`, cls: v > 0 ? 'pos' : 'neg', at: now })
|
||||||
|
}
|
||||||
|
if (next.length === 0) return
|
||||||
|
setItems((old) => (next.length + old.length > 8 ? [] : old).concat(next))
|
||||||
|
}
|
||||||
|
window.addEventListener('fx:drift', onDrift)
|
||||||
|
return () => window.removeEventListener('fx:drift', onDrift)
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<div className="drift-layer">
|
||||||
|
{items.map((it) => (
|
||||||
|
<span key={it.id} className={`drift ${it.cls}`} onAnimationEnd={() => setItems((old) => old.filter((x) => x.id !== it.id))}>{it.text}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,55 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useGameStore } from '../store'
|
import { useGameStore } from '../store'
|
||||||
import { applyEventChoice, rankPower } from '../../game/engine/systems/events'
|
import { applyEventChoice, rankPower } from '../../game/engine/runtime/Systems/events'
|
||||||
import { eventCategoryName } from '../../game/data/events'
|
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/runtime/Systems/combat'
|
||||||
import { FORMATIONS, FormationId } from '../../game/data/formations'
|
import { FORMATIONS, FormationId } from '../../game/data/formations'
|
||||||
|
import { buildingById } from '../../game/data/buildings'
|
||||||
|
import { ModalShell } from './ModalShell'
|
||||||
|
import { isMandateEvent, mandateEventCategoryName } from '../../game/engine/runtime/Systems/mandate'
|
||||||
|
|
||||||
|
const RES_NAME: Record<string, string> = {
|
||||||
|
lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核', stones: '灵石',
|
||||||
|
lingyu: '灵玉', lingmu: '灵木', shoupi: '兽皮', lingguo: '灵果', linglu: '灵露', dansha: '丹砂', fuzhi: '符纸',
|
||||||
|
'talisman-feng': '风符', 'talisman-shan': '山符', 'brew-niang': '灵酿'
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeEff(eff: Record<string, never> | { [k: string]: unknown }): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
const e = eff as { res?: Record<string, number>; rep?: number; relation?: Record<string, number>; addBuilding?: string; pillGain?: Record<string, number>; mission?: string; addTech?: string; raid?: { npcId: string }; trib?: { mode: string }; feisheng?: { stay: boolean }; ascension?: { stage: string; mode: string }; techniqueChance?: number; artifactChance?: number; flag?: Record<string, unknown> }
|
||||||
|
if (e.res) {
|
||||||
|
for (const [k, v] of Object.entries(e.res)) {
|
||||||
|
const name = RES_NAME[k] ?? k
|
||||||
|
parts.push(v > 0 ? `${name}+${v}` : `${name}${v}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.rep) parts.push(`声望${e.rep > 0 ? '+' : ''}${e.rep}`)
|
||||||
|
if (e.relation) {
|
||||||
|
for (const v of Object.values(e.relation)) parts.push(v > 0 ? `交好+${v}` : `交恶${v}`)
|
||||||
|
}
|
||||||
|
if (e.addBuilding) parts.push('增筑「' + (buildingById(e.addBuilding)?.name ?? e.addBuilding) + '」')
|
||||||
|
if (e.pillGain) {
|
||||||
|
for (const v of Object.values(e.pillGain)) parts.push(`得丹+${v}`)
|
||||||
|
}
|
||||||
|
if (e.addTech) parts.push('得功法')
|
||||||
|
if (e.mission) parts.push('触发任务')
|
||||||
|
if (e.techniqueChance) parts.push(`功法机率+${Math.round(e.techniqueChance * 100)}%`)
|
||||||
|
if (e.artifactChance) parts.push(`宝器机率+${Math.round(e.artifactChance * 100)}%`)
|
||||||
|
if (e.raid) parts.push('来敌犯境')
|
||||||
|
if (e.trib) {
|
||||||
|
parts.push(e.trib.mode === 'rash' ? '硬渡天劫' : e.trib.mode === 'guard' ? '护法渡劫' : '渡劫延一年')
|
||||||
|
}
|
||||||
|
if (e.feisheng) parts.push(e.feisheng.stay ? '留世不飞升' : '飞升离世')
|
||||||
|
// 0.1.39:飞升试炼效果描述
|
||||||
|
if (e.ascension) {
|
||||||
|
const stageName = e.ascension.stage === 'heart' ? '心魔试炼' : e.ascension.stage === 'heaven' ? '天劫试炼' : '飞升抉择'
|
||||||
|
const modeName = e.ascension.mode === 'rash' ? '硬抗' : e.ascension.mode === 'guard' ? '护持' : '延后'
|
||||||
|
parts.push(`${stageName}·${modeName}`)
|
||||||
|
}
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
export function EventModal() {
|
export function EventModal() {
|
||||||
const pendingEventId = useGameStore((s) => s.pendingEventId)
|
const pendingEventId = useGameStore((s) => s.pendingEventId)
|
||||||
@@ -18,6 +62,11 @@ export function EventModal() {
|
|||||||
const [formation, setFormation] = useState<FormationId>('vanguard')
|
const [formation, setFormation] = useState<FormationId>('vanguard')
|
||||||
if (!pendingEventId || !pendingEventDef || !world) return null
|
if (!pendingEventId || !pendingEventDef || !world) return null
|
||||||
|
|
||||||
|
// 0.1.39:飞升事件视觉强化——金色辉光标题 + 仙韵边框 + 金边选项
|
||||||
|
const isAscension = pendingEventId.startsWith('ev-ascension-')
|
||||||
|
// 0.1.40:天命事件视觉强化——紫金辉光标题 + 天命边框 + 不可回避
|
||||||
|
const isMandate = isMandateEvent(pendingEventId)
|
||||||
|
|
||||||
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
|
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
|
||||||
const squadPool: Character[] =
|
const squadPool: Character[] =
|
||||||
raidIdx >= 0
|
raidIdx >= 0
|
||||||
@@ -40,7 +89,7 @@ export function EventModal() {
|
|||||||
setSquadSel(squadPool.slice(0, 4).map((c) => c.id))
|
setSquadSel(squadPool.slice(0, 4).map((c) => c.id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applyEventChoice(world, pendingEventId, idx, isRaid ? squadSel : undefined)
|
applyEventChoice(world, pendingEventId, idx, isRaid ? squadSel : undefined, undefined, isRaid ? formation : undefined)
|
||||||
closeModal()
|
closeModal()
|
||||||
const sp = useGameStore.getState().speed
|
const sp = useGameStore.getState().speed
|
||||||
if (sp > 1) setSpeed(1)
|
if (sp > 1) setSpeed(1)
|
||||||
@@ -52,14 +101,46 @@ export function EventModal() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dorm = () => {
|
||||||
|
// 天命不可回避——不可"稍后再议"
|
||||||
|
if (isMandate) return
|
||||||
|
// 「稍后再议」:把事件按回队列,不消灭、不误弃
|
||||||
|
world.requeueEvent(pendingEventId)
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-outer">
|
<ModalShell
|
||||||
<div className="modal">
|
title={pendingEventDef.name}
|
||||||
<div className="modal-title">{pendingEventDef.name}</div>
|
category={`${eventCategoryName(pendingEventDef.category)}${pendingEventDef.once ? ' · 仅此一次' : ''}`}
|
||||||
<div className="modal-cat">
|
width={680}
|
||||||
{eventCategoryName(pendingEventDef.category)}
|
allowOuter={false}
|
||||||
{pendingEventDef.once && ' · 仅此一次'}
|
onClose={dorm}
|
||||||
</div>
|
closeLabel="稍后再议"
|
||||||
|
className={isAscension ? 'modal-ascension' : isMandate ? 'modal-mandate' : undefined}
|
||||||
|
footer={
|
||||||
|
squadPicking ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
setSquadPicking(false)
|
||||||
|
setSquadSel([])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
返回重选
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" disabled={squadSel.length === 0} onClick={() => choose(raidIdx)}>
|
||||||
|
{squadSel.length === 0 ? '族中无可战之人' : `出战!(共${squadSel.length}人)`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : isMandate ? (
|
||||||
|
<div className="dim2" style={{ fontSize: '0.82rem' }}>天命不可违——必须做出抉择</div>
|
||||||
|
) : (
|
||||||
|
<button className="btn" onClick={dorm}>稍后再议</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="modal-text">{pendingEventDef.text}</div>
|
<div className="modal-text">{pendingEventDef.text}</div>
|
||||||
|
|
||||||
{squadPicking && (
|
{squadPicking && (
|
||||||
@@ -95,21 +176,7 @@ export function EventModal() {
|
|||||||
|
|
||||||
<div className="modal-opts">
|
<div className="modal-opts">
|
||||||
{squadPicking ? (
|
{squadPicking ? (
|
||||||
<>
|
<div className="dim2">依选迎战(可继续点选调整阵容)</div>
|
||||||
<button className="opt-btn" onClick={() => choose(raidIdx)}>
|
|
||||||
出战!(点将已毕,共{squadSel.length}人)
|
|
||||||
<span className="hint">依所选人手迎击来犯之敌</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
onClick={() => {
|
|
||||||
setSquadPicking(false)
|
|
||||||
setSquadSel([])
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
返回重选
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
pendingEventDef.options.map((o, i) => {
|
pendingEventDef.options.map((o, i) => {
|
||||||
const isRaid = raidIdx === i
|
const isRaid = raidIdx === i
|
||||||
@@ -118,12 +185,12 @@ export function EventModal() {
|
|||||||
{o.label}
|
{o.label}
|
||||||
{isRaid && <span className="hint">点将迎战 · 可自选阵容</span>}
|
{isRaid && <span className="hint">点将迎战 · 可自选阵容</span>}
|
||||||
{!isRaid && o.hint && <span className="hint">{o.hint}</span>}
|
{!isRaid && o.hint && <span className="hint">{o.hint}</span>}
|
||||||
|
<span className="opt-eff">{describeEff(o.eff as never)}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalShell>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useGameStore } from '../store'
|
import { useGameStore } from '../store'
|
||||||
import { GUIDE_STEPS, computeGuide, guideAdvance } from '../../game/core/guide'
|
import { GUIDE_STEPS, computeGuide, guideAdvance } from '../../game/engine/kernel/guide'
|
||||||
|
|
||||||
export function GuideStrip() {
|
export function GuideStrip() {
|
||||||
const world = useGameStore((s) => s.world)
|
const world = useGameStore((s) => s.world)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { useGameStore } from '../store'
|
import { useGameStore } from '../store'
|
||||||
|
|
||||||
export function LogFeed() {
|
export const LogFeed = memo(function LogFeed() {
|
||||||
|
// 精订阅:仅 logFeed 引用变更时渲染(每 tick revision 不再牵连)
|
||||||
const feed = useGameStore((s) => s.logFeed)
|
const feed = useGameStore((s) => s.logFeed)
|
||||||
const ref = useGameStore((s) => s.revision)
|
const reversed = feed.slice(-120).reverse()
|
||||||
void ref
|
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed">
|
||||||
{feed.slice().reverse().map((item) => (
|
{reversed.map((item) => (
|
||||||
<div key={item.id} className={`feed-item k-${item.kind}`}>
|
<div key={item.id} className={`feed-item k-${item.kind}`}>
|
||||||
<span className="feed-date">{item.year}年{item.month}月</span>
|
<span className="feed-date">{item.year}年{item.month}月</span>
|
||||||
{item.text}
|
{item.text}
|
||||||
@@ -14,4 +15,4 @@ export function LogFeed() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useGameStore } from '../store'
|
import { memo } from 'react'
|
||||||
import { Character } from '../../game/types/domain'
|
import { Character } from '../../game/types/domain'
|
||||||
import { describeRealm } from '../../game/data/realms'
|
import { describeRealm } from '../../game/data/realms'
|
||||||
import { describeRoots } from '../../game/data/elements'
|
import { describeRoots } from '../../game/data/elements'
|
||||||
@@ -13,18 +13,34 @@ const STATE_LABEL: Record<string, { label: string; cls: string }> = {
|
|||||||
dead: { label: '已殁', cls: 'bad' }
|
dead: { label: '已殁', cls: 'bad' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MemberCard({ c }: { c: Character }) {
|
const C_STATE: Record<string, string> = {
|
||||||
const world = useGameStore((s) => s.world)
|
idle: '',
|
||||||
const setSelected = useGameStore((s) => s.setSelected)
|
meditation: 'good',
|
||||||
if (!world) return null
|
expedition: 'warn',
|
||||||
|
wounded: 'bad',
|
||||||
|
dead: 'bad'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0.1.39 渲染提效:MemberCard 改为 memo 纯展示组件——
|
||||||
|
* 不再从 store 获取 world(之前每次 revision bump 全部卡片重渲染)。
|
||||||
|
* 所需外部数据(surname/isHead/age)经 props 传入,只在 c 或依赖 props 变化时重渲染。
|
||||||
|
*/
|
||||||
|
export interface MemberCardProps {
|
||||||
|
c: Character
|
||||||
|
surname: string
|
||||||
|
isHead: boolean
|
||||||
|
age: number
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemberCard = memo(function MemberCard({ c, surname, isHead, age, onSelect }: MemberCardProps) {
|
||||||
const dead = !c.alive
|
const dead = !c.alive
|
||||||
const isHead = world.state.family.headId === c.id
|
|
||||||
const state = STATE_LABEL[dead ? 'dead' : c.state]
|
|
||||||
const blocked = !dead && c.realmProgress >= 100 && c.realm.major !== 'spirit'
|
const blocked = !dead && c.realmProgress >= 100 && c.realm.major !== 'spirit'
|
||||||
return (
|
return (
|
||||||
<div className={`member-card ${dead ? 'dead' : ''} ${blocked ? 'blocked' : ''}`} onClick={() => setSelected(c.id)}>
|
<div className={`member-card ${dead ? 'dead' : ''} ${blocked ? 'blocked' : ''}`} onClick={() => onSelect(c.id)}>
|
||||||
<div className="mc-head">
|
<div className="mc-head">
|
||||||
<div className="m-avatar">{world.state.family.surname}</div>
|
<div className="m-avatar">{surname}</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div className="member-name">
|
<div className="member-name">
|
||||||
<span>{c.name}</span>
|
<span>{c.name}</span>
|
||||||
@@ -34,7 +50,7 @@ export function MemberCard({ c }: { c: Character }) {
|
|||||||
<div className="member-realm">
|
<div className="member-realm">
|
||||||
{dead
|
{dead
|
||||||
? `殁于${c.deathYear}年 · ${c.deathCause ?? '寿终'}`
|
? `殁于${c.deathYear}年 · ${c.deathCause ?? '寿终'}`
|
||||||
: `${describeRealm(c.realm)} · ${world.ageOf(c)}岁 · 修为${Math.floor(c.realmProgress)}%`}
|
: `${describeRealm(c.realm)} · ${age}岁 · 修为${Math.floor(c.realmProgress)}%`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,24 +63,12 @@ export function MemberCard({ c }: { c: Character }) {
|
|||||||
</div>
|
</div>
|
||||||
{!dead && (
|
{!dead && (
|
||||||
<div className="mc-foot">
|
<div className="mc-foot">
|
||||||
<div className="bar" title="灵气修为" style={{ height: 6 }}>
|
<div className={`bar ${c.state === 'meditation' ? 'shimmer' : ''}`} title="灵气修为" style={{ height: 6 }}>
|
||||||
<div style={{ width: `${c.realmProgress}%` }} />
|
<div style={{ width: `${c.realmProgress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
{c.state === 'meditation' && <div className="aura-dots"><i></i><i></i><i></i></div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
function postLabel(c: Character): string {
|
|
||||||
const map: Record<string, string> = { head: '家主', elder: '长老', guardian: '供奉', steward: '执事', master: '掌教' }
|
|
||||||
return map[c.post ?? ''] ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const C_STATE: Record<string, string> = {
|
|
||||||
idle: '',
|
|
||||||
meditation: 'good',
|
|
||||||
expedition: 'warn',
|
|
||||||
wounded: 'bad',
|
|
||||||
dead: 'bad'
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { describeRoots, ROOT_GRADE_NAMES } from '../../game/data/elements'
|
|||||||
import { TRAITS } from '../../game/data/traits'
|
import { TRAITS } from '../../game/data/traits'
|
||||||
import { TECHNIQUES } from '../../game/data/techniques'
|
import { TECHNIQUES } from '../../game/data/techniques'
|
||||||
import { ITEMS, ARTIFACT_POWER } from '../../game/data/items'
|
import { ITEMS, ARTIFACT_POWER } from '../../game/data/items'
|
||||||
import { combatPowerOf } from '../../game/engine/systems/combat'
|
import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
|
||||||
import { lifespanOf } from '../../game/engine/systems/lifecycle'
|
import { lifespanOf } from '../../game/engine/runtime/Systems/lifecycle'
|
||||||
|
import { perAttemptChance } from '../../game/engine/runtime/Systems/cultivation'
|
||||||
import { POSTS, POST_ORDER } from '../../game/data/posts'
|
import { POSTS, POST_ORDER } from '../../game/data/posts'
|
||||||
import { buildBiography } from '../../game/core/biography'
|
import { ModalShell } from './ModalShell'
|
||||||
|
import { buildBiography } from '../../game/engine/narrative/biography'
|
||||||
|
|
||||||
function aspName(c: Character): string {
|
function aspName(c: Character): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@@ -55,15 +57,33 @@ export function MemberModal({ member }: { member: Character }) {
|
|||||||
const power = member.alive ? combatPowerOf(w, member) : 0
|
const power = member.alive ? combatPowerOf(w, member) : 0
|
||||||
const lifespan = lifespanOf(w, member)
|
const lifespan = lifespanOf(w, member)
|
||||||
|
|
||||||
|
const close = () => setSelected(undefined)
|
||||||
|
const confirmBreak = () => {
|
||||||
|
const chance = Math.round(perAttemptChance(w, member) * 100)
|
||||||
|
if (window.confirm(`${member.name} 冲击【${nextName}】,成功率约 ${chance}%。或以族运为注?`)) {
|
||||||
|
w.assistedBreakthrough(member.id)
|
||||||
|
bump()
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="modal-outer" onClick={() => setSelected(undefined)}>
|
<ModalShell
|
||||||
<div className="modal member-modal" onClick={(e) => e.stopPropagation()}>
|
title={member.name}
|
||||||
<div className="modal-title" style={{ fontSize: '1.4rem' }}>
|
category={member.alive ? '第' + member.generation + '代 · ' + (member.gender === 'male' ? '男' : '女') + ' · ' + w.ageOf(member) + '岁' : '第' + member.generation + '代 · 已故'}
|
||||||
{member.name}
|
width={740}
|
||||||
{!member.alive && <span className="bad" style={{ fontSize: '1rem' }}> · 殁</span>}
|
onClose={close}
|
||||||
</div>
|
footer={
|
||||||
<div className="modal-cat">
|
<>
|
||||||
第{member.generation}代 · {member.gender === 'male' ? '男' : '女'} · {w.ageOf(member)}岁 · 寿数约{lifespan} · {describeRoots(member.roots)}
|
{member.children.length > 0 && (
|
||||||
|
<span className="dim2">
|
||||||
|
子女:{member.children.map((cid) => s.members[cid]?.name ?? '?').join('、')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="btn" onClick={close}>阖上</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mm-brief">
|
||||||
|
<span className={member.alive ? '' : 'bad'}>寿数约 {lifespan} · {describeRoots(member.roots)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mm-grid">
|
<div className="mm-grid">
|
||||||
@@ -83,7 +103,7 @@ export function MemberModal({ member }: { member: Character }) {
|
|||||||
</b></div>
|
</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}(${gradeShort(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>
|
||||||
|
|
||||||
@@ -129,10 +149,7 @@ export function MemberModal({ member }: { member: Character }) {
|
|||||||
{member.alive && member.realmProgress >= 100 && member.realm.major !== 'spirit' && (
|
{member.alive && member.realmProgress >= 100 && member.realm.major !== 'spirit' && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-primary"
|
className="btn btn-sm btn-primary"
|
||||||
onClick={() => {
|
onClick={confirmBreak}
|
||||||
w.assistedBreakthrough(member.id)
|
|
||||||
bump()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
冲击{nextName}
|
冲击{nextName}
|
||||||
</button>
|
</button>
|
||||||
@@ -231,6 +248,20 @@ export function MemberModal({ member }: { member: Character }) {
|
|||||||
|
|
||||||
{member.alive && (
|
{member.alive && (
|
||||||
<div className="mm-sec">
|
<div className="mm-sec">
|
||||||
|
<div className="dim" style={{ marginBottom: 6 }}>家主权柄</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
disabled={member.id === w.state.family.headId}
|
||||||
|
title={member.id === w.state.family.headId ? '已在主位' : '册立为家主(名分即天命)'}
|
||||||
|
onClick={() => {
|
||||||
|
w.assignHead(member.id)
|
||||||
|
bump()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
册立家主
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="dim" style={{ marginBottom: 6 }}>职事(族中分工)</div>
|
<div className="dim" style={{ marginBottom: 6 }}>职事(族中分工)</div>
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
@@ -249,7 +280,7 @@ export function MemberModal({ member }: { member: Character }) {
|
|||||||
<button
|
<button
|
||||||
key={pid}
|
key={pid}
|
||||||
className={`btn btn-sm ${member.post === pid ? 'btn-primary' : ''}`}
|
className={`btn btn-sm ${member.post === pid ? 'btn-primary' : ''}`}
|
||||||
disabled={maxed && !!member.post}
|
disabled={maxed}
|
||||||
style={maxed && !member.post ? { opacity: 0.45 } : undefined}
|
style={maxed && !member.post ? { opacity: 0.45 } : undefined}
|
||||||
title={`${def.desc}${w.postCount(pid)}/${def.max} 在任`}
|
title={`${def.desc}${w.postCount(pid)}/${def.max} 在任`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -308,15 +339,6 @@ export function MemberModal({ member }: { member: Character }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
</ModalShell>
|
||||||
{member.children.length > 0 && (
|
|
||||||
<span className="dim2">
|
|
||||||
子女:{member.children.map((cid) => s.members[cid]?.name ?? '?').join('、')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button className="btn" onClick={() => setSelected(undefined)}>阖上</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
export interface ModalShellProps {
|
||||||
|
title: string
|
||||||
|
category?: string
|
||||||
|
width?: number
|
||||||
|
allowOuter?: boolean
|
||||||
|
onClose: () => void
|
||||||
|
footer?: ReactNode
|
||||||
|
footerAlign?: 'right' | 'center' | 'space'
|
||||||
|
children: ReactNode
|
||||||
|
closeLabel?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一弹框壳(0.1.12):
|
||||||
|
* - 右上独立关闭钮(与内容完全分离)
|
||||||
|
* - ESC 键关闭
|
||||||
|
* - 底部独立一排(footer)与内容区明显区分(分隔线 + 底色差异 + 粘性)
|
||||||
|
* - 宽度 min(92vw, Npx),零横向滚动条
|
||||||
|
*/
|
||||||
|
export function ModalShell({
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
width = 680,
|
||||||
|
allowOuter = true,
|
||||||
|
onClose,
|
||||||
|
footer,
|
||||||
|
footerAlign = 'right',
|
||||||
|
children,
|
||||||
|
closeLabel = '关闭',
|
||||||
|
className
|
||||||
|
}: ModalShellProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
// Portal 到 document.body:任何祖先(.main-left z1 / .app)的层叠上下文都无法压住弹框,
|
||||||
|
// 顶部菜单/主域均在其下(0.1.22 修复:弹框出现在宗族/谱系菜单下方的 z-index 越界)。
|
||||||
|
const shell = (
|
||||||
|
<div className="modal-outer" onClick={allowOuter ? onClose : undefined}>
|
||||||
|
<div className={`modal ${className ?? ''}`} style={{ width: `min(92vw, ${width}px)` }} onClick={(e) => e.stopPropagation()} data-modal-kind="shell">
|
||||||
|
<button className="modal-close" onClick={onClose} title={closeLabel} aria-label={closeLabel}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div className="modal-title">{title}</div>
|
||||||
|
{category && <div className="modal-cat">{category}</div>}
|
||||||
|
<div className="modal-body">{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<div className={`modal-footer ${footerAlign === 'center' ? 'center' : footerAlign === 'space' ? 'space' : ''}`}>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
if (typeof document === 'undefined') return shell
|
||||||
|
return createPortal(shell, document.body)
|
||||||
|
}
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
import { useGameStore } from '../store'
|
import { useGameStore } from '../store'
|
||||||
import { YearlyReport } from '../../game/types/domain'
|
import { YearlyReport } from '../../game/types/domain'
|
||||||
|
import { ModalShell } from './ModalShell'
|
||||||
|
|
||||||
export function PaperModal() {
|
export function PaperModal() {
|
||||||
const report = useGameStore((s) => s.paperReport)
|
const report = useGameStore((s) => s.paperReport)
|
||||||
|
const world = useGameStore((s) => s.world)
|
||||||
const closePaper = useGameStore((s) => s.closePaper)
|
const closePaper = useGameStore((s) => s.closePaper)
|
||||||
if (!report) return null
|
if (!report) return null
|
||||||
|
|
||||||
const nets = report.nets
|
const nets = report.nets
|
||||||
const y = report.year
|
const y = report.year
|
||||||
|
const yearEntries = (world?.state.chronicle ?? [])
|
||||||
|
.filter((e) => e.year === y)
|
||||||
|
.sort((a, b) => a.month - b.month)
|
||||||
|
const majors = yearEntries.filter((e) => e.important).slice(0, 5)
|
||||||
|
const obits = yearEntries.filter((e) => e.category === 'death').slice(0, 5)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-outer" onClick={closePaper}>
|
<ModalShell
|
||||||
<div className="modal yearpaper-modal" onClick={(e) => e.stopPropagation()}>
|
title="岁 末 族 簿"
|
||||||
<div className="modal-title" style={{ fontSize: '1.3rem' }}>岁 末 族 簿</div>
|
category={`${y}年 全 族 收 支 与 人 丁 情 形`}
|
||||||
<div className="modal-cat">{y}年 全 族 收 支 与 人 丁 情 形</div>
|
width={620}
|
||||||
|
onClose={closePaper}
|
||||||
|
footer={<button className="btn btn-primary" onClick={closePaper}>翻 过 此 页</button>}
|
||||||
|
>
|
||||||
<div className="modal-text" style={{ textAlign: 'center', fontSize: '0.98rem', lineHeight: 2.1 }}>
|
<div className="modal-text" style={{ textAlign: 'center', fontSize: '0.98rem', lineHeight: 2.1 }}>
|
||||||
<div>灵石盈亏 <b className={nets >= 0 ? 'good' : 'bad'}>{nets >= 0 ? `+${nets}` : nets}</b> 枚</div>
|
<div>灵石盈亏 <b className={nets >= 0 ? 'good' : 'bad'}>{nets >= 0 ? `+${nets}` : nets}</b> 枚</div>
|
||||||
<div>人丁 <b className="good">新增 {report.births}</b> · <b className="bad">辞世 {report.deaths}</b></div>
|
<div>人丁 <b className="good">新增 {report.births}</b> · <b className="bad">辞世 {report.deaths}</b></div>
|
||||||
@@ -21,12 +31,22 @@ export function PaperModal() {
|
|||||||
{report.deaths > report.births && <div className="bad">丁口在减 —— 传宗接代是头等大事</div>}
|
{report.deaths > report.births && <div className="bad">丁口在减 —— 传宗接代是头等大事</div>}
|
||||||
{report.births > report.deaths && <div className="good">丁口渐旺,香火有望。</div>}
|
{report.births > report.deaths && <div className="good">丁口渐旺,香火有望。</div>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
{majors.length > 0 && (
|
||||||
<button className="btn btn-primary" onClick={closePaper}>
|
<div style={{ marginTop: 12, borderTop: '1px solid rgba(140,100,50,0.35)', paddingTop: 10 }}>
|
||||||
翻 过 此 页
|
<div className="dim" style={{ marginBottom: 4 }}>家 族 要 闻</div>
|
||||||
</button>
|
{majors.map((e) => (
|
||||||
</div>
|
<div key={e.id} className="dim" style={{ fontSize: '0.9rem', lineHeight: 1.9 }}>· {e.month}月 {e.text}</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{obits.length > 0 && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div className="dim" style={{ marginBottom: 4 }}>此 年 辞 世 者</div>
|
||||||
|
{obits.map((e) => (
|
||||||
|
<div key={e.id} className="dim" style={{ fontSize: '0.9rem', lineHeight: 1.9 }}>· {e.month}月 {e.text}</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useGameStore } from '../store'
|
import { useGameStore } from '../store'
|
||||||
import { LegacyArch } from '../../game/core/legacy'
|
import { LegacyArch } from '../../game/engine/narrative/legacy'
|
||||||
import { sBell } from '../sound'
|
import { sBell } from '../sound'
|
||||||
|
import { ModalShell } from './ModalShell'
|
||||||
|
|
||||||
function dimLabel(k: string): string {
|
function dimLabel(k: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@@ -13,12 +14,34 @@ function dimLabel(k: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: () => void }) {
|
export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: () => void }) {
|
||||||
|
const close = onClose
|
||||||
return (
|
return (
|
||||||
<div className="modal-outer">
|
<ModalShell
|
||||||
<div className="modal resolve-modal">
|
title={`「${arch.title}」`}
|
||||||
<div className="modal-cat" style={{ color: '#97835c', letterSpacing: 4 }}>
|
category={'—— 百年望气 · 定鼎之文 ——'}
|
||||||
—— 百年望气 · 定鼎之文 ——
|
width={700}
|
||||||
</div>
|
onClose={close}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={close}>暂不落印</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
const w = useGameStore.getState().world
|
||||||
|
if (w) {
|
||||||
|
sBell()
|
||||||
|
w.resolveLegacyNow()
|
||||||
|
useGameStore.getState().bump()
|
||||||
|
useGameStore.getState().refreshSlots()
|
||||||
|
}
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
落 印 定 局
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="resolve-title">「{arch.title}」</div>
|
<div className="resolve-title">「{arch.title}」</div>
|
||||||
<div className="resolve-dims">
|
<div className="resolve-dims">
|
||||||
{Object.entries(arch.dims)
|
{Object.entries(arch.dims)
|
||||||
@@ -46,26 +69,7 @@ export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: ()
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="resolve-stamp">仙途家族志 · 青册</div>
|
<div className="resolve-stamp">仙途家族志 · 青册</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 10, marginTop: 14 }}>
|
</ModalShell>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useGameStore } from '../store'
|
import { useGameStore } from '../store'
|
||||||
import { computeUrgency } from '../../game/core/urgency'
|
import { computeUrgency } from '../../game/engine/kernel/urgency'
|
||||||
|
|
||||||
export function UrgentBadges() {
|
export function UrgentBadges() {
|
||||||
const world = useGameStore((s) => s.world)
|
const world = useGameStore((s) => s.world)
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/** Canvas 粒子渲染层(单 RAF 实例,GPU 友好) */
|
||||||
|
import { seasonOf } from '../game/data/season'
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
life: number
|
||||||
|
maxLife: number
|
||||||
|
size: number
|
||||||
|
rot: number
|
||||||
|
vr: number
|
||||||
|
kind: 'gold' | 'mist' | 'light'
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement | null = null
|
||||||
|
let ctx: CanvasRenderingContext2D | null = null
|
||||||
|
let raf = 0
|
||||||
|
let running = false
|
||||||
|
let particles: Particle[] = []
|
||||||
|
let density = 40
|
||||||
|
let mistTimer = 0
|
||||||
|
|
||||||
|
function ensureCanvas(): HTMLCanvasElement | null {
|
||||||
|
if (canvas && canvas.isConnected) return canvas
|
||||||
|
const parent = document.body
|
||||||
|
if (!parent) return null
|
||||||
|
canvas = document.createElement('canvas')
|
||||||
|
canvas.id = 'fx-layer'
|
||||||
|
Object.assign(canvas.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: '0',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: '45',
|
||||||
|
opacity: '0.9'
|
||||||
|
})
|
||||||
|
parent.appendChild(canvas)
|
||||||
|
ctx = canvas.getContext('2d')
|
||||||
|
resize()
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(): void {
|
||||||
|
if (!canvas) return
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1)
|
||||||
|
canvas.width = window.innerWidth * dpr
|
||||||
|
canvas.height = window.innerHeight * dpr
|
||||||
|
ctx?.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function seasonColor(): string {
|
||||||
|
const attr = typeof document !== 'undefined' ? document.documentElement.getAttribute('data-season') : null
|
||||||
|
const gameSeason = (['spring', 'summer', 'autumn', 'winter'] as const).includes(attr as never) ? attr : seasonOf(new Date().getMonth() + 1)
|
||||||
|
const s = gameSeason as 'spring' | 'summer' | 'autumn' | 'winter'
|
||||||
|
return ({ spring: '154,180,130', summer: '224,189,84', autumn: '224,148,74', winter: '138,167,189' } as Record<string, string>)[s] ?? '216,182,120'
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawn(kind: 'gold' | 'mist' | 'light', x?: number, y?: number): void {
|
||||||
|
if (!canvas) return
|
||||||
|
const W = window.innerWidth
|
||||||
|
const H = window.innerHeight
|
||||||
|
const c = seasonColor()
|
||||||
|
const p: Particle = {
|
||||||
|
x: x ?? Math.random() * W,
|
||||||
|
y: y ?? Math.random() * H,
|
||||||
|
vx: (Math.random() - 0.5) * 0.5,
|
||||||
|
vy: -0.25 - Math.random() * 0.6,
|
||||||
|
life: 0,
|
||||||
|
maxLife: kind === 'gold' ? 120 : kind === 'light' ? 50 : 200,
|
||||||
|
size: kind === 'gold' ? 2 + Math.random() * 2 : kind === 'mist' ? 10 + Math.random() * 24 : 3,
|
||||||
|
rot: Math.random() * Math.PI * 2,
|
||||||
|
vr: (Math.random() - 0.5) * 0.06,
|
||||||
|
kind,
|
||||||
|
color: kind === 'gold' ? `rgba(236, 199, 118,` : kind === 'light' ? `rgba(250, 233, 185,` : `rgba(${c},`
|
||||||
|
}
|
||||||
|
particles.push(p)
|
||||||
|
if (particles.length > density * 6) particles.splice(0, particles.length - density * 6) // 竞态防胀
|
||||||
|
}
|
||||||
|
|
||||||
|
import { ensureFx } from './fx'
|
||||||
|
|
||||||
|
function frame(): void {
|
||||||
|
if (!running) return
|
||||||
|
const g = ensureFx()
|
||||||
|
if (g.preferReduced) {
|
||||||
|
// reduced-motion:粒子层静默,但保留单次清屏
|
||||||
|
raf = window.requestAnimationFrame(frame as FrameRequestCallback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx?.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
||||||
|
const dt = 1
|
||||||
|
mistTimer += 1
|
||||||
|
if (mistTimer % 18 === 0 && particles.length < density && g.mode !== 'soft') spawn('mist')
|
||||||
|
for (let i = particles.length - 1; i >= 0; i--) {
|
||||||
|
const p = particles[i]
|
||||||
|
p.life += dt
|
||||||
|
p.x += p.vx
|
||||||
|
p.y += p.vy
|
||||||
|
p.rot += p.vr
|
||||||
|
if (p.life >= p.maxLife || p.x < -60 || p.x > window.innerWidth + 60 || p.y < -60) {
|
||||||
|
particles.splice(i, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!ctx) continue
|
||||||
|
const alpha = Math.max(0, (1 - p.life / p.maxLife)) * (p.kind === 'mist' ? 0.22 : 0.85)
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(p.x, p.y)
|
||||||
|
ctx.rotate(p.rot)
|
||||||
|
ctx.fillStyle = `${p.color}${alpha})`
|
||||||
|
if (p.kind === 'mist') {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.ellipse(0, 0, p.size, p.size * 0.5, 0, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
} else {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, -p.size)
|
||||||
|
ctx.lineTo(p.size * 0.6, 0)
|
||||||
|
ctx.lineTo(0, p.size)
|
||||||
|
ctx.lineTo(-p.size * 0.6, 0)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
raf = window.requestAnimationFrame(frame as FrameRequestCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startFxLayer(modeDensity: number): void {
|
||||||
|
const c = ensureCanvas()
|
||||||
|
if (!c || running) return // 单循环防重入
|
||||||
|
density = modeDensity
|
||||||
|
running = true
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
spawnBurst('spark', 'center')
|
||||||
|
raf = window.requestAnimationFrame(frame as FrameRequestCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopFxLayer(): void {
|
||||||
|
running = false
|
||||||
|
if (raf) cancelAnimationFrame(raf)
|
||||||
|
raf = 0
|
||||||
|
particles = []
|
||||||
|
mistTimer = 0
|
||||||
|
window.removeEventListener('resize', resize)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnBurst(kind: 'spark' | 'blade' | 'ripple', origin?: string): void {
|
||||||
|
if (!running) {
|
||||||
|
// 从空态唤醒(0.1.33:待机停帧后,事件到来即重启)
|
||||||
|
running = true
|
||||||
|
raf = window.requestAnimationFrame(frame as FrameRequestCallback)
|
||||||
|
}
|
||||||
|
if (kind === 'spark') {
|
||||||
|
const X = window.innerWidth / 2
|
||||||
|
const Y = window.innerHeight * (origin === 'center' ? 0.5 : 0.3)
|
||||||
|
for (let i = 0; i < 26; i++) spawn('gold', X + (Math.random() - 0.5) * 160, Y + (Math.random() - 0.5) * 60)
|
||||||
|
} else if (kind === 'blade') {
|
||||||
|
for (let i = 0; i < 14; i++) spawn('light', window.innerWidth * (0.2 + Math.random() * 0.6), window.innerHeight * 0.4)
|
||||||
|
} else if (kind === 'ripple') {
|
||||||
|
for (let i = 0; i < 10; i++) spawn('gold', window.innerWidth * 0.5, window.innerHeight * 0.06)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user