docs(enemy): 计划新增 T5b——BrainBuilder.DeclareState 防模块间状态名冲突

契约质量审查提出:模块化后 State() 的"有则取"语义使两个独立模块
取同名状态时后者静默覆盖前者回调。与 T1 的 RequireState 是同一类
失败的两面,趁 0 个模块实现时补代价最低。附带补充交战/死亡层
"为何单阶段 Build 足够"的不变量说明。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 11:57:57 +08:00
co-authored by Claude Opus 5
parent 17e71dac39
commit 5dcbce6019
@@ -680,6 +680,130 @@ git commit -m "feat(enemy): 未发现层/交战层/死亡层三个模块契约"
---
## Task 5b: `BrainBuilder.DeclareState` 防状态名冲突
> 本任务是执行 T5 后由契约质量审查提出并采纳的追加项,非原稿内容。
>
> **风险来由**`BrainBuilder.State(name)` 是「有则取、无则建」。改造前 `PerceptionStateMachine`
> 是单个函数独占全部状态名,撞名不可能发生。改成「策划在下拉里自由组合三层独立模块」之后,
> 两个不同作者的模块取了同一个状态名(或某模块内部态叫 `Alert`,撞上骨架保留的那个),
> 后声明者的 `.OnEnter/.Tick/.OnExit` 会**静默覆盖**前者 —— 敌人跑错行为且零报错。
>
> 这与 T1 的 `RequireState` 是同一类失败的两面:`RequireState` 守「挂边到未声明的态」,
> `DeclareState` 守「重复声明同一个态」。趁只有 0 个模块实现时补,代价最低。
**Files:**
- Modify: `Assets/_Game/Scripts/AI/BrainBuilder.cs`
- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs`
- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs`
- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs`
- Test: `Assets/Tests/EditMode/AI/BrainBuilderTests.cs`
- [ ] **Step 1: 写失败测试**
`BrainBuilderTests.cs` 类体内追加:
```csharp
[Test]
public void DeclareState_Throws_OnDuplicateName()
{
var b = new BrainBuilder();
b.DeclareState("Dup");
var ex = Assert.Throws<System.InvalidOperationException>(() => b.DeclareState("Dup"));
StringAssert.Contains("Dup", ex.Message);
}
[Test]
public void DeclareState_ReturnsUsableBuilder_OnFirstDeclare()
{
var b = new BrainBuilder();
b.Entry("A");
b.DeclareState("A").To("B").When(c => true, "go");
b.DeclareState("B");
Assert.DoesNotThrow(() => b.Build());
}
[Test]
public void State_StaysLenient_ForAttachingToDeclaredState()
{
// State() 仍是"有则取"——骨架要给模块已声明的态挂升级边,靠的就是这个。
var b = new BrainBuilder();
b.Entry("A");
b.DeclareState("A");
Assert.DoesNotThrow(() => b.State("A").To("A").When(c => false, "noop"));
}
```
- [ ] **Step 2: 运行测试确认失败**
运行 `BrainBuilderTests`。期望:编译错误 `'BrainBuilder' does not contain a definition for 'DeclareState'`
- [ ] **Step 3: 实现 `DeclareState`**
`BrainBuilder.cs``RequireState` 之后插入:
```csharp
/// <summary>
/// 首次声明一个状态。名字已存在即抛——防止两个独立模块取了同名状态时,
/// 后者的 OnEnter/Tick/OnExit 静默覆盖前者(敌人跑错行为且零报错)。
/// 只有"首次声明行为"的调用点用它;给已声明的态挂边仍用 State()。
/// </summary>
public StateBuilder DeclareState(string name)
{
if (_states.ContainsKey(name))
throw new InvalidOperationException(
$"BrainBuilder: 状态 '{name}' 已被声明过。两个模块取了同名状态会互相覆盖回调——" +
"请给其中一个换个不冲突的名字。");
return State(name);
}
```
- [ ] **Step 4: 让四个建态原语走 `DeclareState`**
`AiStateFragments.cs` 中,把四个原语里的 `b.State(state)` 全部换成 `b.DeclareState(state)`
`Locomotion` / `Ability` / `AbilityOnce` / `Terminal` 各一处)。私有辅助方法不动。
- [ ] **Step 5: 给交战层 / 死亡层契约补一条不变量说明**
`IEngagementModule.cs` 的接口 XML 注释末尾追加一段:
```
/// 为何本层只需单阶段 Build(而未发现层要拆 Declare/Link):骨架只把边**指向**
/// EntryState,从不在本模块自己的状态上挂边,所以 Build 内声明的边不会被外部抢占。
/// 将来若修改骨架、使其向交战态挂边,必须先回来重新审视本契约。
```
`IDeathModule.cs` 的接口 XML 注释末尾追加:
```
/// 同 IEngagementModule:骨架只把全局 Died 边指向 EntryState,不在死亡链内部挂边,
/// 故单阶段 Build 足够。
```
并在 `IUnawareModule.cs``Entry` 成员注释上补一句区分:
```
/// <summary>图入口(出生态)。注意与交战/死亡层的 EntryState 不同:
/// 本属性会成为整张图唯一的 BrainBuilder.Entry,而 EntryState 只是别处 To() 的目标。</summary>
```
- [ ] **Step 6: 运行测试确认通过**
运行 `BrainBuilderTests``AiStateFragmentsTests`,再跑全量 EditMode。
期望:新增 3 个测试,全量从 222 变为 225,0 失败。
> 若有既有测试因重复声明而失败,**停下报告** —— 那说明框架里本来就存在重复声明,
> 是真问题,不要靠把 `DeclareState` 改宽松来绕过。
- [ ] **Step 7: 提交**
```bash
git add Assets/_Game/Scripts/AI/BrainBuilder.cs Assets/_Game/Scripts/Enemies/AIBrain/Modules Assets/Tests/EditMode/AI/BrainBuilderTests.cs
git commit -m "feat(ai): BrainBuilder.DeclareState——重复声明状态即报错,防模块间同名静默覆盖"
```
---
## Task 6: 未发现层三个模块
**Files:**