Compare commits

..
79 Commits
Author SHA1 Message Date
thzxx 631099fd8c 0.1.41 天命·兑现:天命flag全量接线15+项即时兑现 + NPC演化种子派生零主rng消耗 + pushNews/purgeNpc确定性修复 + 族史卷叙事层chronicle-scroll.ts + 时效flag自动清理 + UI建筑专精加成预览 + LegacyPanel族史卷视图 + 金钟罩重算 2026-09-13 16:38:24 +08:00
thzxx beeda036f6 0.1.40 文档同步: README金钟罩基线+版本沿革+0.1.40条目 / PROJECT_MEMORY技术债清理+测试数7058 2026-09-13 15:00:45 +08:00
thzxx 54face8275 0.1.40 FINAL: 版本号统一更新(7处) + 金钟罩基线重算(18指纹) + 4测试文件基线同步(版本号/指纹/顶峰衰概率容错) 2026-09-13 14:31:00 +08:00
thzxx 989bc3f444 0.1.40 P2-1~P2-3: 天下大事记叙事层 + WorldPanel展示区 + LegacyPanel引用 (saga.ts + buildSaga + 8类标签色系) 2026-09-13 14:17:26 +08:00
thzxx 2c7596cec7 0.1.40 P1-2: UI渲染效率memo化4处 (NpcRow/NewsList/groupedEntries/specialtyId) 2026-09-13 14:12:44 +08:00
thzxx dc970ff13b 0.1.40: 天命系统 + NPC演化增强 + 建筑专精 (P0-1~P1-1) 2026-09-13 14:05:28 +08:00
thzxx dcd2345192 0.1.39 文档全量同步:README/PROJECT_MEMORY/MOD_GUIDE/AGENTS 版本号+测试数+金钟罩基线+版本沿革 2026-09-13 13:17:17 +08:00
thzxx 07c0a218eb 0.1.39 P5: 文档同步(AGENTS.md 版本号+金钟罩基线注释+package.json 版本号) 2026-09-13 13:11:29 +08:00
thzxx 7ba64750bc 0.1.39 P2: 飞升叙事视觉强化(金色辉光标题+仙韵边框+金边选项+ascension效果描述补全) 2026-09-13 13:09:16 +08:00
thzxx 818c6025f4 0.1.39 P1: MemberCard memo 化 + FamilyPanel useMemo 排序缓存(脱离 store 订阅) 2026-09-13 13:04:10 +08:00
thzxx fdc9d750f2 0.1.39 P4: purgeNpc 清理 worldGen.npcs/relations 僵尸定义 + 金钟罩重算 + 4例测试 2026-09-13 13:01:00 +08:00
thzxx e2070c8fcb 0.1.39 P3: worldsim tick 入口双闸守卫 + 关停冻结测试(4 例全绿) 2026-09-13 12:55:59 +08:00
thzxx 62da87a4e4 0.1.39 P0: missionIds prune + chronicle smart trim 2026-09-13 12:48:53 +08:00
thzxx fb23afa322 fix: 存档根治——自建 IndexedDB 持久化后端注入 (0.1.38) 2026-09-13 08:33:49 +08:00
thzxx c0efc63434 review: 移除未使用导入(useEffect/StorageBackendType) 2026-09-13 08:07:27 +08:00
thzxx 5030eb04d2 0.1.38 存档根治·石碑重刻:OPFS降级+错误上浮+回读校验+持久化请求+诊断面板+driver.open锁死修复+exportSave补open 2026-09-13 08:02:04 +08:00
thzxx 8f1f8d48ce chore: 版本号升级到 0.1.37(package.json/AGENTS.md/MOD_GUIDE.md/ApiFacade/storeHelper/SettingsPanel/slots/测试断言) 2026-09-10 23:46:51 +08:00
thzxx 87cd71ad21 0.1.37 天人感应·因果织锦:功德系统+飞升试炼+世界奇观+MOD扩展口 2026-09-10 23:37:57 +08:00
thzxx 2984d05647 docs: 四文档同步 0.1.36——CSP 红线纪律
AGENTS:版本头 0.1.36(三问题处置/arith 根因)/测试 7011/新增 CSP 红线(严禁 Function()/eval——一律 arith)/
打包版≠开发版(CSP 分野)环境坑 + dev:web 浏览器通道说明(Tabbit 劫持端口用 WSL IP 绕过)
README:测试数 102/7011 + 版本沿革 0.1.36 行
MOD_GUIDE:版本头/尾注 0.1.36 + produceExpr 改用 arith 求值说明(表达式无脚本化能力)
MEMORY:时间线 0.1.36 + 测试数 + 新增 8e 决策(CSP 血案全纪录/军规/真实错误文字教训)
2026-08-24 22:38:37 +08:00
thzxx 41cffdfdb6 v0.1.36b: 真根因修复——CSP unsafe-eval 拦截 Function() 字符串求值(推进崩溃+定制白屏同根)
【根因】Electron 打包 renderer CSP 禁 unsafe-eval;buildings.bonusOf / exprToTable / modManager.booleanExprTable
三处用 Function()+正则白名单字符串求值——任何调用点抛 EvalError:
·推进崩溃=每月月率 bonusOf('juling','expBonus') 等(MarketPanel craft 表头同源)
·定制白屏=craft 页渲染行内 bonusOf('danfang','craftChance') → React 崩 → 只剩背景
【修复】全新 arith.ts 纯解析器(令牌化+递归下降:数字/L/四则/括号/幂/负号——白名单天然,字母函数名一律 NaN),
三处替换零 Function/eval;非法输入 NaN 不抛(调用方 Number.isFinite 跳过)
【测试】arith 4 例(四则/幂右结合/负号/除零/非法输入防注入)+全量 7011 绿
【版本】0.1.36(同版本内提交——不另立迭代)
2026-08-24 22:20:25 +08:00
thzxx 9571c58c07 version: 0.1.35→0.1.36——补齐版本流程(0.1.36a 修复已存在,版本未升是我的疏漏)
package.json/ApiFacade/SettingsPanel/storeHelper/slots(GAME_VERSION)/facade-registry/gameengine 断言 6 处同步;
AGENTS 版本头同步(三问题处置状态诚实口径):弹框双排名已修,推进/白屏根因待真实环境错文;
build 绿 7007 测试绿
2026-08-24 22:11:45 +08:00
thzxx 9323963dd4 feat: dev:web 纯浏览器开发通道(绕开 Electron 窗口/Tabbit localhost 劫持)
【背景】npm run dev 构建链全绿正常;但用户环境 Tabbit 浏览器后台 node 劫持 localhost 端口段
(5173/5179 全 502)——非我们 dev 故障
【方案】vite.web.config.ts:root=src/renderer+react 插件+port 5179+host 0.0.0.0;
package.json scripts.dev:web=vite --config vite.web.config.ts
【用法】WSL:npm run dev:web → Windows 浏览器:http://<WSL_IP>:5179(设备 localhost 不可用场景)
window.api(导出/导入/MOD 扫描)不存在时自动跳过——其余全功能等价(存档走浏览器 OPFS)
【验证】Windows 侧 curl http://192.168.1.225:5179/ = 200
2026-08-24 22:07:48 +08:00
thzxx e36937ade2 test: 删除无效 ui-render 测试(node 环境 zustand 订阅不触发——非真实崩溃信号,避免误报) 2026-08-24 21:59:31 +08:00
thzxx 73fa9eb7f2 test: 存储真链集成测试(MetonaSqlark memory 方言——非 mock)——save/load/export/slot 管理 6 种子全链
此前所有存量测试用 SaveDbDriver mock——真实 SQL 方言链从未被 vitest 覆盖(唯一 GUI 冒烟因环境不可用);
本测试直接注入 MeSqlark memory 后端:saveState→list→loadState→resume advance→exportAll→slot 管理面/remove;
为 0.1.36 三问题(推进崩溃/存档丢失)提供真实存储链防线;+1 套件
2026-08-24 21:59:00 +08:00
thzxx f5e49ef9bd v0.1.36a: 三问题修复——成员弹框双排名/界面白屏防线/推进异常取证+出险保障
【Bug2 双排名】MemberModal 顶部与 ModalShell 壳顶重复渲染 (title+category 各两排)
→ children 内嵌 title/cat 移除(壳已承载),改 mm-brief 一行(寿数/根骨)
【Bug3 定制白屏】App 无错误边界 → 全树崩溃只剩背景
→ AppErrorBoundary 一级防线(显示错误信息+重新加载按钮,不再白屏)
【Bug1 推进崩溃】advance 异常 toast 原文只写“已保存”——实为 Stash 静默吞错+双保险缺失
→ ①toast 显示根错前 120 字(玩家可见——此前只有 console) ②出险保存双保险:
Stash 失败时 localStorage 应急副本+明示“存储也失败”提示手动存档 ③speed timer 内 advance().catch 隔离
【验证】tsc 0 错/build 绿/7000 测试绿
2026-08-24 21:53:04 +08:00
thzxx 293d04fc20 docs: MOD_GUIDE 全新重写(953 行)——从零完整教程,所有字段取自真实接口
【结构】目录+零 B 君校验表+五分钟上手(含路径/文件名/最小包)+十五口全景表(卸载行为)+十五口示意壳
【字段全以真实 TS 接口为据】EventDef/Cond 18 键/EffectDef 17 键/MemberEffect 10by×8target 两表;
NpcFamilyDef 全字段+境界-寿元表;worldgen 四池;灾因 family/effect 双通道;丹方/铸器含红牌+内置量级参照;
worldNum 四组全键表(0.1.34 warFatigue/断供/新秘境全套);内容面七口(六类生态+四流派效果+建筑 DSL 五键+
职事 5 效果/志向 6 效果——修正旧文 combat/stamina 错值+性格 danger 映射);patch 层 5 条规则;
§10 速查表 9 组全 id(丹药 5/兵器 6/功法 23/建筑 14/天下家/秘境含 wonder);FAQ 9 问;官方范例实样 JSON
【新章节】零 B 君(安装即校验安全区间表)+2.1/2.2/2.3(顶层字段表/十五口全景/示意壳)+9.5.x 小节编码
2026-08-23 22:57:49 +08:00
thzxx c9fca1b0b1 docs: MOD_GUIDE 全面重排——完整示例+排版表格化+0.1.31-0.1.35 增量缝合
重构自 0.1.32 全量底稿(551 行完整示例/字段表/排版保留),精确打补丁:
§0 红线 5 口→15 口全图;§2 新增 2.1 data 全景表(世界侧/内容面/配方/patch 四组);
§7/§8 红牌(产出必先立名——含内置丹方参照);§9 worldNum 补 warFatigue/断供四键+池底-拒单分离说明;
§9.5 功法重排(四流派效果表+量级参照+元素时节共鸣+平衡提醒);
新增 §9.6 patch 层(规则/mult-add/日志/卸载复原/worldNum 区别);
§10 速查表全表格化:丹药 5/兵器 6(战力列)/功法 23 全列(流派效果)/建筑 14(产物列)/秘境含 m-wonder 注;
§13 官方示例实样 JSON(风物集/战备解军)+灵纹刃立名修正注;FAQ 补 Q8/Q9;尾注四版;更新戳 0.1.35
2026-08-23 22:51:17 +08:00
thzxx 088fda2df0 docs: MOD_GUIDE 依 0.1.35 真实代码重写(362 行)+ bug 修复
【bug 修复】官方示例包 zhanbei-xv 的灵纹刃产出物缺 ITEMS 定义(0.1.33 幽灵物品纪律违反——装包即产出不可见武器)→ 补 weapon-lingwoon 定义(图标/价 700/战力 0.35);删 wordgen 死占位字段
【MOD_GUIDE 重写】与真实 modSchema/插件/内置包逐口对齐:
§2 补 patch/kind/四效果键;§9.5 功法补四流派效果(含平衡提醒/validate 校验区间);
新增 §9.6 patch 层(0.5~2/mult add 二选一/卸载复原/缺键 warn);
§10 速查表全量更新:丹药 5(和灵/虚元)/兵器 6(玉澜刃/灵纹刃)/功法 23 本全列(含流派备注)/建筑 14(符箓坊消耗式)/秘境含 m-wonder-*;
§13 官方范例修正(真实字段——原谓词错配);FAQ 补 Q8 patch;尾注 0.1.33/34/35 三注;
【验证】builtin-mods/mod/economy 回归绿;tsc 0 错
2026-08-23 22:41:44 +08:00
thzxx b963e954c7 v0.1.35: 涤荡·向新——缺陷歼灭+兼容层铲除+测试 7000
【测试】5989→7000(101 套件:matrix-purity/harmony/long×3/surge/7k +1011)
【修复】transition 补分号(build 修复——0.1.35a 正则吞尾分号导致 CSS 解析错)
【版本】0.1.35(build 通过)
【文档】AGENTS(存档防线改写——迁移链铲除)/README(沿革+矩阵域)/MEMORY(8d 未发布不兼容铁律+时间线)/MOD_GUIDE(无迁移注)
2026-08-23 22:38:05 +08:00
thzxx 9b0f36c70a v0.1.35b: 兼容层铲除——迁移链/版本戳/死表死代码/normalize 兼容批/死字段
【A+I】migrate.ts 迁移链(CURRENT_SCHEMA/MIGRATIONS/migrateIfNeeded/MigrationError)整链铲除→validateLoadedState 结构校验(seed/family/members 断言);信封统一 payload 单格式(去双格式兜底);APP_ID 保留
【F】schemaVersion 字段全删(domain/creation/save 8 处);SaveMeta.version 记游戏版本字符串
【C】chronicle 死表+saveChronicle 死方法(首行 return+unreachable legacy 僵尸)全删
【D/E】GameEngine __state globalThis 泄漏口+seed 三元(恒不可达)删除;engineFromSnapshot/noAutoCreate 保留(测试便利——非兼容机器)
【B/H】normalizeGameState 兼容批删:老档字段补(28行)/worldGen 重放(18行)/P0-2 占位 def(9行)/worldSim 字段批删;保留 npcs 重注册/headId 修复/d distress 防护;initSim 补 0.1.34 四键(唯一初始源)
【G】domain 收紧 5 兼容 optional(techniqueRank/Progress/tribBoost/worldGen 必填)+pcgen 必写
【测试】删 9 个兼容验证体(audit 0.1.0-era/全残缺矩阵/空 worldSim 兜底);5989 全绿——金钟罩零漂(新档结构全字段,删除不涉世界数值)
2026-08-23 22:35:17 +08:00
thzxx 1b1dfe8ac8 test: matrix-tech 断言随 0.1.35 平衡修正(t-zidian 0.1/t-shiling 新员) 2026-08-23 22:31:13 +08:00
thzxx b420fa0c67 v0.1.35a: 缺陷歼灭——wonder 崩链/boss 卡队/patch 泄漏/拒单门/衰减/数值平衡 17 条
【高】wonder 全链修复(sendMission/settle/missionById wonders 回退+ExpeditionPanel 合并列表);
boss 战败 releaseSquad(整队不再永困 expedition);patchUndo 按插件归属 Map+rollbackPatch(跨 MOD/跨世界不再泄漏)
【中】buyItem 拒单线>池底(0.35>0.25——断供真断供);warFatigue 年首×0.9 衰减(长局不永久冷降);
MOD 功法注入 trackedTech 卸载摘除;patch 缺失键 warn;guard>1 钳制 Math.max(0,1-guard);
transition 12 处逗号简写→每属性独立时长(0.1.33 动画实际恢复);t-shanling/t-zidian 越级下调;土系恒应(×1.02)
【低】断供计数封顶12/播报 month 修正/era 权重下界 0.05/新贵 id 碰撞 7 位/新字段 normalize 补/
go() 停时器/saveNow 并发锁
【金钟罩】0.1.35 基线重算(warFatigue衰减/池底分离/功法平衡受控变更) 61 例绿
2026-08-23 22:30:02 +08:00
thzxx ae3fcabc20 docs: MEMORY 补 0.1.34——8c2 世界生成红牌军规(B0 教训)+时间线 0.1.34+测试数 6000 2026-08-23 22:12:18 +08:00
thzxx bb82947bb6 v0.1.34: 万象归元——功法流派/世界自演化/插件MOD 进化 + 测试 6000
【版本】0.1.34(build 通过)
【测试】5242→6000(94 套件:matrix-tech/worldsim/mod/stable/final +758 矩阵域)
【文档】AGENTS(MOD 协议章节 14 卡/红线补 worldNum 键位 + 金钟罩 0.1.34 基线注释)/README(沿革+矩阵域)/MEMORY(8c2 世界状态红牌纪律 + B0 军规)/MOD_GUIDE(4 效果字段+patch 层注解)
2026-08-23 22:12:07 +08:00
thzxx a7fe166e85 v0.1.34b: 插件新卡+MOD 功法效果口/patch 层/冲突日志
【插件】SYSTEM_DEFS + worldview 观察卡/tech 功法卡(14);PluginContext + addTechniqueDef(全字段入 PACK 数组, grade≤3 自动池) + onWorldSimTick 观察回调(零 rng 零时序——proxy 追踪注销);
【MOD】ModPackData.techniques 补 4 流派效果字段;新增 patch 层(target 数值 mult 0.5~2/add——install 应用 uninstall 复原,validate 规则进预检);同 id 功法冲突 warn 日志
【门面】Runtime 未动;卸载回滚栈 patchUndo
2026-08-23 22:09:38 +08:00
thzxx 3fb64f6e78 v0.1.34a(修正): 新秘境改种子派生伪随机——零 rng 消耗(修复跨世界/跨跑分叉)
B0 原用 World.rng.chance/pick/int——序列漂移疑虑(实际为模块级全局 EXTRA_MISSIONS 残留污染);
现改为 seed 哈希派生(year/npcSuccessions 异或哈希——纯确定性零 rng),金钟罩序列零伤;
SecretQi/mission def 全走世界状态 s.wonders(每档独立),确定性复跑矩阵恢复绿;
金钟罩 0.1.34 基线固化(功法流派/时节共鸣/warFatigue/换代/断供受控变更——值变序列受控重算 62 例绿)
2026-08-23 22:05:53 +08:00
thzxx a491b9c54a v0.1.34a: 功法万象+世界自演化——流派效果/元素时节/悟位大成/战争疲劳/换代半衰/断供应急/新秘境
【功法】TechniqueDef 新增 critChance/guardBonus/tribBonus/secretBonus(optional 零漂);
6 部新功法(玄冰凝甲/紫电惊雷/丹鼎护道/风遁青冥/山灵镇岳/澜海听涛);
季节元素共鸣(春木夏火秋金冬水——修炼额外×1.04);演武场联动悟位积累;悟位2大成战斗效果倍增
【世界】warFatigue 互攻累积→世界温度冷降→乱世加速(打不动即荒年);
NpcDyn 换代恩义清(关系网向零收敛×0.5);断供应急(连续3月池深<70%→广播+价×1.5);
新秘境灵机(年首几率·乱世×2·模板化生成 addMission 扩展口);addMission 公共口(secrets.ts)
【修复】shortage 广播模块级全局污染→世界状态化(多世界确定性恢复)
【测试】world 确定性恢复绿;金钟罩预期漂移(换代/共鸣/warFatigue)——0.1.34 基线待重算
2026-08-23 22:00:55 +08:00
thzxx b7c5f1eca0 docs: 四文档同步 0.1.33 补全——金钟罩基线注释/生态闭环纪律/符箓坊建筑/动效纪律
AGENTS:金钟罩 0.1.32→0.1.33 基线注释(符箓战力/灾年入产/tribBoost 清零时序内变更)+ 新增生态闭环纪律段
MOD_GUIDE:建筑 id 表补 fuzhifang(消耗式产符)
MEMORY:新增 8c 决策(闭环三问/动效空态纪律/测试编写教训)
README:确认 0.1.33 沿革已在
2026-08-23 21:47:38 +08:00
thzxx 7839c7b55a v0.1.33: 闭环·流光——物品生态闭环+引擎止血+UI动效打磨+测试 5230
【版本】0.1.33(build 通过)
【测试】5009→5230(+200 matrix-close-loop 闭环四验宏量)
【文档】AGENTS/README/MOD_GUIDE/MEMORY 四份同步 0.1.33(闭环注/四同步纪律/测试数)
2026-08-23 21:39:23 +08:00
thzxx 2369f342dd v0.1.33c: UI/动效打磨——过渡动画/停帧节能/季节对齐/反馈闭环
【动效】Modal 淡入+升起缓动(modalFadeIn/modalRise);LogFeed 入场动画(feedIn);
fx std 档放行刀光(战场核心反馈);任务凯旋补 spark/战殁补 ripple
【节能】DriftLayer 2.6s 转 onAnimationEnd 删除节点(消除空转 setInterval×slice 定期 setState);
fx-canvas 粒子空态停帧(空 rAF 取消+spawnBurst 唤醒)——低端机待机零开销
【季节】fx-canvas 季节色改读 data-season(游戏月驱动,不再用系统月——夏季粒子不再飘冬季色)
【性能】transition all 12 处→具体属性(background-color/color/transform/opacity);
backdrop-filter 保留(视觉主)
【UI 修复】MemberModal 装备栏空守卫(未知名器)+职事按钮 disabled={maxed} 统一;
议和按钮灵石不足 disabled+title(不再静默);事件弹窗 RES_NAME 补新材料/增筑显示中文名;
回卷按钮无存点 disabled+title;速度按钮 sel/gel 类名统一;MarketPanel 删死变量 stock;
debug 后门 __SMOKE__/DEV 门控(生产干净,冒烟仍可用——main 注入 __SMOKE__)
【测试】fx-timesense std 刀光断言更新 5030 全绿
2026-08-23 21:38:02 +08:00
thzxx 3e4ed4d882 v0.1.33b: 引擎止血——白屏/经济/治理 15 项
【白屏】equip() 校验物品存在;MemberModal 空守卫(B1 属 UI 同阶段)
【经济】tribBoost 渡劫成/败/陨落三分支尾部清零(+12% 破境丹不再永久白送);
assistAlly 先校验 NPC 再扣款;giftNpc/makePeace 加 npc 守卫
【治理】about 版本透传(删硬编码 0.1.29);engineFromSnapshot 尊重 noAutoCreate(不再重放创世);
回声事件 npc 空池守卫;squad 陈旧 id 过滤;mission 日志悬空 join 修复;
flag 修剪扩 legacyDone/tournamentYear + tauntCD 随家族消亡删;purgeNpc 删无效 auction 键;
chronicle/battle splice 去重;lifecycle 注释 240→200 一致;worldsim-data 俄语注释中文化;
maxRealmIdx 占位 0 且 normalize 重算(防虚报);TECH_GRADE_BASE 复用删除(实为局部 const 未动)
【测试】gameengine about 断言修正(0.1.32 透传) 5030 全绿
2026-08-23 21:34:29 +08:00
thzxx bce50b6e2b v0.1.33a: 物品闭环——符箓生效/幽灵物归位/灾年接入
【符箓闭环】talismanWarPrep 返回类型,开战消耗 1 张;风符全队×1.12/山符×1.15+受创减半(零 rng)
【符箓坊】建筑耗符纸×2+灵木×2/级,月造风符1枚;坊3级另出山符(消耗式零 rng)
【幽灵物品归位】and灵丹/虚元丹/玉澜刃补 ITEMS 定义(含 icon/价格/战力)+craftPill/forgeArtifact union 扩键+ARTIFACT_POWER 补玉澜刃 0.4
【灾年生效】farmFactor/mineFactor 从算而未用→真正乘入灵田/药园/矿场/灵果/木坊产量(旱灾×0.7等)
【金钟罩】0.1.33 基线重算(符箓/灾年/新丹受控变更),RAW+RES 共 18 值
【测试】+3(economy-loop-0.1.33 闭环矩阵) 全绿
2026-08-23 21:31:19 +08:00
thzxx fa477fb3ef docs: AGENTS 同步 0.1.32(金钟罩注释修正 + p 文件教训入修改纪律 + 物品六类生态纪律) 2026-08-23 21:10:04 +08:00
thzxx 9fbbe16dd8 fix: 删除误入库的 p 文件(0.1.32 脚本笔误 open('p','w') 误写workdir,内容为 worldsim-data.ts 副本) 2026-08-23 21:09:11 +08:00
thzxx 29eb25524b docs: 三文档同步 0.1.32《万宝琳琅》(按真实源码核对)
README:测试数 86/5009 + 版本沿革补 0.1.31/0.1.32 + 矩阵域 29→40
MOD_GUIDE:§10 速查更新——资源/材料 id 十种 + 符箓/灵酿 id + 来源速记(与真实掉落/建筑对应)
+ 建筑 id 补灵果园/灵木坊 + 篇章尾注 0.1.32 生态观(六类循环/消耗类零 rng 纪律)
PROJECT_MEMORY:时间线 0.1.31/0.1.32 + 新决策 8b「物品六类生态观」
(材料循环/入池则活/零 rng 消耗纪律)+ 技术债测试数同步
2026-08-23 21:08:05 +08:00
thzxx 4dfa07cc9f v0.1.32: 万宝琳琅——物品六类化 + 新材料七种 + 循环接入 + 测试 5009
【资源物品大扩容】
- ItemKind 6 值:resource/material/pill/talisman/brew/artifact——市场分群/背包/售卖/modSchema 全链
- 新材料七种:灵玉/灵木/兽皮/灵果(POOL_BASE+MARKET_IDS+worldgen 偏移四同步入世界池)、
  灵露/丹砂/符纸(道具料)
