diff --git a/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs index ddca8932..1e58e3a6 100644 --- a/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs @@ -94,5 +94,58 @@ namespace BaseGames.Tests.EditMode.AI Assert.AreEqual(SinglePost.Post, second.EntryState); // 新模块生效 Object.DestroyImmediate(so); } + + static System.Collections.Generic.List Validate(PerceptionRecipeSO so) + => new System.Collections.Generic.List(so.Validate()); + + [Test] + public void Validate_Clean_WhenFullyConfigured() + { + var so = MakeE001Recipe(); + CollectionAssert.IsEmpty(Validate(so)); + Object.DestroyImmediate(so); + } + + [Test] + public void Validate_ReportsError_WhenAbilityMissing() + { + // 默认配方:RushEngagement 未配能力 + var so = ScriptableObject.CreateInstance(); + var results = Validate(so); + Assert.IsTrue(results.Exists(r => r.Severity == BaseGames.Core.ValidationSeverity.Error)); + Object.DestroyImmediate(so); + } + + [Test] + public void Validate_DoesNotLeaveCachedGraph() + { + // 校验期试建的图不能留给运行时——否则改了模块后拿到的是校验时的旧图。 + var so = MakeE001Recipe(); + Validate(so); + so.AssignModules(new SinglePost(LocomotionMode.Idle), + new RushEngagement("other", RushExit.OnLostTarget)); + Assert.AreEqual(SinglePost.Post, so.GetOrBuildGraph().EntryState); + Object.DestroyImmediate(so); + } + + [Test] + public void Validate_ReportsError_WhenCommittedRushHasZeroCooldown() + { + // Committed 的防抖动保证依赖能力自身有冷却;cooldown<=0 时冲锋态↔未发现态 + // 每帧抖动,这是配置期就能发现的错误,必须在校验里显式报出。 + var ability = ScriptableObject.CreateInstance(); + ability.abilityId = "e001_chase"; + ability.cooldown = 0f; + + var so = ScriptableObject.CreateInstance(); + so.AssignModules(new DisguiseThenPatrol(), new RushEngagement(ability, RushExit.Committed)); + + var results = Validate(so); + Assert.IsTrue(results.Exists(r => + r.Severity == BaseGames.Core.ValidationSeverity.Error && r.Message.Contains("cooldown"))); + + Object.DestroyImmediate(so); + Object.DestroyImmediate(ability); + } } } diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs index 96cf612a..108ebe62 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using BaseGames.AI; +using BaseGames.Core; namespace BaseGames.Enemies { @@ -40,5 +42,9 @@ namespace BaseGames.Enemies .OnExit (x => x.Combat.InterruptAbilities()) .To(Approach).When(x => !x.Combat.IsAbilityRunning(), "attackDone"); } + + // 本模块无自身配置:招式的射程/冷却/权重全归敌人身上的 EnemyAttackSelector, + // 而那是 GameObject 上的组件,配方资产看不到,无法在此校验。 + public IEnumerable Validate() => Array.Empty(); } } diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs index 8e7b389f..a82909ad 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushEngagement.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using UnityEngine; using BaseGames.AI; +using BaseGames.Core; using BaseGames.Enemies.Abilities; namespace BaseGames.Enemies @@ -52,6 +54,27 @@ namespace BaseGames.Enemies s.To(rest).When(AiStateFragments.LostAllZones, "leftAllZones"); } + public IEnumerable Validate() + { + if (_ability.IsEmpty) + { + yield return ValidationResult.Error("RushEngagement:未配置冲锋能力。"); + yield break; + } + + if (_ability.HasConflict) + yield return ValidationResult.Warning( + "RushEngagement:能力引用同时配了资产和字符串 id,资产优先、字符串被忽略。"); + + // Committed 的防抖动保证依赖能力自身有冷却:cooldown==0 时能力结束的下一帧 + // CanUseAbility 即恢复为真,冲锋态↔未发现态每帧抖动照旧出现。 + if (_exit == RushExit.Committed && _ability.Asset != null && _ability.Asset.cooldown <= 0f) + yield return ValidationResult.Error( + $"RushEngagement:脱战语义为 Committed,但所引用能力 '{_ability.Asset.name}' 的 cooldown 为 " + + $"{_ability.Asset.cooldown}。Committed 的防抖动保证依赖 cooldown > 0,否则冲锋打完下一帧" + + "就会再次触发,出现每帧抖动。请给该能力配置冷却,或把脱战语义改为 OnLostTarget。"); + } + // 校验放这里而非构造函数:反序列化路径不走 ctor,漏配必须在建图时暴露。 string RequireAbilityId() { diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs index bc677abb..109be1a8 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using BaseGames.AI; +using BaseGames.Core; namespace BaseGames.Enemies { @@ -31,5 +33,9 @@ namespace BaseGames.Enemies /// CanEngage 的可读标签(trace / 图导出用)。 string EngageLabel { get; } + + /// 校验本模块自身的配置。由 PerceptionRecipeSO.Validate() 汇总上报。 + /// 无可失效配置的模块返回空序列。 + IEnumerable Validate(); } } diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs index 2c0a4f6c..c8e80b16 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs @@ -1,5 +1,7 @@ +using System.Collections.Generic; using UnityEngine; using BaseGames.AI; +using BaseGames.Core; namespace BaseGames.Enemies { @@ -8,7 +10,7 @@ namespace BaseGames.Enemies /// 两个下拉各选一个模块即可,无需写代码。有独门机制的敌人改写 AiScript 子类。 /// [CreateAssetMenu(menuName = "BaseGames/AI/感知型 AI 配方", fileName = "ENM_")] - public sealed class PerceptionRecipeSO : AiRecipeSO + public sealed class PerceptionRecipeSO : AiRecipeSO, IValidatable { [Tooltip("未发现层:玩家尚未被发现时的行为形状")] [SerializeReference, SubclassSelector] IUnawareModule _unaware = new SinglePost(); @@ -30,5 +32,35 @@ namespace BaseGames.Enemies _unaware = unaware; _engagement = engagement; InvalidateGraph(); } + + /// SOValidationRunner 自动扫描调用。结构性校验(模块是否选、建图是否成功) + /// 由配方负责;模块自身的配置(能力引用、冷却语义等)委托给模块的 Validate()—— + /// 新增一种打法不需要回来改这里。 + public IEnumerable Validate() + { + string who = name; + + if (_unaware == null) + yield return ValidationResult.Error($"{who}:未选择未发现层模块。"); + if (_engagement == null) + yield return ValidationResult.Error($"{who}:未选择交战层模块。"); + if (_unaware == null || _engagement == null) yield break; + + foreach (var r in _engagement.Validate()) + yield return new ValidationResult(r.Severity, $"{who}:{r.Message}"); + + // 结构性校验:直接试建一次图,模块与骨架的建图期守卫会把问题抛出来 + // (能力漏配、Rest 不在 States、态未声明、跨模块状态名冲突等)。 + // 这是最彻底的检查——凡是运行时会炸的,这里就会炸。 + ValidationResult? buildFailure = null; + try { GetOrBuildGraph(); } + catch (System.Exception e) + { + buildFailure = ValidationResult.Error($"{who}:建图失败 —— {e.Message}"); + } + finally { InvalidateGraph(); } // 校验期建的图不留给运行时 + + if (buildFailure.HasValue) yield return buildFailure.Value; + } } } diff --git a/Assets/_Game/Scripts/Enemies/Abilities/AbilityRef.cs b/Assets/_Game/Scripts/Enemies/Abilities/AbilityRef.cs index aca18df0..0cdd3c03 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/AbilityRef.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/AbilityRef.cs @@ -15,6 +15,9 @@ namespace BaseGames.Enemies.Abilities [NonSerialized] string _literal; // 定制脚本路径用;不参与序列化 + /// 引用的能力资产;走字符串路径时为 null。供校验器读取冷却等配置。 + public EnemyAbilitySO Asset => _asset; + public AbilityRef(EnemyAbilitySO asset) { _asset = asset; _literal = null; } public AbilityRef(string id) { _asset = null; _literal = id; } public AbilityRef(EnemyAbilitySO asset, string id) { _asset = asset; _literal = id; }