Compare commits

...
7 Commits
16 changed files with 336 additions and 54 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
# AGENTS.md
仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。
当前版本 **0.1.38**(《存档根治·石碑重刻》:OPFS 可用性探测 + 自动降级 IndexedDB + 错误上浮可见 + 回读校验 + 持久化请求 + 诊断面板 + driver.open 永久锁死修复 + exportSave open 补全 + 测试 N)。
当前版本 **0.1.39**(《淬体·洗髓》:missionIds 只增不减根治 + chronicle 智能裁剪保留里程碑 + worldsim tick 入口双闸守卫 + purgeNpc 清理 worldGen.npcs/relations 僵尸定义 + MemberCard memo 化脱离 store 订阅 + 飞升叙事视觉强化)。
## 命令
@@ -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.33 基线**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.33 符箓战力乘量/灾年因子入产/tribBoost 清零均为时序内更改)。指纹 `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.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 生灭/世鉴路径真实锁定)。**有意变更时序/数值时**:指纹一并重算并在注释注明原因。
## 世界自演进模型(0.1.14 sim/ 层,改数值勿动流程)
@@ -109,6 +109,6 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
5. **高危文件白名单**world.ts / events.ts / store.ts / styles.css / cultivation.ts / **WorldSim.ts / worldsim-data.ts**)改动后必须 `git diff --stat` 核对改动面与预期一致。
6. 每次迭代收尾:`git status` 应干净 + `git log --oneline` 确认每步都有提交记录;凡 "git stash/checkout/rm" 类命令,执行前后都 `git status` 留痕。
- 提交前:`npm test`(当前 89 套件 5230 例全绿)+ `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。
- 提交前:`npm test`(当前 108 套件 7058 例全绿)+ `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。
- 平衡数值集中在 `game/data/``engine/sim/worldsim-data.ts`(世界循环参数唯一权威),调平衡不改引擎流程。
- 发布产物 `release/``out/` 均已 gitignore,勿入库。
+11 -5
View File
@@ -12,7 +12,7 @@
npm install # 依赖(含 git.metona.cn 私有 registrytoken 在用户级 ~/.npmrc
npm install-scripts approve electron esbuild # npm12 首装时必须(Electron 二进制)
npm run dev # 开发模式(热更新)
npm test # 引擎测试(89 套件 / 5230 用例,纯 Node 无 GUI
npm test # 引擎测试(108 套件 / 7058 用例,纯 Node 无 GUI
npm run build # 产物到 out/
npm run package # 打包(Windows 需在 Windows 侧执行)
```
@@ -42,12 +42,12 @@ 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.21 基线**。有意变更时序需受控更新并注明。
- **双金钟罩**`tests/clock.test.ts``GOLDEN`(直调冻结世界)+ `GOLDEN_RESOLVED`(自动 resolve 长跑现实世界),各 3 固定 seed × 560/1200/2160 月(47/100/180 年)= 18 指纹;指纹含全世界轴(era/stance/prosperity/关系网/事件闸);**0.1.39 基线**。有意变更时序需受控更新并注明。
- **事件闸**pending 唯一入口 `fire(w,id,priority)`——高优(raid/渡劫/大比)可顶替被占者,被顶替者入队下月兑现;`applyEventChoice` 前置校验防陈旧 Modal 二次结算。
- **插件化**:万物皆是插件(系统/数据包/事件池),三核心插件 protected 常驻;示例见 tests/helpers/examplePlugin.ts。
- **可回放**:种子随机(sfc32,引擎域零 Math.random+ 快照回放保真(restore 重建内核+回置 rng)。
- **审计轮**0.1.15(引擎 P0 歼灭)/ 0.1.20(事件闸·市场黑洞·双金钟罩)/ 0.1.28(稳石:词库回滚·新贵落档·校验接生产·bundle 闭环·指纹补盲)/ **0.1.29(续命:续代断链·敌强度相对化·权重防洪)**
- **测试规模****89 套件 / 5230 例**41 个矩阵域:随机序列/世界生成/市场实体化/事件闸/数据表全量/插件 MOD 组合/省级矩阵等——语义真实,无刷水)。
- **审计轮**0.1.15(引擎 P0 歼灭)/ 0.1.20(事件闸·市场黑洞·双金钟罩)/ 0.1.28(稳石:词库回滚·新贵落档·校验接生产·bundle 闭环·指纹补盲)/ 0.1.29(续命:续代断链·敌强度相对化·权重防洪)/ **0.1.35(荡涤:17 缺陷歼灭·兼容层铲除·validateLoadedState 替代迁移)**
- **测试规模****108 套件 / 7058 例**(矩阵域:随机序列/世界生成/市场实体化/事件闸/数据表全量/插件 MOD 组合/省级矩阵等——语义真实,无刷水)。
## 打包产物
@@ -55,7 +55,7 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
- 图标资源 resources/icon.png|ico —— electron-builder 自动使用
- 注意:WSL/Linux 交叉打 win 包可行但未签名,正规发布请用 Windows 侧重跑
## 版本沿革(0.1.0 → 0.1.21
## 版本沿革(0.1.0 → 0.1.39
| 版本 | 主题 |
|---|---|
@@ -82,5 +82,11 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存
| 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) |
详见:`docs/PROJECT_MEMORY.md`(设计哲学与每个迭代的"为什么")。
+2 -2
View File
@@ -1,6 +1,6 @@
# MOD 开发教程 · Chronicle of the Immortal Clan
> 版本 **0.1.36·《CSP 根因》** · MOD_SCHEMA_VERSION = 1 · 全口纯 JSON(零脚本)
> 版本 **0.1.39·《淬体·洗髓》** · 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.37)。字段若有新增,此处先行。</sub>
<sub>⚔️ 本文档随游戏版本更新(0.1.39)。字段若有新增,此处先行。</sub>
+3
View File
@@ -50,6 +50,9 @@
| 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** |
## 三、关键架构决策(及为什么)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志",
"version": "0.1.38",
"version": "0.1.39",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js",
"author": "MetonaTeam",
@@ -191,7 +191,9 @@ export function recallAll(w: World, missionId: string): void {
w.log('info', '探索队伍奉命返家。')
}
/** 队伍成员状态归位:伤者保持养伤,其余恢复闲居 */
/** 队伍成员状态归位:伤者保持养伤,其余恢复闲居
* 0.1.39 根治:完成后从 missionIds 移除——missionIds 只跟踪活跃任务 id
* 避免长跑 180 年后死引用堆积百条。state.missions 全量保留用于史书回看。 */
function releaseSquad(w: World, m: MissionState): void {
for (const id of m.memberIds) {
const c = w.memberById(id)
@@ -199,6 +201,9 @@ function releaseSquad(w: World, m: MissionState): void {
if (c.state === 'wounded') continue
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)
}
/** 月度巡查:亡者除名、重伤者离队疗伤;人数不足则提前收队 */
+17 -1
View File
@@ -334,6 +334,13 @@ export class World {
this.out.forEach((o) => o.onLog(kind, text))
}
/**
* 0.1.39 根治:chronicle 智能裁剪——保留全部 important=true 里程碑 +
* 最近 MAX_NON_IMPORTANT 条非重要记录。旧版无差别 splice 会丢失关键里程碑。
* 裁剪在 push 后触发,对调用方透明(不改变确定性序列——裁剪不消耗 rng)。
*/
private static readonly CHRONICLE_MAX_NON_IMPORTANT = 400
chronicle(cat: ChronicleEntry['category'], text: string, memberId?: Id, important = false): void {
const entry: ChronicleEntry = {
id: this.seq(),
@@ -345,7 +352,16 @@ export class World {
important
}
this.state.chronicle.push(entry)
if (this.state.chronicle.length > 520) this.state.chronicle.splice(0, this.state.chronicle.length - 520)
// 0.1.39 智能裁剪:仅在总数超限时触发,保留全部 important +最近非 important
const max = World.CHRONICLE_MAX_NON_IMPORTANT
if (this.state.chronicle.length > max + 200) {
const important_ones = this.state.chronicle.filter((e) => e.important)
const non_important = this.state.chronicle.filter((e) => !e.important)
const trimmed_non = non_important.slice(-max)
this.state.chronicle = [...important_ones, ...trimmed_non].sort(
(a, b) => (a.year - b.year) || (a.month - b.month) || (a.id < b.id ? -1 : 1)
)
}
this.out.forEach((o) => o.onChronicle(entry, important))
}
+20 -1
View File
@@ -25,6 +25,10 @@ export class WorldSim {
}
tick(): void {
// 0.1.39 根治双闸:clocks.ts 外部守卫 + 此处入口二次守卫。
// 与 tournament/apprentice 等系统卡同模式(sysEnabled ?? true 兜底,
// 注册后默认开、关停后 false)。防止未来新增直接调 tick() 的路径绕过守卫。
if (!this.w.sysEnabled('worldsim')) return
const s = this.s()
const rng = this.w.rng
// ---- C. 灵气潮汐(大周期) ----
@@ -739,7 +743,10 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
}
}
/** 覆灭清理:npcFamilies/npcDyn/关系网/raidCD/排队事件/distress/dynamic def 全摘 */
/** npcFamilies/npcDyn//raidCD//distress/dynamic def/worldGen.npcs
* 0.1.39 worldGen.npcs + worldGen.relations NPC
* purgeNpc 180 worldGen.npcs 20+
* NPC def */
function purgeNpc(w: World, s: WorldSimState, id: string): void {
delete w.state.npcFamilies[id]
delete s.npcDyn[id]
@@ -751,6 +758,18 @@ function purgeNpc(w: World, s: WorldSimState, id: string): void {
w.state.eventQueue = (w.state.eventQueue ?? []).filter((e) => !e.startsWith('ev-raid-') || !e.endsWith(id))
if (s.distress?.id === id) s.distress = undefined
unregisterNpcDef(id)
// 0.1.39:从 worldGen.npcs 移除已灭亡 NPC 的定义(存档体积精简——僵尸 def 不再落盘)
if (w.state.worldGen?.npcs) {
const idx = w.state.worldGen.npcs.findIndex((n) => n.id === id)
if (idx >= 0) w.state.worldGen.npcs.splice(idx, 1)
}
// 0.1.39:从 worldGen.relations 清理已灭亡 NPC 的关系条目(存档体积精简)
if (w.state.worldGen?.relations) {
delete w.state.worldGen.relations[id]
for (const rid of Object.keys(w.state.worldGen.relations)) {
delete w.state.worldGen.relations[rid]?.[id]
}
}
w.emitFx('ripple', `purge:${id}`)
}
+11 -1
View File
@@ -17,7 +17,7 @@ const RES_NAME: Record<string, string> = {
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 }; techniqueChance?: number; artifactChance?: number; flag?: Record<string, unknown> }
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
@@ -41,6 +41,12 @@ function describeEff(eff: Record<string, never> | { [k: string]: unknown }): str
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(' · ')
}
@@ -55,6 +61,9 @@ export function EventModal() {
const [formation, setFormation] = useState<FormationId>('vanguard')
if (!pendingEventId || !pendingEventDef || !world) return null
// 0.1.39:飞升事件视觉强化——金色辉光标题 + 仙韵边框 + 金边选项
const isAscension = pendingEventId.startsWith('ev-ascension-')
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
const squadPool: Character[] =
raidIdx >= 0
@@ -103,6 +112,7 @@ export function EventModal() {
allowOuter={false}
onClose={dorm}
closeLabel="稍后再议"
className={isAscension ? 'modal-ascension' : undefined}
footer={
squadPicking ? (
<>
+27 -19
View File
@@ -1,4 +1,4 @@
import { useGameStore } from '../store'
import { memo } from 'react'
import { Character } from '../../game/types/domain'
import { describeRealm } from '../../game/data/realms'
import { describeRoots } from '../../game/data/elements'
@@ -13,18 +13,34 @@ const STATE_LABEL: Record<string, { label: string; cls: string }> = {
dead: { label: '已殁', cls: 'bad' }
}
export function MemberCard({ c }: { c: Character }) {
const world = useGameStore((s) => s.world)
const setSelected = useGameStore((s) => s.setSelected)
if (!world) return null
const C_STATE: Record<string, string> = {
idle: '',
meditation: 'good',
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 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'
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="m-avatar">{world.state.family.surname}</div>
<div className="m-avatar">{surname}</div>
<div style={{ flex: 1 }}>
<div className="member-name">
<span>{c.name}</span>
@@ -34,7 +50,7 @@ export function MemberCard({ c }: { c: Character }) {
<div className="member-realm">
{dead
? `殁于${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>
@@ -55,12 +71,4 @@ export function MemberCard({ c }: { c: Character }) {
)}
</div>
)
}
const C_STATE: Record<string, string> = {
idle: '',
meditation: 'good',
expedition: 'warn',
wounded: 'bad',
dead: 'bad'
}
})
+4 -2
View File
@@ -11,6 +11,7 @@ export interface ModalShellProps {
footerAlign?: 'right' | 'center' | 'space'
children: ReactNode
closeLabel?: string
className?: string
}
/**
@@ -29,7 +30,8 @@ export function ModalShell({
footer,
footerAlign = 'right',
children,
closeLabel = '关闭'
closeLabel = '关闭',
className
}: ModalShellProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -46,7 +48,7 @@ export function ModalShell({
// 顶部菜单/主域均在其下(0.1.22 修复:弹框出现在宗族/谱系菜单下方的 z-index 越界)。
const shell = (
<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>
+20 -15
View File
@@ -1,4 +1,5 @@
import { useGameStore } from '../store'
import { useMemo } from 'react'
import { MemberCard } from '../components/MemberCard'
import { MemberModal } from '../components/MemberModal'
import { Character } from '../../game/types/domain'
@@ -11,25 +12,29 @@ export default function FamilyPanel() {
void revision
if (!world) return null
const s = world.state
const alive = 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 aHead = a.id === s.family.headId ? -1 : 0
const bHead = b.id === s.family.headId ? -1 : 0
return aHead - bHead || world.ageOf(b) - world.ageOf(a)
})
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined
const marriageReady = (() => {
if (!world) return []
return alive.filter(
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 dead = Object.values(s.members).filter((c) => !c.alive)
const sorted = [...alive].sort((a, b) => {
const aHead = a.id === headId ? -1 : 0
const bHead = b.id === headId ? -1 : 0
return aHead - bHead || world.ageOf(b) - world.ageOf(a)
})
const marriageReady = alive.filter(
(c) =>
world.ageOf(c) >= 16 &&
world.ageOf(c) <= 46 &&
c.state !== 'expedition' &&
(!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 (
<div>
@@ -78,10 +83,10 @@ export default function FamilyPanel() {
)}
<div className="member-grid">
{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) => (
<MemberCard key={c.id} c={c} />
<MemberCard key={c.id} c={c} surname={surname} isHead={false} age={world.ageOf(c)} onSelect={onSelect} />
))}
</div>
{alive.length === 0 && (
+32
View File
@@ -1910,3 +1910,35 @@ html[data-blade] .battle-lines {
font-size: 0.8rem;
color: #a8c2da;
}
/* ---------- 0.1.39 飞升叙事视觉强化 ---------- */
.modal-ascension .modal-title {
color: #8a6d2f;
text-shadow: 0 0 12px rgba(201, 162, 39, 0.5), 0 1px 0 rgba(255, 250, 235, 0.8);
letter-spacing: 8px;
}
.modal-ascension .modal-text {
border-top-color: rgba(201, 162, 39, 0.4);
border-bottom-color: rgba(201, 162, 39, 0.3);
color: #3a2f1a;
}
.modal-ascension .modal-cat {
color: #a08540;
}
.modal-ascension .opt-btn {
border-color: #c9a227;
background: linear-gradient(180deg, #fbf8ee, #f5edda);
}
.modal-ascension .opt-btn:hover {
border-color: #8a6d2f;
background: #fbf5e0;
box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.3), inset 0 1px 0 rgba(255, 255, 250, 0.6);
}
.modal-ascension .opt-btn .hint {
color: #8a6d2f;
}
.modal-ascension::before,
.modal-ascension::after {
background: linear-gradient(180deg, #3a3018, #241e0d);
border-color: #8a6d2f;
}
+3 -3
View File
@@ -7,11 +7,11 @@ import { World } from '../src/renderer/game/engine/runtime/World'
* //
* **** seed
*/
// 0.1.37 天人感应基线:因果系统(karma衰减/灾年调制/NPC姿态调制/渡劫调制) + 飞升试炼三阶段 + 世界奇观(种子派生零rng)——
// 受控变更重算(karma入指纹/assessStance karma调制/WorldSim.tick新增D-0.8/D-0.9段)。
// 0.1.39 淬体·洗髓基线:purgeNpc 清理 worldGen.npcs/relations 僵尸定义(存档体积精简——时序内更改)。
// 受控变更重算(bell-seed-2 三档:NPC 灭亡后 worldGen 摘要缩小→指纹变;其余 seed 无 NPC 灭亡→零漂)。
const GOLDEN: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: '45b0c17a', 1200: '62e037c1', 2160: 'b85dd7c7' },
'bell-seed-2': { 560: '1279a1da', 1200: '3439bd8f', 2160: 'a29efe9f' },
'bell-seed-2': { 560: 'be278aba', 1200: '01126e70', 2160: 'fd03f9b2' },
'bell-seed-3': { 560: 'f24480f4', 1200: 'e52bed85', 2160: 'abb2a5d2' }
}
+107
View File
@@ -0,0 +1,107 @@
/**
* 0.1.39 P3worldsim
* NPC
* tradeSettle worldsim
*
*/
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/runtime/World'
import { WorldSim } from '../src/renderer/game/engine/sim/WorldSim'
import { resetSaveBus, attachLogSink } from './world.helpers'
import type { WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '封', familyName: '封家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('0.1.39 worldsim 关停根治', () => {
it('sysEnabled("worldsim") 默认开 → toggleSystem 后关', () => {
const w = baseWorld('ws-toggle-1')
expect(w.sysEnabled('worldsim')).toBe(true)
const after = w.toggleSystem('worldsim')
expect(after).toBe(false)
expect(w.sysEnabled('worldsim')).toBe(false)
})
it('关停后 tick() 直接 return(入口双闸守卫)', () => {
const w = baseWorld('ws-freeze-1')
// 推进 12 月让 worldSim 有初始状态
for (let i = 0; i < 12; i++) w.advanceMonth()
const ws = w.state.worldSim as WorldSimState
expect(ws).toBeDefined()
// 快照关键字段
const tideBefore = ws.tide
const tideTicksBefore = ws.tideTicks
const poolBefore = { ...ws.marketPool }
// 关停 worldsim
w.toggleSystem('worldsim')
expect(w.sysEnabled('worldsim')).toBe(false)
// 直接调 tick() 应被入口守卫拦截
new WorldSim(w).tick()
// 状态完全冻结(tick 内所有逻辑未执行)
expect(ws.tide).toBe(tideBefore)
expect(ws.tideTicks).toBe(tideTicksBefore)
expect(ws.marketPool).toEqual(poolBefore)
})
it('关停后 advanceMonth 不再自主演进世界(潮汐/NPC/时代弧/快讯冻结)', () => {
const w = baseWorld('ws-freeze-2')
// 推进 24 月让世界有实质演化
for (let i = 0; i < 24; i++) w.advanceMonth()
const ws = w.state.worldSim as WorldSimState
expect(ws).toBeDefined()
// 快照世界自主演进指标
const tideTicksBefore = ws.tideTicks
const npcDynBefore = JSON.stringify(ws.npcDyn)
const secretQiBefore = JSON.stringify(ws.secretQi)
const eraBefore = ws.era
const newsLenBefore = ws.newsFeed.length
const calamityBefore = ws.calamity
const warFatigueBefore = ws.warFatigue
// 关停
w.toggleSystem('worldsim')
// 推进 36 月——世界自主演进应纹丝不动
for (let i = 0; i < 36; i++) w.advanceMonth()
// tick 内逻辑全部冻结
expect(ws.tideTicks).toBe(tideTicksBefore) // 潮汐不推进
expect(JSON.stringify(ws.npcDyn)).toBe(npcDynBefore) // NPC 动力学不变
expect(JSON.stringify(ws.secretQi)).toBe(secretQiBefore) // 秘境灵气不自恢复
expect(ws.era).toBe(eraBefore) // 时代弧不变
expect(ws.newsFeed.length).toBe(newsLenBefore) // 无新快讯
expect(ws.calamity).toBe(calamityBefore) // 灾年状态不变
expect(ws.warFatigue).toBe(warFatigueBefore) // 战争疲劳不变
})
it('重新开启后世界恢复演进(可逆性)', () => {
const w = baseWorld('ws-resume-1')
for (let i = 0; i < 12; i++) w.advanceMonth()
const ws = w.state.worldSim as WorldSimState
// 关停 12 月
w.toggleSystem('worldsim')
const tideFrozen = ws.tide
const ticksFrozen = ws.tideTicks
for (let i = 0; i < 12; i++) w.advanceMonth()
expect(ws.tide).toBe(tideFrozen)
expect(ws.tideTicks).toBe(ticksFrozen)
// 重新开启
w.toggleSystem('worldsim')
expect(w.sysEnabled('worldsim')).toBe(true)
for (let i = 0; i < 12; i++) w.advanceMonth()
// 世界恢复演进——tideTicks 必须增长
expect(ws.tideTicks).toBeGreaterThan(ticksFrozen)
})
})
+69
View File
@@ -0,0 +1,69 @@
/**
* 0.1.39 P4purgeNpc worldGen.npcs/relations
* NPC worldGen.npcs worldGen.relations
*/
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/runtime/World'
import { resetSaveBus, attachLogSink } from './world.helpers'
import { stateFingerprint, longRun } from './fingerprint.helper'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '封', familyName: '封家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('0.1.39 purgeNpc 存档体积精简', () => {
it('开局 worldGen.npcs 非空且与 npcFamilies 一一对应', () => {
const w = baseWorld('purge-1')
const genNpcs = w.state.worldGen?.npcs ?? []
const famIds = Object.keys(w.state.npcFamilies)
expect(genNpcs.length).toBe(famIds.length)
for (const n of genNpcs) {
expect(famIds).toContain(n.id)
}
})
it('长跑后灭亡 NPC 不残留于 worldGen.npcs', () => {
// 用长跑让 NPC 有机会灭亡(180 年 = 2160 月)
const w = longRun('purge-2', 2160)
if (w.state.gameOver) return // 族灭则跳过
const genNpcs = w.state.worldGen?.npcs ?? []
const famIds = new Set(Object.keys(w.state.npcFamilies))
// 所有 worldGen.npcs 中的 id 必须在 npcFamilies 中仍然存活
for (const n of genNpcs) {
expect(famIds.has(n.id)).toBe(true)
}
// 反过来,所有存活 NPC 必须在 worldGen.npcs 中有定义
for (const fid of famIds) {
expect(genNpcs.some((n) => n.id === fid)).toBe(true)
}
})
it('长跑后灭亡 NPC 不残留于 worldGen.relations', () => {
const w = longRun('purge-3', 2160)
if (w.state.gameOver) return
const famIds = new Set(Object.keys(w.state.npcFamilies))
const rels = w.state.worldGen?.relations ?? {}
// relations 的 key 必须都是存活 NPC
for (const rid of Object.keys(rels)) {
expect(famIds.has(rid)).toBe(true)
// relations 的 value 中的 key 也必须都是存活 NPC
for (const oid of Object.keys(rels[rid] ?? {})) {
expect(famIds.has(oid)).toBe(true)
}
}
})
it('确定性:同 seed 同月数 worldGen.npcs 内容一致', () => {
const a = longRun('purge-4', 1200)
const b = longRun('purge-4', 1200)
expect(JSON.stringify(a.state.worldGen?.npcs)).toBe(JSON.stringify(b.state.worldGen?.npcs))
expect(stateFingerprint(a.state)).toBe(stateFingerprint(b.state))
})
})