docs(10-26): 修复高级 II 读者问题(第2轮)

This commit is contained in:
unanmed 2026-10-21 20:25:18 +08:00
parent 2d25a32660
commit 153bc8b373
7 changed files with 94 additions and 26 deletions

View File

@ -37,6 +37,25 @@
- `index.md`:高级 III 占位表述;「优先插件,必要时改内置包」与本层多篇直接改 `packages-user` 的张力。
- `custom-range`:`radius` 需为整数,否则 `iterateLoc` 产生非整数索引。
## 第 2 轮 · 问题清单
### 阻断性(2)
| # | 问题 | 定位 |
| ---- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| B-13 | 护甲验证预期方向写反:护甲 50→0 时回合数减半,总伤害应大致减半,而非翻倍 | `adding-enemy-attribute.md`「怎么验证」 |
| B-14 | 装备对比建议「比较对象应放在目标槽之外」与「别的槽会重复计入」自相矛盾;`-1` 的基线语义也未说清 | `equipment-system.md`「装备对比」「边界与注意」 |
### 非阻断(7)
- `custom-replay-command`:主动步调用 `next(Success)` 后读取流何时推进、`execute` 应返回什么;`next(Failed)` 是否会中止播放。
- `enemy-context-and-combat`:`refreshEnemy` 是否重跑特殊光环的 `applySpecial` 与特殊属性。
- `equipment-system`:`getModifiers()` 懒构建与 `add` 时构建的关系;`equip` 是否会触发懒构建。
- `adding-hero-attribute`:统一「默认值只保证类型完整、运行时只来自 core.jsonc」的表述。
- `adding-enemy-attribute`:默认模板何时被使用;内容条目不与模板合并。
- `adding-hero-attribute`:自爆并非完全不受护甲影响。
- `pathfinding`:`InvalidInput` 的原因补充「缺少方向处理器」。
## 迭代记录
### 第 1 轮修复(2026-10-21)
@ -71,3 +90,25 @@
- `custom-range.md`:`radius` 必须为整数,示例统一截断。
**验证**:prettier 格式化通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 通过(仅 VitePress chunk 体积警告)。
### 第 2 轮修复(2026-10-21)
对照当前源码逐条修复,构建通过。
**阻断性**
- B-13 `adding-enemy-attribute.md`:验证预期改为 `armor` 50→0 后勇士每回合伤害翻倍、回合数大致减半、总伤害大致减半,并给出 `damage = (turn - 1) * enemyPerDamage` 的依据。
- B-14 `equipment-system.md`:重写「装备对比」。明确模拟基线是「当前属性减去目标槽现役装备」;A/B 只能是目标槽上的现役装备或完全未装备,不能穿在别的槽上(否则会重复计入)。补充 `compareEquip` 的逐键回退规则与 `-1` 的实际语义。
- **与审阅建议的差异**:审阅建议称目标槽有装备时传 `-1` 得到「相对空槽」。核对 `HeroEquipment.compareEquip` 源码后,缺失的一侧在差值阶段回退到 `this.attribute.getFinalAttribute(key)`,即当前真实最终属性(含目标槽现役装备);因此文中按源码写为「相对现役装备」。现有测试 `compareEquip(axe, bow, 0).atk === 7`(axe +12、sword +5 现役)也验证了这一点。
**非阻断**
- `custom-replay-command.md`:说明 `next(Success)` / `next(Ignored)` 在调用时即推进读取流并触发 `onStep`;`next(Failed)` 不推进也不自行中止;主动步 `execute` 的返回值才决定回放是否继续,`next` 返回 `void` 无法传递失败。
- `enemy-context-and-combat.md`:明确 `refreshEnemy` 会重跑命中该怪物的特殊光环 `applySpecial`、特殊查询与常规查询,但不重建 `sortedAura` / `convertedAura` 拓扑;新增或删除特殊属性产生的光环只告警(code 101),不会进入本轮排序表。
- `equipment-system.md`:`add` 与 `loadState` 都会立即 `buildModifiers()`,正常实例的 `getModifiers()` 不会再次构建,`equip` 也不会触发懒构建;只有直接 `new EquipmentState(...)` 才可能触发兜底构建。
- `adding-hero-attribute.md`:统一为 `HERO_DEFAULT_ATTRIBUTE` 只保证类型完整,运行时初始值仅来自 `core.jsonc`,它不是兜底;同步修正验证与边界表述。
- `adding-enemy-attribute.md`:说明默认模板只以 `enemy-default` 配置供编辑器/校验使用,运行时 `enemyStore` 只读内容条目,不会与模板合并。
- `adding-hero-attribute.md`:更正自爆与护甲的关系:护甲先缩放,自爆只把低于 `hp - 1` 的结果抬到下限,下限本身不缩放。
- `pathfinding.md`:`InvalidInput` 的原因补充「缺少方向处理器」。
**验证**:prettier 格式化通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 通过。

