ThreeKingdomsSlingshot/Assets/Tests/PlayMode/BattleFlowTests.cs

339 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;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
namespace TKS.Tests
{
/// <summary>병종 4종 규칙·턴 흐름·승리 판정을 실제 씬에서 검증한다(헤드리스 실행 가능).</summary>
public class BattleFlowTests
{
BattleManager bm;
BalanceConfig cfg;
[UnitySetUp]
public IEnumerator SetUp()
{
SceneManager.LoadScene("Battle");
yield return null;
yield return null;
bm = BattleManager.Instance;
Assert.IsNotNull(bm, "GameBootstrap 이 BattleManager 를 만들어야 한다");
cfg = bm.Config;
Time.timeScale = 20f;
}
[UnityTearDown]
public IEnumerator TearDown()
{
Time.timeScale = 1f;
yield return null;
}
IEnumerator Fresh()
{
bm.StopAllCoroutines();
bm.ClearUnits();
bm.ForcePhase(BattlePhase.PlayerTurn);
yield return null; // 파괴된 콜라이더가 물리 월드에서 빠질 시간
}
[UnityTest]
public IEnumerator Bootstrap_SpawnsHeroesAndStageEnemies()
{
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase);
Assert.AreEqual(Mathf.Min(5, cfg.heroes.Length), bm.AliveCount(Faction.Player));
Assert.AreEqual(cfg.stages[0].enemies.Count, bm.AliveCount(Faction.Enemy));
yield return null;
}
[Test]
public void Matchup_TriangleAndArcherWeakness()
{
var c = cfg != null ? cfg : Resources.Load<BalanceConfig>("BalanceConfig");
Assert.Greater(c.Matchup(UnitType.Infantry, UnitType.Spear), 1f);
Assert.Greater(c.Matchup(UnitType.Spear, UnitType.Cavalry), 1f);
Assert.Greater(c.Matchup(UnitType.Cavalry, UnitType.Infantry), 1f);
Assert.Less(c.Matchup(UnitType.Spear, UnitType.Infantry), 1f);
Assert.Less(c.Matchup(UnitType.Cavalry, UnitType.Spear), 1f);
Assert.Less(c.Matchup(UnitType.Infantry, UnitType.Cavalry), 1f);
Assert.Greater(c.Matchup(UnitType.Infantry, UnitType.Archer), 1f);
Assert.Greater(c.Matchup(UnitType.Cavalry, UnitType.Archer), 1f);
Assert.Greater(c.Matchup(UnitType.Spear, UnitType.Archer), 1f);
}
[UnityTest]
public IEnumerator Infantry_AttacksFirstEnemyOnPath_BonusMoveOnFinalHit()
{
yield return Fresh();
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병");
var foe = bm.SpawnUnit(UnitType.Spear, Faction.Enemy, new Vector2(0f, 0f), "적창병");
var o = new ActionOutcome();
yield return bm.ResolveForTest(inf, foe.Position, false, o);
Assert.Contains(foe, o.Attacked);
Assert.Less(foe.Hp, foe.MaxHp, "경로 위 첫 적을 공격");
Assert.IsTrue(o.BonusMove, "최종 목표점 명중 → 추가 이동");
Assert.Less(Vector2.Distance(inf.Position, foe.Position), inf.Radius + foe.Radius + 0.2f, "적에 닿는 지점까지 이동");
Assert.Less(inf.Hp, inf.MaxHp, "근접 공격은 반격 피해를 받는다");
int foeHp = foe.Hp;
var o2 = new ActionOutcome();
yield return bm.ResolveForTest(inf, inf.Position + new Vector2(2.5f, 0f), true, o2);
Assert.AreEqual(0, o2.Attacked.Count, "추가 이동은 이동만");
Assert.AreEqual(foeHp, foe.Hp);
Assert.IsTrue(o2.MovedOnly);
}
[UnityTest]
public IEnumerator Infantry_NoBonus_WhenTargetBeyondEnemy()
{
yield return Fresh();
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0f), "적보병");
var o = new ActionOutcome();
yield return bm.ResolveForTest(inf, new Vector2(0f, 1.4f), false, o);
Assert.Contains(foe, o.Attacked);
Assert.IsFalse(o.BonusMove);
}
[UnityTest]
public IEnumerator Cavalry_PiercesAllOnPath_AndReturnsOnFinalHit()
{
yield return Fresh();
var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -4f), "기병");
var e1 = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -1.5f), "적1");
var e2 = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1f), "적2");
var o = new ActionOutcome();
yield return bm.ResolveForTest(cav, e2.Position, false, o);
Assert.Less(e1.Hp, e1.MaxHp, "경로 위 첫 적 관통 피해");
Assert.Less(e2.Hp, e2.MaxHp, "경로 위 둘째 적 관통 피해");
Assert.IsTrue(o.Returned, "최종 목표점 명중 → 제자리 복귀");
Assert.Less(Vector2.Distance(cav.Position, new Vector2(0f, -4f)), 0.05f);
}
[UnityTest]
public IEnumerator Cavalry_StaysAtEnd_WithoutFinalHit()
{
yield return Fresh();
var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -4f), "기병");
var e1 = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -1f), "적1");
var o = new ActionOutcome();
yield return bm.ResolveForTest(cav, new Vector2(0f, 2f), false, o);
Assert.Less(e1.Hp, e1.MaxHp);
Assert.IsFalse(o.Returned);
Assert.Greater(cav.Position.y, 1.5f, "목표점까지 이동");
Assert.GreaterOrEqual(Vector2.Distance(cav.Position, e1.Position), cav.Radius + e1.Radius - 0.05f, "적과 겹치지 않음");
}
[UnityTest]
public IEnumerator Archer_RangeIsSlingTimesRatio_MovesIntoRange_ShootsWithoutCounter()
{
yield return Fresh();
var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병");
Assert.AreEqual(arc.SlingMaxDistance * cfg.archerRangeRatio, arc.AttackRange, 0.001f, "궁병 사거리 = 슬링샷 최대 × 비율 (PD 2026-11-04 · +20 %)");
// 적 가장자리까지 4.8 − 반지름 합 ≈ 4.0 ≤ 슬링샷 4.0 + 사거리 0.8 → 행동 가능 · 목표점 (0,−1) 은 적 원 밖(크리 아님)
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -0.2f), "적보병");
var o = new ActionOutcome();
yield return bm.ResolveForTest(arc, new Vector2(0f, -1f), false, o);
Assert.IsFalse(o.Cancelled);
Assert.Contains(foe, o.Attacked, "조준 방향의 적을 사거리까지 이동 후 사격");
Assert.Less(foe.Hp, foe.MaxHp);
Assert.AreEqual(arc.MaxHp, arc.Hp, "궁병은 반격을 받지 않는다");
float gap = Vector2.Distance(arc.Position, foe.Position) - arc.Radius * Combat.CastRadiusScale - foe.Radius;
Assert.LessOrEqual(gap, arc.AttackRange + 0.1f, "적 가장자리가 사거리에 들어올 때까지만 이동");
Assert.Greater(arc.Position.y, -5f + 2.5f, "사거리에 들어오기 위해 실제로 이동");
Assert.IsFalse(o.Crit);
}
[UnityTest]
public IEnumerator Archer_CannotAct_WhenNoEnemyWithinSlingPlusRatio()
{
yield return Fresh();
var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병");
// 적 가장자리까지 6.0 − 반지름 합 ≈ 5.2 > 4.0 + 0.8 → 행동 불가(이동도 없음)
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1f), "적보병");
Assert.IsFalse(bm.AnyEnemyWithinArcherReach(arc), "공격 가능 범위 안에 적 없음(도넛 회색)");
var o = new ActionOutcome();
yield return bm.ResolveForTest(arc, new Vector2(0f, -1f), false, o);
Assert.IsTrue(o.Cancelled, "슬링샷 범위 + 비율 안에 적이 없으면 궁병은 행동하지 않는다");
Assert.AreEqual(0, o.Attacked.Count);
Assert.AreEqual(foe.MaxHp, foe.Hp);
Assert.Less(Vector2.Distance(arc.Position, new Vector2(0f, -5f)), 0.01f, "이동도 하지 않는다");
var near = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -0.4f), "가까운적");
Assert.IsTrue(bm.AnyEnemyWithinArcherReach(arc), "범위 안에 적이 생기면 도넛 붉게");
Assert.IsTrue(near.IsAlive);
}
[UnityTest]
public IEnumerator Archer_CritOnFinalHit()
{
yield return Fresh();
var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0f), "적보병");
var o = new ActionOutcome();
yield return bm.ResolveForTest(arc, foe.Position, false, o);
Assert.Contains(foe, o.Attacked);
Assert.IsTrue(o.Crit, "최종 목표점 명중 → 크리티컬");
Assert.AreEqual(arc.MaxHp, arc.Hp);
}
[UnityTest]
public IEnumerator Spear_OverwatchOnFinalHit_DamagesMovingEnemy()
{
yield return Fresh();
var spear = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, -3f), "창병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0f), "적보병");
var o = new ActionOutcome();
yield return bm.ResolveForTest(spear, foe.Position, false, o);
Assert.IsTrue(o.Overwatch && spear.Overwatch, "최종 목표점 명중 → 대기(견제)");
int hp = foe.Hp;
var path = new List<Vector2> { foe.Position, foe.Position + new Vector2(1.5f, 0f) };
yield return bm.ApplyOverwatch(foe, path);
Assert.Less(foe.Hp, hp, "반경 안에서 움직인 적에게 피해");
// 반경 밖에서만 움직인 적은 피해 없음
var far = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(5f, 6f), "먼적");
int farHp = far.Hp;
yield return bm.ApplyOverwatch(far, new List<Vector2> { far.Position, far.Position + new Vector2(-1f, 0f) });
Assert.AreEqual(farHp, far.Hp);
}
[UnityTest]
public IEnumerator EnemyTurn_RunsAndReturnsToPlayerTurn()
{
yield return Fresh();
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "아군");
var e = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 3f), "적군");
p.MarkActed();
int turn = bm.TurnNumber;
yield return bm.RunEnemyTurn();
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase);
Assert.AreEqual(turn + 1, bm.TurnNumber);
Assert.IsFalse(p.ActedThisTurn, "새 턴에 행동 플래그 초기화");
Assert.IsTrue(e.Position.y < 2.9f || p.Hp < p.MaxHp, "적이 접근하거나 공격했다");
}
[UnityTest]
public IEnumerator PlayerActions_AutoEndTurn_ThenEnemyTurn()
{
yield return Fresh();
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-3f, -5f), "아군");
var e = bm.SpawnUnit(UnitType.Archer, Faction.Enemy, new Vector2(3f, 6f), "적궁병");
Assert.IsTrue(bm.RequestPlayerAction(p, p.Position + new Vector2(0f, 2f)));
float t0 = Time.realtimeSinceStartup;
while (bm.Phase != BattlePhase.PlayerTurn && Time.realtimeSinceStartup - t0 < 10f) yield return null;
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase, "버튼 없이 모든 유닛 행동 → 적군 턴 → 아군 턴");
Assert.AreEqual(2, bm.TurnNumber);
Assert.IsTrue(e.IsAlive);
}
[UnityTest]
public IEnumerator Win_WhenAllEnemiesDead()
{
yield return Fresh();
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "아군");
var e = bm.SpawnUnit(UnitType.Archer, Faction.Enemy, new Vector2(0f, -1f), "적궁병");
e.Hp = 1;
Assert.IsTrue(bm.RequestPlayerAction(p, e.Position));
float t0 = Time.realtimeSinceStartup;
while (bm.Phase == BattlePhase.Resolving && Time.realtimeSinceStartup - t0 < 10f) yield return null;
Assert.AreEqual(BattlePhase.StageClear, bm.Phase);
Assert.IsFalse(e.IsAlive);
}
[UnityTest]
public IEnumerator EnemyAI_HoldsOutsideAggroRange_ActsInside()
{
yield return Fresh();
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -13f), "아군");
var far = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 10f), "먼적");
var near = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -5f), "가까운적");
var players = bm.AliveUnits(Faction.Player);
Assert.IsFalse(EnemyAI.Decide(far, players, 1f, 11f, bm.Rng, bm.Board).HasValue, "교전 범위 밖 → 대기");
Assert.IsTrue(EnemyAI.Decide(near, players, 1f, 11f, bm.Rng, bm.Board).HasValue, "교전 범위 안 → 행동");
Assert.IsTrue(p.IsAlive);
}
[UnityTest]
public IEnumerator Camera_BoardWiderThanView_ClampsAndPans()
{
var cc = Camera.main.GetComponent<CameraController>();
Assert.IsNotNull(cc, "넓은 전장 카메라 컨트롤러");
Assert.Greater(bm.Config.boardSize.y, bm.Config.cameraViewHeight, "전장이 화면보다 넓어야 스크롤이 의미 있다");
var r = bm.Board.Rect;
cc.SnapTo(new Vector2(1000f, 1000f));
var v = cc.ViewRect;
Assert.LessOrEqual(v.yMax, r.yMax + cc.TopMargin + 0.01f, "전장 위쪽 밖으로 못 나감(HUD 여유만큼만)");
Assert.LessOrEqual(v.xMax, r.xMax + cc.Margin + 0.01f + Mathf.Max(0f, v.width - r.width), "전장 오른쪽 밖으로 못 나감");
cc.SnapTo(Vector2.zero);
cc.PanBy(new Vector2(0f, 2f));
Assert.AreEqual(2f, cc.transform.position.y, 0.01f, "빈 곳 드래그 스크롤");
yield return null;
}
[UnityTest]
public IEnumerator RangeIndicator_ShowsSlingRadius_ArcherDonut_EnemyRange()
{
yield return Fresh();
var sling = bm.Sling;
Assert.IsNotNull(sling, "GameBootstrap 이 SlingshotInput 을 만들어야 한다");
var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병");
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(3f, -5f), "보병");
var foe = bm.SpawnUnit(UnitType.Spear, Faction.Enemy, new Vector2(0f, 4f), "적창병");
yield return null;
var r = sling.Range;
sling.Select(arc);
Assert.IsTrue(r.RingVisible, "플레이어 터치 → 사거리(슬링샷 범위) 얇은 원");
Assert.AreEqual(arc.SlingMaxDistance * 2f, r.RingDiameter, 0.01f);
Assert.IsTrue(r.DonutVisible, "궁병 → 공격 가능 범위 도넛");
Assert.AreEqual(arc.SlingMaxDistance * (1f + cfg.archerRangeRatio) * 2f, r.DonutDiameter, 0.01f, "도넛 바깥 = 슬링샷 범위 + 비율");
Assert.AreEqual(arc.Position, (Vector2)r.transform.position);
sling.Select(inf);
Assert.IsTrue(r.RingVisible);
Assert.IsFalse(r.DonutVisible, "궁병이 아니면 도넛 없음");
Assert.AreEqual(inf.SlingMaxDistance * 2f, r.RingDiameter, 0.01f);
sling.Select(foe);
Assert.IsTrue(r.RingVisible, "적 유닛 터치 → 적의 사거리");
Assert.AreEqual(foe.SlingMaxDistance * 2f, r.RingDiameter, 0.01f);
Assert.AreEqual(foe.Position, (Vector2)r.transform.position);
sling.Select(null);
Assert.IsFalse(r.RingVisible);
Assert.IsFalse(r.DonutVisible);
}
[UnityTest]
public IEnumerator UnitArt_HeroWithArt_PlaysBackIdle_UnitWithoutArt_IsShape()
{
// 부트스트랩 스테이지 1: 유비·관우·장비 = 스켈레탈 세션 아트(Resources/Units/liubei·guanyu·zhangfei) · 조운·황충·적 = 도형
Unit liubei = null;
Unit shape = null;
foreach (var u in bm.AliveUnits(Faction.Player))
{
if (u.DisplayName == "유비") liubei = u;
if (string.IsNullOrEmpty(u.ArtId)) shape = u;
}
Assert.IsNotNull(liubei);
Assert.IsTrue(liubei.View.HasArt, "유비 아트(back/front 클립)가 Resources/Units/liubei 에 있어야 한다");
Assert.AreEqual("back_idle", liubei.View.Anim.Current, "아군은 뒷모습 대기");
Assert.IsNotNull(liubei.View.Anim.Renderer.sprite);
Assert.Greater(liubei.View.Anim.Renderer.bounds.size.y, 0.5f, "스프라이트가 월드 크기로 보인다");
Assert.IsNotNull(shape, "아트 없는 유닛(조운·황충)은 도형");
Assert.IsFalse(shape.View.HasArt);
liubei.View.PlayAttack();
Assert.AreEqual("back_action", liubei.View.Anim.Current, "공격 자세 클립");
yield return null;
}
}
}