Files
zeling_v2/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs
T

463 lines
17 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 UnityEngine;
using NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
/// <summary>
/// PerceptionStateMachine 全感知↔状态矩阵测试(spec《敌人感知↔状态》):
/// 待机/巡逻(未发现) → 追击(优先)/警觉(次);警觉 → 追击/降级R/保持;
/// 追击 → 脱离全部感知才降级R(永不回警觉);死亡=全局事件。
/// 覆盖 HasAlert 分支、locomotion 意图、追击能力触发/中断、de-escalation。
/// </summary>
public class PerceptionStateMachineTests
{
// 实现 IAiContext + IEnemyActor(HasAlertState 可配),复用 FakeAiContext.cs 的子 fake。
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 bool HasAlert = true;
public ISensor Sensor => S;
public IEnemyLocomotion Locomotion => L;
public ICombatant Combat => C;
public IActorVitals Vitals => V;
public Blackboard Blackboard => BB;
public bool HasAlertState => HasAlert;
}
static AiGraph Graph(string entry = "Idle")
{
var b = new BrainBuilder();
PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
{
Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Chase = "Chase", Death = "Death",
Entry = entry, Rest = "Patrol",
IdleMode = LocomotionMode.Idle,
PatrolMode = LocomotionMode.Patrol,
AlertMode = LocomotionMode.Face,
ChaseAbilityId = "chase",
});
return b.Build();
}
// PatrolBetweenChases 变体:冲刺打完→巡逻(CD),冷却结束仍在追逐区→再冲刺。
static AiGraph GraphCD()
{
var b = new BrainBuilder();
PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
{
Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Chase = "Chase", Death = "Death",
Entry = "Idle", Rest = "Patrol",
IdleMode = LocomotionMode.Idle, PatrolMode = LocomotionMode.Patrol, AlertMode = LocomotionMode.Face,
ChaseAbilityId = "chase",
PatrolBetweenChases = true,
});
return b.Build();
}
// ── 入口 / 待机 ───────────────────────────────────────────────────
[Test]
public void Entry_IsIdle_WithIdleLocomotion()
{
var ctx = new Ctx();
var rt = new AiRuntime(Graph(), ctx);
Assert.AreEqual("Idle", rt.CurrentStateName);
Assert.AreEqual(LocomotionMode.Idle, ctx.L.CurrentMode);
}
[Test]
public void Idle_Stays_WhenNoPerception()
{
var ctx = new Ctx();
var rt = new AiRuntime(Graph(), ctx);
rt.Tick(0.1f);
Assert.AreEqual("Idle", rt.CurrentStateName);
}
[Test]
public void Idle_ToChase_WhenInChaseZone()
{
var ctx = new Ctx();
var rt = new AiRuntime(Graph(), ctx);
ctx.S.Chase = true;
rt.Tick(0.1f);
Assert.AreEqual("Chase", rt.CurrentStateName);
}
[Test]
public void Idle_ToAlert_WhenInVision_AndHasAlert()
{
var ctx = new Ctx { HasAlert = true };
var rt = new AiRuntime(Graph(), ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual("Alert", rt.CurrentStateName);
}
[Test]
public void Idle_StaysIdle_WhenInVision_ButNoAlertState()
{
var ctx = new Ctx { HasAlert = false }; // 无警觉态 → 不进警觉
var rt = new AiRuntime(Graph(), ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual("Idle", rt.CurrentStateName);
}
[Test]
public void Idle_ChaseBeatsAlert_WhenBothZones()
{
var ctx = new Ctx { HasAlert = true };
var rt = new AiRuntime(Graph(), ctx);
ctx.S.Chase = true; ctx.S.Vision = true; // 两者都满足 → 追击优先
rt.Tick(0.1f);
Assert.AreEqual("Chase", rt.CurrentStateName);
}
// ── 巡逻(entry=Patrol):与待机同规则 ─────────────────────────────
[Test]
public void Patrol_ToChase_WhenInChaseZone()
{
var ctx = new Ctx();
var rt = new AiRuntime(Graph("Patrol"), ctx);
Assert.AreEqual("Patrol", rt.CurrentStateName);
Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode);
ctx.S.Chase = true;
rt.Tick(0.1f);
Assert.AreEqual("Chase", rt.CurrentStateName);
}
[Test]
public void Patrol_ToAlert_WhenInVision_AndHasAlert()
{
var ctx = new Ctx { HasAlert = true };
var rt = new AiRuntime(Graph("Patrol"), ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual("Alert", rt.CurrentStateName);
}
// ── 警觉 ──────────────────────────────────────────────────────────
Ctx InAlert(out AiRuntime rt)
{
var ctx = new Ctx { HasAlert = true };
rt = new AiRuntime(Graph(), ctx);
ctx.S.Vision = true; // Idle → Alert
rt.Tick(0.1f);
Assert.AreEqual("Alert", rt.CurrentStateName);
return ctx;
}
[Test]
public void Alert_AppliesFaceLocomotion()
{
var ctx = InAlert(out var rt);
Assert.AreEqual(LocomotionMode.Face, ctx.L.CurrentMode);
CollectionAssert.Contains(ctx.L.Calls, "Face");
}
[Test]
public void Alert_ToChase_WhenInChaseZone()
{
var ctx = InAlert(out var rt);
ctx.S.Chase = true;
rt.Tick(0.1f);
Assert.AreEqual("Chase", rt.CurrentStateName);
}
[Test]
public void Alert_ToRest_WhenLeftVision()
{
var ctx = InAlert(out var rt);
ctx.S.Vision = false; // 脱离视野 → 降级 R(=Patrol)
rt.Tick(0.1f);
Assert.AreEqual("Patrol", rt.CurrentStateName);
}
[Test]
public void Alert_Stays_WhenStillInVision()
{
var ctx = InAlert(out var rt);
ctx.S.Vision = true; ctx.S.Chase = false;
rt.Tick(0.1f);
Assert.AreEqual("Alert", rt.CurrentStateName);
}
// ── 追击 ──────────────────────────────────────────────────────────
Ctx InChase(out AiRuntime rt)
{
var ctx = new Ctx();
rt = new AiRuntime(Graph(), ctx);
ctx.S.Chase = true; // Idle → Chase
rt.Tick(0.1f);
Assert.AreEqual("Chase", rt.CurrentStateName);
return ctx;
}
[Test]
public void Chase_TriggersAbility_NotLocomotion()
{
var ctx = InChase(out var rt);
CollectionAssert.Contains(ctx.C.Used, "chase"); // 触发追击能力
// 进 Chase 后不应再有 locomotion 意图声明(追击移动归能力)
int lastStop = ctx.L.Calls.FindLastIndex(s => s == "Stop");
var afterEnter = ctx.L.Calls.GetRange(lastStop + 1, ctx.L.Calls.Count - lastStop - 1);
CollectionAssert.IsEmpty(afterEnter);
}
[Test]
public void Chase_ToRest_WhenLeftAllZones()
{
var ctx = InChase(out var rt);
ctx.S.Chase = false; ctx.S.Vision = false; // 追逐区且视野区都不在 → R
rt.Tick(0.1f);
Assert.AreEqual("Patrol", rt.CurrentStateName);
}
[Test]
public void Chase_Stays_WhenInChaseZoneOnly()
{
var ctx = InChase(out var rt);
ctx.S.Chase = true; ctx.S.Vision = false; // 视野被墙挡漏检,但仍在追逐区
rt.Tick(0.1f);
Assert.AreEqual("Chase", rt.CurrentStateName);
}
[Test]
public void Chase_Stays_WhenInVisionZoneOnly()
{
var ctx = InChase(out var rt);
ctx.S.Chase = false; ctx.S.Vision = true; // 出追逐区但仍在视野 → 不降级
rt.Tick(0.1f);
Assert.AreEqual("Chase", rt.CurrentStateName);
}
[Test]
public void Chase_NeverGoesToAlert()
{
// 追击中即便"在视野且有警觉态",也绝不回警觉——只在脱离全部感知时降级 R。
var ctx = InChase(out var rt);
ctx.HasAlert = true;
ctx.S.Chase = false; ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual("Chase", rt.CurrentStateName); // 不是 Alert
Assert.AreNotEqual("Alert", rt.CurrentStateName);
}
[Test]
public void Chase_OnExit_InterruptsAbility()
{
var ctx = InChase(out var rt);
Assert.AreEqual("chase", ctx.C.Running); // 追击能力运行中
ctx.S.Chase = false; ctx.S.Vision = false;
rt.Tick(0.1f); // Chase → ROnExit 中断能力
Assert.IsNull(ctx.C.Running);
}
[Test]
public void RestIsPatrol_AfterChaseDeescalation()
{
var ctx = InChase(out var rt);
ctx.S.Chase = false; ctx.S.Vision = false;
rt.Tick(0.1f);
Assert.AreEqual("Patrol", rt.CurrentStateName); // R = Patrol
Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode); // 落地即巡逻移动意图
}
// ── ApproachAttack 交战风格 ───────────────────────────────────────
static AiGraph GraphApproach(string entry = "Idle")
{
var b = new BrainBuilder();
PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
{
Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Death = "Death",
Entry = entry, Rest = "Patrol",
IdleMode = LocomotionMode.Idle, PatrolMode = LocomotionMode.Patrol, AlertMode = LocomotionMode.Face,
Engagement = PerceptionStateMachine.EngagementStyle.ApproachAttack,
ApproachState = "Approach", AttackState = "Attack",
});
return b.Build();
}
[Test]
public void Approach_EnteredFromChaseZone_UsesPursue()
{
var ctx = new Ctx();
var rt = new AiRuntime(GraphApproach(), ctx);
ctx.S.Chase = true;
rt.Tick(0.1f);
Assert.AreEqual("Approach", rt.CurrentStateName);
CollectionAssert.Contains(ctx.L.Calls, "Pursue");
}
[Test]
public void Approach_ToAttack_WhenEligibleAttack()
{
var ctx = new Ctx();
var rt = new AiRuntime(GraphApproach(), ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
ctx.C.Eligible = true;
rt.Tick(0.1f);
Assert.AreEqual("Attack", rt.CurrentStateName);
CollectionAssert.Contains(ctx.C.Used, "best");
}
[Test]
public void Attack_StopsLocomotion_OnEnter()
{
var ctx = new Ctx();
var rt = new AiRuntime(GraphApproach(), ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
ctx.C.Eligible = true; rt.Tick(0.1f);
CollectionAssert.Contains(ctx.L.Calls, "Stop");
}
[Test]
public void Attack_ToApproach_WhenAttackDone()
{
var ctx = new Ctx();
var rt = new AiRuntime(GraphApproach(), ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
ctx.C.Eligible = true; rt.Tick(0.1f);
Assert.AreEqual("Attack", rt.CurrentStateName);
ctx.C.Running = null;
rt.Tick(0.1f);
Assert.AreEqual("Approach", rt.CurrentStateName);
}
[Test]
public void Approach_ToRest_WhenLeftAllZones()
{
var ctx = new Ctx();
var rt = new AiRuntime(GraphApproach(), ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
ctx.S.Chase = false; ctx.S.Vision = false;
rt.Tick(0.1f);
Assert.AreEqual("Patrol", rt.CurrentStateName);
}
[Test]
public void ApproachAttack_Death_IsGlobal()
{
var ctx = new Ctx();
var rt = new AiRuntime(GraphApproach(), ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
rt.Send(AiSignal.Died); rt.Tick(0.1f);
Assert.AreEqual("Death", rt.CurrentStateName);
}
// ── 死亡(全局事件,任意态可达)───────────────────────────────────
[Test]
public void Death_OnDiedSignal_FromIdle()
{
var ctx = new Ctx();
var rt = new AiRuntime(Graph(), ctx);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual("Death", rt.CurrentStateName);
}
[Test]
public void Death_OnDiedSignal_FromChase()
{
var ctx = InChase(out var rt);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual("Death", rt.CurrentStateName);
}
[Test]
public void Death_IsTerminal_NoTransitionOut()
{
var ctx = new Ctx();
var rt = new AiRuntime(Graph(), ctx);
rt.Send(AiSignal.Died); rt.Tick(0.1f);
Assert.AreEqual("Death", rt.CurrentStateName);
ctx.S.Chase = true; ctx.S.Vision = true; // 即便重新满足感知也不复活
rt.Tick(0.1f);
Assert.AreEqual("Death", rt.CurrentStateName);
}
// ── PatrolBetweenChasesCD 期巡逻)───────────────────────────────
// 驱动到 Chase 后模拟冲刺进行中(能力 running)。
Ctx InCD_Chase(out AiRuntime rt)
{
var ctx = new Ctx();
rt = new AiRuntime(GraphCD(), ctx);
ctx.S.Chase = true; ctx.C.CanUse = true;
rt.Tick(0.1f); // Idle → ChaseOnEnter 触发冲刺(Running="chase")
Assert.AreEqual("Chase", rt.CurrentStateName);
Assert.AreEqual("chase", ctx.C.Running);
return ctx;
}
[Test]
public void CD_Chase_Stays_WhileDashRunning()
{
var ctx = InCD_Chase(out var rt);
ctx.S.Chase = false; ctx.S.Vision = false; // 冲刺是 committed 冲锋:感知丢失也不中途打断
rt.Tick(0.1f);
Assert.AreEqual("Chase", rt.CurrentStateName);
}
[Test]
public void CD_Chase_ToPatrol_WhenDashDone()
{
var ctx = InCD_Chase(out var rt);
ctx.C.Running = null; ctx.C.CanUse = false; // 冲刺打完,进入冷却
rt.Tick(0.1f);
Assert.AreEqual("Patrol", rt.CurrentStateName); // 冲完→CD 期巡逻
}
[Test]
public void CD_Patrol_Stays_DuringCooldown_NoOscillation()
{
var ctx = InCD_Chase(out var rt);
ctx.C.Running = null; ctx.C.CanUse = false;
rt.Tick(0.1f); // → Patrol
Assert.AreEqual("Patrol", rt.CurrentStateName);
// CD 中仍在追逐区:不能回追击,多帧保持巡逻(不抖)
ctx.S.Chase = true; ctx.C.CanUse = false;
for (int i = 0; i < 5; i++) rt.Tick(0.1f);
Assert.AreEqual("Patrol", rt.CurrentStateName);
}
[Test]
public void CD_Patrol_ToChase_WhenCooldownEnds_AndStillInChaseZone()
{
var ctx = InCD_Chase(out var rt);
ctx.C.Running = null; ctx.C.CanUse = false;
rt.Tick(0.1f); // → Patrol
ctx.S.Chase = true; ctx.C.CanUse = true; // 冷却结束 + 仍在追逐区
rt.Tick(0.1f);
Assert.AreEqual("Chase", rt.CurrentStateName); // 再次冲刺
Assert.AreEqual("chase", ctx.C.Running);
}
[Test]
public void CD_Patrol_StaysHome_WhenPlayerLost()
{
var ctx = InCD_Chase(out var rt);
ctx.C.Running = null; ctx.C.CanUse = false;
rt.Tick(0.1f); // → Patrol
// 玩家丢失(脱离追逐区):即便冷却结束也不回追击,留在巡逻(降级回家)
ctx.S.Chase = false; ctx.S.Vision = false; ctx.C.CanUse = true;
rt.Tick(0.1f);
Assert.AreEqual("Patrol", rt.CurrentStateName);
}
}
}