View File

@ -31,6 +31,8 @@ export interface IEnemyAttr {
具体数值来自 `IEnemyRawData.attribute`:怪物模板由 `num`(图块数字)、`attribute`(属性对象)与 `special`(特殊属性)组成。默认模板写在 `src/content/defaults/enemy.jsonc`,实际内容写在 `src/content/enemy.jsonc`。内容文件里每个怪物条目都有一份自己的 `attribute`,不会与默认模板合并。
默认模板在运行时不会被用来补全内容条目:加载器只是把它以 `enemy-default` 的标识读进配置表,供编辑器/校验端新建条目、核对字段形状;`enemyStore` 构建怪物时只读 `enemy.jsonc` 里的条目。因此它既不是新游戏内容的兜底值,也不会参与读档迁移。编辑器接入前,可以把它当作字段清单与类型示例。
`guard` 与其他字段不同:它是运行时由支援光环写入的集合(`GuardAura.apply` 会把来源怪物视图 `add` 进去),不是内容文件里手填的普通数值。改接口时不要把它当成内容字段,也不要在 `enemy.jsonc` 里补它。
## 改接口
@ -441,7 +443,7 @@ function verifyEnemyAttribute(
预期现象:
- `armor` 从 `50` 改为 `0` 后,勇士每回合伤害恢复,回合数减半,总伤害大致翻倍;
- `armor` 从 `50` 改为 `0` 后,勇士每回合伤害翻倍,回合数大致减半;攻速没变时怪物每回合伤害不变,按 `damage = (turn - 1) * enemyPerDamage`,总伤害也大致减半;
- 计算后护甲可能高于模板值(光环或最终效果会继续叠加),所以打印的是 `getComputedEnemy()` 的结果;
- `atkSpeed` 从 `100` 提到 `120` 后,怪物每回合伤害提高 20%,总伤害随之上升。
@ -451,7 +453,7 @@ function verifyEnemyAttribute(
## 边界与注意
- **接口、默认模板、内容条目都要补键**:三处缺一不可。内容对象不会与默认模板合并,缺键就是 `undefined`。
- **接口、内容条目、默认模板都要补键**:接口决定类型,内容条目决定运行时取值,默认模板只供编辑器/校验对照。内容对象不会与默认模板合并,运行时缺键就是 `undefined`。
- **`guard` 不要写进内容**:它是运行时由支援光环维护的集合,手填会与运行时行为冲突。
- **百分数统一用整数存储**:`20` 表示 20%,计算时再除以 `100`;不要一处存 `0.2`、另一处存 `20`。
- **新增字段会进入怪物存档**:读旧档时缺失的键不会自动补齐,需要按上文「旧存档迁移」在 `state.loadState` 之前给默认值。

View File

@ -1,6 +1,6 @@
# 新增勇士属性
勇士属性不只是数据表里多一列。一个属性会同时被默认值、内容配置、修饰器、伤害计算、装备与界面读取;只在接口里加字段,读取处会拿到 `undefined`,参与运算后变成 `NaN`。新增属性真正要补的,是这条链路上的每一处。
勇士属性不只是数据表里多一列。一个属性会同时被类型常量、内容配置、修饰器、伤害计算、装备与界面读取;只在接口里加字段,读取处会拿到 `undefined`,参与运算后变成 `NaN`。新增属性真正要补的,是这条链路上的每一处。
本篇以攻速(`atkSpeed`,每回合造成的伤害按百分比缩放)与护甲(`armor`,按百分比减免最终伤害)为例,走完「改接口 → 补默认值与初始值 → 读取与修饰 → 接入伤害计算」的数据端流程。编辑器新增属性项与状态栏显示属于对应端的实现,本篇只留占位。
@ -14,7 +14,7 @@
| 计算接入 | `src/data/enemy/calculator.ts` 等读取处 | 让属性真正影响伤害或其他运算 |
| 显示 | 编辑器与渲染端 | 编辑器属性项、状态栏显示(本篇不实现) |
前四处缺一不可。接口决定编译期可见性,默认值与内容配置决定运行期取值,计算接入决定属性是否真的起作用。显示可以后补,但数据端必须先跑通。
前四处缺一不可。接口决定编译期可见性,`core.jsonc` 决定运行期取值,计算接入决定属性是否真的起作用;`HERO_DEFAULT_ATTRIBUTE` 只保证类型形状完整,不是运行时兜底。显示可以后补,但数据端必须先跑通。
中级教程的[勇士属性](../../intermediate/hero-attribute.md)已经讲过基础属性、最终属性与修饰器的常用调用,这里补齐「新增一个属性」的完整改动面。
@ -59,7 +59,7 @@ export interface IHeroAttr {
## 补默认值与初始值
`HERO_DEFAULT_ATTRIBUTE` 是属性对象的完整形状。接口增加字段后,这里必须同步补齐,否则 `IHeroAttr` 的类型检查会报缺键:
`HERO_DEFAULT_ATTRIBUTE` 是属性对象的完整形状,只用于类型完整与示例,不会进入运行时。接口增加字段后,这里必须同步补齐,否则 `IHeroAttr` 的类型检查会报缺键:
```ts
/**
@ -342,7 +342,7 @@ export class MainDamageCalculator implements IDamageCalculator<
// [需改] 护甲按最终伤害百分比减伤。
// 放在魔防、负伤归零、固伤、仇恨之后,因此这些部分都会被护甲缩放;
// 放在自爆之前,自爆仍然把伤害抬到 hp - 1,保持「必定重创」的语义。
// 放在自爆之前:自爆只把低于 hp - 1 的伤害抬到下限,下限本身不缩放。
// 如果你希望固伤/仇恨绕过护甲,把这段移到固伤之前。
const armor = Math.min(
Math.max(hero.getFinalAttribute('armor'), 0),
@ -381,7 +381,7 @@ export class MainDamageCalculator implements IDamageCalculator<
两处插入点的含义:
- **攻速**:替换的是「每回合伤害」的初值,位置在吸血、连击、支援之前,但仍在 `turn` 计算与 `<= 0` 早退之前。不能等 `turn` 算完再乘——那样回合数不会变,只是把总伤害整体缩小。
- **护甲**:放在所有伤害分量汇总之后,缩放的是最终伤害。魔防已经先减掉,固伤与仇恨随后加上,所以它们也会被护甲缩放;自爆在其后把下限抬到 `hp - 1`,因此自爆不受护甲影响。不要把护甲乘进 `enemyPerDamage`:那样只减免每回合伤害,先攻、破甲、净化、固伤等一次性伤害会漏掉护甲。
- **护甲**:放在所有伤害分量汇总之后,缩放的是最终伤害。魔防已经先减掉,固伤与仇恨随后加上,所以它们也会被护甲缩放;自爆在其后执行 `damage = max(damage, hp - 1)`。注意自爆只保证 `hp - 1` 这个**下限**不被护甲缩放:护甲仍会先缩放之前的伤害,只有缩放后低于下限时才被抬回;缩放后仍高于下限时不会恢复。因此不能说「自爆完全不受护甲影响」。不要把护甲乘进 `enemyPerDamage`:那样只减免每回合伤害,先攻、破甲、净化、固伤等一次性伤害会漏掉护甲。
`getCriticalLimit` 也要跟着新属性考虑。它决定临界计算的搜索上界;自定义属性若参与伤害,至少要让读取该属性的方式与计算器一致,避免界面显示一个值、战斗按另一个值结算。
@ -462,11 +462,11 @@ function verifyHeroAttribute(
- 穿上 +15% 攻速的装备后,最终攻速变为 `115`,同一只怪物的战斗回合数下降;
- 把护甲调高后,`damage` 随护甲上升而下降,`turn` 不变。
若最终攻速是 `NaN`,先检查 `core.jsonc` 与 `HERO_DEFAULT_ATTRIBUTE` 是否都补了键;若装备穿上后数值不变,检查装备定义的键名是否与接口字段完全一致。
若最终攻速是 `NaN`,先检查 `core.jsonc` 的 `init.hero.attribute` 是否补了键;`HERO_DEFAULT_ATTRIBUTE` 只影响类型检查,补了它也不会在运行时兜底。若装备穿上后数值不变,检查装备定义的键名是否与接口字段完全一致。
## 边界与注意
- **所有创建英雄属性的位置都要补键**:默认值、内容配置、测试夹具各一处。`Partial<IHeroAttr>` 不会报缺键,但运行时仍会读到 `undefined`。
- **所有创建英雄属性的位置都要补键**:类型常量(`HERO_DEFAULT_ATTRIBUTE`)、内容配置、测试夹具各一处。`Partial<IHeroAttr>` 不会报缺键,但运行时仍会读到 `undefined`。
- **内容配置不会与默认值合并**:`createAttribute` 直接使用传入对象,缺键就是 `undefined`;不要指望默认值兜底。
- **百分数属性统一用百分数存储**:`15` 表示 15%,计算时再除以 `100`。装备的 `percentage` 则是倍率,`0.15` 表示 15%。
- **不要把新属性塞进既有汇总字段**:例如不要把攻速加进 `money` 或 `exp` 的统计逻辑,读这些字段的系统并不知道新属性的语义。

View File

@ -117,6 +117,15 @@ export interface IReplayPassiveHandler {
3. `next` 推进读取流并触发 `onStep`;重复调用 `getPassive()` 在推进前会拿到同一个 handler / 同一步。
`next(Success)` 与 `next(Ignored)` 会在调用时立刻读入被动步之后的一步,并触发被动步的 `onStep`;推进发生在 `next` 里,不等主动步 `execute` 返回。`next(Failed)` 则只记录 code 72 错误、保持读取位置不变,它本身不会中止播放。
被动步的成败不直接决定回放是否继续;主动步 `execute` 的返回值才是决定因素:
- 主动步返回 `Success`(推荐):播放继续。若被动步没有推进,下一轮 `replayStep` 会把这条未消费的被动步按 code 194 告警并跳过,播放继续但该步效果丢失;
- 主动步返回 `Failed`:整条播放中止,即使被动步已经推进,后续步骤也不会执行。
因此主动步在 `await passive.next(...)` 之后通常应返回 `Success`。`next` 的返回类型是 `Promise<void>`,主动步无法从它得知被动步是成功还是失败;如果某种被动失败必须中止回放,要在主动步里根据业务结果自行返回 `Failed`,不能依赖 `next(Failed)`。
被动指令本身只需要声明类型并注册,`execute` 不会被沙盒调用:
```ts
@ -434,7 +443,7 @@ async function verifyUseSkill(
- **不要复用已删除的旧指令码**:数值属于录像格式,旧录像会按旧值解码。新语义一律追加新值。
- **`Uint8` 下不要超过 255**:写入会截断,产生无法回放的录像。需要更多码时切 `Uint16`,并把它当成格式变更处理。
- **参数类型必须与 `paramTypes` 一致**:只支持 `number`、`string`、`boolean`、`bigint`。传对象等无法编码的值会被丢弃,参数个数随之变化,基类会判为失败。
- **被动步忘记 `next` 不会推进**:沙盒不会自己前进;重复调用 `getPassive()` 会得到同一个 handler / 同一步。被动步没有在主动步期间取走时,下一次 `replayStep` 会告警 code 194 并跳过它——播放不会永远卡住,但这一步的效果丢了。
- **被动步忘记 `next` 不会推进**:沙盒不会自己前进;重复调用 `getPassive()` 会得到同一个 handler / 同一步。被动步没有在主动步期间取走时,下一次 `replayStep` 会告警 code 194 并跳过它——播放不会永远卡住,但这一步的效果丢了。`next(Failed)` 同样不推进,也不会自行中止回放;中止与否取决于主动步 `execute` 随后返回的状态。
- **`finalize` 返回 `Failed` 会中止播放**:它返回的是 `Promise<ReplayCommandResult>`,和 `execute` 一样会中断后续步骤,不要在里面吞掉错误。
- **指令对象跨回放复用**:指令对象在注册时创建一次,之后每次回放都用同一个实例。成员变量不会自动清空;如果 `execute` 里累积了跨步状态,要在每次 `execute` 开头重置,或在 `finalize` 末尾清理,否则第二次回放会带着上一次的残留。
- **重复注册不会覆盖**:同一指令码重复注册会告警(code 163)并保留先注册的对象。确认注册只发生一次。

View File

@ -105,7 +105,13 @@ graph TD
- `view.markDirty()` 把视图加入 `dirtyEnemy`,同时让伤害系统删掉这只怪物的缓存;
- `context.requestRefresh(view)` 决定怎么刷新:若 `needTotallyRefresh` 命中该视图,或上下文本身已标记为需要更新,就回到全量 `buildup()`;否则调用 `refreshEnemy(view)`,只重算命中该怪物的光环与查询。
`refreshEnemy` 会先 `reset()` 该视图,然后按优先级重新判断「哪些光环命中这个位置」「哪些特殊查询作用于这只怪物」,再执行基础光环、常规查询与最终效果。它不会重建光环拓扑,因此比全量构建便宜。
`refreshEnemy` 会先 `reset()` 该视图,再按上一次全量构建留下的 `sortedAura` 与特殊查询表逐优先级重算:
- 对每条 `couldApplySpecial` 的光环重新调用 `applySpecial`,并通过 `refreshSpecialModifier` 就地增删、修改这只怪物的特殊属性;同一优先级下再重跑特殊查询;
- 然后按优先级执行命中的基础光环;
- 最后执行常规查询与最终效果。
所以特殊属性与特殊查询都会在这只怪物身上重跑,并不只是基础属性。但它**不重建光环拓扑**:`sortedAura` 与 `convertedAura` 仍是上次 `buildupSpecials()` 的结果。局部刷新期间新产生的特殊属性即使能转换成光环,也只会在开发环境告警(code 101),不会加入排序表;被删除特殊属性对应的旧光环也不会从排序表移除。要让新光环参与构建,必须走一次全量 `buildup()`(通过 `needTotallyRefresh` / `needUpdate` 触发)。
`requestedCommonContext` 记录的是「查询过上下文」的怪物:常规查询调用 `query()` 时会登记。局部刷新某只怪物时,上下文会把这些依赖外部状态的怪物一并重算,避免它们读到过期的周围环境。这也是[怪物查询](../../intermediate/enemy-query.md)强调「必须用 `query()` 而不是 `handler.context`」的原因:绕过 `query()` 就不会被登记,周围变化后它不会重算。

View File

@ -70,7 +70,7 @@ function grantEquipment(state: ICoreState, id: string): number {
- `setValue(name, value)` 与 `getValue(name)`:数值加成,对应 `ValueModifier`;
- `setPercentage(name, value)` 与 `getPercentage(name)`:百分比加成,对应 `PercentageModifier`;
- `getModifiers()`:输出 `[属性名, 修饰器]`,首次调用会自动 `buildModifiers()`。
- `getModifiers()`:输出 `[属性名, 修饰器]`;实例尚未构建时会先调用 `buildModifiers()` 兜底。
把加成设为 `0` 会删除对应条目与修饰器,而不是留下一个值为 0 的修饰器;读取不存在的属性返回 `0`。
@ -178,9 +178,19 @@ this.attribute.addModifier(name, modifier, false);
## 装备对比
`compareEquip(equipA, equipB, slot)` 返回「装备 A 时属性减装备 B 时属性」的差值。`equipA` 与 `equipB` 传实例 `uid`,传 `-1` 表示该侧为空;`slot` 只接受槽位索引。
`compareEquip(equipA, equipB, slot)` 返回「装备 A 时属性减装备 B 时属性」的差值。`equipA` 与 `equipB` 传实例 `uid`,传 `-1` 表示该侧没有装备;`slot` 只接受槽位索引。
内部会克隆当前属性,先移除该槽现有装备的修饰器,再分别模拟 A、B,最后逐键相减。真实属性不会被修改。返回值只包含 A 或 B 涉及到的属性键:
内部会克隆当前属性,先移除该槽现有装备的修饰器,再把 A、B 分别挂到这个克隆上模拟。真实属性不会被修改。返回值的键只包含 A 或 B 涉及的属性。
差值并不总是两份模拟直接相减:某一侧没有提供某个键时,该侧会回退到**当前真实最终属性**(仍包含目标槽上的现役装备)。因此:
- 两侧都提供同一个键:差值是「空槽 + A」减「空槽 + B」;
- 只有 A 提供该键:差值是「空槽 + A」减当前状态(含目标槽现役装备);
- 只有 B 提供该键:差值是当前状态减「空槽 + B」。
目标槽为空时,当前状态就是空槽基线,三种情况都退化为 A − B。传 `-1` 表示该侧没有装备:目标槽为空时,`compareEquip(a, -1, slot)` 是 A 相对空槽的提升;目标槽已有装备 X 时,它得到的是 A 相对 X 的差值(A − X),**不是**相对空槽。反过来 `compareEquip(-1, b, slot)` 会得到 X − B,符号容易看反,按需要的方向选择参数顺序。
A 与 B 要么就是目标槽上的现役装备,要么完全不装备。**不要把它们穿在另一个槽上**:那样它们的修饰器会同时留在基线(当前属性)和模拟里,被重复计入。
```ts
import { IHeroAttr } from '@user/data-common';
@ -199,7 +209,7 @@ function compareWeapons(state: ICoreState, a: number, b: number): void {
}
```
某件装备没有涉及的属性不会出现在结果里。比较「某件装备相对空槽」时,传 `-1` 作为 B:结果就是该装备相对当前状态的提升。
返回值里不会出现 A、B 都没涉及的属性;某一侧缺失的键按上面的回退规则处理。需要「某件装备相对空槽」的差值时,先确保目标槽为空,再传 `-1` 作为另一侧。
## 排序器
@ -267,8 +277,8 @@ function watchEquipment(state: ICoreState, uid: number): void {
`IItemRawData.equip.loadEvent` 与 `unloadEvent` 不是自动钩子。引擎不会在穿戴或卸下时执行它们,目前它们只是数据字段。**引擎也没有内置「装备穿上/卸下」钩子**;`onChangeModifier` 不能替代它:
- 如果装备实例在 `add` 时已经 `buildModifiers()`,之后 `equip` 只是把已有修饰器挂到属性上,不会触发 `onChangeModifier`;
- 如果实例是在别的时机才首次 `getModifiers()`,`equip` 可能顺带触发一次懒构建,但那是构建副作用,不是稳定的穿戴信号;
- `HeroEquipsStore.add` 在创建实例后会立即调用 `buildModifiers()`,`loadState` 重建实例后也会调用。因此正常获得的实例在 `equip` 时已经构建完毕,`equip` 只是把已有修饰器挂到属性上,不会触发 `onChangeModifier`;
- 只有绕过 `add`、直接 `new EquipmentState(...)` 且从未调用过 `getModifiers()` 时,`equip` 才可能顺带触发一次懒构建;那是兜底副作用,不是稳定的穿戴信号;
- 强化或改值同样会触发它,即使没有穿戴。
需要「穿上后触发」的行为,就在调用 `equip` / `unequip` 的统一入口里处理,或者包装一个自己的装备服务:
@ -289,7 +299,7 @@ function equipWithCallback(
}
```
`onChangeModifier` 在 `buildModifiers()` 时也会触发一次。实例在 `add` 时就已经构建过修饰器,所以之后才 `addHook` 的监听者收不到这次初始构建,只会收到后续变化。
`onChangeModifier` 在 `buildModifiers()` 时也会触发一次。经 `add` / `loadState` 得到的实例在建立时就已构建,所以之后才 `addHook` 的监听者收不到那次初始构建,只会收到后续变化;对这些实例,`equip` 不会触发再次构建。
## 怎么验证
@ -356,9 +366,9 @@ function verifyEquipment(state: ICoreState): void {
- **不要缩短已使用的 `slots`**:`equips` 以槽位索引为键,缩短数组会留下看不见但仍生效的槽位。先卸下再改。
- **读档会重建实例**:`HeroEquipsStore.loadState` 先清空再按存档重建,旧实例对象不再代表当前状态。读档后按 `uid` 重新 `get`,不要长期缓存实例引用。
- **`compareEquip` 两侧都为空时**返回 `{}` 并告警(code 195),不会抛错;`slot` 只接受索引。
- **`compareEquip` 的基线是当前属性**:如果 A 或 B 已经穿在别的槽上,它的修饰器仍在基线里,再模拟会重复计入。比较对象应放在目标槽之外。
- **`compareEquip` 的基线是当前属性减去目标槽现役装备**:A 或 B 可以就是目标槽上的现役装备,也可以完全未装备;**不要**把它们穿在另一个槽上,否则修饰器会在基线里再算一次。缺失的一侧回退到当前真实最终属性,所以目标槽有装备时,`-1` 得到的是「相对现役装备」而不是「相对空槽」。
- **排序器不影响装备槽**:它只决定 `instances()` 与 `instancesOf()` 的展示顺序;`getEquipped` 与 `getEquips` 永远按槽位顺序。
- **`getModifiers()` 会触发构建**:首次调用有副作用(构建修饰器并触发一次 `onChangeModifier`),不要把它当纯查询频繁调用。
- **`getModifiers()` 只在未构建时兜底构建**:正常经 `add` / `loadState` 得到的实例已经构建过,调用它不会再次触发 `onChangeModifier`;它返回的是实时迭代器,不要长期保存到回调之外。
编辑器与状态栏的展示不在本篇范围内。 <!-- TODO(editor): 装备栏与状态栏展示 -->

View File

@ -65,12 +65,12 @@ function bindPathfindingLayer(state: ICoreState): void {
`IPathfindingResult` 只有两个字段:`status` 与 `path`。`path` 仅在状态为 `Success` 时非空。
| 状态 | 含义 | 处理建议 |
| -------------------------------- | ------------------------------------------------------ | ------------------------------ |
| `PathfindingStatus.Success` | 找到路径 | 读取 `path`,交给移动器 |
| `PathfindingStatus.InvalidInput` | 输入不合法:未绑定移动器、图层或谓词,或起点不在地图内 | 检查绑定与起点坐标 |
| `PathfindingStatus.NoPath` | 从起点出发无法到达目标点 | 不要移动;先检查通行谓词与图层 |
| `PathfindingStatus.TargetUnder` | 目标点就在起点脚下 | 不需要移动 |
| 状态 | 含义 | 处理建议 |
| -------------------------------- | ------------------------------------------------------------------ | ------------------------------ |
| `PathfindingStatus.Success` | 找到路径 | 读取 `path`,交给移动器 |
| `PathfindingStatus.InvalidInput` | 输入不合法:未绑定移动器、图层、谓词或方向处理器,或起点不在地图内 | 检查绑定与起点坐标 |
| `PathfindingStatus.NoPath` | 从起点出发无法到达目标点 | 不要移动;先检查通行谓词与图层 |
| `PathfindingStatus.TargetUnder` | 目标点就在起点脚下 | 不需要移动 |
每一步是 `IPathfindingStep`,包含 `dir`(移动方向)、`from`(出发位置)与 `to`(到达位置)。`from` 与 `to` 都是只读的坐标对象;路径按起点到终点的顺序排列。