- 循环接入:新建筑灵果园(灵果)/灵木坊(灵木);productionTick 泛化逐建筑读 produceTable 全键;
  秘境掉落材料化(灵玉/兽皮/灵木);材料类同向微漂(物产同源生态)

【新用途】
- 符箓:war 战斗开战武备自动消耗(talismanWarPrep 零 rng)
- 灵酿:渡劫伤后自动回气 +5
- 新丹方和灵丹/虚元丹 + 玉澜刃/灵酿铸器配方

【测试 5009】86 套件(+1000:matrix-treasure-a/b 950 + 断链修整);
【金钟罩】0.1.32 基线重算(物品/池/生产泛化受控变更);build 通过
2026-08-23 21:05:03 +08:00
thzxx c08f3c1f7f v0.1.31: 万物皆可 MOD——内容面七口 + kind 分类 + 数值松弛 + 测试 4006
【MOD 内容面总攻(七口新开:14 口全)】
- items/techniques:PACK 增量合入(extend 键级/mergeAdd 数组——内置不覆盖)
  ——新丹药武器有名字/新功法入藏书阁·掉落·拍卖
- buildings:新建筑(produceTable 表达式 DSL '4 * L' 白名单)——领地页可建可升、
  productionTick 自动消费、World/build/upgrade 走双源建筑表
- posts/aspirations/formations/traits:职事(11 效果型)/志向(5 型)/阵型(攻防系数)/
  性格(bonus 白名单集)——全部双源注册(registerXxx),卸载只摘自己的键

【MOD 类型(M5)】kind: content/data/rules 分类标签;【M6】ERA_DURA 聚合(eraDuraOf)
+ REGION_TIDE 聚合(regionTideOf)——默认零漂(金钟罩实证未红)

【测试 4006】85 套件(+1000:matrix-macro 100 + sprint-a/b/c 900 +
内容面覆盖矩阵后续)——全部语义真实;金钟罩 0.1.31 零漂(聚合默认表不变)

【验证】4006 全绿;build 通过
2026-08-23 20:53:22 +08:00
thzxx 0965eaead4 docs: MOD_GUIDE 重写为零源码依赖的开发教程(464 行新手友好版)
- 零源码承诺:全文字段字典/取值/默认值自足,无需读类型文件
- 五分钟上手(含路径/可复制最小 MOD/装上看效果三步)
- 七个能力教程(事件/模板/词库/灾因/丹方/铸器/世界数值):
  每章字段表+条件表+效果表(Cond/MemberEffect 全集,从源码萃取)
  + 完整示例 + 常见坑(幂权重克制/新家衰微/灾名唯一/卸后世界不变)
- §10 速查表:内置 id 大全(资源/丹药/武器/功法/建筑/天下家/秘境)
- §11 排错 FAQ 七问(含存档误放 mods 目录的常见混淆)
- §12 依赖冲突/合包导出/读档重装纪律(改 MOD 必升 version)
- §13 官方范例拆解;文档全部示例经 parse/preflight/validate/install 实测校验
2026-08-23 20:34:00 +08:00
thzxx 716a91bd96 docs: 四文档同步 0.1.30《兼济》
README:测试数 81/3006 + 版本沿革补 0.1.29/0.1.30 + 审计轮次改「四轮」
PROJECT_MEMORY:时间线 0.1.29/0.1.30 + 新平衡观决策第 8 条(续代是玩法命脉/规模即代价)
MOD_GUIDE:版本 0.1.30 + §8.5 官方范例(风物集/战备解军观摩指南)+ 预览影响标注
AGENTS:0.1.30 小节与测试 3006(上轮已写,核对无漂)
2026-08-23 20:29:06 +08:00
thzxx 8a81022862 v0.1.30: 兼济——人口-世界成本闭环 + 世界关注度 + MOD 生态成型 + 测试 3006
【世界自演进】
- W1 人口-世界成本闭环:人口 >20 修行耗草额外抽池(人多粮少——家族规模有世界级代价)
- W2 世界关注度(匹夫无罪):战力超邻家均值×2 → 劫掠×1.5/拍卖×1.15(强族举世瞩目)
- W3 生灭激活实证:decline 150 后 3×180 月——1/3 世界经历覆灭/新贵(灾难级频度合理固化)

【MOD 生态成型】
- M1 内置 MOD 仓库:风物集(词库+瘴雨灾因 brief)/战备解军(灵纹刃配方+worldNum 覆写)
  ——设置页一键安装,官方范式模板
- M2 冲突矩阵 UI:PluginStatus conflicts 展示(依赖/冲突行内可视化)
- M3 NewGame 预览:已装 MOD 集影响标注(本局世界生成)
- M4 plugin-public 0.1.30 完整 API 清单 + 回滚契约

【测试 3000 攻坚】2014 → 3006(81 套件):
matrix-continuity(22)/economy-new(26)/combat-rel(17)/mod-cycle(9)/npc-life(17)/
rule-world(50)/depth(120)/deep(204)/final-circle(292)/rising(210)/peak30(25)/audit-0.1.29(6)+
——全部语义真实矩阵;金钟罩 0.1.30 基线(人口成本/关注度受控变更)

【验证】81 套件 3006 全绿;build 通过
2026-08-23 20:27:06 +08:00
thzxx a99420da09 v0.1.29: 续命——第四轮全量审计歼灭(2014 全绿)
【P0-A 续代断链(3seed×183年实测:170 年零出生老人院)——本轮最重】
- 生育窗随境界延长(化神 300+ 可育);天年=min(span,200)(850 寿元永不达的死亡回归)
- 族外婚注入(媒人枯竭引外姓——0.1.1 至今缺失的最后一环)
- 全局年胎 cap(≤18 人年 3 胎/18-24 1 胎/>24 停)→ 稳态 ~25 人、gens 3、2200 月 420ms
- 确定性回归:inlaw 判定长度化+单一 chance(修复过程中的真 bug——det 二分锁定)

【P1 七件】legacypass 年锚(同一年 12 连发 224 次实测)/敌强度相对化(0.35+rel×0.55,
cap 2.6——5000+ 战力零风险碾压的定锚)/eventRoll 插件权重防垄断(>核心 50% 压缩)/
allEvents 按 enabled 过滤(禁用 MOD 事件双闸全面兑现)/bundle 全量导入(modParseAll 导出 2 回 2)/
chronicle 表治理(write-only→no-op)/Stash 并发(open in-flight 去重+save try)
+ PluginManager catch 全回滚 + about 版本统一

【P2 群】decline 150 激活生灭/化神月俸 20 石泄灵石死水/灾因 brief+descOf 接线/
worldGenMods 落空/EventModal 文案对齐/LegacyPanel 恒等清理

【测试】70 套件 2014 全绿(audit-0.1.29 六例:人口曲线/传薪年锚/强度公式/bundle 全量/异常回滚/灾因 brief);
【金钟罩】0.1.29 基线重算(续代/战斗/事件数值受控变更);build 通过
2026-08-23 20:14:42 +08:00
thzxx 6cbd604e24 docs: 四文档同步 0.1.28《稳石》
AGENTS:金钟罩注释 0.1.24→0.1.28(指纹补盲清单:worldGen 摘要/sagaAnnals/distress/declineYears)
README:测试数 69/2008 + 版本沿革 0.1.28 + 守护机制补「审计三轮」
PROJECT_MEMORY:时间线 0.1.28 + 教训 6e「承诺-实现间隙」+ 技术债更新(缓做项定案)
MOD_GUIDE:版本 0.1.28 + 兑现注记(预检生产链/卸载真回滚/worldNum 接线/缺失提示/bundle 再导入闭环)
2026-08-23 18:29:13 +08:00
thzxx da4fb9f635 v0.1.28: 稳石——第三轮全量审计歼灭(2008 全绿 + 指纹补盲)
【P0 批(7 项,实证驱动)】
- P0-1 世界词库池无回滚(真实漂移实证)→ WorldGenPools 来源追踪 + removePool 精确回滚
  + MOD uninstall 自动摘除(池/灾因/配方/数值四类全局注册——P2-3 一并根治)
