847 lines
48 KiB
C#
847 lines
48 KiB
C#
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using NUnit.Framework;
|
||
using UnityEngine;
|
||
using UnityEngine.SceneManagement;
|
||
using UnityEngine.TestTools;
|
||
|
||
namespace TKS.Tests
|
||
{
|
||
/// <summary>병종 4종 규칙(PD 2026-11-04 #1080 충돌 규칙 포함)·턴 흐름·승리 판정을 실제 씬에서 검증한다(헤드리스 실행 가능).</summary>
|
||
public class BattleFlowTests
|
||
{
|
||
BattleManager bm;
|
||
BalanceConfig cfg;
|
||
|
||
[UnitySetUp]
|
||
public IEnumerator SetUp()
|
||
{
|
||
WorldProgress.Reset();
|
||
SceneManager.LoadScene("Battle");
|
||
yield return null;
|
||
yield return null;
|
||
bm = BattleManager.Instance;
|
||
Assert.IsNotNull(bm, "GameBootstrap 이 BattleManager 를 만들어야 한다");
|
||
// 시작 화면 = 월드맵(PD 2026-11-05) → 전투 테스트는 1-1 팝업 출전과 같은 경로로 들어가 시작
|
||
var wm = WorldMapScreen.Instance;
|
||
if (wm != null && wm.IsVisible) wm.EnterStage(0, 0);
|
||
yield return null;
|
||
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; // 파괴된 콜라이더가 물리 월드에서 빠질 시간
|
||
}
|
||
|
||
/// <summary>경로 질의가 쓰는 접촉 거리(행동 유닛 캐스트 반지름 + 상대 반지름).</summary>
|
||
static float CastContact(Unit mover, Unit other)
|
||
{
|
||
return mover.Radius * Combat.CastRadiusScale + other.Radius;
|
||
}
|
||
|
||
static float Gap(Unit a, Unit b)
|
||
{
|
||
return Vector2.Distance(a.Position, b.Position) - a.Radius - b.Radius;
|
||
}
|
||
|
||
[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_HitsEnemy_StopsAtContact_PushesIt_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), "적창병");
|
||
float contactY = -3f + (3f - CastContact(inf, foe));
|
||
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, "최종 목표점 명중 → 추가 이동");
|
||
float remaining = CastContact(inf, foe); // 당긴 거리 3 − 접촉까지 거리 = 남은 힘
|
||
Assert.AreEqual(contactY - remaining * cfg.collisionSelfBounceRatio, inf.Position.y, 0.1f, "보병 자신도 반대 방향으로 튕긴다(PD #1081 · 남은 힘 × 비율)");
|
||
Assert.Contains(foe, o.Pushed, "부딪힌 유닛은 밀려난다");
|
||
Assert.Greater(foe.Position.y, 0.3f, "적이 남은 힘만큼 밀려남");
|
||
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 Infantry_PushesAlly_AllyAttacksTouchingEnemy_OncePerPush()
|
||
{
|
||
yield return Fresh();
|
||
// A(보병) 가 B(아군 창병)를 +x 로 밀어 적에게 붙이면 B 가 공격 · C 가 B 를 다시 밀면 B 가 또 공격 (규칙 ⑤)
|
||
var a = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-3f, -2f), "보병A");
|
||
var b = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, -2f), "창병B");
|
||
float pushX = CastContact(a, b); // A 는 3 − 접촉거리 만큼 가서 닿으므로 남는 힘(= 밀리는 거리) = 접촉거리
|
||
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(pushX, -2f + b.Radius + 0.45f + 0.05f), "적보병");
|
||
Assert.AreEqual(b.MaxHp, b.Hp);
|
||
|
||
var o = new ActionOutcome();
|
||
yield return bm.ResolveForTest(a, new Vector2(0f, -2f), false, o);
|
||
Assert.AreEqual(0, o.Attacked.Count, "아군에 부딪힌 보병 자신은 공격하지 않음");
|
||
Assert.Contains(b, o.Pushed);
|
||
Assert.AreEqual(pushX, b.Position.x, 0.1f, "B 가 남은 힘만큼 밀려남");
|
||
Assert.LessOrEqual(Gap(b, foe), Combat.ContactTolerance + 0.05f, "밀려서 적과 닿음");
|
||
Assert.Less(foe.Hp, foe.MaxHp, "밀린 아군 B 가 적을 공격");
|
||
Assert.AreEqual(a.MaxHp, a.Hp, "A 는 피해 없음");
|
||
Assert.Less(a.Position.x, -pushX - 0.2f, "A 도 반대로 튕김(아군과 부딪혀도 알까기)");
|
||
Assert.IsFalse(b.ActedThisTurn, "B 의 공격은 자기 행동을 소모하지 않음");
|
||
Assert.AreEqual(1, o.Notes.Count);
|
||
|
||
int hp = foe.Hp;
|
||
var c = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(pushX, -5f), "보병C");
|
||
var o2 = new ActionOutcome();
|
||
yield return bm.ResolveForTest(c, new Vector2(pushX, -2.2f), false, o2);
|
||
Assert.Contains(b, o2.Pushed, "C 가 B 를 다시 밀었다");
|
||
Assert.Less(foe.Hp, hp, "B 가 C 의 기회로 또 공격");
|
||
Assert.AreEqual(0, o2.Attacked.Count);
|
||
}
|
||
|
||
// ---------- 기병 ----------
|
||
|
||
[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 Cavalry_PassesThroughAlly_OrStopsJustBeforeIt()
|
||
{
|
||
yield return Fresh();
|
||
var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -4f), "기병");
|
||
var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -1.5f), "아군보병");
|
||
var o = new ActionOutcome();
|
||
yield return bm.ResolveForTest(cav, new Vector2(0f, 2f), false, o);
|
||
Assert.AreEqual(2f, cav.Position.y, 0.1f, "아군을 관통해 목표점까지");
|
||
Assert.AreEqual(-1.5f, ally.Position.y, 0.01f, "아군은 밀리지 않음");
|
||
Assert.AreEqual(0, o.Pushed.Count);
|
||
|
||
var cav2 = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(4f, -4f), "기병2");
|
||
var ally2 = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(4f, -1.5f), "아군2");
|
||
float sum = cav2.Radius + ally2.Radius;
|
||
var o2 = new ActionOutcome();
|
||
yield return bm.ResolveForTest(cav2, new Vector2(4f, -1.5f + sum * 0.5f), false, o2); // 지나칠 거리가 안 나옴
|
||
Assert.AreEqual(-1.5f - sum, cav2.Position.y, 0.1f, "통과할 거리가 안 나오면 아군 직전에 멈춤");
|
||
Assert.GreaterOrEqual(Vector2.Distance(cav2.Position, ally2.Position), sum - 0.05f, "아군과 겹치지 않음");
|
||
Assert.AreEqual(-1.5f, ally2.Position.y, 0.01f);
|
||
}
|
||
|
||
// ---------- 궁병 ----------
|
||
|
||
[UnityTest]
|
||
public IEnumerator Archer_ShootsInPlace_WhenEnemyWithinSlingRange()
|
||
{
|
||
yield return Fresh();
|
||
var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병");
|
||
Assert.AreEqual(arc.SlingMaxDistance, arc.AttackRange, 0.001f, "궁병 사거리 = 슬링샷 범위 (PD #1080)");
|
||
Assert.AreEqual(arc.SlingMaxDistance * cfg.archerRangeRatio, arc.ArcherMoveAllow, 0.001f, "접근 허용 = 슬링샷 × 비율");
|
||
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -1.5f), "적보병");
|
||
Assert.Less(3.5f - CastContact(arc, foe), arc.AttackRange, "전제: 적 가장자리가 사거리 안");
|
||
var o = new ActionOutcome();
|
||
yield return bm.ResolveForTest(arc, new Vector2(0f, -2.5f), false, o);
|
||
Assert.Contains(foe, o.Attacked, "사거리 안의 적을 사격");
|
||
Assert.Less(foe.Hp, foe.MaxHp);
|
||
Assert.Less(Vector2.Distance(arc.Position, new Vector2(0f, -5f)), 0.01f, "움직이지 않고 제자리에서");
|
||
Assert.AreEqual(arc.MaxHp, arc.Hp, "원거리 사격은 반격을 받지 않는다");
|
||
Assert.IsFalse(o.Crit);
|
||
}
|
||
|
||
[UnityTest]
|
||
public IEnumerator Archer_StepsInOnlyAsNeeded_WhenEnemyInDonut()
|
||
{
|
||
yield return Fresh();
|
||
var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병");
|
||
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0.3f), "적보병");
|
||
float hitDist = 5.3f - CastContact(arc, foe);
|
||
Assert.Greater(hitDist, arc.AttackRange, "전제: 사거리 밖");
|
||
Assert.LessOrEqual(hitDist, arc.AttackRange + arc.ArcherMoveAllow, "전제: 공격 가능 범위(도넛) 안");
|
||
float need = hitDist - arc.AttackRange;
|
||
var o = new ActionOutcome();
|
||
yield return bm.ResolveForTest(arc, new Vector2(0f, -1f), false, o);
|
||
Assert.Contains(foe, o.Attacked, "사거리에 들어올 만큼만 다가가 사격");
|
||
Assert.AreEqual(-5f + need, arc.Position.y, 0.1f, "필요한 만큼만 이동");
|
||
Assert.AreEqual(arc.MaxHp, arc.Hp);
|
||
Assert.IsFalse(o.Crit);
|
||
}
|
||
|
||
[UnityTest]
|
||
public IEnumerator Archer_MovesToTarget_WhenNoEnemyWithinReach()
|
||
{
|
||
yield return Fresh();
|
||
var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병");
|
||
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1f), "적보병");
|
||
Assert.Greater(6f - CastContact(arc, foe), arc.AttackRange + arc.ArcherMoveAllow, "전제: 공격 가능 범위 밖");
|
||
Assert.IsFalse(bm.AnyEnemyWithinArcherReach(arc), "공격 가능 범위 안에 적 없음(도넛 회색)");
|
||
var o = new ActionOutcome();
|
||
var target = new Vector2(0f, -1f);
|
||
yield return bm.ResolveForTest(arc, target, false, o);
|
||
Assert.IsTrue(o.MovedOnly, "공격 가능 범위에 적이 없으면 목표점까지 이동만 (PD 2026-11-04 2차 지시)");
|
||
Assert.AreEqual(0, o.Attacked.Count);
|
||
Assert.AreEqual(foe.MaxHp, foe.Hp);
|
||
Assert.Less(Vector2.Distance(arc.Position, target), 0.05f, "목표 지점까지 이동");
|
||
|
||
var near = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(2f, -1f), "가까운적");
|
||
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 Archer_MeleePenalty_DealsRatio_TakesRatio_RangedUnaffected()
|
||
{
|
||
yield return Fresh();
|
||
var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병");
|
||
var arc2 = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(2f, -5f), "궁병2");
|
||
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(4f, -5f), "적보병");
|
||
|
||
int ranged = Combat.Damage(cfg, arc, inf, false, new System.Random(7), false);
|
||
int melee = Combat.Damage(cfg, arc, inf, false, new System.Random(7), true);
|
||
Assert.AreEqual(ranged * cfg.archerMeleeDealtRatio, melee, 1f, "궁병이 근접 유닛과 근접 전투하면 주는 피해 × 비율(기본 50 %)");
|
||
|
||
int takenRanged = Combat.Damage(cfg, inf, arc, false, new System.Random(7), false);
|
||
int takenMelee = Combat.Damage(cfg, inf, arc, false, new System.Random(7), true);
|
||
Assert.AreEqual(takenRanged * cfg.archerMeleeTakenRatio, takenMelee, 1f, "근접 유닛에게 근접으로 맞으면 받는 피해 × 비율(기본 +50 %)");
|
||
|
||
int aa = Combat.Damage(cfg, arc, arc2, false, new System.Random(7), true);
|
||
int ab = Combat.Damage(cfg, arc, arc2, false, new System.Random(7), false);
|
||
Assert.AreEqual(ab, aa, "궁병끼리는 근접 패널티 없음(근접 유닛 상대만)");
|
||
yield return null;
|
||
}
|
||
|
||
// ---------- 창병 ----------
|
||
|
||
[UnityTest]
|
||
public IEnumerator Spear_PlowsEnemyForward_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.Contains(foe, o.Attacked);
|
||
Assert.Less(foe.Hp, foe.MaxHp, "부딪힌 적에게 피해");
|
||
Assert.Contains(foe, o.Pushed, "튕기지 않고 밀며 함께 전진");
|
||
Assert.Greater(foe.Position.y, 0.5f, "적이 남은 거리만큼 앞으로 밀림");
|
||
Assert.AreEqual(CastContact(spear, foe), foe.Position.y - spear.Position.y, 0.1f, "창병은 접촉 간격을 유지하며 따라감");
|
||
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 Spear_PlowsAlly_NoDamage_AllyAttacksWhenPushedOntoEnemy()
|
||
{
|
||
yield return Fresh();
|
||
var spear = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, -4f), "창병");
|
||
var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -2f), "아군보병");
|
||
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.5f), "적보병");
|
||
int allyHp = ally.Hp;
|
||
var o = new ActionOutcome();
|
||
yield return bm.ResolveForTest(spear, new Vector2(0f, 0f), false, o);
|
||
Assert.AreEqual(0, o.Attacked.Count, "아군에 부딪힌 창병 자신은 공격하지 않음");
|
||
Assert.Contains(ally, o.Pushed);
|
||
Assert.Greater(ally.Position.y, 0.4f, "아군을 밀며 함께 전진(적에 닿을 때까지)");
|
||
Assert.LessOrEqual(Gap(ally, foe), Combat.ContactTolerance + 0.05f, "밀린 아군이 적과 닿음");
|
||
Assert.AreEqual(CastContact(spear, ally), ally.Position.y - spear.Position.y, 0.1f, "창병은 아군 바로 뒤까지 따라옴");
|
||
Assert.Less(foe.Hp, foe.MaxHp, "밀린 아군이 적을 공격(규칙 ⑤)");
|
||
Assert.AreEqual(spear.MaxHp, spear.Hp, "창병은 피해 없음");
|
||
Assert.AreEqual(1, o.Notes.Count);
|
||
Assert.IsTrue(allyHp >= ally.Hp, "아군은 밀려서 피해를 받지 않는다(근접 공격 반격만 가능)");
|
||
}
|
||
|
||
// ---------- 턴 · 승패 · AI · 카메라 · 표시 ----------
|
||
|
||
[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: 아군 5인 = 원화 세션 재디자인(PD #1081 · Resources/Units/liubei·guanyu·zhangfei·zhaoyun·huangzhong · 스켈레탈 B단계 전까지 정지 그림)
|
||
Unit liubei = null;
|
||
foreach (var u in bm.AliveUnits(Faction.Player))
|
||
{
|
||
if (u.DisplayName == "유비") liubei = u;
|
||
Assert.IsTrue(u.View.HasArt, "아군 5인 전원 원화(" + u.DisplayName + " · " + u.ArtId + ")");
|
||
Assert.IsTrue(u.View.Anim.Current.EndsWith("_idle"), "대기 클립(뒷모습이 없으면 앞모습으로 대체)");
|
||
}
|
||
Assert.IsNotNull(liubei);
|
||
Assert.IsNotNull(liubei.View.Anim.Renderer.sprite);
|
||
Assert.Greater(liubei.View.Anim.Renderer.bounds.size.y, 0.5f, "스프라이트가 월드 크기로 보인다");
|
||
|
||
// 클립 동작은 앞·뒤 포즈가 모두 있는 아트(아군 = front/back × idle·ready·action · 2026-11-05 키아트 세션 뒷모습)로 확인
|
||
var rigged = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(6f, -13f), "리그확인", 1f, 1f, false, cfg.heroes[0].art);
|
||
Assert.AreEqual("back_idle", rigged.View.Anim.Current, "아군 시작 = 뒷모습 대기");
|
||
rigged.View.PlayAttack();
|
||
Assert.AreEqual("back_action", rigged.View.Anim.Current, "공격 자세 클립");
|
||
|
||
// PD 2026-11-05 #1084: 조준 중 = 출발 자세(ready) · 돌진 중 = 돌진 포즈(action 고정) · 멈추면 대기
|
||
rigged.View.SetAiming(true);
|
||
Assert.AreEqual("back_ready", rigged.View.Anim.Current, "조준(당기는) 중 = 출발 자세");
|
||
rigged.View.SetAiming(false);
|
||
rigged.View.SetCharging(true);
|
||
Assert.AreEqual("back_action", rigged.View.Anim.Current, "돌진 중 = 병종 돌진 포즈");
|
||
rigged.View.OnHit();
|
||
Assert.AreEqual("back_action", rigged.View.Anim.Current, "돌진 중 피격은 포즈 유지");
|
||
rigged.View.SetCharging(false);
|
||
Assert.AreEqual("back_idle", rigged.View.Anim.Current, "멈추면 대기");
|
||
var o2 = new ActionOutcome();
|
||
yield return bm.ResolveForTest(rigged, rigged.Position + new Vector2(0f, 2f), false, o2);
|
||
Assert.IsFalse(rigged.View.Charging, "슬링샷이 끝나면 돌진 포즈 해제");
|
||
Assert.AreEqual("back_idle", rigged.View.Anim.Current);
|
||
|
||
// 원화 세션 1차 납품(2026-11-04): 황건적 5종 정지 그림(front_idle 1장) + 전장 배경
|
||
int enemyArt = 0;
|
||
foreach (var e in bm.AliveUnits(Faction.Enemy))
|
||
{
|
||
if (e.View.HasArt)
|
||
{
|
||
enemyArt++;
|
||
Assert.AreEqual("front_idle", e.View.Anim.Current, "적은 앞모습 정지 그림");
|
||
}
|
||
}
|
||
Assert.AreEqual(bm.AliveCount(Faction.Enemy), enemyArt, "스테이지 1 황건적은 전원 원화(hgj_*)로 표시");
|
||
Assert.IsTrue(bm.Board.HasArt, "전장 배경 = Resources/Board/" + cfg.boardArt);
|
||
|
||
// 원화 2차 납품(2026-11-04): 동탁군 보·창·기·궁 4종 반영 → 스테이지 표의 첫 동탁군 스테이지(2장)
|
||
int dzStage = System.Array.FindIndex(cfg.stages, s => s.faction == "dz");
|
||
Assert.GreaterOrEqual(dzStage, 0, "동탁군 스테이지");
|
||
bm.StartStage(dzStage);
|
||
yield return null;
|
||
foreach (var e in bm.AliveUnits(Faction.Enemy))
|
||
{
|
||
if (e.IsBoss) continue;
|
||
Assert.IsTrue(e.View.HasArt, "동탁군 병종 4종은 원화(" + e.ArtId + ")");
|
||
}
|
||
bm.StartStage(0);
|
||
yield return null;
|
||
}
|
||
|
||
[UnityTest]
|
||
public IEnumerator Facing_FourWays_FollowsLastMoveDirection_BounceTurnsAround()
|
||
{
|
||
yield return Fresh();
|
||
// PD #1082: 오른쪽 보기(기본) ↘ · 왼쪽 ↙ · 왼쪽 위 ↖ · 오른쪽 위 ↗ = 앞/뒤 세트 + 좌우 반전 · 보는 방향 = 마지막 이동 방향
|
||
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -5f), "보병", 1f, 1f, false, cfg.heroes[0].art); // 앞·뒤 포즈가 모두 있는 아트(아군)
|
||
Assert.IsTrue(u.View.HasArt);
|
||
Assert.IsTrue(u.View.FacingBack && !u.View.FacingLeft, "아군 시작 = ↗(위 · 오른쪽)");
|
||
Assert.AreEqual("back_idle", u.View.Anim.Current);
|
||
|
||
yield return bm.ResolveForTest(u, u.Position + new Vector2(2f, 2f), false, new ActionOutcome());
|
||
Assert.IsTrue(u.View.FacingBack && !u.View.FacingLeft, "↗ 이동 → 뒷모습 · 반전 없음");
|
||
Assert.IsFalse(u.View.Anim.Renderer.flipX);
|
||
|
||
yield return bm.ResolveForTest(u, u.Position + new Vector2(-2f, 2f), false, new ActionOutcome());
|
||
Assert.IsTrue(u.View.FacingBack && u.View.FacingLeft, "↖ 이동 → 뒷모습 · 좌우 반전");
|
||
Assert.IsTrue(u.View.Anim.Renderer.flipX);
|
||
Assert.AreEqual("back_idle", u.View.Anim.Current);
|
||
|
||
yield return bm.ResolveForTest(u, u.Position + new Vector2(-2f, -2f), false, new ActionOutcome());
|
||
Assert.IsTrue(!u.View.FacingBack && u.View.FacingLeft, "↙ 이동 → 앞모습 · 좌우 반전");
|
||
Assert.AreEqual("front_idle", u.View.Anim.Current, "앞/뒤 세트가 바뀌면 대기 클립도 바뀐다");
|
||
|
||
yield return bm.ResolveForTest(u, u.Position + new Vector2(2f, -2f), false, new ActionOutcome());
|
||
Assert.IsTrue(!u.View.FacingBack && !u.View.FacingLeft, "↘ 이동 → 앞모습 · 반전 없음");
|
||
Assert.IsFalse(u.View.Anim.Renderer.flipX);
|
||
|
||
yield return bm.ResolveForTest(u, u.Position + new Vector2(0f, 2f), false, new ActionOutcome());
|
||
Assert.IsTrue(u.View.FacingBack && !u.View.FacingLeft, "곧장 위로 → 뒷모습 · 좌우는 이전 값 유지");
|
||
|
||
// 위의 적을 쳐서 튕겨 나오면(아래로) 아래를 보고, 밀린 적은 밀린 방향(위)을 본다
|
||
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, u.Position + new Vector2(0f, 2.5f), "적보병");
|
||
Assert.IsFalse(foe.View.FacingBack, "적 시작 = ↘(아래)");
|
||
var o = new ActionOutcome();
|
||
yield return bm.ResolveForTest(u, foe.Position, false, o);
|
||
Assert.Contains(foe, o.Pushed);
|
||
Assert.IsFalse(u.View.FacingBack, "튕겨 나와 방향이 바뀌면 그 방향(아래)을 본다");
|
||
Assert.IsTrue(foe.View.FacingBack, "밀린 적은 밀린 방향(위)을 본다");
|
||
}
|
||
|
||
// ---------- 월드맵 · 스테이지 팝업(PD 2026-11-05) ----------
|
||
|
||
[UnityTest]
|
||
public IEnumerator WorldMap_BootStartsOnMap_NotBattle()
|
||
{
|
||
WorldProgress.Reset();
|
||
SceneManager.LoadScene("Battle");
|
||
yield return null;
|
||
yield return null;
|
||
var wm = WorldMapScreen.Instance;
|
||
Assert.IsNotNull(wm, "GameBootstrap 이 월드맵을 만들어야 한다");
|
||
Assert.IsTrue(wm.HasData, "Resources/" + WorldData.Path + ".json");
|
||
Assert.IsTrue(wm.IsVisible, "실행하면 전투가 아니라 월드맵에서 시작");
|
||
var b = BattleManager.Instance;
|
||
Assert.AreEqual(BattlePhase.Setup, b.Phase, "월드맵 표시 중 = 전투 없음");
|
||
Assert.AreEqual(0, b.Units.Count, "월드맵 표시 중 = 유닛 없음");
|
||
Assert.IsNotNull(wm.MapSprite, "챕터 1 맵 그림");
|
||
Assert.AreEqual(wm.NodesIn(0), wm.NodeCount, "노드 수 = 스테이지 표 1장 스테이지 수");
|
||
Assert.AreEqual(NodeState.Open, wm.StateOf(0, 0), "1-1 = 도전 가능");
|
||
Assert.AreEqual(NodeState.Lock, wm.StateOf(0, 1), "1-2 = 잠김");
|
||
Assert.IsFalse(wm.ChapterUnlocked(1), "2장은 1장 보스를 깨야 열림");
|
||
}
|
||
|
||
[UnityTest]
|
||
public IEnumerator WorldMap_PopupEntersBattle_ResultReturnsToMapAndUnlocksNext()
|
||
{
|
||
var wm = WorldMapScreen.Instance;
|
||
wm.Show(0);
|
||
yield return null;
|
||
Assert.IsTrue(wm.IsVisible);
|
||
Assert.AreEqual(BattlePhase.Setup, bm.Phase, "월드맵으로 오면 전투 정리");
|
||
wm.OpenPopup(1);
|
||
Assert.IsTrue(wm.PopupVisible, "잠긴 노드도 팝업은 뜬다(출전 불가)");
|
||
wm.ConfirmPopup();
|
||
Assert.IsTrue(wm.IsVisible, "잠긴 노드는 출전 안 됨");
|
||
wm.OpenPopup(0);
|
||
wm.ConfirmPopup();
|
||
yield return null;
|
||
Assert.IsFalse(wm.IsVisible, "출전 → 월드맵 닫힘");
|
||
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase);
|
||
Assert.AreEqual(wm.StageIndexOf(0, 0), bm.StageIndex, "1-1 노드 = 스테이지 표 1-1 전투");
|
||
var stage = cfg.stages[wm.StageIndexOf(0, 0)];
|
||
Assert.AreEqual(stage.enemies.Count, bm.AliveCount(Faction.Enemy), "팝업 노드의 전투가 시작");
|
||
|
||
bm.ForcePhase(BattlePhase.StageClear);
|
||
bm.ContinueAfterResult();
|
||
yield return null;
|
||
Assert.IsTrue(wm.IsVisible, "승리 결과 탭 → 월드맵 복귀");
|
||
Assert.AreEqual(0, bm.Units.Count, "전투 정리");
|
||
Assert.AreEqual(NodeState.Clear, wm.StateOf(0, 0));
|
||
Assert.AreEqual(NodeState.Open, wm.StateOf(0, 1), "다음 노드 열림");
|
||
|
||
wm.OpenPopup(1);
|
||
wm.ConfirmPopup();
|
||
yield return null;
|
||
bm.ForcePhase(BattlePhase.Defeat);
|
||
bm.ContinueAfterResult();
|
||
yield return null;
|
||
Assert.IsTrue(wm.IsVisible, "패배 결과 탭 → 월드맵 복귀");
|
||
Assert.AreEqual(NodeState.Open, wm.StateOf(0, 1), "패배하면 진행 그대로");
|
||
}
|
||
|
||
[Test]
|
||
public void ChapterMap_ResourcesLoad_MapsUiAndRoads()
|
||
{
|
||
var d = WorldData.Load();
|
||
Assert.IsNotNull(d, WorldData.Path);
|
||
Assert.Greater(d.chapters.Length, 0);
|
||
foreach (var c in d.chapters)
|
||
{
|
||
var sp = Resources.Load<Sprite>("ChapterMap/Maps/" + c.map);
|
||
Assert.IsNotNull(sp, c.id + " 맵 그림(스프라이트 임포트)");
|
||
Assert.AreEqual(d.mapSize[0], sp.rect.width, 0.5f, c.id + " 맵 가로 = world.json mapSize");
|
||
Assert.AreEqual(d.mapSize[1], sp.rect.height, 0.5f, c.id + " 맵 세로");
|
||
Assert.GreaterOrEqual(c.roadX.Length, 2, c.id + " 길");
|
||
}
|
||
foreach (var k in d.kit)
|
||
Assert.IsNotNull(Resources.Load<Sprite>("ChapterMap/UI/" + k.name), "UI 부품 " + k.name);
|
||
Assert.Greater(Resources.Load<Sprite>("ChapterMap/UI/plate").border.x, 0f, "이름판 9-slice(import.json)");
|
||
}
|
||
|
||
[UnityTest]
|
||
public IEnumerator UnitArt_PoseSets_SwapByAction()
|
||
{
|
||
// 아군 5인 = 앞·뒷모습 3포즈(키아트 세션) · 적 = 납품된 세력의 앞모습 3포즈(위군 미납품 → 도형)
|
||
string[] views = { "front", "back" };
|
||
string[] poses = { "idle", "ready", "action" };
|
||
foreach (var h in cfg.heroes)
|
||
{
|
||
var a = UnitArt.Load(h.art);
|
||
Assert.IsNotNull(a, "아군 원화 " + h.art);
|
||
foreach (var v in views)
|
||
foreach (var p in poses)
|
||
Assert.IsTrue(a.Clips.ContainsKey(v + "_" + p), h.art + " " + v + "_" + p);
|
||
}
|
||
// 병사 = 납품 세력(hgj·dz·cc)은 반드시 원화 · 장수(<세력>_boss_<병음>) = 납품된 것만 원화(나머지는 도형 대체 · StageData_MissingArt 테스트)
|
||
string[] delivered = { "hgj_", "dz_", "cc_" };
|
||
foreach (var s in cfg.stages)
|
||
foreach (var e in s.enemies)
|
||
{
|
||
bool general = e.art.Contains("_boss_");
|
||
bool want = false;
|
||
foreach (var f in delivered) want |= e.art.StartsWith(f);
|
||
if (general) want = Resources.Load<TextAsset>("Units/" + e.art + "/meta") != null;
|
||
if (!want) continue;
|
||
var a = UnitArt.Load(e.art);
|
||
Assert.IsNotNull(a, "적 원화 " + e.art);
|
||
foreach (var p in poses) Assert.IsTrue(a.Clips.ContainsKey("front_" + p), e.art + " front_" + p);
|
||
}
|
||
|
||
// 동작별 스프라이트 교체: 위로 이동 = 뒷모습 · 조준 = 출발 자세 · 돌진 = 돌진 포즈 · 멈춤 = 대기 · 아래로 = 앞모습
|
||
yield return Fresh();
|
||
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -4f), cfg.heroes[0].name, 1f, 1f, false, cfg.heroes[0].art);
|
||
yield return null;
|
||
u.View.SetFacing(Vector2.up);
|
||
Assert.AreEqual("back_idle", u.View.Anim.Current, "위를 보면 뒷모습 대기");
|
||
u.View.SetAiming(true);
|
||
Assert.AreEqual("back_ready", u.View.Anim.Current, "조준 = 출발 자세");
|
||
u.View.SetCharging(true);
|
||
Assert.AreEqual("back_action", u.View.Anim.Current, "돌진 = 돌진 포즈");
|
||
u.View.SetCharging(false);
|
||
Assert.AreEqual("back_idle", u.View.Anim.Current, "멈추면 대기");
|
||
u.View.SetFacing(new Vector2(-1f, -1f));
|
||
Assert.AreEqual("front_idle", u.View.Anim.Current, "아래를 보면 앞모습");
|
||
Assert.IsTrue(u.View.Anim.Renderer.flipX, "왼쪽 = 좌우 반전");
|
||
}
|
||
|
||
// ---------- 스테이지 표(Assets/Data/Stages/*.csv · 145 스테이지 · 2026-11-05 #1086) ----------
|
||
|
||
static StageTable.Tables ReadStageTable()
|
||
{
|
||
var dir = Path.Combine(Application.dataPath, StageTable.Folder);
|
||
var texts = new string[StageTable.Files.Length];
|
||
for (int i = 0; i < texts.Length; i++) texts[i] = File.ReadAllText(Path.Combine(dir, StageTable.Files[i]), System.Text.Encoding.UTF8);
|
||
return StageTable.Parse(texts);
|
||
}
|
||
|
||
[Test]
|
||
public void StageTable_Csv_145Rows_BossEveryFifth_ValidUnitAndArtIds()
|
||
{
|
||
var t = ReadStageTable();
|
||
Assert.IsEmpty(t.errors, "형식 오류(병종 id · 세력 · 무장 id · 번호): " + string.Join(" / ", t.errors.ToArray()));
|
||
Assert.AreEqual(145, t.stages.Count, "오픈 스펙 = 챕터 1~10 · 145 스테이지(기획서 #1086 · D1 = A)");
|
||
var artPrefixes = new HashSet<string>();
|
||
foreach (var f in t.factions.Values) artPrefixes.Add(f.art);
|
||
foreach (var f in t.factions.Values) artPrefixes.Add(f.code);
|
||
var idRule = new System.Text.RegularExpressions.Regex("^([a-z]+)_boss_[a-z]+$");
|
||
foreach (var s in t.stages)
|
||
{
|
||
string at = s.chapter + "-" + s.stage;
|
||
Assert.AreEqual(s.stage % 5 == 0, s.kind == StageKind.Boss, at + " 보스 = 5의 배수 스테이지(기획서 §1 원칙 1)");
|
||
Assert.Greater(s.SoldierCount, 0, at + " 병사");
|
||
int bosses = s.generals.FindAll(x => x.boss).Count;
|
||
if (s.kind == StageKind.Boss) Assert.GreaterOrEqual(bosses, 1, at + " 보스 스테이지에 보스 무장");
|
||
if (s.kind == StageKind.Elite) Assert.GreaterOrEqual(s.generals.Count, 1, at + " 엘리트 스테이지에 무장");
|
||
if (s.kind == StageKind.Normal) Assert.AreEqual(0, s.generals.Count, at + " 일반 스테이지 = 무장 없음");
|
||
foreach (var g in s.generals)
|
||
{
|
||
var m = idRule.Match(g.id);
|
||
Assert.IsTrue(m.Success, at + " 무장 아트 id 규칙 <세력>_boss_<병음>: " + g.id);
|
||
Assert.IsTrue(artPrefixes.Contains(m.Groups[1].Value), at + " 무장 id 세력 접두: " + g.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public void StageTable_ImportedConfig_MatchesCsv_FormationFitsBoard()
|
||
{
|
||
var c = Resources.Load<BalanceConfig>("BalanceConfig");
|
||
var t = ReadStageTable();
|
||
Assert.AreEqual(t.stages.Count, c.stages.Length, "CSV 를 고쳤으면 TKS/Import Stages 로 다시 임포트");
|
||
Assert.AreEqual(t.chapters.Count, c.chapters.Length);
|
||
float halfW = c.boardSize.x / 2f, halfH = c.boardSize.y / 2f;
|
||
float playerFront = float.MinValue;
|
||
foreach (var p in c.playerSpawnPoints) playerFront = Mathf.Max(playerFront, p.y);
|
||
for (int i = 0; i < c.stages.Length; i++)
|
||
{
|
||
var s = c.stages[i];
|
||
var row = t.stages.Find(x => x.chapter == s.chapter && x.stage == s.stage);
|
||
string at = s.chapter + "-" + s.stage;
|
||
Assert.IsNotNull(row, at + " CSV 행");
|
||
Assert.AreEqual(row.kind, s.kind, at);
|
||
Assert.AreEqual(row.title, s.title, at);
|
||
Assert.AreEqual(row.SoldierCount + row.generals.Count, s.enemies.Count, at + " 적 수 = 병사 + 무장");
|
||
var counts = new int[UnitTypeInfo.Count];
|
||
int bosses = 0;
|
||
foreach (var e in s.enemies)
|
||
{
|
||
if (!e.art.Contains("_boss_")) counts[(int)e.type]++;
|
||
if (e.isBoss) bosses++;
|
||
Assert.IsFalse(string.IsNullOrEmpty(e.art), at + " 아트 id");
|
||
float r = c.GetStats(e.type).radius * (e.isBoss ? c.bossRadiusMult : 1f);
|
||
Assert.IsTrue(Mathf.Abs(e.position.x) <= halfW - r && e.position.y <= halfH - r, at + " " + e.name + " 전장 안 " + e.position);
|
||
Assert.Greater(e.position.y, playerFront + 2f, at + " " + e.name + " 아군 출발선보다 앞(적 쪽)");
|
||
}
|
||
for (int k = 0; k < UnitTypeInfo.Count; k++) Assert.AreEqual(row.soldiers[k], counts[k], at + " " + UnitTypeInfo.Korean((UnitType)k) + " 수");
|
||
Assert.AreEqual(row.generals.FindAll(g => g.boss).Count, bosses, at + " 보스 수");
|
||
for (int a = 0; a < s.enemies.Count; a++)
|
||
for (int b = a + 1; b < s.enemies.Count; b++)
|
||
{
|
||
var ea = s.enemies[a];
|
||
var eb = s.enemies[b];
|
||
float ra = c.GetStats(ea.type).radius * (ea.isBoss ? c.bossRadiusMult : 1f);
|
||
float rb = c.GetStats(eb.type).radius * (eb.isBoss ? c.bossRadiusMult : 1f);
|
||
Assert.Greater(Vector2.Distance(ea.position, eb.position), ra + rb, at + " " + ea.name + "·" + eb.name + " 겹침");
|
||
}
|
||
}
|
||
}
|
||
|
||
[UnityTest]
|
||
public IEnumerator StageData_MissingArt_FallsBackToShapes()
|
||
{
|
||
// 아트가 아직 없는 무장(보스 신규 25 · 엘리트 25)·세력(원소·원술·형주 베리에이션) = 도형으로 정상 전투
|
||
int idx = -1;
|
||
for (int i = 0; i < cfg.stages.Length && idx < 0; i++)
|
||
foreach (var e in cfg.stages[i].enemies)
|
||
if (e.isBoss && UnitArt.Load(e.art) == null) { idx = i; break; }
|
||
Assert.GreaterOrEqual(idx, 0, "아트 없는 보스 스테이지");
|
||
bm.StartStage(idx);
|
||
yield return null;
|
||
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase);
|
||
Assert.AreEqual(cfg.stages[idx].enemies.Count, bm.AliveCount(Faction.Enemy));
|
||
int shapes = 0;
|
||
foreach (var e in bm.AliveUnits(Faction.Enemy))
|
||
{
|
||
bool has = UnitArt.Load(e.ArtId) != null;
|
||
Assert.AreEqual(has, e.View.HasArt, e.DisplayName + " 아트 있으면 원화 · 없으면 도형");
|
||
if (!has) shapes++;
|
||
}
|
||
Assert.Greater(shapes, 0, "도형 대체 유닛");
|
||
bm.StartStage(0);
|
||
yield return null;
|
||
}
|
||
|
||
[UnityTest]
|
||
public IEnumerator WorldMap_ChaptersFromStageTable_AnchorSlotsBossNodesUnlock()
|
||
{
|
||
var wm = WorldMapScreen.Instance;
|
||
Assert.AreEqual(cfg.chapters.Length, wm.ChapterCount, "챕터 = chapters.csv");
|
||
int total = 0;
|
||
for (int ch = 0; ch < wm.ChapterCount; ch++)
|
||
{
|
||
var def = cfg.chapters[ch];
|
||
var stages = cfg.StagesOfChapter(def.no);
|
||
Assert.AreEqual(def.map, wm.MapOf(ch).map, "제" + def.no + "장 맵 = chapters.csv map");
|
||
Assert.IsNotNull(Resources.Load<Sprite>("ChapterMap/Maps/" + def.map), def.map + " 맵 그림");
|
||
bool fromAnchors;
|
||
var slots = wm.NodeSlots(ch, out fromAnchors);
|
||
Assert.IsTrue(fromAnchors, "제" + def.no + "장 노드 자리 = anchors slots_" + stages.Count);
|
||
Assert.AreEqual(stages.Count, slots.Length);
|
||
foreach (var p in slots)
|
||
Assert.IsTrue(p.x >= 0f && p.x <= wm.Data.mapSize[0] && p.y >= 0f && p.y <= wm.Data.mapSize[1], def.map + " 노드가 맵 안");
|
||
wm.Show(ch);
|
||
yield return null;
|
||
Assert.AreEqual(stages.Count, wm.NodeCount, "제" + def.no + "장 노드 수 = 스테이지 수");
|
||
int bosses = 0;
|
||
foreach (var si in stages)
|
||
if (cfg.stages[si].kind == StageKind.Boss) bosses++;
|
||
Assert.AreEqual(bosses, wm.BossNodeCount, "제" + def.no + "장 보스 노드");
|
||
total += stages.Count;
|
||
}
|
||
Assert.AreEqual(cfg.stages.Length, total, "모든 스테이지가 어느 챕터 노드");
|
||
|
||
// 해금 = 앞 스테이지 클리어 · 챕터 마지막 노드를 깨면 다음 챕터 열림 · 마지막 노드 클리어 → 다음 챕터 맵으로
|
||
Assert.IsFalse(wm.ChapterUnlocked(1));
|
||
for (int n = 0; n < wm.NodesIn(0) - 1; n++) WorldProgress.MarkCleared(0, n);
|
||
Assert.AreEqual(NodeState.Open, wm.StateOf(0, wm.NodesIn(0) - 1));
|
||
Assert.IsFalse(wm.ChapterUnlocked(1), "보스 전 = 2장 잠김");
|
||
wm.EnterStage(0, wm.NodesIn(0) - 1);
|
||
yield return null;
|
||
Assert.AreEqual(wm.StageIndexOf(0, wm.NodesIn(0) - 1), bm.StageIndex);
|
||
Assert.AreEqual(StageKind.Boss, bm.Stage.kind, "1장 마지막 = 보스");
|
||
bm.ForcePhase(BattlePhase.StageClear);
|
||
bm.ContinueAfterResult();
|
||
yield return null;
|
||
Assert.IsTrue(wm.ChapterUnlocked(1), "1장 보스 클리어 → 2장 열림");
|
||
Assert.AreEqual(1, wm.Chapter, "다음 챕터 맵으로");
|
||
Assert.AreEqual(NodeState.Open, wm.StateOf(1, 0));
|
||
}
|
||
}
|
||
}
|