Compare commits

..
5 Commits
30 changed files with 1461 additions and 177 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
# AGENTS.md
仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。
当前版本 **0.1.39**(《淬体·洗髓》:missionIds 只增不减根治 + chronicle 智能裁剪保留里程碑 + worldsim tick 入口双闸守卫 + purgeNpc 清理 worldGen.npcs/relations 僵尸定义 + MemberCard memo 化脱离 store 订阅 + 飞升叙事视觉强化)。
当前版本 **0.1.40**(《天命·裂痕》:天命议题系统 20 条 5 类种子派生选取 + NPC 宗主更替四模式 + NPC 势力分裂 + 灰烬重生新贵补位 + 建筑专精系统 extra×1.5 + UI memo 化 4 处 + 天下大事记叙事层 saga.ts + WorldPanel/LegacyPanel 大事记展示)。
## 命令
@@ -64,7 +64,7 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
- **插件架构**0.1.8 起,路径已迁 engine/runtime|kernel):`kernel/plugin.ts` 协议、`runtime/pluginManager.ts` 安装管线(依赖校验/异常回滚/追踪型上下文——注销时钩子全摘)、`plugin-bootstrap.ts` 三核心插件(core-systems/core-data/core-events protected)。事件多池 `world.eventPools`**findEvent 必须带 world 参数查池**。能力卡 12 张经 `viaCap` 注册;tournament/tribulation/apprentice/season 四卡各自内部检查 `w.sysEnabled`(无独立钩子)。
- **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.39 基线**0.1.25/0.1.26/0.1.31 零漂;0.1.27/0.1.28/0.1.29/0.1.30/0.1.32/0.1.33 受控变更重算;0.1.39 purgeNpc 清理 worldGen.npcs/relations 僵尸定义——bell-seed-2 三档受控漂移,其余零漂)。指纹 `tests/fingerprint.helper.ts` 含全世界轴(era/stance/prosperity/relationsWithOthers/calamity/pendingEvent/eventQueue/worldAnnals/**worldGen 摘要/sagaAnnals/distress/declineYears 汇总**——0.1.27 生灭/世鉴路径真实锁定)。**有意变更时序/数值时**:指纹一并重算并在注释注明原因。
- **金钟罩(双基线)**`tests/clock.test.ts``GOLDEN`(直调冻结世界)+ `GOLDEN_RESOLVED`(自动 resolve 长跑"现实"世界),各 3 seed × 三档月数(560/1200/2160)共 18 值。当前为 **0.1.40 基线**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演化增强+建筑专精——金钟罩重算)。指纹 `tests/fingerprint.helper.ts` 含全世界轴(era/stance/prosperity/relationsWithOthers/calamity/pendingEvent/eventQueue/worldAnnals/**worldGen 摘要/sagaAnnals/distress/declineYears 汇总**——0.1.27 生灭/世鉴路径真实锁定)。**有意变更时序/数值时**:指纹一并重算并在注释注明原因。
## 世界自演进模型(0.1.14 sim/ 层,改数值勿动流程)
+3 -2
View File
@@ -42,7 +42,7 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
## 守护机制
- **双金钟罩**`tests/clock.test.ts``GOLDEN`(直调冻结世界)+ `GOLDEN_RESOLVED`(自动 resolve 长跑“现实”世界),各 3 固定 seed × 560/1200/2160 月(47/100/180 年)= 18 指纹;指纹含全世界轴(era/stance/prosperity/关系网/事件闸);**0.1.39 基线**。有意变更时序需受控更新并注明。
- **双金钟罩**`tests/clock.test.ts``GOLDEN`(直调冻结世界)+ `GOLDEN_RESOLVED`(自动 resolve 长跑“现实”世界),各 3 固定 seed × 560/1200/2160 月(47/100/180 年)= 18 指纹;指纹含全世界轴(era/stance/prosperity/关系网/事件闸);**0.1.40 基线**。有意变更时序需受控更新并注明。
- **事件闸**pending 唯一入口 `fire(w,id,priority)`——高优(raid/渡劫/大比)可顶替被占者,被顶替者入队下月兑现;`applyEventChoice` 前置校验防陈旧 Modal 二次结算。
- **插件化**:万物皆是插件(系统/数据包/事件池),三核心插件 protected 常驻;示例见 tests/helpers/examplePlugin.ts。
- **可回放**:种子随机(sfc32,引擎域零 Math.random+ 快照回放保真(restore 重建内核+回置 rng)。
@@ -55,7 +55,7 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
- 图标资源 resources/icon.png|ico —— electron-builder 自动使用
- 注意:WSL/Linux 交叉打 win 包可行但未签名,正规发布请用 Windows 侧重跑
## 版本沿革(0.1.0 → 0.1.39
## 版本沿革(0.1.0 → 0.1.40
| 版本 | 主题 |
|---|---|
@@ -88,5 +88,6 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
| 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 大事记展示) |
详见:`docs/PROJECT_MEMORY.md`(设计哲学与每个迭代的"为什么")。
+2 -2
View File
@@ -1,6 +1,6 @@
# MOD 开发教程 · Chronicle of the Immortal Clan
> 版本 **0.1.39·《淬体·洗髓》** · MOD_SCHEMA_VERSION = 1 · 全口纯 JSON(零脚本)
> 版本 **0.1.40·《天命·裂痕》** · MOD_SCHEMA_VERSION = 1 · 全口纯 JSON(零脚本)
> **本教程即全部所需**:你不需要读任何源码或类型文件——所有 MOD 能用的字段、取值、默认值、坑点、示例都写在下面。
> 照例动手即可:最快 5 分钟做出你的第一个 MOD。
@@ -989,4 +989,4 @@ MOD 可以注册**自定义奇观类型**,加入年首种子派生选取池:
> 0.1.35 涤荡注:无存档迁移(游戏未发布——旧档随版本演进失效属预期);`patch` 层按插件归属回滚;validate 覆盖 §12 全部规则。
> 0.1.37 因果奇观注:奇观零 rng(种子派生选取池含 MOD 扩展);`eff.karma` 天然支持;`wonderChance`/`karmaDecayRate`/`karmaHighThreshold`/`karmaLowThreshold` 可经 worldNum 覆写;飞升事件 `ev-ascension-*` 前缀保护防劫持。
<sub>⚔️ 本文档随游戏版本更新(0.1.39)。字段若有新增,此处先行。</sub>
<sub>⚔️ 本文档随游戏版本更新(0.1.40)。字段若有新增,此处先行。</sub>
+4 -3
View File
@@ -53,6 +53,7 @@
| 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 百年报告引用)|
## 三、关键架构决策(及为什么)
@@ -146,7 +147,7 @@
2. GameFacade act 通道与面板直调并存(**仲裁结论**:直调是事实通道、act 为测试/自动化通道——actDirect 已补齐 24 项等价映射)。
3. `guide.ts`(引导任务)只做 UI 条,钩子目标点不完("提示"非"强制")。
4. `family.flag` 长线键已有 3 年剪枝、但 `state.missions/missionIds` 只增不减(未剪枝)。
5. NPC 四家是固定成分(无"覆灭/新贵补位"——0.1.19 留作 0.1.22+ 候选)附庸/吞并仅 UI"衰"标记。
5. ~~NPC 四家是固定成分(无"覆灭/新贵补位"——0.1.19 留作 0.1.22+ 候选)~~ **已解决**0.1.23 覆灭/新贵补位 + 0.1.40 宗主更替四模式/势力分裂/灰烬重生)。附庸/吞并仅 UI"衰"标记。
6. `worldsim` 的能力卡开关无独立钩子(viaCap 恒真已注册,实际不可关)——如需可关需重构。
## 七、已知技术债补充(0.1.26)
@@ -154,7 +155,7 @@
7. MOD 目录形态仅支持单文件(目录/分包/依赖多个 .cotymod 自动解析留待)。
8. CALAMITY 与 WORLDSTIM 数值表的大规模注入(多 MOD 叠加平衡)未做。
9. NPC"中期势力轮替"曲线(newborn 频度/覆灭线)仍单参数,可寻参数化。
10. **测试规模 7011**102 套件矩阵域),后续新增机制优先在对应矩阵域追加参(seed×组合),避免重开大文件。
10. **测试规模 7058**108 套件矩阵域),后续新增机制优先在对应矩阵域追加参(seed×组合),避免重开大文件。
11. **缓做项(0.1.28 审计定案)**declineYears 盛世误触/闪烁——重平衡归 0.1.29 独立调参(避免二次基线漂移);CALAMITY_FAMILY 独立口收益低(灾因口已覆盖);ERA_DURA/REGION_TIDE 松弛版数值口 0.1.29+(必须走"默认优先+聚合"模式)。
## 八、如何开始修改(给下一个智能体的路径)
@@ -165,7 +166,7 @@
改结构:注册进 clocks/plugins;世界循环参数唯一权威 = worldsim-data.ts
→ 5. 改 UI:先改 storerevision)再改组件;纯逻辑放 game/engine 可测
→ 6. 加测试:双金钟罩不动,新增用例挂 tests/xxx-0.1.x.test.ts(按主题命名)
→ 7. 提交前:npm test102 套件 7011++ typecheck + GUI 冒烟需 WSLg 在线)+ 更新三份文档 + MOD_GUIDE(触 MOD 时)
→ 7. 提交前:npm test108 套件 7058++ typecheck + GUI 冒烟需 WSLg 在线)+ 更新三份文档 + MOD_GUIDE(触 MOD 时)
```
**一句话原则**:先复现、再小改、后金钟罩。改坏确定性比改错玩法更危险;改数值去 worldsim-data,改流程去 clocks,改事件去 fire()。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志",
"version": "0.1.39",
"version": "0.1.40",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js",
"author": "MetonaTeam",
+14 -3
View File
@@ -152,13 +152,24 @@ export function buildingYieldOf(id: string, level: number): Record<string, numbe
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 为单一权威。 */
export function bonusOf(id: string, key: string, level: number): number {
* 白名单校验后求值——所有数字加成以 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)
return Number.isFinite(v) ? v : 0
if (!Number.isFinite(v)) return 0
// 0.1.40 专精加成
return isSpecialty(id, specialtyId) ? v * SPECIALTY_MULT : v
}
+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]
}
+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'
}
@@ -38,7 +38,7 @@ export function monthlyRate(w: World, c: Character, herbFactor = 1): number {
rate *= 0.65
}
const buildings = st.family.buildings
rate *= 1 + bonusOf('juling', 'expBonus', buildings['juling'] ?? 0)
rate *= 1 + bonusOf('juling', 'expBonus', buildings['juling'] ?? 0, st.family.flag['buildingSpecialty'] as string | undefined)
if (w.sysEnabled('season')) rate *= 1 + seasonMod(st.month, 'cult')
rate *= 1 + w.postBonus('expAll')
if (st.family.flag['fengFeiBless']) rate *= 1.05
@@ -49,7 +49,7 @@ export function monthlyRate(w: World, c: Character, herbFactor = 1): number {
if (c.state === 'meditation') {
rate *= 1.35
rate *= 1 + w.postBonus('meditation') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0)
rate *= 1 + bonusOf('dongfu', 'expBonus', buildings['dongfu'] ?? 0)
rate *= 1 + bonusOf('dongfu', 'expBonus', buildings['dongfu'] ?? 0, st.family.flag['buildingSpecialty'] as string | undefined)
} else if (c.state === 'expedition') {
rate *= 0.25
} else if (c.state === 'wounded') {
@@ -12,6 +12,7 @@ import { pack } from '../../../data/registry'
import { findInheritor } from '../creation'
import { resolveTribulation, resolveAscension } from './tribulation'
import { nextRealm, describeRealm } from '../../../data/realms'
import { findMandateEvent } from './mandate'
const ALL_EVENTS: EventDef[] = [...EVENTS]
@@ -270,6 +271,10 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
]
}
}
// 0.1.40 天命事件查找
if (id.startsWith('mandate-')) {
return findMandateEvent(id)
}
return undefined
}
@@ -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)
}
+3 -3
View File
@@ -778,7 +778,7 @@ export class World {
inv['lingcao'] -= r.lingcao
inv['beastcore'] -= r.beastcore
fam.stones -= r.stones
const chance = bonusOf('danfang', 'craftChance', lvl)
const chance = bonusOf('danfang', 'craftChance', lvl, fam.flag['buildingSpecialty'] as string | undefined)
if (this.rng.chance(chance)) {
inv[`pill-${kind}`] = (inv[`pill-${kind}`] ?? 0) + 1
this.log('good', `丹房炼成一枚${r.name}`)
@@ -855,8 +855,8 @@ export class World {
const fam = this.state.family
const bonus =
1 +
bonusOf('yanwu', 'powerBonus', fam.buildings['yanwu'] ?? 0) +
bonusOf('lingshou', 'powerBonus', fam.buildings['lingshou'] ?? 0) +
bonusOf('yanwu', 'powerBonus', fam.buildings['yanwu'] ?? 0, fam.flag['buildingSpecialty'] as string | undefined) +
bonusOf('lingshou', 'powerBonus', fam.buildings['lingshou'] ?? 0, fam.flag['buildingSpecialty'] as string | undefined) +
this.postBonus('battlePower')
const top = this.aliveMembers()
.map((c) => combatPowerOf(this, c))
@@ -9,6 +9,7 @@ import { eventRoll } from './Systems/events'
import { diplomacyTick } from './Systems/diplomacy'
import { yearStartMarriage } from './Systems/marriage'
import { WorldSim } from '../sim/WorldSim'
import { checkMandate } from './Systems/mandate'
/** 原语义保留:满门凋零后仅存生产/寿元与收尾 */
function ifAlive(fn: (w: World) => void): (w: World) => void {
@@ -62,6 +63,8 @@ export function installCoreSystems(ctx: PluginContext): Array<() => void> {
}
}))
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
}
+192 -17
View File
@@ -676,21 +676,82 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
const cur = dyn.relationsWithOthers[oid] ?? 0
dyn.relationsWithOthers[oid] = clamp(cur + (w.rng.chance(0.5) ? 1 : -1) * 5, -100, 100)
}
// 换代评估:寿终阈值 或 年首小概率(退隐/遇害)——约 50 年一代
// 0.1.40 换代评估:四模式(退隐传贤/遇害暴毙/夺位篡权/寿终正寝)
const chanceNow = dyn.leaderAge > lifespan * 0.9 ? 0.5 : 0.02
if (w.rng.chance(chanceNow) && !w.rng.chance(0.25)) {
dyn.leaderAge = 30 + w.rng.int(0, 25)
dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + 1, 5)
dyn.leaderName = `${def.name.replace('氏', '')}氏新主`
dyn.prosperity = 55 + w.rng.int(0, 20)
npc.power = Math.round(Math.max(40, npc.power + 15))
dyn.lastEvent = '宗祧更替'
const eraNow = (s.era ?? 'pingshi') as string
const prosperityNow = dyn.prosperity ?? 50
let mode: 'retire' | 'death' | 'usurp' | 'normal'
if (eraNow === 'luanshi' || eraNow === 'mofa') {
if (prosperityNow < 30 && w.rng.chance(0.4)) mode = 'death'
else if (npc.power > 400 && w.rng.chance(0.25)) mode = 'usurp'
else mode = 'normal'
} else {
if (prosperityNow > 70 && w.rng.chance(0.45)) mode = 'retire'
else if (npc.power > 400 && w.rng.chance(0.15)) mode = 'usurp'
else mode = 'normal'
}
switch (mode) {
case 'retire': {
// 退隐传贤:平盛世高景气——宗主功成身退,传位后辈
dyn.leaderAge = 30 + w.rng.int(0, 20)
dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + 1, 5)
dyn.leaderName = `${def.name.replace('氏', '')}氏新主`
dyn.prosperity = clamp(prosperityNow - 5, 8, 100)
npc.power = Math.round(Math.max(40, npc.power - 5))
dyn.lastEvent = '退隐传贤'
break
}
case 'death': {
// 遇害暴毙:乱世末法低景气——宗主陨落,群龙无首
dyn.leaderAge = 25 + w.rng.int(0, 20)
dyn.leaderRealmIdx = Math.max(0, dyn.leaderRealmIdx) // 境界不变或降
dyn.leaderName = `${def.name.replace('氏', '')}氏少主`
dyn.prosperity = clamp(prosperityNow - 20, 8, 100)
npc.power = Math.round(Math.max(40, npc.power - 30))
dyn.lastEvent = '宗主遇害'
break
}
case 'usurp': {
// 夺位篡权:高压家族——强人夺位,势力激增但人心不稳
dyn.leaderAge = 35 + w.rng.int(0, 15)
dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + (w.rng.chance(0.4) ? 2 : 1), 5)
dyn.leaderName = `${def.name.replace('氏', '')}氏篡主`
dyn.prosperity = clamp(prosperityNow + 8, 8, 100)
npc.power = Math.round(Math.max(40, npc.power + 40))
dyn.lastEvent = '夺位篡权'
// 篡权者树敌:与邻家关系恶化
for (const oid of ids) {
if (oid === id) continue
const cur = dyn.relationsWithOthers[oid] ?? 0
dyn.relationsWithOthers[oid] = clamp(cur - 12, -100, 100)
}
break
}
default: {
// 寿终正寝:常规换代
dyn.leaderAge = 30 + w.rng.int(0, 25)
dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + 1, 5)
dyn.leaderName = `${def.name.replace('氏', '')}氏新主`
dyn.prosperity = 55 + w.rng.int(0, 20)
npc.power = Math.round(Math.max(40, npc.power + 15))
dyn.lastEvent = '宗祧更替'
}
}
dyn.lastEventYear = year
s.npcSuccessions++
pushNews(w, s, [id])
w.log('info', `【天下】${def.name} 更易宗主,气象一新。`)
const eventDesc = dyn.lastEvent === '退隐传贤' ? '宗主功成身退,后辈继位'
: dyn.lastEvent === '宗主遇害' ? '宗主遇害身亡,少主仓促继位'
: dyn.lastEvent === '夺位篡权' ? '强人夺位,人心浮动'
: '更易宗主,气象一新'
w.log('info', `【天下】${def.name} ${eventDesc}`)
w.emitFx('ripple', `succession:${id}`)
}
// 0.1.40 NPC 势力分裂:power>500 且景气<25 的家族,年首 3% 概率分裂
if (w.rng.chance(0.03) && npc.power > 500 && (dyn.prosperity ?? 50) < 25 && !npc.allied) {
splitNpcDynasty(w, s, id)
}
// B7 互攻:与世仇(关系<-50)年首相搏——败方伤筋动骨
for (const oid of ids) {
if (oid === id) continue
@@ -773,27 +834,136 @@ function purgeNpc(w: World, s: WorldSimState, id: string): void {
w.emitFx('ripple', `purge:${id}`)
}
const NEWBORN_STYLES = ['新锐剑宗', '灵植世家', '商盟豪族', '兵修门阀', '符箓仙门'] as const
const NEWBORN_REGIONS = ['南麓青泽', '西山雾谷', '东溪云汉', '北原古井'] as const
const NEWBORN_STYLES = ['新锐剑宗', '灵植世家', '商盟豪族', '兵修门阀', '符箓仙门', '丹道世家', '阵法传承', '御兽门阀'] as const
const NEWBORN_REGIONS = ['南麓青泽', '西山雾谷', '东溪云汉', '北原古井', '中州云台', '东海浮屿'] as const
/** 新贵补位:随机风格/区域/初始 power 的新家族(乱世更频) */
/**
* 0.1.40 NPC
*
* power>500 <25 且非附庸年首 3% 概率
*
* - power 40% 15
* - 35% power +
* - NPCregisterNpcDef + npcFamilies + npcDyn
* - worldGen.npcs
*/
function splitNpcDynasty(w: World, s: WorldSimState, parentId: string): void {
const parent = w.state.npcFamilies[parentId]
const parentDef = npcById(parentId)
const parentDyn = s.npcDyn[parentId]
if (!parent || !parentDef || !parentDyn) return
const rng = w.rng
const splitPower = Math.round(parent.power * 0.35)
parent.power = Math.round(parent.power * 0.6)
parentDyn.prosperity = 15
parentDyn.lastEvent = '势力分裂'
parentDyn.lastEventYear = w.state.year
// 分裂出的新家族
let seedHash = 0
for (let i = 0; i < w.state.seed.length; i++) seedHash = (seedHash * 31 + w.state.seed.charCodeAt(i)) >>> 0
const newId = `n-split-${(seedHash % 4096).toString(36)}-${rng.int(1, 9999999)}`
const newName = `${parent.name.slice(0, 2)}分支`
const newStyle = rng.pick([...NEWBORN_STYLES])
const newRegion = parent.region || rng.pick([...NEWBORN_REGIONS])
const newDef = {
id: newId,
name: newName,
region: newRegion,
desc: `${parentDef.name}内乱分裂而出,另立门户。`,
style: newStyle,
leaderRealm: 'foundation' as const,
initialPower: splitPower,
powerGrowth: [3, 9] as [number, number],
sells: parentDef.sells ? [...parentDef.sells] : [],
buys: parentDef.buys ? [...parentDef.buys] : ['lingcao', 'lingkuang']
}
registerNpcDef(newDef)
// 落档 worldGen.npcs
if (w.state.worldGen) {
if (!w.state.worldGen.npcs) w.state.worldGen.npcs = []
w.state.worldGen.npcs.push({
id: newDef.id, name: newDef.name, region: newDef.region, style: newDef.style,
desc: newDef.desc, leaderRealm: newDef.leaderRealm, initialPower: newDef.initialPower,
powerGrowth: newDef.powerGrowth, sells: newDef.sells, buys: newDef.buys
})
}
w.state.npcFamilies[newId] = {
id: newId, name: newName, region: newRegion,
power: splitPower, relation: 0, allied: false, raidCount: 0, declineYears: 0
}
const newDyn = initDynFor(newId)
newDyn.leaderName = `${newName.slice(0, 2)}氏少主`
newDyn.leaderAge = 25 + rng.int(0, 15)
newDyn.leaderRealmIdx = Math.max(0, parentDyn.leaderRealmIdx - 1)
newDyn.prosperity = 40
newDyn.lastEvent = '分裂自立'
newDyn.lastEventYear = w.state.year
// 继承部分关系网(与原家族的盟友关系减半、仇敌转为中立)
for (const [oid, rel] of Object.entries(parentDyn.relationsWithOthers)) {
if (oid === parentId) continue
newDyn.relationsWithOthers[oid] = rel > 0 ? Math.round(rel * 0.5) : Math.round(rel * 0.3)
// 分裂消息传至邻家:对原家族关系微降、对新家族态度不明
const otherDyn = s.npcDyn[oid]
if (otherDyn) {
otherDyn.relationsWithOthers[parentId] = clamp((otherDyn.relationsWithOthers[parentId] ?? 0) - 5, -100, 100)
otherDyn.relationsWithOthers[newId] = 0 // 中立起步
}
}
// 父子反目
newDyn.relationsWithOthers[parentId] = -40
parentDyn.relationsWithOthers[newId] = -40
s.npcDyn[newId] = newDyn
s.newsFeed.push({
year: w.state.year, month: 1, src: '史官',
text: `${parent.name} 内乱分裂——${newName} 另立门户,天下格局再变。`, kind: 'annal'
})
w.log('info', `【天下】${parent.name} 内乱分裂,${newName} 崛起自立。`)
w.emitFx('ripple', `split:${newId}`)
}
/** // power
* 0.1.40
*
* rng
* - 35%
* - 65% /
*/
function spawnNewbornDynasty(w: World, s: WorldSimState): void {
const rng = w.rng
const style = rng.pick([...NEWBORN_STYLES])
const region = rng.pick([...NEWBORN_REGIONS])
// 0.1.40 灰烬重生模式:检查 worldGen.npcs 中已被 purge 的家族区域
let ashesRegion: string | undefined
let ashesStyle: string | undefined
if (w.rng.chance(0.35) && w.state.worldGen?.npcs) {
// 从 worldGen.npcs 找到已不在 npcFamilies 中的灭亡家族
const fallen = w.state.worldGen.npcs.filter((n) => !w.state.npcFamilies[n.id])
if (fallen.length > 0) {
const pick = fallen[rng.int(0, fallen.length - 1)]!
ashesRegion = pick.region
// 不继承原风格——灰烬中重生的新风格
ashesStyle = rng.pick([...NEWBORN_STYLES])
}
}
const style = ashesStyle ?? rng.pick([...NEWBORN_STYLES])
const region = ashesRegion ?? rng.pick([...NEWBORN_REGIONS])
// P1-10 id 掺 seed 指纹(防跨档碰撞)
let seedHash = 0
for (let i = 0; i < w.state.seed.length; i++) seedHash = (seedHash * 31 + w.state.seed.charCodeAt(i)) >>> 0
const id = `n-new-${(seedHash % 4096).toString(36)}-${rng.int(1, 9999999)}` // 0.1.35 A-10 碰撞窗口 9999→9999999
const name = `${region.slice(0, 2)}${NEWBORN_NAME_SUFFIX[rng.int(0, NEWBORN_NAME_SUFFIX.length - 1)]}`
const isAshes = !!ashesRegion
const def = {
id,
name,
region,
desc: `${style}初立,闷头搞了十年发展,如今渐攒起一份家业。`,
desc: isAshes
? `${style}于故地废墟上重建——前人虽去,灵脉犹存,新主收拾残局再起。`
: `${style}初立,闷头搞了十年发展,如今渐攒起一份家业。`,
style,
leaderRealm: 'foundation' as const,
initialPower: 100 + rng.int(0, 60),
initialPower: isAshes ? 90 + rng.int(0, 50) : 100 + rng.int(0, 60), // 灰烬重生起步略低
powerGrowth: [3, 9] as [number, number],
sells: [],
buys: ['lingcao', 'lingkuang']
@@ -820,8 +990,13 @@ function spawnNewbornDynasty(w: World, s: WorldSimState): void {
dyn.leaderName = `${name.slice(0, 2)}氏新主`
s.npcDyn[id] = dyn
const asr = s.era ?? 'pingshi'
w.log('good', `【天下】新贵${name}${region}崛起(${rgStyleName(def.style)}),天下格局松动。`)
s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `新贵${name}崛起${region}——天下格局松动。`, kind: 'annal' })
if (isAshes) {
w.log('good', `【天下】灰烬重生——${name}${region}故地重建(${rgStyleName(def.style)}),旧地新主。`)
s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `灰烬重生——${name}${region}故地重建,旧地新主。`, kind: 'annal' })
} else {
w.log('good', `【天下】新贵${name}${region}崛起(${rgStyleName(def.style)}),天下格局松动。`)
s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `新贵${name}崛起于${region}——天下格局松动。`, kind: 'annal' })
}
void asr
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
import { validateLoadedState, exportEnvelope, parseImportEnvelope } from './migrate'
const GAME_VERSION = '0.1.38'
const GAME_VERSION = '0.1.40'
const DB_PREFIX = 'cotyc-save-'
let seqCounter = 0
+8 -1
View File
@@ -8,6 +8,7 @@ import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
import { FORMATIONS, FormationId } from '../../game/data/formations'
import { buildingById } from '../../game/data/buildings'
import { ModalShell } from './ModalShell'
import { isMandateEvent, mandateEventCategoryName } from '../../game/engine/runtime/Systems/mandate'
const RES_NAME: Record<string, string> = {
lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核', stones: '灵石',
@@ -63,6 +64,8 @@ export function EventModal() {
// 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 squadPool: Character[] =
@@ -99,6 +102,8 @@ export function EventModal() {
}
const dorm = () => {
// 天命不可回避——不可"稍后再议"
if (isMandate) return
// 「稍后再议」:把事件按回队列,不消灭、不误弃
world.requeueEvent(pendingEventId)
closeModal()
@@ -112,7 +117,7 @@ export function EventModal() {
allowOuter={false}
onClose={dorm}
closeLabel="稍后再议"
className={isAscension ? 'modal-ascension' : undefined}
className={isAscension ? 'modal-ascension' : isMandate ? 'modal-mandate' : undefined}
footer={
squadPicking ? (
<>
@@ -129,6 +134,8 @@ export function EventModal() {
{squadSel.length === 0 ? '族中无可战之人' : `出战!(共${squadSel.length}人)`}
</button>
</>
) : isMandate ? (
<div className="dim2" style={{ fontSize: '0.82rem' }}></div>
) : (
<button className="btn" onClick={dorm}></button>
)
+3 -2
View File
@@ -1,5 +1,5 @@
import { useGameStore } from '../store'
import { useMemo, useState } from 'react'
import { useMemo, useState, memo } from 'react'
const CAT_NAME: Record<string, string> = {
birth: '诞庆',
@@ -61,6 +61,7 @@ export default function ChroniclePanel() {
if (!world) return null
const battles = world.state.battles.slice().reverse()
const battle = battles.find((b) => b.id === battleId)
const groupedEntries = useMemo(() => grouped(entries), [entries])
return (
<div>
@@ -99,7 +100,7 @@ export default function ChroniclePanel() {
))}
</div>
<div className="chronicle-list">
{grouped(entries).map((g) => (
{groupedEntries.map((g) => (
<div key={g.year}>
<div className="ch-yearbar">{g.year} <span className="dim2">{g.items.length} · {statOf(g)}</span></div>
{g.items.map((e) => (
+81 -75
View File
@@ -1,12 +1,89 @@
import { useGameStore } from '../store'
import { npcById } from '../../game/data/npcs'
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 type { World } from '../../game/engine/runtime/World'
import type { NpcFamilyState } from '../../game/types/domain'
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']
const NpcRow = memo(function NpcRow({ npc, world, bump }: { npc: NpcFamilyState; world: World; bump: () => void }) {
const def = npcById(npc.id)
if (!def) return null
const [relLabel, relCls] = REL_TYPE(npc.relation)
const married = npc.allied
return (
<div className="npc-row">
<div className="npc-name">
<div style={{ fontSize: '1.15rem' }}>{npc.name}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{def.style} · {npc.region}</div>
</div>
<div className="relation-bar">
<div className="bar" style={{ borderColor: npc.relation >= 0 ? '#2e5a2e' : '#6e2f2f' }}>
<div
style={{
width: `${(npc.relation + 100) / 2}%`,
background: npc.relation >= 0 ? '#5a8a44' : '#8a4444'
}}
/>
</div>
<div className={`dim ${relCls}`} style={{ marginTop: 4, fontSize: '0.82rem' }}>
{npc.relation} · {relLabel} · {npc.power}
</div>
</div>
<div className="relation-num">
<span className="dim2">{MAJOR_NAMES[def.leaderRealm]}</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{GIFT_TIERS.map((t) => (
<button
key={t}
className="btn btn-sm"
disabled={world.state.family.stones < t}
title={`关系 +${calcGiftGain(t)}`}
onClick={() => { giftNpc(world, npc.id, t); bump() }}
>
{t}(+{calcGiftGain(t)})
</button>
))}
<button
className="btn btn-sm"
disabled={npc.relation < 25 || married}
title={npc.relation < 25 ? `需关系≥25(当前${npc.relation}` : married ? '一姓已结亲' : '关系≥25方可联姻'}
onClick={() => { marryNpcFamily(world, npc.id); bump() }}
>
{married ? '已联姻' : '联姻'}
</button>
<button
className="btn btn-sm"
title="关系-20,可能招致劫掠;每年一次"
onClick={() => {
if (window.confirm(`${npc.name}寻衅?关系将下降 20,可能招致劫掠。`)) {
world.tauntNpc(npc.id)
bump()
}
}}
>
</button>
{npc.relation < -30 && (
<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>
)}
</div>
</div>
)
})
export default function DiplomacyPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
@@ -23,80 +100,9 @@ export default function DiplomacyPanel() {
<div className="help-text">
</div>
{sorted.map((npc) => {
const def = npcById(npc.id)
if (!def) return null
const [relLabel, relCls] = REL_TYPE(npc.relation)
const married = npc.allied
return (
<div key={npc.id} className="npc-row">
<div className="npc-name">
<div style={{ fontSize: '1.15rem' }}>{npc.name}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{def.style} · {npc.region}</div>
</div>
<div className="relation-bar">
<div className="bar" style={{ borderColor: npc.relation >= 0 ? '#2e5a2e' : '#6e2f2f' }}>
<div
style={{
width: `${(npc.relation + 100) / 2}%`,
background: npc.relation >= 0 ? '#5a8a44' : '#8a4444'
}}
/>
</div>
<div className={`dim ${relCls}`} style={{ marginTop: 4, fontSize: '0.82rem' }}>
{npc.relation} · {relLabel} · {npc.power}
</div>
</div>
<div className="relation-num">
<span className="dim2">{MAJOR_NAMES[def.leaderRealm]}</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{GIFT_TIERS.map((t) => (
<button
key={t}
className="btn btn-sm"
disabled={w.state.family.stones < t}
title={`关系 +${calcGiftGain(t)}`}
onClick={() => { giftNpc(w, npc.id, t); bump() }}
>
{t}(+{calcGiftGain(t)})
</button>
))}
<button
className="btn btn-sm"
disabled={npc.relation < 25 || married}
title={npc.relation < 25 ? `需关系≥25(当前${npc.relation}` : married ? '一姓已结亲' : '关系≥25方可联姻'}
onClick={() => { marryNpcFamily(w, npc.id); bump() }}
>
{married ? '已联姻' : '联姻'}
</button>
<button
className="btn btn-sm"
title="关系-20,可能招致劫掠;每年一次"
onClick={() => {
if (window.confirm(`${npc.name}寻衅?关系将下降 20,可能招致劫掠。`)) {
w.tauntNpc(npc.id)
bump()
}
}}
>
</button>
{npc.relation < -30 && (
<button
className="btn btn-sm"
disabled={w.state.family.stones < 200}
title={w.state.family.stones < 200 ? '需灵石 200' : '与对方结下和约'}
onClick={() => { makePeace(w, npc.id); bump() }}
>
</button>
)}
</div>
</div>
)
})}
{sorted.map((npc) => (
<NpcRow key={npc.id} npc={npc} world={w} bump={bump} />
))}
<div className="dim2" style={{ marginTop: 12 }}>
</div>
+21
View File
@@ -5,6 +5,8 @@ import { ResolveModal } from '../components/ResolveModal'
import { buildBiography } from '../../game/engine/narrative/biography'
import { yearAxis, decadeLabel } from '../../game/engine/narrative/yearaxis'
import { buildReport, verdictLine } from '../../game/engine/narrative/legacyreport'
import { buildSaga, sagaKindName, sagaKindClass } from '../../game/engine/narrative/saga'
import type { WorldSimState } from '../../game/engine/sim/worldsim-data'
export default function LegacyPanel() {
@@ -155,6 +157,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: 6 }}>{r.breakthroughs.slice(-6).map((b) => `${b.year}年×${b.count}`).join(' · ')}</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>
+1 -1
View File
@@ -160,7 +160,7 @@ export default function MarketPanel() {
<div className="help-text">
+++
</div>
<h4 className="dim" style={{ margin: '8px 0 4px' }}> {fam.buildings['danfang'] ?? 0} · {Math.round(bonusOf('danfang', 'craftChance', fam.buildings['danfang'] ?? 0) * 100)}%</h4>
<h4 className="dim" style={{ margin: '8px 0 4px' }}> {fam.buildings['danfang'] ?? 0} · {Math.round(bonusOf('danfang', 'craftChance', fam.buildings['danfang'] ?? 0, fam.flag['buildingSpecialty'] as string | undefined) * 100)}%</h4>
<table className="market-table">
<thead>
<tr><th></th><th></th><th></th><th></th><th></th></tr>
+1 -1
View File
@@ -354,7 +354,7 @@ export default function SettingsPanel() {
· <br />
·
</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.38 · Chronicle of the Immortal Clan</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.40 · Chronicle of the Immortal Clan</div>
</div>
</div>
)
+24 -3
View File
@@ -1,5 +1,5 @@
import { useGameStore } from '../store'
import { BUILDINGS, buildingById, listBuildingDefs } from '../../game/data/buildings'
import { BUILDINGS, buildingById, listBuildingDefs, SPECIALTY_MULT } from '../../game/data/buildings'
import { useMemo } from 'react'
export default function TerritoryPanel() {
@@ -32,12 +32,13 @@ export default function TerritoryPanel() {
!!cost &&
fam.stones >= cost.stones &&
fam.inventory['lingkuang'] >= cost.lingkuang
const isSpecialtyBuilding = fam.flag['buildingSpecialty'] === id
return (
<div key={id} className={`bld-card ${built ? '' : 'uc'}`}>
<div key={id} className={`bld-card ${built ? '' : 'uc'} ${isSpecialtyBuilding ? 'bld-specialty' : ''}`}>
<div className="bld-head">
<span className="bld-icon">{def.icon}</span>
<div>
<div>{def.name}</div>
<div>{def.name}{isSpecialtyBuilding && <span className="gold" style={{ marginLeft: 4, fontSize: '0.75rem' }}></span>}</div>
<div className="dim2" style={{ fontSize: '0.75rem' }}>{def.kind === 'produce' ? '产出' : '功效'}</div>
</div>
<span className="bld-lv">{built ? `${level}` : ''}</span>
@@ -95,6 +96,26 @@ export default function TerritoryPanel() {
{world.state.year - ((fam.flag['lastRiteYear'] as number) ?? -9) < 2 ? '(冷却)' : '150灵石'}
</button>
)}
{built && def.extra && Object.keys(def.extra).length > 0 && (
<button
className="btn btn-sm"
style={{ marginLeft: 4, fontSize: '0.7rem' }}
disabled={isSpecialtyBuilding ? false : fam.stones < 200}
title={isSpecialtyBuilding ? '取消专精(免费)' : `设为专精(耗200灵石)· extra 加成 ×${SPECIALTY_MULT}`}
onClick={() => {
if (isSpecialtyBuilding) {
delete fam.flag['buildingSpecialty']
} else {
if (fam.stones < 200) return
fam.stones -= 200
fam.flag['buildingSpecialty'] = id
}
bump()
}}
>
{isSpecialtyBuilding ? '取消专精' : '设为专精'}
</button>
)}
<span className="dim2" style={{ marginLeft: 'auto' }}>{levelLabel(level)}</span>
</>
) : (
+63 -35
View File
@@ -1,5 +1,5 @@
import { useGameStore } from '../store'
import { useMemo } from 'react'
import { useMemo, memo } from 'react'
import { npcById } from '../../game/data/npcs'
import { MAJOR_NAMES } from '../../game/data/realms'
import type { WorldSimState } from '../../game/engine/sim/worldsim-data'
@@ -7,10 +7,54 @@ import { POOL_BASE, WORLDSIM, ERA_CONF } from '../../game/engine/sim/worldsim-da
import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
import { sellItem, buyItem, marketPrice } from '../../game/engine/sim/Market'
import { useState } from 'react'
import type { World } from '../../game/engine/runtime/World'
import { buildSaga, sagaKindName, sagaKindClass } from '../../game/engine/narrative/saga'
const RES_LABEL: Record<string, string> = { lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核' }
const REALM_IDX = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit'] as const
/** 0.1.40 memo 化天下快讯——120 条 newsFeed 不随主面板其他状态变动重渲染 */
const NewsList = memo(function NewsList({ news, world, bump }: { news: { year: number; month: number; src: string; text: string; itemId?: string; kind?: string }[]; world: World; bump: () => void }) {
return (
<>
{news.length === 0 && <div className="dim2"></div>}
{news.map((row, i) => {
const canBuy = row.kind === 'quote' && row.itemId && world.state.family.stones >= marketPrice(world, row.itemId) * 5
const canSell = row.kind === 'quote' && row.itemId && (world.state.family.inventory[row.itemId] ?? 0) >= 5
const isCrisis = row.kind === 'calamity'
return (
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
<span className="ch-month">{row.year}{row.month}</span>
<span>{row.text}</span>
{isCrisis && world.state.family.stones >= 50 && (
<span style={{ marginLeft: 8 }}>
<button
className="btn btn-sm"
title="引粥施药,换两家之谊"
onClick={() => {
world.reliefCalamity(50)
bump()
}}
></button>
</span>
)}
{canBuy && (
<span style={{ marginLeft: 8 }}>
<button className="btn btn-sm" title="行情见底,出手吃进(买5)" onClick={() => { buyItem(world, row.itemId!, 5); bump() }}>5</button>
</span>
)}
{canSell && !canBuy && (
<span style={{ marginLeft: 8 }}>
<button className="btn btn-sm" title="行情见顶,集市抛售(卖5)" onClick={() => { sellItem(world, row.itemId!, 5); bump() }}>5</button>
</span>
)}
</div>
)
})}
</>
)
})
export default function WorldPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
@@ -123,40 +167,7 @@ export default function WorldPanel() {
<div>
<h4 className="dim" style={{ margin: '4px 0 6px' }}></h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 380, overflowY: 'auto' }}>
{news.length === 0 && <div className="dim2"></div>}
{news.map((row, i) => {
const canBuy = row.kind === 'quote' && row.itemId && w.state.family.stones >= marketPrice(w, row.itemId) * 5
const canSell = row.kind === 'quote' && row.itemId && (w.state.family.inventory[row.itemId] ?? 0) >= 5
const isCrisis = row.kind === 'calamity'
return (
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
<span className="ch-month">{row.year}{row.month}</span>
<span>{row.text}</span>
{isCrisis && w.state.family.stones >= 50 && (
<span style={{ marginLeft: 8 }}>
<button
className="btn btn-sm"
title="引粥施药,换两家之谊"
onClick={() => {
w.reliefCalamity(50)
bump()
}}
></button>
</span>
)}
{canBuy && (
<span style={{ marginLeft: 8 }}>
<button className="btn btn-sm" title="行情见底,出手吃进(买5)" onClick={() => { buyItem(w, row.itemId!, 5); bump() }}>5</button>
</span>
)}
{canSell && !canBuy && (
<span style={{ marginLeft: 8 }}>
<button className="btn btn-sm" title="行情见顶,集市抛售(卖5)" onClick={() => { sellItem(w, row.itemId!, 5); bump() }}>5</button>
</span>
)}
</div>
)
})}
<NewsList news={news} world={w} bump={bump} />
</div>
</div>
<div>
@@ -229,6 +240,23 @@ export default function WorldPanel() {
</div>
</div>
</div>
{/* 0.1.40 天下大事记展示区 */}
<div style={{ marginTop: 16 }}>
<h4 className="dim" style={{ margin: '4px 0 6px' }}></h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto' }}>
{(() => {
const saga = buildSaga(ws)
if (saga.length === 0) return <div className="dim2"></div>
return saga.slice(0, 30).map((e, i) => (
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
<span className="ch-month">{e.year}</span>
<span className={`tag ${sagaKindClass(e.kind)}`} style={{ marginRight: 6 }}>{sagaKindName(e.kind)}</span>
<span>{e.text}</span>
</div>
))
})()}
</div>
</div>
</div>
)
}
+1 -1
View File
@@ -14,7 +14,7 @@ export function metaFromState(state: GameState, slot: number): SaveMeta {
members: alive,
reputation: state.family.reputation,
updatedAt: new Date().toISOString(),
version: '0.1.38'
version: '0.1.40'
}
}
+46
View File
@@ -1050,6 +1050,10 @@ html[data-blade] .battle-lines {
color: #4a3d29;
filter: saturate(0.6);
}
.bld-card.bld-specialty { /* 0.1.40 专精建筑 */
border-color: #c9a227;
box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.25), 0 2px 8px rgba(201, 162, 39, 0.1);
}
/* ============================================================
坊市 / 外交 / 探秘
@@ -1942,3 +1946,45 @@ html[data-blade] .battle-lines {
background: linear-gradient(180deg, #3a3018, #241e0d);
border-color: #8a6d2f;
}
/* ---------- 0.1.40 天命叙事视觉强化(紫金辉光) ---------- */
.modal-mandate .modal-title {
color: #6b4c9a;
text-shadow: 0 0 14px rgba(107, 76, 154, 0.45), 0 1px 0 rgba(245, 240, 255, 0.7);
letter-spacing: 6px;
}
.modal-mandate .modal-text {
border-top-color: rgba(107, 76, 154, 0.4);
border-bottom-color: rgba(107, 76, 154, 0.3);
color: #2a1e3d;
}
.modal-mandate .modal-cat {
color: #7a5aa8;
}
.modal-mandate .opt-btn {
border-color: #8b6db5;
background: linear-gradient(180deg, #f8f5fd, #f0ebf8);
}
.modal-mandate .opt-btn:hover {
border-color: #6b4c9a;
background: #f3edfa;
box-shadow: 0 0 0 1px rgba(107, 76, 154, 0.3), inset 0 1px 0 rgba(255, 250, 255, 0.6);
}
.modal-mandate .opt-btn .hint {
color: #6b4c9a;
}
.modal-mandate::before,
.modal-mandate::after {
background: linear-gradient(180deg, #2a1e3d, #1a1228);
border-color: #6b4c9a;
}
/* ---------- 0.1.40 天下大事记标签色系 ---------- */
.saga-era { color: #c9a227; border-color: #c9a227; }
.saga-calamity { color: #c05a41; border-color: #a33; }
.saga-succession { color: #6b8a9a; border-color: #4a6a7a; }
.saga-split { color: #8a5a8a; border-color: #6a4a6a; }
.saga-newborn { color: #5a8a44; border-color: #4a6a34; }
.saga-war { color: #c08a4a; border-color: #a06a3a; }
.saga-wonder { color: #7a5aa8; border-color: #6b4c9a; }
.saga-annal { color: #8a7a5a; border-color: #6a5a3a; }
+3 -3
View File
@@ -82,8 +82,8 @@ describe('审计回归:P0 修复固化', () => {
})
it('防御性修补后金钟罩不变(行为等价确认)', () => {
// 0.1.37 因果/奇观/飞升逻辑变更,指纹受控重算
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('45b0c17a')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('f24480f4')
// 0.1.40 天命·裂痕:天命系统+NPC宗主更替四模式+势力分裂+灰烬重生——全线受控变更重算
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('b050ad28')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('0b56c19b')
})
})
+8 -8
View File
@@ -7,20 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World'
* //
* **** seed
*/
// 0.1.39 淬体·洗髓基线:purgeNpc 清理 worldGen.npcs/relations 僵尸定义(存档体积精简——时序内更改)。
// 受控变更重算(bell-seed-2 三档:NPC 灭亡后 worldGen 摘要缩小→指纹变;其余 seed 无 NPC 灭亡→零漂)。
// 0.1.40 天命·裂痕基线:天命系统+NPC宗主更替四模式+NPC势力分裂+灰烬重生新贵补位+建筑专精——
// 全线受控变更重算(NPC演化逻辑增强+天命epilogue注册改变了时序/确定性序列)。
const GOLDEN: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: '45b0c17a', 1200: '62e037c1', 2160: 'b85dd7c7' },
'bell-seed-2': { 560: 'be278aba', 1200: '01126e70', 2160: 'fd03f9b2' },
'bell-seed-3': { 560: 'f24480f4', 1200: 'e52bed85', 2160: 'abb2a5d2' }
'bell-seed-1': { 560: 'b050ad28', 1200: '379ad2e1', 2160: '2fada283' },
'bell-seed-2': { 560: 'e3b4d40b', 1200: '5fb3aa1f', 2160: '50ad6832' },
'bell-seed-3': { 560: '0b56c19b', 1200: '4737eedf', 2160: 'f597c050' }
}
/** resolve "" tick
* /"冻结世界" */
const GOLDEN_RESOLVED: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: '2837ad55', 1200: 'f6798d97', 2160: 'bc713cca' },
'bell-seed-2': { 560: '9c5eddc5', 1200: '6a3c8522', 2160: '0791881d' },
'bell-seed-3': { 560: '79e8126e', 1200: '21d9c804', 2160: '95de644d' }
'bell-seed-1': { 560: '88f28074', 1200: 'd6b9cc85', 2160: 'c5b9b102' },
'bell-seed-2': { 560: '5ca6b6a9', 1200: '6a66a750', 2160: 'c8e8e982' },
'bell-seed-3': { 560: 'fb5bc0e8', 1200: '3e09f864', 2160: 'fae786ae' }
}
const TIERS = [
+7 -3
View File
@@ -122,14 +122,18 @@ describe('0.1.27 生灭可达化', () => {
})
it('顶峰衰:power≥700 年首可回落(非贴顶硬化)', () => {
// 0.1.40 修正:rng 序列变更后 60 月(5 年首) 8% × 5 ≈ 34% 命中率太低。
// 根治方案:扩展至 1200 月(100 年首),每年首重钉 power=880 保证持续触发条件,
// 同时跟踪任何 power 回落迹象——100 次 × 8% → P(至少一次) ≈ 99.97%。
const w = worldAt('wl-2', 24)
const ws = w.state.worldSim as WorldSimState
const top = Object.keys(w.state.npcFamilies).sort((a, b) => w.state.npcFamilies[b].power - w.state.npcFamilies[a].power)[0]!
w.state.npcFamilies[top].power = 880
let fell = false
for (let i = 0; i < 60 && !fell; i++) {
for (let i = 0; i < 1200 && !fell; i++) {
// 每年首前重钉 power,防止回落后又涨回 880 导致 fell 被覆盖
if (w.state.month === 12) w.state.npcFamilies[top].power = 880
w.advanceMonth()
if (w.state.npcFamilies[top]!.power < 880) fell = true
if (w.state.npcFamilies[top] && w.state.npcFamilies[top]!.power < 880) fell = true
}
expect(fell).toBe(true)
})
+3 -3
View File
@@ -170,8 +170,8 @@ describe('GameFacade 门面', () => {
it('默认配置金钟罩不受门面化影响', () => {
PACK.reset()
// 0.1.37 因果/奇观/飞升逻辑变更,指纹受控重算
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('45b0c17a')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('f24480f4')
// 0.1.40 天命·裂痕:天命系统+NPC宗主更替四模式+势力分裂+灰烬重生——全线受控变更重算
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('b050ad28')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('0b56c19b')
})
})
+5 -5
View File
@@ -261,18 +261,18 @@ describe('0.1.38 存档根治 · 存储链路回归(hybrid memory backend',
})
})
describe('0.1.38 存档根治 · 版本号一致性', () => {
it('slots.ts GAME_VERSION = 0.1.38', async () => {
describe('0.1.40 存档根治 · 版本号一致性', () => {
it('slots.ts GAME_VERSION = 0.1.40', async () => {
const { buildSimpleMeta } = await import('../src/renderer/game/storage/slots')
const w = World.create({ seed: 'ver-1', surname: '周', familyName: '周家', motto: 'm', difficulty: 'normal' })
const meta = buildSimpleMeta(w.state, 1, 5)
expect(meta.version).toBe('0.1.38')
expect(meta.version).toBe('0.1.40')
})
it('storeHelper metaFromState version = 0.1.38', async () => {
it('storeHelper metaFromState version = 0.1.40', async () => {
const { metaFromState } = await import('../src/renderer/ui/storeHelper')
const w = World.create({ seed: 'ver-2', surname: '吴', familyName: '吴家', motto: 'm', difficulty: 'normal' })
const meta = metaFromState(w.state, 1)
expect(meta.version).toBe('0.1.38')
expect(meta.version).toBe('0.1.40')
})
})