- P0-2 新贵 def 不落档(僵尸实证:power 恒 120 零演化)→ 生成即写 worldGen.npcs
  + normalize 占位 def 双保险 + 新贵 id 掺 seed 指纹(P1-10 防跨档碰撞)
- P0-3 校验死代码化 → installModText 装前 modPreflight(冲突拒+警告 toast)
- P0-4 MOD 读档静默消失 → state.pluginsMissing 收集提示(与 MOD_GUIDE 承诺对齐)
- P0-5 救济按钮乌龙(哈希序首个+直改 state)→ World.reliefCalamity(景气最低+双检)
- P0-6 天下快讯永久冻结(memo 依赖恒 120 length)→ 去 memo 每次 reverse
- P0-7 MOD 导出单行道 → modParse 识别 {app:'cotymod',bundle} 逐包递归(roundtrip 闭环)

【P1 批】
- P1-8 指纹补盲(worldGen/sagaAnnals/distress/declineYears 入 hash——0.1.27 成果真实锁定)+ 双基线重算
- P1-9 重放同步 worldGen.npcs(自捕获);P1-11 worldNum 字段接线(install/uninstall 复位)
- P1-13 落盘兜底(beforeunload/visibilitychange 突发 save,堵 12-tick 节流窗口)
- P1-14 onGameOver 停 timer + IPC mods 路径白名单(扩展名正则防穿越)
- P1-15 DataPack 摘除 npcs(覆写死路删除,与五口宣言对齐);P1-4 techniques 双源统一(techniquePrice 读包)

【P2 扫尾】tauntCD 清理/sagaAnnals 上限 40/worldGenMods 落空写入/validateMod 深度校验随 P0-3 提交

【测试】69 套件 2008 全绿(audit-0.1.28 八例:池回滚/僵尸/preflight/bundle/freeze/指纹敏感/worldNum/救济);
金钟罩 0.1.28 基线重算(指纹补盲+受控时序变更);build 通过
2026-08-23 18:07:31 +08:00
thzxx d8d34fcb74 docs: MOD_GUIDE 同步 0.1.27
- 版本头 0.1.26→0.1.27;数据口五→六(新增 §5.5 世界数值 worldNum 白名单覆写)
- 安全红线补第 6 条(worldNum 只动白名单键)
- 依赖语义补充(注册库内自动先装);§9 分发补「游戏内导出 MOD 集」
- 范式一句话更新(六口入炉)
2026-08-23 17:25:41 +08:00
thzxx c8c38ea3b3 docs: 三文档同步 0.1.27 + 测试 2000
AGENTS:测试数 68 套件 2000 例(++修正)
README:版本沿革补 0.1.27 + 测试规模节
PROJECT_MEMORY:0.1.27 时间线(实测驱动)+ 教训 6d(实测优先于直觉)+ 技术债第 10 条矩阵域追加参纪律
2026-08-23 17:23:16 +08:00
thzxx ba02dde1a5 test: 测试规模 1054→2000(达成目标)
新增 11 个矩阵测试域(198 个 it.each / 946 例),全部真实语义断言:
- matrix-probability(40):rng 序列/分布/范围/挑池/突破概率/境界矩
- matrix-world(55):worldgen 12seed(家数/去重/对称/era)/市场实体化 5 商品 × 深浅池/断供/超卖/潮汐/era
- matrix-engine(51):事件闸优先级矩阵/normalize 十类篡改/时轮锚点/插件协议与 MOD 校验/Kernel
- matrix-save-num(33):配方/铸器门缺/灵石 clamp/难度初始/快照往返/库存矩阵
- matrix-npc(23):姿态域/景气网/关系漂移/换代/互攻/快照
- matrix-production-cult(35):四季产出/建筑等级/修行合成因子/年龄/herbFactor/炼丹铸器
- matrix-mission-combat(25):遭遇矩阵/战力/阵型/战利灵气/秘境任务/遣队校验
- matrix-events(79):条件矩阵 flag×12/事件表结构 24/事件闸生命周期/周期节点
- matrix-modplugin(29):MOD schema 7 组合/重复安装/preflight 冲突/持久化往返/启停双闸
- matrix-data-tables(93):物品/功法/建筑/职事/性格/志向/阵型/灵根/境界/季节 全表字段合法性
- matrix-family-state(87):寿命/成员状态机/编年分类/灵石与库存/境界路径/季节因子/系统卡
- matrix-epochs(36)/matrix-law(43)/matrix-law2(55)/matrix-final(88)/matrix-gain(20)/matrix-compo(37)/matrix-capstone(24)/matrix-peak(40)/matrix-eight(8)

纪律:金钟罩/指纹类测试未动;全部断言语义真实(含 3 处 vitest it.each 双元素数组展开怪癖绕过);
同时真实修复:modPreflight 动态前缀黑名单(ev-raid-* 等防 ID 冲突漏检)、
插件持久化往返机制测试化(engineFromSnapshot 路径)
2026-08-23 17:21:03 +08:00
thzxx 40c4da34a0 test: about 版本断言 0.1.27 2026-08-23 17:03:26 +08:00
thzxx 8b6f2f7189 v0.1.27: 活水长流——生灭盘活 + 修为回环 + 世鉴 + MOD 生态闭环(1054 全绿)
【世界模型(180 年实测驱动的机制修复)】
- 生灭可达化:探子模拟实测证实——原覆灭/新贵 183 年零触发(力量通胀+景气自校正);
  修复:脆弱判定放宽(power<110 弱即衰 / warLosses≥2 战损衰)+ yearGrowth 按 era 调制
  (盛世 0.7/平世 1/乱世 -0.8/末法 -1.4——乱世真换血)+ 顶峰衰(power≥700 年首 8% 回落)
- 修为↔市场回环:修行月耗灵草 tradeSettle(-n) 抽走世界池(最后半环闭合)
- 世鉴 sagaAnnals:50/100 年宏观对比(开局 vs 现在/era 路径/最强家更替)——独立数组零漂
- 世界数值 MOD 口:worldNum 聚合读(灾年概率/供需/贸易/新贵/秘灵 5 项可覆写可复位)
- 世界面板显性化:世温/景气/衰微倒计时;修复 WorldPanel 救济按钮 rng 单源违规

【插件/MOD】
- 依赖拓扑自动安装(registry 内先装、栈深 3);MOD source 挂载 + exportModBundle
  + IPC mods:write + 设置页「导出当前 MOD 集」

【测试】1054 全绿(47 套件,+7:乱世压力覆灭/顶峰衰/era 负增/世鉴/数值口/导出/拓扑);
【金钟罩】0.1.27 基线重算(双基线 18 值,生灭/回环受控变更);build 通过
2026-08-23 17:02:46 +08:00
thzxx d941df5c3d docs: 三文档同步至 0.1.26
AGENTS.md:金钟罩注释修正(0.1.21→0.1.24 基线,25/26 零漂注明)+ MOD 红线小节
+ 架构树补 worldgen/modSchema/modManager + 世界模型补(灾因聚合/家族生灭/世界种子)
+ 测试数 47/1047

README.md:版本沿革补 0.1.22-0.1.26(灯下春秋/生灭千秋/寰宇初构/乾坤一统/墨规清辉)
+ MOD_GUIDE 链接

PROJECT_MEMORY.md:时间线补 5 个迭代「为什么」+ 新决策(世界种子独立 rng/MOD=插件一等子类/
落档则永稳纪律)+ 坑教训两条(生成必落档/聚合默认零漂)+ 技术债补 3 条 + 修改路径更新
2026-08-23 16:44:27 +08:00
thzxx ba9766e152 v0.1.26: 墨规清辉——MOD 规范范式 + 灾因注册表 + 页面提亮(1047 全绿)
【MOD 规范与范式(核心命题)】
- docs/MOD_GUIDE.md:完整作者守则——命名/id 规约/semver/schema:1/五数据口
  (事件/模板/词库/灾因/配方)/依赖冲突/安全红线(无脚本·禁碰 rng·不动核心时序)/
  打包分发(.cotymod 单文件)/行为承诺
- validateMod 深度校验:category/weight/境界/负成本/非空/options.eff 形状
- modPreflight 权威冲突检测:findEvent(池+动态分支 raid/渡劫/大比/传薪全量)
- MOD_SCHEMA_VERSION=1 + schema 声明与升级提示;PluginStatus.author 展示

【灾因归一化(平衡版落地)】
- calamities 由 as const 死表→聚合注册表(calamityNames/calamityFamilyOf/calamityEffectOf+addCalamity)
- WorldSim 灾签/生产/Market 全链路走聚合读口——MOD 灾因(剑灾/灵潮湍变)进入世界循环
- 默认表不变→基准世界指纹零漂(clock.test 全绿实证)

【页面可读性(有点暗)】
- .dim #6d6150→#a99c80(3.4:1→7+:1)、.dim2 #96896e→#c3b595、.help-text 提亮、
  .feed-item 显色 #efe4c9 + 灰金系 8 处提亮(styles.css 12+/11- 纯色值)
- tests/contrast-readability.test.ts:WCAG 对比度审计(≥4.5:1)+ 历史暗色迁移断言

【测试】1047 全绿(47 套件):mod-system 9(+validate/灾因/冲突/预检)、contrast 3;
【金钟罩】0.1.25 基线零漂;build 通过
2026-08-23 16:37:11 +08:00
thzxx 5d9c333c76 v0.1.25: 乾坤一统——MOD 系统首版(1040 全绿,金钟罩不漂)
【P0 修复(0.1.24 遗留)】worldGen.npcs defs 落档 + normalize 幂等重注册——读档后
动态家族(n-g-*)npcById 永命中;MOD 卸载后存档世界仍完整(存档世界永稳)

【MOD 系统四层】
- M2 内容层:modSchema.ts(ModPack 全 JSON:manifest+data{events/npcs/worldgen/pills/forges};
  modParse 纯解析校验 id/name/version/事件模板合法性)
