Files
zeling_v2/Assets/Tests/EditMode/AI/BrainBuilderDeadEndTests.cs
joywayerandClaude Opus 5 0f676b0e88 feat(ai): Build() 校验「非终态必须有出口」,堵住零报错的永久卡死
BrainBuilder.Build() 此前只做正向校验——边指向的态是否已声明(RequireState
的同款关切);反向从不检查。一个已声明却没有任何出边的非终态,会让敌人进去
就永久停在那里,且全程零报错。计划里点名的风险点是 BossFragments.PhaseTransition:
它按设计不自带出边,出口全靠调用方补挂,漏一条就把 Boss 锁死。

做法:AiState 加 IsTerminal,StateBuilder 加 .Terminal() 显式标记,
AiStateFragments.Terminal() 自带该标记;Build() 对「非终态且无出口」抛异常。

「有出口」的判定比计划原话严一档,两点都是照 AiRuntime 的实际行为定的:
- 自转换不算——Switch 对 Target == 当前态直接 return false,根本出不去;
- 全局转换不算——全局事件边要外部推信号才触发,不是自主出口。把它算作出口,
  等于让「只有死了才出得去」的死角通过校验,而那恰是本校验要暴露的东西。

校验放在 Build() 而非各生产入口:生产的完整图装配点是 AiScript.GetOrBuildGraph
与 AiRecipeSO 两处,放 Build() 能让将来新增的入口默认受保护。代价是模块单测
需要一条别的路——单个 IUnawareModule / IEngagementModule 按设计只声明自己的态,
升级边由 PerceptionSkeleton 事后挂,单独构建时的死角是正常中间态而非缺陷。
为此加 BuildPartial():跳过本项校验,其余照旧。

测试侧按语义分两类处理,没有一刀切:
- 概念上确实是终态的(Dead)标 .Terminal(),保留 Build() 的全量校验;
- 隔离验证单个模块/片段的部分图改用 BuildPartial();
- AiScriptTests / AiDefinitionRegistryTests 的夹具经 GetOrBuildGraph 走生产
  Build(),给桩汇态补了回边——这两条本就该验完整图。
- 断言 Build() 因别的原因抛异常的用例原样保留。
PerceptionSkeleton 与嘲风图建的都是完整图,无需改动即通过——这本身就是覆盖。

验证:编译 0 错;EditMode 269/269(264 + 新增 5)。
加校验后曾有 49 条既有测试变红,全部为上述两类夹具,逐条按语义修正后转绿。

变异验证:注释掉 ChaoFengAi 里 PhaseTx 的 txDone 出边后,ChaoFengAiTests
8 条全部变红,报错精确点名 'PhaseTransition';还原后复验 269/269。
本改动之前,同样的删除是零报错的——Boss 会静默锁死在过渡态,
这正是本校验存在的理由。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:21:07 +08:00

82 lines
3.2 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
/// <summary>
/// Build() 必须挡住「非终态却没有出口」的状态。
///
/// 既有校验只做正向——边指向的态是否已声明(RequireState 的同款关切);
/// 反向从不检查。一个已声明却没有任何出边的非终态会让敌人永久停在那里,
/// 且全程零报错,与 RequireState 要防的静默失败是同一类,只是方向相反。
/// </summary>
public class BrainBuilderDeadEndTests
{
[Test]
public void Build_NonTerminalStateWithNoOutgoing_Throws()
{
var b = new BrainBuilder();
b.Entry("A");
b.DeclareState("A").To("B").When(c => true, "go");
b.DeclareState("B"); // 忘了挂出边
var ex = Assert.Throws<System.InvalidOperationException>(() => b.Build());
StringAssert.Contains("B", ex.Message, "报错必须点名是哪个态,否则大图里无从查起");
}
[Test]
public void Build_TerminalState_IsAllowedToHaveNoOutgoing()
{
var b = new BrainBuilder();
b.Entry("A");
b.DeclareState("A").To("Dead").When(c => true, "die");
b.DeclareState("Dead").Terminal();
Assert.DoesNotThrow(() => b.Build());
}
[Test]
public void Build_StateWithOnlySelfTransition_Throws()
{
// 自转换在 AiRuntime.Switch 里是 no-opTarget == 当前态直接 return false),
// 出不去——等同于没有出边,不能算作出口。
var b = new BrainBuilder();
b.Entry("A");
b.DeclareState("A").To("B").When(c => true, "go");
b.DeclareState("B").To("B").When(c => true, "self");
var ex = Assert.Throws<System.InvalidOperationException>(() => b.Build());
StringAssert.Contains("B", ex.Message);
}
[Test]
public void Build_GlobalTransition_DoesNotCountAsOutgoing()
{
// 全局事件边要外部推信号才触发,不是自主出口;把它算作出口,
// 等于让「只有死了才出得去」的死角通过校验——那正是要暴露的东西。
var b = new BrainBuilder();
b.Entry("A");
b.DeclareState("A").To("B").When(c => true, "go");
b.DeclareState("B");
b.DeclareState("Dead").Terminal();
b.Global().To("Dead").OnEvent(AiSignal.Died);
Assert.Throws<System.InvalidOperationException>(() => b.Build());
}
[Test]
public void AiStateFragments_Terminal_MarksStateAsTerminal()
{
// 生产入口:骨架与 Boss 图都经这个片段声明死亡终态,
// 它必须自带标记,否则每个图都要额外记得手动标一次。
var b = new BrainBuilder();
b.Entry("A");
b.DeclareState("A").To("Death").When(c => true, "die");
AiStateFragments.Terminal(b, "Death");
Assert.DoesNotThrow(() => b.Build());
}
}
}