Files
zeling_v2/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs
T
joywayerandClaude Opus 5 3665504a58 feat(ai): 新增 IBossControl facet——Boss 阶段状态经 IAiContext 供图读取
非 Boss 敌人访问 IAiContext.Boss 显式抛 InvalidOperationException,
不返回空对象掩盖小怪图误挂 Boss 边的错误。BossResource 补 IsFull 供图条件边判定。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:15:54 +08:00

289 lines
12 KiB
C#
Raw 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 System.Collections.Generic;
using NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
public class PerceptionSkeletonTests
{
sealed class Ctx : IAiContext, IEnemyActor
{
public FakeSensor S = new FakeSensor();
public FakeLocomotion L = new FakeLocomotion();
public FakeCombat C = new FakeCombat();
public FakeVitals V = new FakeVitals();
public Blackboard BB = new Blackboard();
public FakeBossControl B = new FakeBossControl();
public bool HasAlert = true;
public ISensor Sensor => S;
public IEnemyLocomotion Locomotion => L;
public ICombatant Combat => C;
public IActorVitals Vitals => V;
public Blackboard Blackboard => BB;
public IBossControl Boss => B;
public bool HasAlertState => HasAlert;
}
static AiRuntime Build(Ctx ctx, IUnawareModule unaware = null, IEngagementModule engagement = null)
{
var b = new BrainBuilder();
PerceptionSkeleton.Add(b,
unaware ?? new SinglePost(LocomotionMode.Patrol),
engagement ?? new RushEngagement("rush", RushExit.OnLostTarget));
return new AiRuntime(b.Build(), ctx);
}
// ── 升级 ──────────────────────────────────────────────────────────
[Test]
public void Unaware_ToEngagement_WhenInChaseZone()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Chase = true;
rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
[Test]
public void Unaware_ToAlert_WhenInVision_AndHasAlert()
{
var ctx = new Ctx { HasAlert = true };
var rt = Build(ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
}
[Test]
public void Unaware_StaysUnaware_WhenInVision_ButNoAlertState()
{
var ctx = new Ctx { HasAlert = false };
var rt = Build(ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
}
[Test]
public void ChaseZone_BeatsAlert_WhenBothActive()
{
var ctx = new Ctx { HasAlert = true };
var rt = Build(ctx);
ctx.S.Chase = true; ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
// 升级边必须优先于未发现层内部计时边
[Test]
public void UpgradeEdge_BeatsUnawareInternalDwellEdge()
{
var ctx = new Ctx();
var rt = Build(ctx, new AlternatingIdlePatrol(idleDwell: 1f, patrolDwell: 1f));
Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName);
ctx.S.Chase = true;
rt.Tick(5f); // 计时早已到点,但追击边优先
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
// ── 警觉 ──────────────────────────────────────────────────────────
[Test]
public void Alert_ToEngagement_WhenEnteringChaseZone()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
[Test]
public void Alert_ToRest_WhenLeavingVision()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
ctx.S.Vision = false; rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
}
[Test]
public void Alert_FacesLastKnown()
{
var ctx = new Ctx();
ctx.S.Last = new UnityEngine.Vector2(7f, 2f);
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
Assert.AreEqual(new UnityEngine.Vector2(7f, 2f), ctx.L.FacedAt);
}
// ── 降级:追击后永不回警觉 ─────────────────────────────────────────
[Test]
public void AfterEngagement_GoesToRest_NeverBackToAlert()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 不是 Alert
}
// ── 死亡 ──────────────────────────────────────────────────────────
[Test]
public void Died_TransitionsToDeath_FromAnyState()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
// 真实死亡路径:PerformDeath 先 ForceState(Dead) 再发 Died
// 所以信号被处理时 IsControllable 已为假。事件边必须仍能通过。
[Test]
public void Died_WorksEvenWhenNotControllable()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.V.Controllable = false; // 已是 Dead 物理态
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
[Test]
public void Death_IsTerminal_NoConditionEdgesEvaluatedAfterwards()
{
var ctx = new Ctx();
var rt = Build(ctx);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
ctx.V.Controllable = false; // 死后不可控
ctx.S.Chase = true; // 即便玩家又进追逐区
for (int i = 0; i < 10; i++) rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); // 也不复活
}
// ── 校验 ──────────────────────────────────────────────────────────
sealed class BadUnaware : IUnawareModule
{
public string Entry => "X";
public string Rest => "NotInStates";
public IReadOnlyList<string> States => new[] { "X" };
public void Declare(BrainBuilder b) => AiStateFragments.Locomotion(b, "X", LocomotionMode.Idle);
public void Link(BrainBuilder b) { }
}
sealed class UndeclaredUnaware : IUnawareModule
{
public string Entry => "Y";
public string Rest => "Y";
public IReadOnlyList<string> States => new[] { "Y" };
public void Declare(BrainBuilder b) { } // 忘了声明行为
public void Link(BrainBuilder b) { }
}
[Test]
public void Throws_WhenRestNotInStates()
{
var b = new BrainBuilder();
Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
b, new BadUnaware(), new RushEngagement("rush")));
}
[Test]
public void Throws_WhenUnawareStateNotDeclared()
{
var b = new BrainBuilder();
Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
b, new UndeclaredUnaware(), new RushEngagement("rush")));
}
[Test]
public void Throws_WhenModuleIsNull()
{
var b = new BrainBuilder();
Assert.Throws<System.ArgumentNullException>(() => PerceptionSkeleton.Add(
b, null, new RushEngagement("rush")));
}
// ── 模块组合:抽象的存在理由,必须端到端验证 ─────────────────────
[Test]
public void Skeleton_WithApproachAttack_EscalatesAndDisengages()
{
var ctx = new Ctx();
var rt = Build(ctx, new SinglePost(LocomotionMode.Patrol), new ApproachAttackEngagement());
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName);
ctx.C.Eligible = true; rt.Tick(0.1f);
Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName);
ctx.C.Running = null; rt.Tick(0.1f); // 招式打完
Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName);
ctx.S.Chase = false; ctx.S.Vision = false; ctx.C.Eligible = false;
rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 脱战回 Rest
}
[Test]
public void Skeleton_WithDisguiseThenPatrol_DisengagesToPatrol_NotDisguise()
{
// 唯一 Entry != Rest 的未发现层:出生伪装静止,脱战只回巡逻,伪装态从此不可达。
var ctx = new Ctx();
var rt = Build(ctx, new DisguiseThenPatrol());
Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f);
Assert.AreEqual(DisguiseThenPatrol.Patrol, rt.CurrentStateName); // 不是 Disguise
// 再交战一轮,仍然只回巡逻——伪装态不可逆
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
ctx.S.Chase = false; rt.Tick(0.1f);
Assert.AreEqual(DisguiseThenPatrol.Patrol, rt.CurrentStateName);
}
[Test]
public void EntryCanBeOverridden_ForEnemiesWithPrefixStates()
{
// 骨架注释承诺的出路:掉落链 / 出场链这类前置态,调 Add 之后再覆盖 Entry。
var ctx = new Ctx();
var b = new BrainBuilder();
PerceptionSkeleton.Add(b, new SinglePost(LocomotionMode.Patrol),
new RushEngagement("rush", RushExit.OnLostTarget));
AiStateFragments.Locomotion(b, "Ceiling", LocomotionMode.Idle)
.To(SinglePost.Post).When(x => x.Sensor.SeesPlayer(), "landed");
b.Entry("Ceiling"); // 覆盖骨架设的入口
var rt = new AiRuntime(b.Build(), ctx);
Assert.AreEqual("Ceiling", rt.CurrentStateName);
ctx.S.Sees = true; rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 落地后并入感知层
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); // 升级边照常生效
}
}
}