- M3 管理器:modToPlugin(MOD→CotycPlugin 一等子类——插件持久化/双闸/启停/回滚全复用)
- M1 传输层:main IPC mods:scan/read(userData/mods/*.json|*.cotymod)+ preload + env.d.ts
  + store refreshMods/installModByName/installModText
- M4 UI:SettingsPanel「MOD 层」卡(扫描目录/候选列表/安装);内置示例包通路

【世界/插件协同】
- WorldGenPools 聚合词库池(默认不变→基准世界不变;reset() 供隔离);MOD 于新档参与生成
- items.ts 配方聚合注册表(PILL/FORGE base+扩展);PluginContext addPillRecipe/addForgeRecipe/addWorldGenPool

【边界(明注)】灾因/世界数值表注入归平衡版;脚本型 MOD 不做;MOD 只影响新档世界(老档不变形)

【测试】1040 全绿(46 套件):worldGen 存档 1 例 + mod-system 5 例(解析/转换/模板/依赖/基准指纹);
金钟罩 0.1.24 基线未漂(世界生成默认池不变);build 通过
2026-08-23 16:29:29 +08:00
thzxx ec25bf92fc v0.1.24: 寰宇初构——世界种子生成器 + 插件时轮锚点(1034 全绿)
【世界种子生成器(NPC 数量不再定死)】
- sim/worldgen.ts:generateWorld(seed) 确定性塑造天下——NPC 4~8 家随机
  (老牌世家模板 + 词库组合新贵,名字去重)、初始关系网(1-2世仇+1盟友)、
  开局 era 三态、区域风味、市场偏移 ±15%
- 独立派生 rng(seed::worldgen)——World.rng 主序列零消耗:同 seed 世界可重放、
  玩法随机序列不受生成器移动(金钟罩玩法序保护)
- state.worldGen 落档(normalize 旧档按 seed 重放=同世界,存档兼容)
- creation 初始化 npcFamilies/initSim 关系网/era/池偏置全走 worldgen——开局即恩怨
- 局内补位目标 = 开局家数(4~8),生灭闭环持续

【插件深化(0.1.24 第二组钩子)】
- PluginContext.beforePhase/afterPhase(时轮锚点:phase 前后挂勾,卸载全摘)
- PluginContext.addNpcTemplate(世界模板注入——词库插件化)
- plugin-public 文档同步;示例 Plugin 契约升级

【测试适配(世界名单随机化)】
- 静态 npc id 假设全面改动态取家(anyNpc helper 共享);
- 难度梯度用例改「同 seed 不同难度」比较;worldgen 确定性/范围/去重/关系对称 7 例

【测试】1034 全绿(45 套件);金钟罩 0.1.24 基线固化(worldgen 随机世界)+ worldAnnals 等;
build 通过
2026-08-23 16:15:22 +08:00
thzxx 04ef80faa5 v0.1.23: 生灭千秋 + 插件生态化第一课(1027 全绿)
【世界模型:世界会死人也会新生】
- 家族生灭:declineYears(power<58+景气<38 连续8年)→ 覆灭(最强邻分食+史官广播)+ 新贵补位(6%/年,乱世×2)
  —— NPC 永远四家铁打的现状终结;玩家补刀(raid×0.82)可加速衰亡
- npcById 双源可空(静态+动态注册表;不再 throw——15 处消费者防御化)
- purgeNpc 全局清理(raidCD/事件队列/恩怨网/distress/动态 def)
- 秘境产出回池(loot 20% 经 tradeSettle 回流)——资源循环最后单向封口
- WorldSnapshot 只读摘要门面(UI/史书消费,零行为变更)
- 潮汐长波(10 年正弦 ±0.08 叠加,灵潮三十年河东)

【插件生态化(0.1.8 后第一次大进化)】
- 持久化:GameState.plugins 落盘 + PLUGIN_REGISTRY 读档重装(engineFromSnapshot 真实路径验证)
- plugin-public.ts 公共导出面(第三方单入口+纪律)
- 双闸:能力卡随插件启停联动;池自动回滚
- 契约补完:Proxy 追踪(池/卡/pack 自动回滚)+ onUninstall 真调 + 能力卡入 World 实例(防跨档泄漏)
- SettingsPanel 启停按钮/依赖详情;demo 与 tests 去重(发现并修复:npcById 双源静态源未绑定——全局 NPC 停滞回归)

【测试】1027 全绿(44 套件):dynasty-plugin 7 例(覆灭/新贵/回池/快照/持久化/双闸/自动回滚);
金钟罩 0.1.23 基线固化;build 通过
2026-08-23 16:03:01 +08:00
thzxx d2787ee3c8 fix(0.1.22): 姓氏选择假功能修复——文档化传递链贯通
根因:GameEngine 构造硬编码 surname:'林'/familyName:'林氏',
NewGame 输入的姓氏从未传入引擎(startNewGame 只传 seed)

修复:GameEngineOptions 增加 surname/familyName/motto/difficulty 透传;
store.startNewGame 全参传递;createWorldState 按 opts 建档(缺省林氏兜底)
验证:慕容家/慕容临渊 贯通(tsx 实证)+ 新增 2 例防回归测试

测试:1020 全绿(43+1 套件);build 通过
2026-08-23 15:44:27 +08:00
thzxx 65ab00a677 test: about 版本断言 0.1.22 同步(原停在 0.1.20) 2026-08-23 15:41:04 +08:00
thzxx 4a1a034f1e v0.1.22: 灯下春秋(双 UI 修复)+ 文档同步
【速度暂停根因】onYearPaper 岁末族簿弹出时强制 speed:0——缓行一年整遇年报即'自动变暂停'
→ 修复:年报不打断自动速度(弹窗与时间流解耦;玩家随手翻页)

【弹框层级根因】MemberModal/EventModal 渲染于 .main-left(z-index:1) 内部,
层叠上下文被 tabs(z-index:2) 压制——'弹框出现在宗族/谱系菜单下面'
→ 修复:ModalShell 全量走 React Portal 到 document.body(免疫一切祖先层叠上下文)
+ .modal-outer z-index 50→100 双保险

【测试】1018 全绿(纯 UI 修复,引擎基线 0.1.21 不动);build 通过
2026-08-23 15:40:29 +08:00
thzxx 8f9c3c9a72 docs: 三文档完整更新至 0.1.21
AGENTS.md:架构章节重写(0.1.14 大重构路径)+ 引擎红线(时轮/随机/事件闸/特效出口/插件/门面/双金钟罩)
+ 世界自演进模型 + sqlark陷阱 + 数值基调(0.1.16 修为经济化/0.1.17 市场实体化)+ 修改纪律

README.md:快速开始(1018 用例)+ 0.1.14+ 架构一览 + 守护机制(双金钟罩/事件闸/插件/回放)
+ 版本沿革 0.1.12-0.1.21

PROJECT_MEMORY.md:版本时间线补齐(0.1.12-0.1.21 每迭代的为什么)+ 关键架构决策
(事件闸定稿/WorldSim 四层递进/引擎表现双通道/normalize即迁移/线程定论)
+ 踩坑教训(era 无延续档/潮汐符号反向/事件闸竞态/每tick存档/金钟罩盲区)
+ 诚实技术债清单 + 修改路径
2026-08-23 15:26:48 +08:00
thzxx 10ed1e7d9b v0.1.21: 大势流转(线程定论 + era景气双向闭环 + 世界史表 + 渲染管线)
【线程架构定论(实测)】
- 引擎与 UI 同在 renderer 主线程(单线程);2160 月实测 advanceMonth 平均 0.05ms/tick
- 结论:Worker 化无收益——0.05ms 不需要换线程;此前 60ms 告警来自 UI 渲染+存档(已修复)

【世界自演进闭环(双向因果,自演进最高形态)】
- era↔景气双向闭环:世界温度(marketPool 景气均值)调制 era 转移权重
  (tempEraK=0.8)——大世托盛世、市道衰微催乱世/末法;时代由天下兴衰孕育
- 天下史书独立表 worldAnnals[]:十年一鉴独立留档(免 newsFeed 120 条裁剪丢失)
- region 灵潮异质:REGION_TIDE(4 家×4 era)接入 NPC 贸易速率(乱世北岳凶、盛世东丘旺)
- 灾年全环:灾年群雄相噬(互攻概率×1.5)

【渲染管线(性能)】
- LogFeed 精订阅(去掉 revision 级联重渲)+ 滑动窗 120
- WorldPanel newsFeed useMemo 预聚合(不再每 render 全量重扫)

【测试】1018 全绿(43 套件):tide-0.1.21 7 例(延续档/温度/史表/区域/灾年/指纹敏感/滑动窗);
双金钟罩 0.1.21 基线固化;build 通过
2026-08-23 15:22:19 +08:00
thzxx be6f2f5fe7 v0.1.20: 万世无谬(第二轮全量审计歼灭,1011 测试+双金钟罩)
【事件闸(机制重锤)】
- fire(w,id,priority):单闸+优先级——高优(raid/渡劫/大比)顶替被占者、被顶替入 eventQueue 下月兑现不丢失;普通被占返回 false 调用方冷却(raid 被吞也写 CD)
- pending 唯一写入入口:渡劫/破境丹改走 fire(防覆盖丢弃);名宿传薪年锚 flag(防同年重复 12 次)
- applyEventChoice 前置校验 pendingEvent===id(陈旧 Modal 二次结算防线)

【世界模型四修(真·活世界)】
- era 延续档:flow 权重和<1(留白=续任)——盛世能撑 30 年而非固定 25
- 潮汐供给符号修正:灵涨万物丰(原实现反了)+tide 双界 clamp
- driftMarket 弱锚定:rebalance 0.1→0.025——供需/era/灾年真实撬动价格(原五池被锚死±5%)
- 断供/池底单常量 poolFloorPct=0.35(消灭禁买暗雷带)

【知识防线(审计虫洞修复)】
- 指纹扩展:era/stance/prosperity/relationsWithOthers/calamityLeft/pendingEvent 全入 hash
- 第二金钟罩 GOLDEN_RESOLVED:自动 resolve 长跑锁“现实”世界(原指纹锁的只是“事件流冻结”世界)

【性能与存储】
- 存档节流(疾12/常6/缓4,手动即时)+ saveChronicle O(N)→O(1) 单查增量写(2160 月档告别每 tick 数千 OPFS)
- battles≤220/chronicle≤520 截断(stringify 线性膨胀止血);startNewGame/openState 停 timer 摘旧 bus

【杂项】actDirect 对齐 ACT_CATALOG 全 24 项;about 三版本统一 0.1.20;kernel.bus 死总线删;seed 改 RngHub.rollSeed;normalize 补 eraStartYear/overflux/distress;WorldPanel 买卖条件反转修复;音效与视效同闸

【测试】1011 全绿(42 套件):audit-0.1.20 9 例(事件闸/传薪/二次结算/era延续/潮汐符号/断供一致/弱锚定/指纹敏感)
2026-08-23 15:14:28 +08:00
thzxx e853fbdb71 v0.1.19: 八方风雨 + 孤立系统归并(双主线,1002 全绿)
【主线一:世界自演进深化《八方风雨》】
- NPC 战略姿态 stance:守成/扩张/隐忍/结盟四态,年首评估(era>景气>power>relation)
  全行为调制:raid 概率×2/×0.4/×0、互攻阈值 -40/-70/不攻、贸量 ×1.3/×0.7、
  赠礼收益 ally×1.3、结盟门槛 ally 放宽至30——NPC 从统计物理到行为战略
- 超卖潮(谷贱伤农):池>1.8×base 世界产能回调×0.7 + 年度播报(防玩家玩崩市场)
- 盟约连坐:结盟令世仇(rel<-50)对玩家-20;盟者世仇 raid 概率×2
- 盟友求援:互攻失利盟友告急(distress 旗标)——WorldPanel 响应(出资200/见死不救)
- 干预天下:调停(50灵石令世仇-60→-25、声望+2)/ 资助盟友;恩怨网情报视图(各家关系展开+调停钮)

【主线二:孤立系统归并引擎】
- P0 兜底修复:empty() 收敛 makeWorldSimState 单源 + normalize 补 tideTicks/calamityLeft/
  era/eraStartYear/prosperity/distress/overfluxYear(旧档 NaN 级联防死)
- FxGate 从 engine/kernel/fxqueue.ts 并入 ui/fx.ts(kernel 清纯引擎;deltas 顶层导出,
  3 处引用 10 行改动);engine 侧零 UI 依赖再下一城
- 音效语义补充:onFx→blade/pulse/spark→sWar/sGong/sBell;onLog kind 兜底保留(不静音)
- store.advance drift/season 段左移 fx.ts(fxSeason 复活接管 season 检测)
- 死码清除:timesense.tideOf/tintLabel、tideDir 全链(类型/初始/写入/domain)
- domain.ts worldSim 局部类型补全 0.1.18/0.1.19 全部新字段(断言类型安全)

【测试】1002 全绿(41 套件):stance-world 7 例(兜底/姿态/raid/超卖/连坐/调停/求援)
2026-08-23 14:53:46 +08:00
thzxx 2f62eecb08 v0.1.18: 时代绘卷 + 特效入引擎(双主线完成,1002 测试破千)
【主线一:世界自演进深化《时代绘卷》】
- 世纪弧 era 状态机:盛世/平世/乱世/末法四态(ERA_CONF 全参数化)。
  灾年概率×0.5~1.6、世界供需×0.7~1.25、拍卖概率×0.6~1.3、tide 偏置 ±0.09;
  ERA_DURA 控制转换节奏(15~45年),转移时史官快讯+log。世界有大节律了
- NPC 景气仓廪:npcDyn.prosperity(8~100)月结余——买卖+0.5~0.8、断供-2、灾年-3;
  >75 景气 power+1、<30 衰闷 power-1;换代焕新(55~75)。NPC 在过日子
- 蝴蝶效应:互攻胜率 power 加权(强者恒强)、打残广播(败家邻望-8/胜家+4);
  玩家劫掠胜利/结盟直接写 NPC·NPC 关系网(-6/-3)——玩家行动推进世界历史
- 天下十年一鉴:每十年史官综述(灾/换代/行情/灵潮聚合,kind='annal')
- 灾年↔秘境:灾年灵脉恢复减半(末法感)
- WorldPanel 天下志:era 徽标(悬停描述)/史官十年鉴/群雄势衡柱状图/衰微标注

【主线二:特效系统并入引擎(架构升级)】
- WorldEventBus.onFx + Kernel 转发 + World.emitFx 语义发射(零 rng 消耗)
- 12 个语义发射点:突破/化神/渡劫/劫掠胜负/遭遇战/拍卖/大比/灾年起散/换代
  —— 取代 UI log 文本正则猜测(spark/blade 双源锁死)
- makeBus 桥接 FxGate(kind→gate.emit),gate 挡位/密度/reduced 逻辑保留在 UI

【测试】1002 全绿(40 套件):fxqueue-semantic 3 例 + era-world 5 例;
金钟罩 0.1.18 基线固化(era/prosperity/蝴蝶效应受控变更)
2026-08-23 14:38:27 +08:00
thzxx ea9f5c9c5d v0.1.17: 天下为市——世界真炉膛(市场实体化+NPC博弈+灾年持续)
【A 市场实体化(真库存循环)】
- 世界呼吸:池月供给 × 潮汐span - 常驻需求(worldSupplyRate 2% vs worldDemandRate 1.5%)
- NPC 上桌:def.sells/buys 逐月入池出池——池浅采不到→power-15%,池深抛货→市场暖意
- 玩家交易回写:buy 浅池价涨/sell 盈池价跌(tradeSettle 0.35 份额粒度);池深<35% 断供拒单(12% 是枯井)
- marketPrice 统一走 marketMultFor;本地 POOL_BASE/TECH 复刻清除(worldsim-data 单源)

【B 世界格局博弈】
- 战争伤骨:劫掠胜负回写 power(败-18%/胜+6%)+ dead 字段 raidCount/warCooldownYear 启用
- 社交回响:赠礼回礼(30% 灵石/30% 灵草)+ power 微涨;联姻/和约/结盟皆涨 power
- NPC 关系网:relationsWithOthers 填实(同风格亲30~55/异风格隙-35~15)+ 年首±5 漂移;世仇<-50 年首互攻(败方-18%)
- 灾年共苦:灾年 NPC 贸易 ×0.7;tide 对供给 ±0.3 弹性
- 灾年持续 6 月渐退;灾起/灾散快讯(kind 标记)

【UI】
- 天下面板:池深百分比(含断供阈值 tooltip)/本族天下排位/灾年余月
- 快讯响应化:行情见顶抛售5/见底吃进5/灾年救济(50灵石→关系8)
- MarketPanel 法帖价格改 techniquePrice()(硬编码清除)

【测试】994 全绿(38 套件,新增世界炉膛 8 例);金钟罩 0.1.17 基线固化
2026-08-23 14:24:23 +08:00
thzxx 809707408b v0.1.16: 资源闭环 × 世情入味(A+B+C 全套餐)
【A 经济闭环(引擎核心)】
- 修为经济化:灵草成修行燃料——练气以上月耗(闭关2/普通1),无草速修0.6~1.0衰减+缺草每年一告
- 炼丹等级化:bonusOf 白名单表达式求值器(extra 单源);craftChance 转正(L5=100%),丹方 PILL_RECIPES(破境丹需丹房3级),失败折半退料
- 炼器筑宝:FORGE_RECIPES(法器/灵器/法宝)灵矿兽核入炉
- 秘灵灵脉:secretQiOf→rollWarbooty 收益门槛(0.5~1.5 折算),掉落按秘境 techGrades 过滤(暗墨林不再出仙典)
- NPC 实力参战:raid 强度/风险随 npc.power 浮动(0.5~1.8)——外交成长期博弈
- 灾年落效:CALAMITY_FAMILY 月度减产乘子 + 疫病/兽潮一次性体感

【B 世界活在 UI】
- 天下面板:快讯时间线/行情/灾年/群雄谱(宗主详情+换代数)+ 结盟(setAlliance,关联岁差不降+年利)
- 结盟引擎 truly 落地:allied 字段第一次被写值

【C 写史与治理】
- 手写史书:ChroniclePanel 输入条(misc 入史)+ MemberModal 册立家主 + 年报要闻/辞世讣告 + EventModal 收益预览
- C13 破境丹渡劫修正:需渡劫大境界时药力→tribBoost(+12%)入渡劫事件,不再跳三选
- C14 存储防线:loadState 过 migrate;MigrationError(TOO_NEW/未知版本) 醒目提示
- C15 registry 补全(aspirations/formations/season)+ startNewGame facade 复用 bug

【测试】986 全绿(37 套件,新增经济闭环 7 例);金钟罩 0.1.16 基线固化(修为耗草/炼丹/灾害序列变更受控)
2026-08-23 14:13:13 +08:00
thzxx de46808188 feat(0.1.16-P1): 资源闭环引擎(修为耗草/炼丹概率化/铸器/灵脉收益/NPC战力/灾变落效)+ C13C14
【A 经济闭环】
- 修为经济化:练气以上修行者月耗灵草(闭关2普通1),无草速修0.6~1.0衰减+缺草年告警
- 炼丹等级化:bonusOf 表达式引擎(白名单安全求值),craftChance 转正;丹方单源 PILL_RECIPES(聚气L1/凝元L2/破境L3)+ 失败折半退料
- 炼器筑宝:FORGE_RECIPES(法器/灵器/法宝,灵矿+兽核+灵石)
- 秘境灵脉:secretQiOf→rollWarbooty 收益门槛(0.5~1.3折算);掉落按秘境 techGrades 过滤
- NPC 实力参战:raid 强度/风险随 npc.power 浮动(0.5~1.8)
- 灾年落效:疫病/兽潮一次性袭击 + CALAMITY_FAMILY 月度减产乘子

【C 治理】
- C13 破境丹渡劫修正:大境界时药力→tribBoost 加成(+12%)入渡劫事件,不再跳过三选
- C14 存储防线:loadState 补 migrate;MigrationError(TOO_NEW/未知版本)提示升级

【UI 半批】
- 坊市新增 定制tab(丹方/铸器);ChroniclePanel 手写史书输入条;MemberModal 册立家主钮
- 年报摘要(要闻5条+辞世讣告);EventModal 选项收益预览
- 结盟引擎:setAlliance(relation≥40)/盟友岁差不降/年利15灵石
2026-08-23 14:10:42 +08:00
thzxx c5fa2a3357 v0.1.15: 深度审计加固(7 P0 + 20+ P1 歼灭)
【引擎层 P0】
- GameEngine.restore 双重时钟注册(回档后整月双跑)→ restore 重建内核+回置 rng 状态
- engineFromSnapshot 必崩(globalThis.__state 从未设)→ 修正带 seed 重建
- DEV 性能分支二次执行整月管线 → 只读告警(绝不重跑)
- 引擎 advance 遇待决事件死锁 → resolvePending(idx) 通道(自动化/测试推进)

【WorldSim 大修】
- NPC 换代文案垃圾(n-danxin行情降50%)→ 换代快讯专属文案
- secretQi 假闭环(纯写无读)→ 全秘境初始化+missions 统一 consumeSecretQi
- 快讯节流自锁(lastNewsMonth 恒 1 月)→ totalTicks%24
- power 月度收敛坍塌(1400±3)→ 换代/年首化+年度成长
- relation 双重拉零(4 家全 0)→ 交还外交年度漂移
- 市场基准 5 处重复 → POOL_BASE 单一权威;灾年 stale 清理;drift 各池独立噪声
- worldsim 能力卡补全 + normalize 兜底(worldSim/stats 子字段)

【UI/动效/存储】
- ModalShell 三条 CSS 规则真正落地(close 定位/body 滚动/footer 独立底排)——0.1.12 声明未实施项
- fx-canvas:resize 监听移除/mistTimer 清零/reduced 联动/soft 档无雾 + drift 走 gate(过滤器)
- 档位 density 脱钩修复;顶栏“止”钮补 onClick;版本号三处统一 0.1.15
- importAll 清 chronicle 表;自动推进季度落盘(手动逐次)
- 性能:LogFeed memo / Genealogy computeGenealogy memo / Chronicle deps 修正;死码清除(lastPhaseStats/clockFromKernel/哨兵函数等 12 处)
- 删除误入库的“p”游离文件

【测试】979 全绿(36 套件);金钟罩三档 0.1.15 基线固化;
typecheck/build 通过
2026-08-23 13:52:51 +08:00
thzxx 26bd61fba0 feat(0.1.14-P4/P5): UI 单点 + 引擎级测试(979)+ 顶栏天下风云
- GameScreen 顶栏「天下风云」胶囊:潮汐印(灵涨/汐平/灵衰)+ 物价标签(物腾/平稳/物贱)
  + title 显示最近快讯(worldsim-brief 纯函数)
- store 接 GameEngine 起点(startNewGame 走引擎;facade/engine 单源)
- 新测试 gameengine.test.ts ×12:引擎门面(构造/advance单源/act+query+subscribe/datapack覆写+restore/about)
  Kernel 三合一 / WorldSim 世界健康(3 seed × 1000 月:池/灵气在界、换代>0、快讯≤120)/ sim 联动
- 全量 36 套件 / 979 测试通过;金钟罩三档固化(0.1.14 正式);typecheck 0 error
- AGENTS 补「引擎系统」章节;版本 0.1.14
2026-08-23 13:33:39 +08:00
thzxx 5dd5d6b558 feat(0.1.14-P3): 世界自进化 WorldSim(五大闭环)+ 引擎时钟合一
- WorldSim(engine/sim/WorldSim.ts + worldsim-data.ts):
  A 资源循环市场(库存池/再平衡/价格弹性——Market 行情联动 sim.mult)
  B NPC 聚合演化(宗主换代/境界成长/势力=境界+财力+兵员;换代计数)
  C 秘境灵气(探索消耗/恢复)+ 灵气潮汐(6年周期×修炼倍率)
  D 灾年签(旱涝蝗疫兽寒,联动市场池)
  E 天下快讯(节流 24 月滚动 120 条,世界动态 feed)
- 相位:新增 worldsim(diplomacy 后 epilogue 前);capabilities 加「天下演序」卡可停用
- 时钟合一:World.clock = Kernel.clock(单一时钟驱动,消除双时钟漂移)
- 发现并修复:worldsim 注册被文件搬迁覆盖丢失(修改纪律验证中招——已补回);
  WorldSim 内 Math.random 根除(改 w.rng)
- fingerprint 纳入 worldSim 概要(市场/灵脉/潮汐/换代/快讯数)
- 金钟罩三档重固化(0.1.14 正式基线,受控变更)
- 验证:35 套件/967 测试全绿;typecheck 0 error
2026-08-23 13:31:34 +08:00
thzxx 8dfdaf843a refactor(0.1.14-P2): Kernel 三合一 + GameEngine 引擎门面
- Kernel(engine/kernel/Kernel.ts):游戏时钟+种子随机+事件总线——单一实例三合一,
  World 不再自造 clock/rng(内核注入,bus 透传 out)
- GameEngine(engine/GameEngine.ts):唯一引擎入口——
  new GameEngine({seed,datapack}) → world/facade/kernel/datapack,
  advance/act/query/subscribe/about/installPlugin/snapshot/restore/status
- World 构造接受 kernel 注入(保持行为等价:时钟注册顺序不变)
- ApiFacade.subscribe 简化纯实现
- 验证:967 测试全绿、金钟罩三档零漂移、typecheck 0 error
2026-08-23 13:25:42 +08:00
thzxx ff6df8054c refactor(0.1.14-P1): 内核归位——game/ 按引擎架构重排(零行为漂移)
结构(旧 game/core+engine → 新 engine/ 域):
- engine/kernel/  时钟/随机/插件协议/fxqueue/timesense/format/urgency/guide/names(原 core)
- engine/narrative/  legacy/报告/列传/谱系/年轴(原 core 叙事族)
- engine/runtime/   World/creation/pcgen/ApiFacade/capabilities/pluginManager/boot/clocks + Systems/*(12 系统)
- engine/sim/     Market(未来 WorldSim 同行)
- 旧 game/core、engine/systems、engine/world.ts 等路径全部废弃(无 re-export 兼容层)

验证:35 套件/967 测试全绿(金钟罩三档零漂移=纯搬迁无行为变化)
typecheck 0 error
2026-08-23 13:23:25 +08:00
223 changed files with 16487 additions and 2182 deletions
+54 -23
View File
@@ -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 分野)**devvite 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-eventsprotected 不可卸)。 ```
- **事件多池**`world.eventPools`core 池受保护不可删)+ `eventRoll` 聚合抽样;第三方插件 `ctx.addEventPool(id, events)` 即注入内容。**注意 findEvent 必须带 world 参数查池**`events.ts`),否则未知事件直接软锁。 src/renderer/game/ 纯 TS 引擎(无 React/DOMvitest 直测)
- **能力卡**`engine/capabilities.ts` 12 张;时钟注册走 `viaCap`tournament/tribulation/apprentice/season 四卡各自内部检查 `w.sysEnabled`(无独立钩子,勿直接调)。 engine/GameEngine.ts 唯一门面:advance/restore/engineFromSnapshot/about/resolvePending
- **门面**`engine/api.ts` GameFacadeact 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.ts0.1.8 基线已作废、0.1.10 重建后再次漂移、0.1.11 大比 once/prune 后重固化)。 engine/runtime/ World.ts(大对象+normalize+bus 转发)/clocks.ts(时轮注册)/ApiFacade.tsGameFacade
Systems/production/aging/cultivation/missions/events/diplomacy/marriage/tribulation/tournament/lifecycle/combat
capabilities.ts12 能力卡)/pluginManager.ts/plugin-bootstrap.ts/modSchema.tsMOD 范式)/modManager.tsMOD→插件)/creation.ts/pcgen.ts
engine/sim/ 世界自进化: WorldSim.ts / worldsim-data.ts(数值与常量唯一权威)/worldgen.ts(世界种子生成器)/Market.ts / worldsim-brief.ts
engine/narrative/ 族谱/传记/定鼎/年轴/百年报告/族史卷(chronicle-scroll)
data/ balance 表 + DataPackRegistryregistry.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 + zustandstore.ts 主状态;面板按 panel switch 挂载;订阅 revision 刷新)
src/main/ Electron 主进程(app:// 协议 + IPC 存档导出/导入)
```
## 架构要点 ## 引擎红线(0.1.14+
- `src/renderer/game/`:纯 TS 游戏引擎(无 React/DOM import),可被 vitest 直接测试;`types/domain.ts` 是全量领域类型,改状态结构先看它 - **统一时轮**:月度 phaseproduction/aging/cultivation/missions/events/diplomacy/epilogue+ 年首钩子均注册于 `runtime/clocks.ts`。**新增系统 = 注册一行,禁止手改 `advanceMonth` 本体**World.ts:329
- **统一时轮 `core/clock.ts`**:月度 phaseproduction/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 是 mutableadvance 后 `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` GameFacadeact 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.supplyMultvs 常驻需求(`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,15 +85,17 @@ 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`
- 数据在浏览器 OPFSrenderer 内),main 进程无 DB 逻辑;开发与打包后的 origin 不同,两环境存档不互通。 - 数据在浏览器 OPFSrenderer 内),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 年前旧键。
## 修改纪律(防覆盖——血泪教训,务必遵守) ## 修改纪律(防覆盖——血泪教训,务必遵守)
@@ -72,12 +103,12 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附
**硬性规则** **硬性规则**
1. **改动前**`git status` 确认工作区干净(或先提交当前成果,原子提交,一功一提交)。 1. **改动前**`git status` 确认工作区干净(或先提交当前成果,原子提交,一功一提交)。
2. **改动中**:批量替换一律用「先 assert 旧文本存在 → 替换 → 再全局 grep 断言新文本唯一」三步;禁止无检查的 sed/python 盲替。 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 恢复,恢复后跑全量测试确认无伤。 3. **禁止 `git checkout -- <file>` / 整文件回滚**(这是历史事故元凶)。确需回滚时:`git show HEAD:<file> > /tmp/bak` 手动 diff 恢复,恢复后跑全量测试确认无伤。
4. **优先小步快跑**:每个功能块改完立即 `npm test`(引擎)或 `tsc`(UI 类型),绿了再动下一处——绝不在错误状态上加新修改。 4. **优先小步快跑**:每个功能块改完立即 `npm test`(引擎)或 `tsc`(UI 类型),绿了再动下一处——绝不在错误状态上加新修改。
5. **高危文件白名单**world.ts / events.ts / store.ts / styles.css / cultivation.ts)改动后必须 `git diff --stat` 核对改动面与预期一致(±文件行数应与你改的块吻合) 5. **高危文件白名单**world.ts / events.ts / store.ts / styles.css / cultivation.ts / **WorldSim.ts / worldsim-data.ts**)改动后必须 `git diff --stat` 核对改动面与预期一致。
6. 每次迭代收尾:`git status` 应干净 + `git log --oneline` 确认每步都有提交记录;凡 "git stash/checkout/rm" 类命令,执行前后都 `git status` 留痕。 6. 每次迭代收尾:`git status` 应干净 + `git log --oneline` 确认每步都有提交记录;凡 "git stash/checkout/rm" 类命令,执行前后都 `git status` 留痕。
- 提交前:`npm test` + `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。 - 提交前:`npm test`(当前 108 套件 7058 例全绿)+ `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。
- 平衡数值集中在 `game/data/`realms/pacing/buildings/events...),调平衡不改引擎流程。 - 平衡数值集中在 `game/data/``engine/sim/worldsim-data.ts`(世界循环参数唯一权威),调平衡不改引擎流程。
- 发布产物 `release/``out/` 均已 gitignore,勿入库。 - 发布产物 `release/``out/` 均已 gitignore,勿入库。
+60 -28
View File
@@ -4,6 +4,7 @@
一座庄园、一家修士、四邻势力,随年月流转:族人修炼突破、建筑产出、探秘寻宝、婚丧嫁娶; 一座庄园、一家修士、四邻势力,随年月流转:族人修炼突破、建筑产出、探秘寻宝、婚丧嫁娶;
仇家犯境则结阵应战;百年之际,望气定鼎,春秋原上写下一卷族史。 仇家犯境则结阵应战;百年之际,望气定鼎,春秋原上写下一卷族史。
**世界也在自演进**:市场有真库存与供需呼吸,NPC 有战略姿态与恩怨网,时代有盛世乱世的大势流转——天下兴衰与家族命运互为因果。
## 快速开始 ## 快速开始
@@ -11,32 +12,42 @@
npm install # 依赖(含 git.metona.cn 私有 registrytoken 在用户级 ~/.npmrc npm install # 依赖(含 git.metona.cn 私有 registrytoken 在用户级 ~/.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/DOMvitest 直测;单线程,实测 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/ KernelClock+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(时轮注册)· ApiFacadeGameFacade
systems/(生产·寿元·修炼·任务·事件·外交·婚育·渡劫·大比·寄读) Systems/生产/修炼/任务/事件/外交/婚育/渡劫/大比/寿元/战斗)
storage/ db(MetonaSqlark 单例) · slots(存档槽+快照) · migrate(版本迁移链 v1→v2) capabilities12 能力卡)· pluginManager(插件管线)
src/renderer/ui/ React + zustandstore 持 GameFacade;面板订阅 revision 刷新 engine/sim/ WorldSim(世界自进化)· worldsim-data(数值唯一权威)· Market(行情/买卖
engine/narrative/ 族谱/传记/定鼎/年轴/百年报告
data/ balance 表 + DataPackRegistryPILL_RECIPES / FORGE_RECIPES / ERA_CONF
storage/ db(MetonaSqlark 单例) · slots(槽+快照) · migrate(CURRENT_SCHEMA=2normalize 即迁移)
types/domain.ts 全量领域类型(改状态结构先看它)
src/renderer/ui/ React + zustandstore.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`(设计哲学与每个迭代的"为什么")。
+992
View File
@@ -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 资源/材料 idres / 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 丹药 idpillGain / 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 兵器 idforge 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 功法 id23 本全列)
| 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 建筑 id14 座)
| 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 天下家 idrelation / raid
`n-xuanying` 玄影沈氏 · `n-danxin` 丹心木氏 · `n-sihai` 四海王氏 · `n-nulei` 怒雷祝氏——**新档 worldgen 随机凑 4~8 家,你家不一定全有**;写不存在的也不报错,只是关系变化落空。新贵家族 id 是 `n-new-…`(世界新贵+你的模板家混出),写不得——只用上面四家作为"保底名册"。
### 10.7 秘境 idmissionId
| 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 -52
View File
@@ -15,83 +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 | 灯下春秋 | 实拍双 bugModal 被 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→14worldsim 观察/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-evalbonusOf/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 importvitest 直测。 - `src/renderer/game/` 无 React/DOM importvitest 直测;引擎域零 `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 注册表
- 月度 phaseproduction→aging→cultivation→missions→events→diplomacy→epilogue - 月度 phaseproduction→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/Eventsprotected 常驻 - **为什么**0.1.20 审计实证——raid 名义 0.03/月、实际落地 18%(11 命中仅 2 落地),日常事件每月占闸 70~86%,军事线被打 1/5 折扣。且传薪曾同年重复 12 次、渡劫直写会覆盖既有 pending。事件闸语义必须文档化
- **为什么**:玩法内容增量的"可插拔出口";事件多池让第三方能注入事件。
### 4. 存档分层 ### 4. WorldSim 世界自演进(0.1.14+ 的 4 层递进)
- metona-sqlark 在 rendererOPFS+ {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=20.1.14+ 全部新字段靠 `normalizeGameState` 幂等兜底(worldSim/era/prosperity/stance/worldAnnals…)+ `loadState``migrateIfNeeded`TOO_NEW 抛 MigrationError)。
- **为什么**:v1→v2 后才真正需要"转换"的只有一次;其余靠兜底。升 v3 无真实转换内容(每轮审计结论一致:**不升**)。
### 7. 世界种子与 MOD0.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. **修改覆盖事故**(爸爸点名):批量 python 替换命中旧文本 / `git checkout` 回滚误伤已修复文件 / 链式 replace 生成重复属性——多起"改着改着把之前修复覆盖了"。→ 教训:**修改纪律**——原子提交、替换三步断言、禁整文件 checkout、小步绿灯再前进(详见 AGENTS.md「修改纪律」) 6. **每 tick 全量存档**0.1.20):autosaveEvery 死代码 + saveChronicle O(N) 逐条查询 → 2160 月档每 tick 数千 OPFS。→ 教训:**autosave 节流必须真实接线;冗余表用增量写**
6. **模块单例纪律**MetonaSqlark 同库同 tab 一次连接(ARIA_LOCKED);spouse 竞态防了。→ 教训:**存储/稀罕资源一律单例** 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:先改 storerevision)再改组件;纯逻辑放 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 test108 套件 7058++ typecheck + GUI 冒烟需 WSLg 在线)+ 更新三份文档 + MOD_GUIDE(触 MOD 时)
``` ```
**一句话原则**:先复现、再小改、后金钟罩。改坏确定性比改错玩法更危险。 **一句话原则**:先复现、再小改、后金钟罩。改坏确定性比改错玩法更危险;改数值去 worldsim-data,改流程去 clocks,改事件去 fire()
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "chronicle-of-the-immortal-clan", "name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志", "productName": "仙途家族志",
"version": "0.1.13", "version": "0.1.41",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器", "description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "MetonaTeam", "author": "MetonaTeam",
@@ -15,7 +15,8 @@
"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" "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"
+40
View File
@@ -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!, {
+7 -1
View File
@@ -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)
+23
View File
@@ -12,6 +12,29 @@ import { startFxLayer, spawnBurst, stopFxLayer } from './ui/fx-canvas'
import { DriftLayer } from './ui/components/DriftLayer' import { DriftLayer } from './ui/components/DriftLayer'
import { seasonOf } from './game/data/season' 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)
const pendingEventId = useGameStore((s) => s.pendingEventId) const pendingEventId = useGameStore((s) => s.pendingEventId)
+3
View File
@@ -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 }>
} }
} }
-72
View File
@@ -1,72 +0,0 @@
export type FxKind = 'spark' | 'mist' | 'ripple' | 'blade' | 'pulse' | 'seasonShift' | 'drift'
export type FxMode = 'soft' | 'std' | 'full'
export interface FxEmit {
kind: FxKind
payload?: Record<string, number | string>
at: number
}
export interface FxBus {
emit(e: FxEmit): void
}
/** 竞态安全发射闸:单循环、队列不覆盖、档位过滤、reduced-motion 降级 */
export class FxGate {
private queue: FxEmit[] = []
private draining = false
mode: FxMode = 'std'
preferReduced = false
constructor(private bus: FxBus) {}
setMode(m: FxMode): void {
this.mode = m
}
setReduced(r: boolean): void {
this.preferReduced = r
}
allowed(kind: FxKind): boolean {
if (this.mode === 'soft') return kind === 'ripple' || kind === 'pulse' || kind === 'drift'
if (this.mode === 'full') return true
return kind !== 'blade' // std:火花/雾/涟漪/脉动,刀光仅 full
}
emit(kind: FxKind, payload?: FxEmit['payload']): void {
if (!this.allowed(kind) || this.preferReduced || this.draining) return
this.queue.push({ kind, payload, at: Date.now() })
this.drain()
}
private drain(): void {
if (this.draining) return
this.draining = true
// 单帧集中派发(不逐个 setTimeout,防止栈深+竞态覆盖)
const batch = this.queue
this.queue = []
try {
for (const e of batch) this.bus.emit(e)
} finally {
this.draining = false
}
}
pending(): number {
return this.queue.length + (this.draining ? 1 : 0)
}
/** 纯逻辑:资源差量(防负值与 NaN,供漂字用) */
static deltas(prev: Record<string, number>, next: Record<string, number>): Record<string, number> {
const out: Record<string, number> = {}
const keys = new Set([...Object.keys(prev), ...Object.keys(next)])
for (const k of keys) {
const a = prev[k] ?? 0
const b = next[k] ?? 0
if (Number.isFinite(a) && Number.isFinite(b) && b !== a) out[k] = b - a
}
return out
}
}
-56
View File
@@ -1,56 +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
description: string
dependencies?: string[]
}
export type PluginChange = { id: string; action: 'install' | 'remove' | 'enable' | 'disable' }
+124
View File
@@ -0,0 +1,124 @@
/**
* 安全算术表达式求值(0.1.36CSP 禁止 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
}
+6
View File
@@ -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
} }
+81 -3
View File
@@ -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.5specialtyId 传入时自动判定) */
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
} }
+37
View File
@@ -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)
}
+4
View File
@@ -45,6 +45,10 @@ 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 {
+6
View File
@@ -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
} }
+86 -3
View File
@@ -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)
}
+669
View File
@@ -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]
}
+21 -4
View File
@@ -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()]
} }
+6
View File
@@ -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
} }
+5
View File
@@ -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 = ['凡阶', '黄阶', '玄阶', '地阶', '天阶', '仙阶']
+39 -5
View File
@@ -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.28npcById 走静态+动态双源,包覆写是死路——与 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>
+5
View File
@@ -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 },
+48 -3
View File
@@ -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}`)
+9 -1
View File
@@ -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 {
+5
View File
@@ -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)
+140
View File
@@ -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
}
+35
View File
@@ -0,0 +1,35 @@
import { GameClock } from './clock'
import { Rng, seedToRng, RngHub } from './rng'
import type { WorldEventBus } from '../runtime/World'
/** 三合一内核:游戏时钟 + 种子随机(单一实例,由 GameEngine 持有)。
* 注:事件转发走 World.outWorldEventBus[]);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
+85
View File
@@ -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
/** 时轮 anchorphase 前/后挂勾(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
@@ -1,4 +1,4 @@
import { Season } from '../data/season' import { Season } from '../../data/season'
export const TIDES: string[] = [ export const TIDES: string[] = [
'立春', '雨水', '惊蛰', '春分', '清明', '谷雨', '立春', '雨水', '惊蛰', '春分', '清明', '谷雨',
@@ -7,12 +7,6 @@ export const TIDES: string[] = [
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒' '立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
] ]
/** 季节 → 节气(每月两个节气:tideOf(month, phase) 返回 0..1 */
export function tideOf(month: number, phase: 0 | 1): string {
const idx = Math.min(23, (month - 1) * 2 + phase)
return TIDES[idx] ?? '立春'
}
/** 年轮进度(0..1)——用于顶栏年环弧段 */ /** 年轮进度(0..1)——用于顶栏年环弧段 */
export function yearRing(month: number): number { export function yearRing(month: number): number {
return (month - 1) / 12 return (month - 1) / 12
@@ -46,6 +40,3 @@ export function seasonCssVars(season: Season): Record<string, string> {
} }
} }
export function tintLabel(season: Season): string {
return TINTS[season].name
}
@@ -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
@@ -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'
}
@@ -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 → +20karma=-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,4 +1,4 @@
import { GameState } from '../types/domain' import { GameState } from '../../types/domain'
export interface LegacyReport { export interface LegacyReport {
years: number[] years: number[]
+105
View File
@@ -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
+27
View File
@@ -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'
-132
View File
@@ -1,132 +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,
description: p.description,
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,
@@ -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
} }
@@ -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 全链)
// W10.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
@@ -1,19 +1,39 @@
import type { World } from '../world' import type { World } from '../World'
import { npcById } from '../../data/npcs' import { npcById } from '../../../data/npcs'
import { findEvent, fire } from './events' import { findEvent, fire } from './events'
import { WORLDSIM } from '../../sim/worldsim-data'
export function diplomacyTick(w: World): void { export function diplomacyTick(w: World): void {
const s = w.state const s = w.state
const drift = w.rng.chance(0.15) const drift = w.rng.chance(0.15)
for (const npc of Object.values(s.npcFamilies)) { for (const npc of Object.values(s.npcFamilies)) {
if (drift) { if (drift) {
if (npc.relation > 0) npc.relation -= 1 if (npc.relation > 0 && !npc.allied) npc.relation -= 1
else if (npc.relation < 0) npc.relation += 1 else if (npc.relation < 0) npc.relation += 1
} }
if (npc.relation < -50) { if (npc.relation < -50) {
const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0 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}`) 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
} }
} }
} }
@@ -21,10 +41,20 @@ export function diplomacyTick(w: World): void {
export function yearGrowth(w: World): void { export function yearGrowth(w: World): void {
const s = w.state 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)) { for (const npc of Object.values(s.npcFamilies)) {
const def = npcById(npc.id) const def = npcById(npc.id)
if (!def) continue
const [a, b] = def.powerGrowth const [a, b] = def.powerGrowth
npc.power += w.rng.int(a, b) 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(结盟年利)。`)
}
} }
} }
@@ -40,11 +70,25 @@ export const GIFT_TIERS = [40, 120, 300] as const
export function giftNpc(w: World, npcId: string, stones: number): boolean { export function giftNpc(w: World, npcId: string, stones: number): boolean {
const fam = w.state.family const fam = w.state.family
if (stones <= 0 || fam.stones < stones) return false
fam.stones -= stones
const npc = w.state.npcFamilies[npcId] const npc = w.state.npcFamilies[npcId]
const gain = calcGiftGain(stones) 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.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}`) w.log('info', `厚礼送往${npc.name},两家关系 +${gain}`)
return true return true
} }
@@ -52,9 +96,10 @@ export function giftNpc(w: World, npcId: string, stones: number): boolean {
export function makePeace(w: World, npcId: string): boolean { export function makePeace(w: World, npcId: string): boolean {
const fam = w.state.family const fam = w.state.family
const npc = w.state.npcFamilies[npcId] const npc = w.state.npcFamilies[npcId]
if (fam.stones < 200) return false if (!npc || fam.stones < 200) return false
fam.stones -= 200 fam.stones -= 200
npc.relation = Math.max(npc.relation + 35, 30) npc.relation = Math.max(npc.relation + 35, 30)
npc.power = Math.min(900, npc.power + 5)
w.chronicle('diplomacy', `${npc.name}立下和约,两家罢兵互市。`, undefined, true) w.chronicle('diplomacy', `${npc.name}立下和约,两家罢兵互市。`, undefined, true)
w.log('good', `${npc.name}言和。`) w.log('good', `${npc.name}言和。`)
return true return true
@@ -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=idfalse=/
*/
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', '储位已定——以才德为先,来日可期。')
}
} }
@@ -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)
}
@@ -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(
@@ -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.31MOD 建筑经 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))
}
}
@@ -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,声望+30karma+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
@@ -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
} }
@@ -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))
@@ -1,4 +1,5 @@
import { CotycPlugin } from '../core/plugin' import { CotycPlugin } from '../kernel/plugin'
import { registerPluginFactory } from './World'
/** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */ /** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */
export const examplePlugin: CotycPlugin = { export const examplePlugin: CotycPlugin = {
@@ -28,10 +29,7 @@ export const examplePlugin: CotycPlugin = {
} }
]) ])
}, },
uninstall(ctx) { // 0.1.23 契约:清理由 PluginManager 自动回滚(池/能力卡/数据包/时轮钩子全摘);uninstall 留作扩展点
ctx.removeEventPool('demo-peaks')
ctx.removeCapability('demo-guardian')
}
} }
/** 依赖缺失的坏插件:应被拒绝安装 */ /** 依赖缺失的坏插件:应被拒绝安装 */
@@ -44,3 +42,5 @@ export const brokenPlugin: CotycPlugin = {
dependencies: ['demo-not-exists'], dependencies: ['demo-not-exists'],
install() {} 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-M40.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 schema0.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
@@ -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-MallEvents 过滤) */
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
+191
View File
@@ -0,0 +1,191 @@
/**
* 0.1.24
* NPC 4~8 ////
* 使 rngseed + '::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 keyremovePool 退 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']
})
}
// 注册动态 defnpcById 双源命中)
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=120itemId/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,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)
}
-719
View File
@@ -1,719 +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()
}
requeueEvent(eventId: string): void {
const s = this.state
if (!s.eventQueue.includes(eventId)) s.eventQueue.push(eventId)
s.pendingEvent = undefined
}
/** 年度清理:剔除 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
View File
@@ -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 不抛错但数据不持久化),也有同样问题。
//
// 最终方案:
// · 自建 IndexedDBBackendidb-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 backendsetSqlarkBackend),没有 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 backendKVStore.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 内部有大量组件引用 backendFileManager 等),直接替换不安全
// → 跳过,让 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 }
+274
View File
@@ -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'))
})
}
}
+13 -52
View File
@@ -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
* v10.1.8 v20.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)}` }
} }
+27 -24
View File
@@ -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
} }
} }
+60 -4
View File
@@ -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 {
+3 -1
View File
@@ -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>
) )
} }
+5 -9
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
interface DriftItem { id: number; text: string; cls: string } interface DriftItem { id: number; text: string; cls: string; at: number }
/** 资源漂字层:每次 fx:drift 事件追加独立节点(互不覆盖),超 8 条自动修剪尾部 */ /** 资源漂字层:每次 fx:drift 事件追加独立节点(互不覆盖),超 8 条自动修剪尾部 */
export function DriftLayer() { export function DriftLayer() {
@@ -12,22 +12,18 @@ export function DriftLayer() {
const next: DriftItem[] = [] const next: DriftItem[] = []
for (const [k, v] of Object.entries(detail)) { for (const [k, v] of Object.entries(detail)) {
if (v === 0 || !Number.isFinite(v)) continue if (v === 0 || !Number.isFinite(v)) continue
next.push({ id: now + Math.random() + k.length, text: `${v > 0 ? '+' : ''}${v}`, cls: v > 0 ? 'pos' : 'neg' }) next.push({ id: now + Math.random() + k.length, text: `${v > 0 ? '+' : ''}${v}`, cls: v > 0 ? 'pos' : 'neg', at: now })
} }
if (next.length === 0) return if (next.length === 0) return
setItems((old) => [...old, ...next].slice(-8)) setItems((old) => (next.length + old.length > 8 ? [] : old).concat(next))
} }
window.addEventListener('fx:drift', onDrift) window.addEventListener('fx:drift', onDrift)
const timer = setInterval(() => setItems((old) => old.slice(0)), 2600) // 到期清理由 CSS fade 承担 return () => window.removeEventListener('fx:drift', onDrift)
return () => {
window.removeEventListener('fx:drift', onDrift)
clearInterval(timer)
}
}, []) }, [])
return ( return (
<div className="drift-layer"> <div className="drift-layer">
{items.map((it) => ( {items.map((it) => (
<span key={it.id} className={`drift ${it.cls}`}>{it.text}</span> <span key={it.id} className={`drift ${it.cls}`} onAnimationEnd={() => setItems((old) => old.filter((x) => x.id !== it.id))}>{it.text}</span>
))} ))}
</div> </div>
) )
+58 -4
View File
@@ -1,12 +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 { 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)
@@ -19,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
@@ -41,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)
@@ -54,6 +102,8 @@ export function EventModal() {
} }
const dorm = () => { const dorm = () => {
// 天命不可回避——不可"稍后再议"
if (isMandate) return
// 「稍后再议」:把事件按回队列,不消灭、不误弃 // 「稍后再议」:把事件按回队列,不消灭、不误弃
world.requeueEvent(pendingEventId) world.requeueEvent(pendingEventId)
closeModal() closeModal()
@@ -67,6 +117,7 @@ export function EventModal() {
allowOuter={false} allowOuter={false}
onClose={dorm} onClose={dorm}
closeLabel="稍后再议" closeLabel="稍后再议"
className={isAscension ? 'modal-ascension' : isMandate ? 'modal-mandate' : undefined}
footer={ footer={
squadPicking ? ( squadPicking ? (
<> <>
@@ -83,6 +134,8 @@ export function EventModal() {
{squadSel.length === 0 ? '族中无可战之人' : `出战!(共${squadSel.length}人)`} {squadSel.length === 0 ? '族中无可战之人' : `出战!(共${squadSel.length}人)`}
</button> </button>
</> </>
) : isMandate ? (
<div className="dim2" style={{ fontSize: '0.82rem' }}></div>
) : ( ) : (
<button className="btn" onClick={dorm}></button> <button className="btn" onClick={dorm}></button>
) )
@@ -123,7 +176,7 @@ export function EventModal() {
<div className="modal-opts"> <div className="modal-opts">
{squadPicking ? ( {squadPicking ? (
<div className="dim2"></div> <div className="dim2"></div>
) : ( ) : (
pendingEventDef.options.map((o, i) => { pendingEventDef.options.map((o, i) => {
const isRaid = raidIdx === i const isRaid = raidIdx === i
@@ -132,6 +185,7 @@ 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>
) )
}) })
+1 -1
View File
@@ -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)
+6 -5
View File
@@ -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>
) )
} })
+27 -24
View File
@@ -1,4 +1,4 @@
import { useGameStore } from '../store' import { memo } from 'react'
import { Character } from '../../game/types/domain' import { Character } from '../../game/types/domain'
import { describeRealm } from '../../game/data/realms' import { describeRealm } from '../../game/data/realms'
import { describeRoots } from '../../game/data/elements' import { describeRoots } from '../../game/data/elements'
@@ -13,18 +13,34 @@ const STATE_LABEL: Record<string, { label: string; cls: string }> = {
dead: { label: '已殁', cls: 'bad' } dead: { label: '已殁', cls: 'bad' }
} }
export function MemberCard({ c }: { c: Character }) { const C_STATE: Record<string, string> = {
const world = useGameStore((s) => s.world) idle: '',
const setSelected = useGameStore((s) => s.setSelected) meditation: 'good',
if (!world) return null expedition: 'warn',
wounded: 'bad',
dead: 'bad'
}
/**
* 0.1.39 MemberCard memo
* store world revision bump
* surname/isHead/age props c props
*/
export interface MemberCardProps {
c: Character
surname: string
isHead: boolean
age: number
onSelect: (id: string) => void
}
export const MemberCard = memo(function MemberCard({ c, surname, isHead, age, onSelect }: MemberCardProps) {
const dead = !c.alive const dead = !c.alive
const isHead = world.state.family.headId === c.id
const state = STATE_LABEL[dead ? 'dead' : c.state]
const blocked = !dead && c.realmProgress >= 100 && c.realm.major !== 'spirit' const blocked = !dead && c.realmProgress >= 100 && c.realm.major !== 'spirit'
return ( return (
<div className={`member-card ${dead ? 'dead' : ''} ${blocked ? 'blocked' : ''}`} onClick={() => setSelected(c.id)}> <div className={`member-card ${dead ? 'dead' : ''} ${blocked ? 'blocked' : ''}`} onClick={() => onSelect(c.id)}>
<div className="mc-head"> <div className="mc-head">
<div className="m-avatar">{world.state.family.surname}</div> <div className="m-avatar">{surname}</div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div className="member-name"> <div className="member-name">
<span>{c.name}</span> <span>{c.name}</span>
@@ -34,7 +50,7 @@ export function MemberCard({ c }: { c: Character }) {
<div className="member-realm"> <div className="member-realm">
{dead {dead
? `殁于${c.deathYear}年 · ${c.deathCause ?? '寿终'}` ? `殁于${c.deathYear}年 · ${c.deathCause ?? '寿终'}`
: `${describeRealm(c.realm)} · ${world.ageOf(c)}岁 · 修为${Math.floor(c.realmProgress)}%`} : `${describeRealm(c.realm)} · ${age}岁 · 修为${Math.floor(c.realmProgress)}%`}
</div> </div>
</div> </div>
</div> </div>
@@ -55,17 +71,4 @@ export function MemberCard({ c }: { c: Character }) {
)} )}
</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'
}
+22 -12
View File
@@ -5,12 +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/systems/cultivation' 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 { ModalShell } from './ModalShell' import { ModalShell } from './ModalShell'
import { buildBiography } from '../../game/core/biography' 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> = {
@@ -82,12 +82,8 @@ export function MemberModal({ member }: { member: Character }) {
</> </>
} }
> >
<div className="modal-title" style={{ fontSize: '1.4rem' }}> <div className="mm-brief">
{member.name} <span className={member.alive ? '' : 'bad'}>寿 {lifespan} · {describeRoots(member.roots)}</span>
{!member.alive && <span className="bad" style={{ fontSize: '1rem' }}> · </span>}
</div>
<div className="modal-cat">
{member.generation} · {member.gender === 'male' ? '男' : '女'} · {w.ageOf(member)} · 寿{lifespan} · {describeRoots(member.roots)}
</div> </div>
<div className="mm-grid"> <div className="mm-grid">
@@ -107,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>
@@ -252,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
@@ -270,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={() => {
+10 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, type ReactNode } from 'react' import { useEffect, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
export interface ModalShellProps { export interface ModalShellProps {
title: string title: string
@@ -10,6 +11,7 @@ export interface ModalShellProps {
footerAlign?: 'right' | 'center' | 'space' footerAlign?: 'right' | 'center' | 'space'
children: ReactNode children: ReactNode
closeLabel?: string closeLabel?: string
className?: string
} }
/** /**
@@ -28,7 +30,8 @@ export function ModalShell({
footer, footer,
footerAlign = 'right', footerAlign = 'right',
children, children,
closeLabel = '关闭' closeLabel = '关闭',
className
}: ModalShellProps) { }: ModalShellProps) {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -41,9 +44,11 @@ export function ModalShell({
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [onClose]) }, [onClose])
return ( // 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-outer" onClick={allowOuter ? onClose : undefined}>
<div className="modal" style={{ width: `min(92vw, ${width}px)` }} onClick={(e) => e.stopPropagation()} data-modal-kind="shell"> <div className={`modal ${className ?? ''}`} style={{ width: `min(92vw, ${width}px)` }} onClick={(e) => e.stopPropagation()} data-modal-kind="shell">
<button className="modal-close" onClick={onClose} title={closeLabel} aria-label={closeLabel}> <button className="modal-close" onClick={onClose} title={closeLabel} aria-label={closeLabel}>
× ×
</button> </button>
@@ -58,4 +63,6 @@ export function ModalShell({
</div> </div>
</div> </div>
) )
if (typeof document === 'undefined') return shell
return createPortal(shell, document.body)
} }
+22
View File
@@ -4,11 +4,17 @@ 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 (
<ModalShell <ModalShell
@@ -25,6 +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>
{majors.length > 0 && (
<div style={{ marginTop: 12, borderTop: '1px solid rgba(140,100,50,0.35)', paddingTop: 10 }}>
<div className="dim" style={{ marginBottom: 4 }}> </div>
{majors.map((e) => (
<div key={e.id} className="dim" style={{ fontSize: '0.9rem', lineHeight: 1.9 }}>· {e.month} {e.text}</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>
)}
</ModalShell> </ModalShell>
) )
} }
+1 -1
View File
@@ -1,5 +1,5 @@
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' import { ModalShell } from './ModalShell'
+1 -1
View File
@@ -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)
+19 -3
View File
@@ -53,7 +53,9 @@ function resize(): void {
} }
function seasonColor(): string { function seasonColor(): string {
const s = seasonOf(new Date().getMonth() + 1) as 'spring' | 'summer' | 'autumn' | 'winter' 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' 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'
} }
@@ -79,12 +81,20 @@ function spawn(kind: 'gold' | 'mist' | 'light', x?: number, y?: number): void {
if (particles.length > density * 6) particles.splice(0, particles.length - density * 6) // 竞态防胀 if (particles.length > density * 6) particles.splice(0, particles.length - density * 6) // 竞态防胀
} }
import { ensureFx } from './fx'
function frame(): void { function frame(): void {
if (!running) return 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) ctx?.clearRect(0, 0, window.innerWidth, window.innerHeight)
const dt = 1 const dt = 1
mistTimer += 1 mistTimer += 1
if (mistTimer % 18 === 0 && particles.length < density) spawn('mist') if (mistTimer % 18 === 0 && particles.length < density && g.mode !== 'soft') spawn('mist')
for (let i = particles.length - 1; i >= 0; i--) { for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i] const p = particles[i]
p.life += dt p.life += dt
@@ -134,10 +144,16 @@ export function stopFxLayer(): void {
if (raf) cancelAnimationFrame(raf) if (raf) cancelAnimationFrame(raf)
raf = 0 raf = 0
particles = [] particles = []
mistTimer = 0
window.removeEventListener('resize', resize)
} }
export function spawnBurst(kind: 'spark' | 'blade' | 'ripple', origin?: string): void { export function spawnBurst(kind: 'spark' | 'blade' | 'ripple', origin?: string): void {
if (!running) return if (!running) {
// 从空态唤醒(0.1.33:待机停帧后,事件到来即重启)
running = true
raf = window.requestAnimationFrame(frame as FrameRequestCallback)
}
if (kind === 'spark') { if (kind === 'spark') {
const X = window.innerWidth / 2 const X = window.innerWidth / 2
const Y = window.innerHeight * (origin === 'center' ? 0.5 : 0.3) const Y = window.innerHeight * (origin === 'center' ? 0.5 : 0.3)
+71 -1
View File
@@ -1,6 +1,76 @@
import { FxGate, FxKind } from '../game/core/fxqueue'
import { seasonOf } from '../game/data/season' import { seasonOf } from '../game/data/season'
export type FxKind = 'spark' | 'mist' | 'ripple' | 'blade' | 'pulse' | 'seasonShift' | 'drift'
export type FxMode = 'soft' | 'std' | 'full'
export interface FxEmit {
kind: FxKind
payload?: Record<string, number | string>
at: number
}
export interface FxBus {
emit(e: FxEmit): void
}
/** 竞态安全发射闸:单循环、队列不覆盖、档位过滤、reduced-motion 降级 */
export class FxGate {
private queue: FxEmit[] = []
private draining = false
mode: FxMode = 'std'
preferReduced = false
constructor(private bus: FxBus) {}
setMode(m: FxMode): void {
this.mode = m
}
setReduced(r: boolean): void {
this.preferReduced = r
}
allowed(kind: FxKind): boolean {
if (this.mode === 'soft') return kind === 'ripple' || kind === 'pulse' || kind === 'drift'
if (this.mode === 'full') return true
return true // 0.1.33 std 亦放行刀光(战场核心反馈,full 档仍全量)
}
emit(kind: FxKind, payload?: FxEmit['payload']): void {
if (!this.allowed(kind) || this.preferReduced || this.draining) return
this.queue.push({ kind, payload, at: Date.now() })
this.drain()
}
private drain(): void {
if (this.draining) return
this.draining = true
const batch = this.queue
this.queue = []
try {
for (const e of batch) this.bus.emit(e)
} finally {
this.draining = false
}
}
pending(): number {
return this.queue.length + (this.draining ? 1 : 0)
}
}
/** 纯逻辑:资源差量(防负值与 NaN,供漂字用) */
export function deltas(prev: Record<string, number>, next: Record<string, number>): Record<string, number> {
const out: Record<string, number> = {}
const keys = new Set([...Object.keys(prev), ...Object.keys(next)])
for (const k of keys) {
const a = prev[k] ?? 0
const b = next[k] ?? 0
if (Number.isFinite(a) && Number.isFinite(b) && b !== a) out[k] = b - a
}
return out
}
let gate: FxGate | null = null let gate: FxGate | null = null
export function fx(): FxGate | null { export function fx(): FxGate | null {
+29 -5
View File
@@ -1,6 +1,5 @@
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { useMemo, useState } from 'react' import { useMemo, useState, memo } from 'react'
import { ChronicleEntry } from '../../game/types/domain'
const CAT_NAME: Record<string, string> = { const CAT_NAME: Record<string, string> = {
birth: '诞庆', birth: '诞庆',
@@ -52,20 +51,45 @@ export default function ChroniclePanel() {
void revision void revision
const [filter, setFilter] = useState<string>('all') const [filter, setFilter] = useState<string>('all')
const [battleId, setBattleId] = useState<string | null>(null) const [battleId, setBattleId] = useState<string | null>(null)
const [draft, setDraft] = useState('')
const entries = useMemo(() => { const entries = useMemo(() => {
if (!world) return [] if (!world) return []
const all = [...world.state.chronicle] const all = [...world.state.chronicle]
const filtered = filter === 'all' ? all : all.filter((e) => e.category === filter) const filtered = filter === 'all' ? all : all.filter((e) => e.category === filter)
return filtered.slice().sort((a, b) => (b.year - a.year) || (b.month - a.month)) return filtered.slice().sort((a, b) => (b.year - a.year) || (b.month - a.month))
}, [world, filter, revision]) }, [world, filter, world ? world.state.chronicle.length : 0])
if (!world) return null if (!world) return null
const battles = world.state.battles.slice().reverse() const battles = world.state.battles.slice().reverse()
const battle = battles.find((b) => b.id === battleId) const battle = battles.find((b) => b.id === battleId)
const groupedEntries = useMemo(() => grouped(entries), [entries])
return ( return (
<div> <div>
<div className="help-text"> <div className="help-text">
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<input
className="ch-draft"
placeholder="题一笔族史……(如:某年宗祠大修,阖族祭祖)"
value={draft}
maxLength={80}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && draft.trim()) {
world.chronicle('misc', draft.trim(), undefined, false)
setDraft('')
}
}}
/>
<button
className="btn btn-sm"
disabled={!draft.trim()}
onClick={() => {
world.chronicle('misc', draft.trim(), undefined, false)
setDraft('')
}}
></button>
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
<button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : ''}`} onClick={() => setFilter('all')}></button> <button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : ''}`} onClick={() => setFilter('all')}></button>
@@ -76,7 +100,7 @@ export default function ChroniclePanel() {
))} ))}
</div> </div>
<div className="chronicle-list"> <div className="chronicle-list">
{grouped(entries).map((g) => ( {groupedEntries.map((g) => (
<div key={g.year}> <div key={g.year}>
<div className="ch-yearbar">{g.year} <span className="dim2">{g.items.length} · {statOf(g)}</span></div> <div className="ch-yearbar">{g.year} <span className="dim2">{g.items.length} · {statOf(g)}</span></div>
{g.items.map((e) => ( {g.items.map((e) => (
+38 -26
View File
@@ -1,34 +1,21 @@
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { npcById } from '../../game/data/npcs' import { npcById } from '../../game/data/npcs'
import { giftNpc, makePeace, marryNpcFamily, calcGiftGain, GIFT_TIERS } from '../../game/engine/systems/diplomacy' import { giftNpc, makePeace, marryNpcFamily, calcGiftGain, GIFT_TIERS } from '../../game/engine/runtime/Systems/diplomacy'
import { useMemo } from 'react' import { useMemo, memo } from 'react'
import { MAJOR_NAMES } from '../../game/data/realms' import { MAJOR_NAMES } from '../../game/data/realms'
import type { World } from '../../game/engine/runtime/World'
import type { NpcFamilyState } from '../../game/types/domain'
const REL_TYPE = (r: number) => const REL_TYPE = (r: number) =>
r >= 80 ? ['死生之交', 'good'] : r >= 50 ? ['通好之势', 'good'] : r >= 25 ? ['友善', 'warn'] : r >= 10 ? ['温和', 'warn'] : r > -10 ? ['中立', ''] : r > -30 ? ['嫌隙', ''] : r > -50 ? ['敌对', 'bad'] : ['仇雠', 'bad'] r >= 80 ? ['死生之交', 'good'] : r >= 50 ? ['通好之势', 'good'] : r >= 25 ? ['友善', 'warn'] : r >= 10 ? ['温和', 'warn'] : r > -10 ? ['中立', ''] : r > -30 ? ['嫌隙', ''] : r > -50 ? ['敌对', 'bad'] : ['仇雠', 'bad']
export default function DiplomacyPanel() { const NpcRow = memo(function NpcRow({ npc, world, bump }: { npc: NpcFamilyState; world: World; bump: () => void }) {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const w = world
const npcs = Object.values(w.state.npcFamilies)
const sorted = useMemo(() => npcs, [npcs])
return (
<div>
<div className="help-text">
</div>
{sorted.map((npc) => {
const def = npcById(npc.id) const def = npcById(npc.id)
if (!def) return null
const [relLabel, relCls] = REL_TYPE(npc.relation) const [relLabel, relCls] = REL_TYPE(npc.relation)
const married = npc.allied const married = npc.allied
return ( return (
<div key={npc.id} className="npc-row"> <div className="npc-row">
<div className="npc-name"> <div className="npc-name">
<div style={{ fontSize: '1.15rem' }}>{npc.name}</div> <div style={{ fontSize: '1.15rem' }}>{npc.name}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{def.style} · {npc.region}</div> <div className="dim2" style={{ fontSize: '0.78rem' }}>{def.style} · {npc.region}</div>
@@ -54,9 +41,9 @@ export default function DiplomacyPanel() {
<button <button
key={t} key={t}
className="btn btn-sm" className="btn btn-sm"
disabled={w.state.family.stones < t} disabled={world.state.family.stones < t}
title={`关系 +${calcGiftGain(t)}`} title={`关系 +${calcGiftGain(t)}`}
onClick={() => { giftNpc(w, npc.id, t); bump() }} onClick={() => { giftNpc(world, npc.id, t); bump() }}
> >
{t}(+{calcGiftGain(t)}) {t}(+{calcGiftGain(t)})
</button> </button>
@@ -66,7 +53,7 @@ export default function DiplomacyPanel() {
className="btn btn-sm" className="btn btn-sm"
disabled={npc.relation < 25 || married} disabled={npc.relation < 25 || married}
title={npc.relation < 25 ? `需关系≥25(当前${npc.relation}` : married ? '一姓已结亲' : '关系≥25方可联姻'} title={npc.relation < 25 ? `需关系≥25(当前${npc.relation}` : married ? '一姓已结亲' : '关系≥25方可联姻'}
onClick={() => { marryNpcFamily(w, npc.id); bump() }} onClick={() => { marryNpcFamily(world, npc.id); bump() }}
> >
{married ? '已联姻' : '联姻'} {married ? '已联姻' : '联姻'}
</button> </button>
@@ -75,7 +62,7 @@ export default function DiplomacyPanel() {
title="关系-20,可能招致劫掠;每年一次" title="关系-20,可能招致劫掠;每年一次"
onClick={() => { onClick={() => {
if (window.confirm(`${npc.name}寻衅?关系将下降 20,可能招致劫掠。`)) { if (window.confirm(`${npc.name}寻衅?关系将下降 20,可能招致劫掠。`)) {
w.tauntNpc(npc.id) world.tauntNpc(npc.id)
bump() bump()
} }
}} }}
@@ -83,14 +70,39 @@ export default function DiplomacyPanel() {
</button> </button>
{npc.relation < -30 && ( {npc.relation < -30 && (
<button className="btn btn-sm" onClick={() => { makePeace(w, npc.id); bump() }}> <button
className="btn btn-sm"
disabled={world.state.family.stones < 200}
title={world.state.family.stones < 200 ? '需灵石 200' : '与对方结下和约'}
onClick={() => { makePeace(world, npc.id); bump() }}
>
</button> </button>
)} )}
</div> </div>
</div> </div>
) )
})} })
export default function DiplomacyPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const w = world
const npcs = Object.values(w.state.npcFamilies)
const sorted = useMemo(() => npcs, [npcs])
return (
<div>
<div className="help-text">
</div>
{sorted.map((npc) => (
<NpcRow key={npc.id} npc={npc} world={w} bump={bump} />
))}
<div className="dim2" style={{ marginTop: 12 }}> <div className="dim2" style={{ marginTop: 12 }}>
</div> </div>
+19 -4
View File
@@ -1,10 +1,12 @@
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { MISSIONS, missionById } from '../../game/data/secrets' import { MISSIONS, missionById } from '../../game/data/secrets'
import { sendMission, recallAll } from '../../game/engine/systems/missions' import type { MissionDef } from '../../game/data/secrets'
import { combatPowerOf } from '../../game/engine/systems/combat' import { sendMission, recallAll } from '../../game/engine/runtime/Systems/missions'
import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
import { useState } from 'react' import { useState } from 'react'
import { describeRealm, MAJOR_ORDER } from '../../game/data/realms' import { describeRealm, MAJOR_ORDER } from '../../game/data/realms'
import { FORMATIONS, FormationId } from '../../game/data/formations' import { FORMATIONS, FormationId } from '../../game/data/formations'
import { WorldSim } from '../../game/engine/sim/WorldSim'
export default function ExpeditionPanel() { export default function ExpeditionPanel() {
const world = useGameStore((s) => s.world) const world = useGameStore((s) => s.world)
@@ -24,6 +26,14 @@ export default function ExpeditionPanel() {
) )
const def = selectedMissionDef ? missionById(selectedMissionDef) : null const def = selectedMissionDef ? missionById(selectedMissionDef) : null
const qiOf = (id: string): number | null => {
try {
return Math.round(new WorldSim(w).secretQiOf(id))
} catch {
return null
}
}
const toggle = (id: string) => { const toggle = (id: string) => {
setSquad((old) => (old.includes(id) ? old.filter((x) => x !== id) : [...old, id])) setSquad((old) => (old.includes(id) ? old.filter((x) => x !== id) : [...old, id]))
} }
@@ -37,7 +47,7 @@ export default function ExpeditionPanel() {
<div className="mission-card"> <div className="mission-card">
<div className="card-title" style={{ fontSize: '1rem' }}></div> <div className="card-title" style={{ fontSize: '1rem' }}></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(180px,1fr))', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(180px,1fr))', gap: 8 }}>
{MISSIONS.map((m) => { {[...MISSIONS, ...Object.values((w.state.worldSim?.wonders ?? {}) as Record<string, MissionDef>)].map((m) => {
const sel = selectedMissionDef === m.id const sel = selectedMissionDef === m.id
return ( return (
<div <div
@@ -58,6 +68,11 @@ export default function ExpeditionPanel() {
<div className="dim2" style={{ fontSize: '0.78rem' }}>{m.region} · {describeRealm({ major: m.realmHint, minor: 0 })}</div> <div className="dim2" style={{ fontSize: '0.78rem' }}>{m.region} · {describeRealm({ major: m.realmHint, minor: 0 })}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{m.minMembers}-{m.maxMembers} · {m.stages.length}</div> <div className="dim2" style={{ fontSize: '0.78rem' }}>{m.minMembers}-{m.maxMembers} · {m.stages.length}</div>
<div className="dim" style={{ fontSize: '0.8rem', marginTop: 3 }}>{m.desc}</div> <div className="dim" style={{ fontSize: '0.8rem', marginTop: 3 }}>{m.desc}</div>
{qiOf(m.id) !== null && (
<div className="dim2" style={{ fontSize: '0.76rem', marginTop: 3 }}>
{qiOf(m.id)}% -62/
</div>
)}
</div> </div>
) )
})} })}
@@ -112,7 +127,7 @@ export default function ExpeditionPanel() {
<div> <div>
<div className="card-title" style={{ fontSize: '1rem', marginTop: 8 }}></div> <div className="card-title" style={{ fontSize: '1rem', marginTop: 8 }}></div>
{active.map((m) => { {active.map((m) => {
const def2 = missionById(m.defId) const def2 = ((w.state.worldSim?.wonders?.[m.defId] as MissionDef | undefined) ?? missionById(m.defId))
const stage = def2.stages[m.stage] const stage = def2.stages[m.stage]
return ( return (
<div key={m.id} className="mission-card"> <div key={m.id} className="mission-card">
+46 -10
View File
@@ -1,4 +1,5 @@
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { useMemo } from 'react'
import { MemberCard } from '../components/MemberCard' import { MemberCard } from '../components/MemberCard'
import { MemberModal } from '../components/MemberModal' import { MemberModal } from '../components/MemberModal'
import { Character } from '../../game/types/domain' import { Character } from '../../game/types/domain'
@@ -11,25 +12,29 @@ export default function FamilyPanel() {
void revision void revision
if (!world) return null if (!world) return null
const s = world.state const s = world.state
const surname = s.family.surname
const headId = s.family.headId
const { alive, dead, sorted, marriageReady } = useMemo(() => {
const alive = Object.values(s.members).filter((c) => c.alive) const alive = Object.values(s.members).filter((c) => c.alive)
const dead = Object.values(s.members).filter((c) => !c.alive) const dead = Object.values(s.members).filter((c) => !c.alive)
const sorted = [...alive].sort((a, b) => { const sorted = [...alive].sort((a, b) => {
const aHead = a.id === s.family.headId ? -1 : 0 const aHead = a.id === headId ? -1 : 0
const bHead = b.id === s.family.headId ? -1 : 0 const bHead = b.id === headId ? -1 : 0
return aHead - bHead || world.ageOf(b) - world.ageOf(a) return aHead - bHead || world.ageOf(b) - world.ageOf(a)
}) })
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined const marriageReady = alive.filter(
const marriageReady = (() => {
if (!world) return []
return alive.filter(
(c) => (c) =>
world.ageOf(c) >= 16 && world.ageOf(c) >= 16 &&
world.ageOf(c) <= 46 && world.ageOf(c) <= 46 &&
c.state !== 'expedition' && c.state !== 'expedition' &&
(!c.spouseId || world.isWidowed(c)) (!c.spouseId || world.isWidowed(c))
) )
})() return { alive, dead, sorted, marriageReady }
}, [s.members, headId, world])
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined
// 0.1.39:稳定回调引用——避免每次渲染新建函数导致 MemberCard memo 失效
const onSelect = setSelected
return ( return (
<div> <div>
@@ -44,6 +49,29 @@ export default function FamilyPanel() {
<span className="dim"> <b className="gold">{s.family.generation}</b></span> <span className="dim"> <b className="gold">{s.family.generation}</b></span>
</div> </div>
</div> </div>
{/* 0.1.37 因果条带 */}
<div className="karma-strip" style={{
marginBottom: 12, padding: '8px 14px', borderRadius: 6,
display: 'flex', alignItems: 'center', gap: 12,
background: karmaBg(s.family.karma),
}}>
<span className="dim" style={{ whiteSpace: 'nowrap' }}></span>
<div style={{ flex: 1, position: 'relative', height: 8, borderRadius: 4, background: 'rgba(60,50,35,0.6)' }}>
<div style={{
position: 'absolute', top: 0, bottom: 0,
left: '50%', width: `${Math.min(50, Math.abs(s.family.karma ?? 0) * 0.5)}%`,
transform: s.family.karma < 0 ? 'translateX(-100%)' : 'none',
borderRadius: 4,
background: s.family.karma > 0 ? 'linear-gradient(90deg,#5a7a3a,#8fae6a)' : s.family.karma < 0 ? 'linear-gradient(90deg,#8b3a2a,#c05a41)' : 'transparent',
}} />
</div>
<b style={{ color: s.family.karma > 30 ? '#8fae6a' : s.family.karma < -20 ? '#c05a41' : '#c9a227', minWidth: 50, textAlign: 'right' }}>
{s.family.karma > 0 ? '+' : ''}{Math.round((s.family.karma ?? 0) * 10) / 10}
</b>
<span className="dim2" style={{ whiteSpace: 'nowrap' }}>
{(s.family.karma ?? 0) > 30 ? '积善余庆' : (s.family.karma ?? 0) < -20 ? '因果缠身' : '平淡无功'}
</span>
</div>
{marriageReady.length >= 2 && ( {marriageReady.length >= 2 && (
<div <div
className="matchmaker-strip" className="matchmaker-strip"
@@ -55,10 +83,10 @@ export default function FamilyPanel() {
)} )}
<div className="member-grid"> <div className="member-grid">
{sorted.map((c) => ( {sorted.map((c) => (
<MemberCard key={c.id} c={c} /> <MemberCard key={c.id} c={c} surname={surname} isHead={c.id === headId} age={world.ageOf(c)} onSelect={onSelect} />
))} ))}
{dead.map((c) => ( {dead.map((c) => (
<MemberCard key={c.id} c={c} /> <MemberCard key={c.id} c={c} surname={surname} isHead={false} age={world.ageOf(c)} onSelect={onSelect} />
))} ))}
</div> </div>
{alive.length === 0 && ( {alive.length === 0 && (
@@ -71,3 +99,11 @@ export default function FamilyPanel() {
</div> </div>
) )
} }
/** 0.1.37 karma 条带背景色 */
function karmaBg(k: number | undefined): string {
const v = k ?? 0
if (v > 30) return 'rgba(80,100,50,0.15)'
if (v < -20) return 'rgba(120,50,40,0.15)'
return 'rgba(50,45,35,0.1)'
}
+3 -4
View File
@@ -1,8 +1,8 @@
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { computeGenealogy } from '../../game/core/genealogy' import { computeGenealogy } from '../../game/engine/narrative/genealogy'
import { describeRealm } from '../../game/data/realms' import { describeRealm } from '../../game/data/realms'
import { Character, FamilyState } from '../../game/types/domain' import { Character, FamilyState } from '../../game/types/domain'
import { useState } from 'react' import { useMemo, useState } from 'react'
import { MemberModal } from '../components/MemberModal' import { MemberModal } from '../components/MemberModal'
export default function GenealogyPanel() { export default function GenealogyPanel() {
@@ -16,7 +16,7 @@ export default function GenealogyPanel() {
if (!world) return null if (!world) return null
const w = world const w = world
const s = w.state const s = w.state
const rows = computeGenealogy(w) const rows = useMemo(() => computeGenealogy(w), [w, revision])
const deceased = !deceasedTab const deceased = !deceasedTab
? [] ? []
: Object.values(s.members).filter((c) => !c.alive).sort((a, b) => (a.deathYear ?? 0) - (b.deathYear ?? 0)) : Object.values(s.members).filter((c) => !c.alive).sort((a, b) => (a.deathYear ?? 0) - (b.deathYear ?? 0))
@@ -125,4 +125,3 @@ const POST_NAME: Record<string, string> = {
master: '掌教' master: '掌教'
} }
export type { FamilyState }
+76 -10
View File
@@ -1,20 +1,21 @@
import { useState } from 'react' import { useState } from 'react'
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { computeLegacy, resolveLegacy } from '../../game/core/legacy' import { computeLegacy, resolveLegacy } from '../../game/engine/narrative/legacy'
import { resolveLegacy as doesIt } from '../../game/core/legacy'
import { ResolveModal } from '../components/ResolveModal' import { ResolveModal } from '../components/ResolveModal'
import { buildBiography } from '../../game/core/biography' import { buildBiography } from '../../game/engine/narrative/biography'
import { yearAxis, decadeLabel } from '../../game/core/yearaxis' import { yearAxis, decadeLabel } from '../../game/engine/narrative/yearaxis'
import { buildReport, verdictLine } from '../../game/core/legacyreport' import { buildReport, verdictLine } from '../../game/engine/narrative/legacyreport'
import { buildSaga, sagaKindName, sagaKindClass } from '../../game/engine/narrative/saga'
import { buildChronicleScroll, scrollKindName, scrollKindClass } from '../../game/engine/narrative/chronicle-scroll'
import type { WorldSimState } from '../../game/engine/sim/worldsim-data'
void doesIt
export default function LegacyPanel() { export default function LegacyPanel() {
const world = useGameStore((s) => s.world) const world = useGameStore((s) => s.world)
const revision = useGameStore((s) => s.revision) const revision = useGameStore((s) => s.revision)
void revision void revision
const [showResolve, setShowResolve] = useState(false) const [showResolve, setShowResolve] = useState(false)
const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis' | 'legacy'>('dims') const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis' | 'legacy' | 'cscroll'>('dims')
if (!world) return null if (!world) return null
const w = world const w = world
const s = w.state const s = w.state
@@ -56,6 +57,7 @@ export default function LegacyPanel() {
<button className="btn" onClick={() => setView('report')}></button> <button className="btn" onClick={() => setView('report')}></button>
<button className="btn" onClick={() => setView('chronicle')}></button> <button className="btn" onClick={() => setView('chronicle')}></button>
<button className="btn" onClick={() => setView('scroll')}></button> <button className="btn" onClick={() => setView('scroll')}></button>
<button className="btn" onClick={() => setView('cscroll')}></button>
<button className="btn" onClick={() => setView('axis')}></button> <button className="btn" onClick={() => setView('axis')}></button>
<button className="btn" onClick={() => setView('legacy')}></button> <button className="btn" onClick={() => setView('legacy')}></button>
</div> </div>
@@ -83,7 +85,7 @@ export default function LegacyPanel() {
<b className="gold">{dims.total}</b> <b className="gold">{dims.total}</b>
</div> </div>
<div className="dim2" style={{ marginTop: 8 }}> <div className="dim2" style={{ marginTop: 8 }}>
=· · =· · =· · =· =· · =· · =· · =· · =
</div> </div>
</div> </div>
)} )}
@@ -120,6 +122,8 @@ export default function LegacyPanel() {
{view === 'scroll' && <ScrollScroll />} {view === 'scroll' && <ScrollScroll />}
{view === 'cscroll' && <ChronicleScrollView />}
{view === 'chronicle' && ( {view === 'chronicle' && (
<div className="card"> <div className="card">
<div className="card-title"></div> <div className="card-title"></div>
@@ -157,6 +161,25 @@ function LegacyReportView() {
<div className="dim" style={{ marginTop: 10 }}>{Object.entries(r.deathCauses).filter(([k]) => k !== '').sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k}×${v}`).join(' · ') || '尚无记录'}</div> <div className="dim" style={{ marginTop: 10 }}>{Object.entries(r.deathCauses).filter(([k]) => k !== '').sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k}×${v}`).join(' · ') || '尚无记录'}</div>
<div className="dim" style={{ marginTop: 6 }}>{r.breakthroughs.slice(-6).map((b) => `${b.year}年×${b.count}`).join(' · ')}</div> <div className="dim" style={{ marginTop: 6 }}>{r.breakthroughs.slice(-6).map((b) => `${b.year}年×${b.count}`).join(' · ')}</div>
<div className="resolve-poem" style={{ marginTop: 14 }}>{verdictLine(r)}</div> <div className="resolve-poem" style={{ marginTop: 14 }}>{verdictLine(r)}</div>
{/* 0.1.40 天下大事记摘要 */}
{(() => {
const ws = world.state.worldSim as WorldSimState | undefined
if (!ws) return null
const saga = buildSaga(ws).slice(0, 12)
if (saga.length === 0) return null
return (
<div style={{ marginTop: 14 }}>
<div className="dim" style={{ marginBottom: 4 }}></div>
{saga.map((e, i) => (
<div key={i} className="ch-item" style={{ padding: '4px 8px', fontSize: '0.84rem' }}>
<span className="ch-month">{e.year}</span>
<span className={`tag ${sagaKindClass(e.kind)}`} style={{ marginRight: 6, fontSize: '0.72rem' }}>{sagaKindName(e.kind)}</span>
<span className="dim">{e.text}</span>
</div>
))}
</div>
)
})()}
</> </>
)} )}
</div> </div>
@@ -218,9 +241,20 @@ function ScrollScroll() {
<div className="card-title"></div> <div className="card-title"></div>
{mem.slice(0, 40).map((c) => { {mem.slice(0, 40).map((c) => {
const bio = buildBiography(world.state, c.id) const bio = buildBiography(world.state, c.id)
const isAscended = c.deathCause === '飞升太虚' || c.deathCause === '云游飞升'
const isAscensionFall = c.deathCause === '飞升劫陨'
const isFengxian = c.traits.includes('fengxian') && c.alive
return ( return (
<div key={c.id} className="bio-row"> <div key={c.id} className="bio-row" style={
isAscended ? { borderLeft: '3px solid #c9a227', paddingLeft: 8 }
: isAscensionFall ? { borderLeft: '3px solid #c05a41', paddingLeft: 8 }
: isFengxian ? { borderLeft: '3px solid #8fae6a', paddingLeft: 8 }
: undefined
}>
<b>{c.name}</b> <b>{c.name}</b>
{isAscended && <span className="tag head-t" style={{ marginLeft: 6 }}></span>}
{isAscensionFall && <span className="tag bad-t-bg" style={{ marginLeft: 6 }}></span>}
{isFengxian && <span className="tag good-t-bg" style={{ marginLeft: 6 }}></span>}
<span className="dim" style={{ marginLeft: 8 }}> <span className="dim" style={{ marginLeft: 8 }}>
{bio.map((l) => l.text).join(' ').slice(0, 90)}{bio.map((l) => l.text).join(' ').length > 90 ? '……' : ''} {bio.map((l) => l.text).join(' ').slice(0, 90)}{bio.map((l) => l.text).join(' ').length > 90 ? '……' : ''}
</span> </span>
@@ -231,8 +265,40 @@ function ScrollScroll() {
) )
} }
function ChronicleScrollView() {
const world = useGameStore((s) => s.world)
if (!world) return null
const ws = world.state.worldSim as WorldSimState | undefined
const scroll = buildChronicleScroll(world.state, ws)
return (
<div className="card">
<div className="card-title">·· </div>
{scroll.length === 0 ? (
<div className="dim"></div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 500, overflowY: 'auto' }}>
{scroll.map((e, i) => (
<div key={i} className="ch-item" style={{
padding: '5px 8px',
borderLeft: e.important ? '3px solid #c9a227' : undefined,
paddingLeft: e.important ? 10 : 8
}}>
<span className="ch-month">{e.year}</span>
<span className={`tag ${scrollKindClass(e.kind)}`} style={{ marginRight: 6, fontSize: '0.72rem' }}>{scrollKindName(e.kind)}</span>
<span className={e.important ? 'gold-t' : ''}>{e.text}</span>
</div>
))}
</div>
)}
<div className="dim2" style={{ marginTop: 8 }}>
</div>
</div>
)
}
function dimName(k: string): string { function dimName(k: string): string {
const map: Record<string, string> = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火' } const map: Record<string, string> = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火', yinGuo: '因果' }
return map[k] ?? k return map[k] ?? k
} }

Some files were not shown because too many files have changed in this diff Show More