using System.Collections; using System.Collections.Generic; using System.IO; using NUnit.Framework; using TKS.OutGame; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace TKS.Tests { /// 병종 4종 규칙(PD 2026-11-04 #1080 충돌 규칙 포함)·턴 흐름·승리 판정을 실제 씬에서 검증한다(헤드리스 실행 가능). public class BattleFlowTests { BattleManager bm; BalanceConfig cfg; [UnitySetUp] public IEnumerator SetUp() { WorldProgress.Reset(); SaveStore.UseMemory(true); // 신규 유저 세이브(메모리 전용 · 실제 save.json 불변 · 2026-11-05 #1093) SceneManager.LoadScene("Battle"); yield return null; yield return null; bm = BattleManager.Instance; Assert.IsNotNull(bm, "GameBootstrap 이 BattleManager 를 만들어야 한다"); // 시작 화면 = 본영 로비(#1093) → 전투 테스트는 1-1 노드 출전과 같은 경로(EnterStage · 출전 = 편성 프리셋)로 들어가 시작 var wm = WorldMapScreen.Instance; if (wm != null && wm.HasData) 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; // 파괴된 콜라이더가 물리 월드에서 빠질 시간 } /// 경로 질의가 쓰는 접촉 거리(행동 유닛 캐스트 반지름 + 상대 반지름). static float CastContact(Unit mover, Unit other) { return mover.Radius * Combat.CastRadiusScale + other.Radius; } /// 신규 유저 시작 영웅 i(economy.csv start_heroes · HeroDb · 구 BalanceConfig.heroes 대체). static HeroDb.Hero StarterHero(int i) { var db = OutGameData.Default; return db.Heroes.Get(db.Economy.StartHeroes[i]); } 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.Greater(bm.Deployed.Count, 0, "출전 = 편성 프리셋"); Assert.AreEqual(Mathf.Min(OutGameData.Default.Economy.StartHeroes.Length, cfg.playerSpawnPoints.Length), bm.Deployed.Count, "신규 유저 프리셋 = 시작 영웅 5"); Assert.AreEqual(bm.Deployed.Count, 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"); 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_NoBonusMove() { 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.IsFalse(o.BonusMove, "목표점 명중 추가 이동 = 제거(PD #1112 결정 ⑱)"); 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_NoReturnOnFinalHit() { 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.IsFalse(o.Returned, "목표점 명중해도 제자리 복귀 없음(PD 2026-11-10 제거)"); Assert.Greater(cav.Position.y, e1.Position.y, "첫 적을 꿰뚫고 지나가 그 너머에 남음"); Assert.GreaterOrEqual(Vector2.Distance(cav.Position, e2.Position), cav.Radius + e2.Radius - 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.IsFalse(o.Fired, "타겟이 없으면 쏘지 않는다(#1087 ⑥ 유지)"); 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.Fired); Assert.AreEqual(foe, o.ShotHit, "사선이 비면 화살이 타겟을 맞힌다"); Assert.IsTrue(o.Crit, "첫 피격 = 타겟 + 최종 목표점 명중 → 크리티컬"); Assert.AreEqual(arc.MaxHp, arc.Hp); } // ---------- 궁병 투사체 · 책사 (PD 2026-11-05 #1087 ⑤⑥) ---------- /// 사격 피해가 공식 범위 안인지(공 × 상성 × 크리 − 방 × 계수 · 난수 ± damageVariance · 그 뒤 × ratio · 반올림 여유 1). void AssertShotDamage(Unit a, Unit d, int dealt, bool crit, float ratio, string msg) { float raw = a.Atk * cfg.Matchup(a.Type, d.Type) * (crit ? cfg.critMultiplier : 1f) - d.Def * cfg.defenseFactor; float lo = Mathf.Max(1f, raw * (1f - cfg.damageVariance)) * ratio - 1f; float hi = Mathf.Max(1f, raw * (1f + cfg.damageVariance)) * ratio + 1f; Assert.GreaterOrEqual((float)dealt, lo, msg + " (피해 " + dealt + ")"); Assert.LessOrEqual((float)dealt, hi, msg + " (피해 " + dealt + ")"); } [UnityTest] public IEnumerator Archer_Arrow_AllyOnLine_TakesHit_TargetUntouched_NoCrit() { yield return Fresh(); var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병"); var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3.5f), "아군보병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -1.5f), "적보병"); var o = new ActionOutcome(); yield return bm.ResolveForTest(arc, foe.Position, false, o); // 목표점 = 타겟(명중)이지만 사선을 아군이 막음 Assert.IsTrue(o.Fired, "타겟(적)이 있으면 쏜다"); Assert.AreEqual(ally, o.ShotHit, "경로 위 첫 유닛 = 아군이 맞는다"); Assert.Contains(ally, o.FriendlyHit); AssertShotDamage(arc, ally, ally.MaxHp - ally.Hp, false, 1f, "아군 피격 = 같은 피해 공식(상성 적용 · 100 %)"); Assert.AreEqual(foe.MaxHp, foe.Hp, "관통 없음 → 가려진 타겟은 무피해"); Assert.AreEqual(0, o.Attacked.Count); Assert.IsFalse(o.Crit, "첫 피격이 타겟이 아니면 크리 없음"); Assert.AreEqual(arc.MaxHp, arc.Hp, "반격 없음"); Assert.AreEqual(-3.5f, ally.Position.y, 0.01f, "화살은 밀지 않는다"); Assert.AreEqual(0, o.Notes.Count, "밀린 아군 공격(규칙 ⑤) 없음"); Assert.Less(Vector2.Distance(arc.Position, new Vector2(0f, -5f)), 0.01f, "제자리 사격"); } [UnityTest] public IEnumerator Archer_NoEnemyInReach_DoesNotFire_AllyOnLineUnhurt() { yield return Fresh(); var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병"); var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "아군보병"); var far = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 8f), "먼적"); var o = new ActionOutcome(); yield return bm.ResolveForTest(arc, new Vector2(0f, -1.5f), false, o); Assert.IsFalse(o.Fired, "공격 가능 범위에 적이 없으면 쏘지 않는다"); Assert.IsTrue(o.MovedOnly); Assert.AreEqual(ally.MaxHp, ally.Hp, "사선 위 아군도 무피해"); Assert.AreEqual(0, o.FriendlyHit.Count); Assert.AreEqual(far.MaxHp, far.Hp); Assert.LessOrEqual(arc.Position.y, ally.Position.y - CastContact(arc, ally) + 0.05f, "아군에 막혀 그 앞에서 멈춤"); } [UnityTest] public IEnumerator Strategist_Splash_RadiusRatio_AllyRatio_CenterOnlyCrit() { yield return Fresh(); float R = cfg.strategistSplashRadius; var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(0f, -5f), "책사"); Vector2 c = new Vector2(0f, -2f); var center = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, c, "중심적"); var near = bm.SpawnUnit(UnitType.Spear, Faction.Enemy, c + new Vector2(R * 0.8f, 0f), "광역적"); var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, c + new Vector2(-R * 0.8f, 0f), "광역아군"); var far = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, c + new Vector2(R + 0.45f + 0.3f, 0f), "반경밖적"); Assert.Greater(Vector2.Distance(c, far.Position), R + far.Radius, "전제: 반경 밖(몸이 원에 안 닿음)"); var o = new ActionOutcome(); yield return bm.ResolveForTest(st, center.Position, false, o); Assert.IsTrue(o.Fired); Assert.AreEqual(center, o.ShotHit, "사선이 비면 타겟이 광역 중심"); Assert.IsTrue(o.Crit, "중심 = 타겟 + 목표점 명중 → 크리"); AssertShotDamage(st, center, center.MaxHp - center.Hp, true, 1f, "중심 피해(크리 ×" + cfg.critMultiplier + ")"); AssertShotDamage(st, near, near.MaxHp - near.Hp, false, cfg.strategistSplashRatio, "광역 = 대상별 피해 × 광역 비율 · 크리 미적용"); AssertShotDamage(st, ally, ally.MaxHp - ally.Hp, false, cfg.strategistSplashRatio * cfg.strategistAllyDamageRatio, "범위 안 아군 = × 아군 비율"); Assert.AreEqual(far.MaxHp, far.Hp, "반경 밖 무피해"); Assert.Contains(center, o.Attacked); Assert.Contains(near, o.Attacked); if (cfg.strategistAllyDamageRatio > 0f) Assert.Contains(ally, o.FriendlyHit); Assert.AreEqual(st.MaxHp, st.Hp, "반격 없음 · 쏜 책사 자신은 광역 제외"); Assert.Less(Vector2.Distance(st.Position, new Vector2(0f, -5f)), 0.01f, "제자리 사격"); } [UnityTest] public IEnumerator Strategist_AllyOnLine_BecomesCenter_SplashesTarget() { yield return Fresh(); var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(0f, -5f), "책사"); var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3.5f), "아군보병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -2.4f), "적보병"); Assert.LessOrEqual(Vector2.Distance(ally.Position, foe.Position), cfg.strategistSplashRadius + foe.Radius, "전제: 적이 아군 중심 광역 안"); var o = new ActionOutcome(); yield return bm.ResolveForTest(st, foe.Position, false, o); Assert.AreEqual(ally, o.ShotHit, "아군이 사선을 막으면 그 아군이 광역 중심"); AssertShotDamage(st, ally, ally.MaxHp - ally.Hp, false, 1f, "중심 아군 = 사격 피해 100 %(크리 없음)"); AssertShotDamage(st, foe, foe.MaxHp - foe.Hp, false, cfg.strategistSplashRatio, "타겟은 광역으로만"); Assert.IsFalse(o.Crit, "첫 피격이 타겟이 아니면 크리 없음"); Assert.Contains(ally, o.FriendlyHit); Assert.Contains(foe, o.Attacked); } [Test] public void Matchup_5x5_StrategistRowAndColumn_SameAsArcher() { var c = cfg != null ? cfg : Resources.Load("BalanceConfig"); Assert.AreEqual(UnitTypeInfo.Count * UnitTypeInfo.Count, c.matchup.Length, "상성 5×5(보·기·궁·창·책)"); for (int i = 0; i < UnitTypeInfo.Count; i++) { var t = (UnitType)i; Assert.AreEqual(c.Matchup(t, UnitType.Archer), c.Matchup(t, UnitType.Strategist), UnitTypeInfo.Korean(t) + " → 책사 = → 궁병"); Assert.AreEqual(c.Matchup(UnitType.Archer, t), c.Matchup(UnitType.Strategist, t), "책사 → " + UnitTypeInfo.Korean(t) + " = 궁병 →"); } Assert.IsTrue(UnitTypeInfo.IsRanged(UnitType.Strategist) && !UnitTypeInfo.IsMelee(UnitType.Strategist), "책사 = 원거리(반격 없음 · 근접 패널티)"); Assert.AreEqual("strategist", UnitTypeInfo.Key(UnitType.Strategist), "데이터·아트 키"); Assert.AreEqual("책", UnitTypeInfo.Mark(UnitType.Strategist), "표시 글자"); Assert.AreEqual(c.GetStats(UnitType.Archer).slingMaxDistance, c.GetStats(UnitType.Strategist).slingMaxDistance, "슬링 = 사거리 = 궁병과 동일(제안값)"); } [UnityTest] public IEnumerator Strategist_RangeLikeArcher_Donut_MeleePenalty() { yield return Fresh(); var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(0f, -5f), "책사"); var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(3f, -5f), "적보병"); Assert.AreEqual(st.SlingMaxDistance, st.AttackRange, 0.001f, "사거리 = 슬링샷 범위"); Assert.AreEqual(st.SlingMaxDistance * cfg.archerRangeRatio, st.ArcherMoveAllow, 0.001f, "접근 허용 = 궁병과 같은 비율"); int ranged = Combat.Damage(cfg, st, inf, false, new System.Random(7), false); int melee = Combat.Damage(cfg, st, inf, false, new System.Random(7), true); Assert.AreEqual(ranged * cfg.archerMeleeDealtRatio, melee, 1f, "근접 전투 주는 피해 × 궁병과 같은 비율"); int takenRanged = Combat.Damage(cfg, inf, st, false, new System.Random(7), false); int takenMelee = Combat.Damage(cfg, inf, st, false, new System.Random(7), true); Assert.AreEqual(takenRanged * cfg.archerMeleeTakenRatio, takenMelee, 1f, "근접 피격 × 궁병과 같은 비율"); bm.Sling.Select(st); Assert.IsTrue(bm.Sling.Range.DonutVisible, "책사도 공격 가능 범위 도넛"); bm.Sling.Select(null); } static string QuoteCsv(string v) { return "\"" + v.Replace("\"", "\"\"") + "\""; } /// stages.csv 를 다시 쓴다(dropKey 열 빼기 · setRow 행의 setKey 칸 바꾸기 · 모든 칸 따옴표). static string RebuildStages(string csv, string dropKey, int setRow, string setKey, string setValue) { var errs = new List(); var rows = StageTable.ReadCsv(csv, "stages.csv", errs); Assert.IsEmpty(errs); var head = new List(csv.TrimStart('').Split('\n')[0].Trim('\r').Split(',')); if (dropKey != null) head.Remove(dropKey); var sb = new System.Text.StringBuilder(string.Join(",", head.ToArray())).Append('\n'); for (int i = 0; i < rows.Count; i++) { var cells = new List(); foreach (var h in head) cells.Add(QuoteCsv(i == setRow && h == setKey ? setValue : rows[i][h])); sb.Append(string.Join(",", cells.ToArray())).Append('\n'); } return sb.ToString(); } [Test] public void StageTable_StrategistColumn_DefaultZero_OldTableStillParses_PlacedInMiddle() { 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); var t = StageTable.Parse(texts); Assert.IsEmpty(t.errors); Assert.IsTrue(texts[0].Split('\n')[0].Contains(",archer,strategist,"), "stages.csv 머리줄에 strategist 열(#1087 ⑤)"); // 구 4열 표(strategist 열 없음)도 그대로 읽히고 책사 = 0 var old = (string[])texts.Clone(); old[0] = RebuildStages(texts[0], "strategist", -1, null, null); var t2 = StageTable.Parse(old); Assert.IsEmpty(t2.errors, "strategist 열이 없어도 오류 없음"); Assert.AreEqual(t.stages.Count, t2.stages.Count); for (int i = 0; i < t2.stages.Count; i++) { Assert.AreEqual(0, t2.stages[i].soldiers[(int)UnitType.Strategist], "열이 없으면 책사 0"); for (int k = 0; k < UnitTypeInfo.Count; k++) Assert.AreEqual(t.stages[i].soldiers[k], t2.stages[i].soldiers[k]); } // 책사 수를 넣으면 중군에 배치 · 아트 id = <세력>_strategist var mod = (string[])texts.Clone(); mod[0] = RebuildStages(texts[0], null, 0, "strategist", "2"); var t3 = StageTable.Parse(mod); Assert.IsEmpty(t3.errors); var row = t3.stages[0]; Assert.AreEqual(2, row.soldiers[(int)UnitType.Strategist]); var c = Resources.Load("BalanceConfig"); var enemies = StageTable.Enemies(c, row, t3); var strategists = enemies.FindAll(e => e.type == UnitType.Strategist); Assert.AreEqual(2, strategists.Count, "책사 2 배치"); foreach (var e in strategists) { Assert.AreEqual(t3.factions[row.faction].art + "_strategist", e.art, "아트 id <세력>_strategist(그림 없으면 도형)"); Assert.AreEqual(c.formation.middleY, e.position.y, 0.01f, "책사 = 중군 줄"); } } [UnityTest] public IEnumerator EnemyAI_Ranged_PrefersClearLine_StrategistAvoidsOwnSideInSplash() { yield return Fresh(); // 적 궁병: 가까운 아군 A 의 사선을 적 보병이 막음 → 사선이 빈 아군 B 를 노린다 var ea = bm.SpawnUnit(UnitType.Archer, Faction.Enemy, new Vector2(0f, 2f), "적궁병"); var blocker = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0.5f), "적보병"); var a = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -1f), "아군A"); var b = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(3f, 0f), "아군B"); var players = bm.AliveUnits(Faction.Player); var aimOld = EnemyAI.Decide(ea, players, 1f, 11f, bm.Rng, bm.Board); Assert.Less(Vector2.Distance(aimOld.Value, a.Position), 0.05f, "cfg 없이 = 가장 가까운 대상(기존)"); var aim = EnemyAI.Decide(ea, players, 1f, 11f, bm.Rng, bm.Board, cfg); Assert.Less(Vector2.Distance(aim.Value, b.Position), 0.05f, "사선이 빈 대상 우선"); Assert.IsTrue(blocker.IsAlive); // 적 책사: A 는 사선이 비지만 광역에 적(자기 편)이 섞임 → 광역에 자기 편이 없는 B 를 노린다 yield return Fresh(); var es = bm.SpawnUnit(UnitType.Strategist, Faction.Enemy, new Vector2(0f, 2f), "적책사"); var a2 = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -1f), "아군A"); var own = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, a2.Position + new Vector2(cfg.strategistSplashRadius * 0.8f, 0f), "적보병"); var b2 = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-3f, 0f), "아군B"); var aim2 = EnemyAI.Decide(es, bm.AliveUnits(Faction.Player), 1f, 11f, bm.Rng, bm.Board, cfg); Assert.Less(Vector2.Distance(aim2.Value, b2.Position), 0.05f, "광역에 자기 편이 없는 대상 우선"); Assert.IsTrue(own.IsAlive); } [UnityTest] public IEnumerator AimPreview_ArcherShotLine_AllyRed_StrategistSplashCircle() { yield return Fresh(); var sling = bm.Sling; var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병"); var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3.5f), "아군보병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -1.5f), "적보병"); yield return null; int foes = sling.ShowPreview(arc, foe.Position, null); Assert.IsTrue(sling.ShotLineVisible, "화살 경로선"); Assert.IsTrue(ally.View.Highlighted && ally.View.HighlightFriendly, "첫 피격 = 아군 → 붉게"); Assert.IsFalse(foe.View.Highlighted, "가려진 타겟은 공격 예정 아님"); Assert.AreEqual(0, foes); Assert.IsFalse(sling.SplashVisible, "궁병은 광역 원 없음"); sling.HidePreview(); Assert.IsFalse(sling.ShotLineVisible); Assert.IsFalse(ally.View.Highlighted); float R = cfg.strategistSplashRadius; var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(4f, -5f), "책사"); var foe2 = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(4f, -2f), "적보병2"); var ally2 = bm.SpawnUnit(UnitType.Infantry, Faction.Player, foe2.Position + new Vector2(-R * 0.8f, 0f), "아군보병2"); yield return null; foes = sling.ShowPreview(st, foe2.Position, null); Assert.IsTrue(sling.SplashVisible, "책사 = 명중 지점 광역 원"); Assert.AreEqual(R * 2f, sling.SplashDiameter, 0.01f); Assert.Less(Vector2.Distance(foe2.Position, sling.SplashCenter), 0.01f, "광역 중심 = 첫 피격 유닛"); Assert.IsTrue(foe2.View.Highlighted && !foe2.View.HighlightFriendly); Assert.IsTrue(ally2.View.Highlighted && ally2.View.HighlightFriendly, "광역에 휩쓸릴 아군 붉게"); Assert.AreEqual(1, foes); sling.HidePreview(); Assert.IsFalse(sling.SplashVisible); } [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_PushesEnemyFar_RecoilsLittle_NoOverwatch() { 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, "부딪힌 상대를 밀친다"); float remaining = CastContact(spear, foe); // 당긴 3 − 접촉까지(3 − 접촉 거리) float push = remaining * cfg.spearPushRatio; // PD 2026-11-10 「적을 크게 밀친다」 Assert.AreEqual(push, foe.Position.y, 0.1f, "적 = 남은 거리 × spearPushRatio 만큼 앞으로 밀림"); Assert.AreEqual(-CastContact(spear, foe) - push * cfg.spearRecoilRatio, spear.Position.y, 0.1f, "창병 = 밀친 거리 × spearRecoilRatio 만큼만 반대로(PD 2026-11-10 「자신은 적게 밀린다」)"); Assert.IsFalse(o.Overwatch || spear.Overwatch, "목표점 명중해도 대기(견제) 없음(PD 2026-11-10 제거)"); } [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, "밀린 아군이 적과 닿음"); float contactY = -2f - CastContact(spear, ally); float push = 4f - (contactY + 4f); // 남은 거리 = 당긴 4 − 접촉까지 Assert.AreEqual(contactY - push * cfg.spearPushRatio * cfg.spearRecoilRatio, spear.Position.y, 0.1f, "창병 반작용 = 밀친 거리(남은 거리 × spearPushRatio) × spearRecoilRatio 만큼 뒤로(PD #1098 · 2026-11-10)"); 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(); 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, StarterHero(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, StarterHero(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_BootStartsOnLobby_SortieOpensMap() { WorldProgress.Reset(); SaveStore.UseMemory(true); 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"); var og = OutGameRoot.Instance; Assert.IsNotNull(og, "GameBootstrap 이 아웃게임(본영 로비)을 만들어야 한다"); Assert.IsTrue(og.LobbyVisible, "실행하면 본영 로비에서 시작(#1093)"); Assert.IsFalse(wm.IsVisible, "로비 표시 중 = 월드맵 닫힘"); var b = BattleManager.Instance; Assert.AreEqual(BattlePhase.Setup, b.Phase, "로비 표시 중 = 전투 없음"); Assert.AreEqual(0, b.Units.Count, "로비 표시 중 = 유닛 없음"); og.OpenWorldMap(); // 출정 yield return null; Assert.IsTrue(wm.IsVisible, "출정 = 챕터 월드맵"); Assert.IsFalse(og.ScreensVisible, "월드맵 표시 중 = 로비 화면 닫힘"); 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("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("ChapterMap/UI/" + k.name), "UI 부품 " + k.name); Assert.Greater(Resources.Load("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" }; for (int hi = 0; hi < OutGameData.Default.Economy.StartHeroes.Length; hi++) { var h = StarterHero(hi); 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 · 원소·원술·형주는 D3 로 cc 재사용)은 반드시 원화(책사 병사 아트는 미납품) · 장수 = 납품된 것만 원화(나머지는 도형 대체 · StageData_MissingArt 테스트) string[] delivered = { "hgj_", "dz_", "cc_" }; foreach (var s in cfg.stages) foreach (var e in s.enemies) { bool want = false; foreach (var f in delivered) want |= e.art.StartsWith(f); if (e.type == UnitType.Strategist) want = false; if (e.isGeneral) want = Resources.Load("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), StarterHero(0).nameKo, 1f, 1f, false, StarterHero(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, "왼쪽 = 좌우 반전"); } [UnityTest] public IEnumerator UnitArt_SizeTable_SameHeadSize_MountedRiderLikeFoot() { // PD 2026-11-09 「캐릭터 크기 동일 · 말 탄 유닛도 보병·창병과 비슷한 비율」 + 2026-11-10 「새 유닛이 이전 유닛보다 크다 · 이전 기준에 맞게」 — // 크기 표(art_size.csv · lock 1 = PD 확인 기준 20종 고정 · 나머지 = 눈·얼굴·머리 폭·정수리 가운데 값 × adjust · Tools/unit_art_size.py)의 // size_px 가 모든 유닛에서 표준 키(월드)가 되도록 배율을 정한다 · 기마 유닛은 표준 키보다 커진다(탄 사람 = 보병 크기) var ta = Resources.Load(UnitArt.Root + UnitArt.SizeTable); Assert.IsNotNull(ta, "크기 표 Resources/Units/art_size.csv"); var lines = ta.text.TrimStart('').Split('\n'); var head = new List(lines[0].Trim().Split(',')); int ia = head.IndexOf("art"), im = head.IndexOf("mounted"), isz = head.IndexOf("size_px"), il = head.IndexOf("lock"); Assert.GreaterOrEqual(il, 0, "lock 열(PD 확인 기준 행)"); int rows = 0, mounted = 0, locked = 0; var go = new GameObject("SizeProbe"); var fa = go.AddComponent(); for (int i = 1; i < lines.Length; i++) { var c = lines[i].Trim().Split(','); if (c.Length <= Mathf.Max(isz, il)) continue; var a = UnitArt.Load(c[ia]); Assert.IsNotNull(a, "표의 아트 " + c[ia]); Assert.AreEqual(float.Parse(c[isz], System.Globalization.CultureInfo.InvariantCulture), a.SizePx, 0.01f, c[ia] + " size_px 읽음"); fa.Setup(a, cfg.unitArtHeight, 0); Assert.AreEqual(cfg.unitArtHeight, a.SizePx / a.Meta.ppu * go.transform.localScale.x, 1e-3f, c[ia] + " 크기 표 키(월드) = 표준 키"); rows++; if (c[il] == "1") locked++; if (c[im] == "1") { mounted++; Assert.Greater(fa.TopHeight, cfg.unitArtHeight * 1.2f, c[ia] + " 기마 = 탄 사람이 보병 크기가 되도록 커짐"); } } Object.Destroy(go); Assert.GreaterOrEqual(rows, 15, "표 행 수"); Assert.GreaterOrEqual(mounted, 5, "기마 행 수"); Assert.GreaterOrEqual(locked, 10, "PD 확인 기준 행(lock) 수"); // 배치된 기마 영웅 = HP 바 기준(ArtHeight)이 실제 머리 끝 yield return Fresh(); var u = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -4f), "조운", 1f, 1f, false, "zhaoyun"); yield return null; Assert.AreEqual(u.View.Anim.TopHeight, u.ArtHeight, 1e-4f, "ArtHeight = 그려진 머리 끝"); // PD 2026-11-09 「완벽한 비율 아니어도 됨 · 보병보다 조금 크게 · 너무 커지지 않게」 = 머리 끝 상한(표준 키 × mountedArtMaxTop) Assert.Greater(cfg.mountedArtMaxTop, 1f, "기마 상한 > 표준 키"); Assert.AreEqual(cfg.unitArtHeight * cfg.mountedArtMaxTop, u.ArtHeight, 1e-3f, "기마 머리 끝 = 표준 키 × 상한(조운 원래 ×1.56 → 상한)"); var foot = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(2f, -4f), "유비", 1f, 1f, false, "liubei"); yield return null; Assert.AreEqual(foot.View.Anim.TopHeight, foot.ArtHeight, 1e-4f, "보병 = 상한 무관(크기 표 그대로)"); Assert.Less(foot.ArtHeight, u.ArtHeight, "기마가 보병보다 조금 큼"); } // ---------- 스테이지 표(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(); 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"); 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, generals = 0; foreach (var e in s.enemies) { if (!e.isGeneral) counts[(int)e.type]++; else generals++; if (e.isBoss) bosses++; Assert.IsTrue(!e.isBoss || e.isGeneral, at + " 보스는 무장"); 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.Count, generals, at + " 무장 수"); 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() { // 아트 없는 유닛 = 도형으로 정상 전투. 2026-11-10 전 유닛 아트(91종) 반영으로 표에 아트 없는 보스가 없어짐 // → 보스 1기의 아트 id 를 없는 이름으로 잠시 바꿔 대체 경로를 본다(끝나면 되돌림) int idx = -1, k = -1; for (int i = 0; i < cfg.stages.Length && idx < 0; i++) for (int j = 0; j < cfg.stages[i].enemies.Count; j++) if (cfg.stages[i].enemies[j].isBoss) { idx = i; k = j; break; } Assert.GreaterOrEqual(idx, 0, "보스 스테이지"); string saved = cfg.stages[idx].enemies[k].art; cfg.stages[idx].enemies[k].art = "__no_art_test__"; try { 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, "도형 대체 유닛"); } finally { cfg.stages[idx].enemies[k].art = saved; } bm.StartStage(0); yield return null; } /// 점 p 와 길(world.json 맵 항목의 꺾은선) 사이 최단 거리(맵 px). static float DistanceToRoad(WorldChapter c, Vector2 p) { float best = float.MaxValue; for (int i = 1; i < Mathf.Min(c.roadX.Length, c.roadY.Length); i++) { var a = new Vector2(c.roadX[i - 1], c.roadY[i - 1]); var ab = new Vector2(c.roadX[i], c.roadY[i]) - a; float t = Mathf.Clamp01(Vector2.Dot(p - a, ab) / Mathf.Max(ab.sqrMagnitude, 1e-6f)); best = Mathf.Min(best, Vector2.Distance(a + ab * t, p)); } return best; } [Test] public void WorldMap_PlanMaps_TenChapters_NodesOnRoad_BossEveryFifth() { // PD #1091 ③(2026-11-05): 챕터맵 보강 세션 기획 확정판 10장 · 노드 = Data/<맵>.json stages[].at var c = Resources.Load("BalanceConfig"); var d = WorldData.Load(); var nodeKit = d.Kit("flag_open"); Assert.IsNotNull(nodeKit, "노드 부품 크기(kit flag_open)"); float onRoad = d.dotSpacing * 0.5f; // 노드 중심이 길 점 간격의 절반 안 = 길 위(납품 실측 ≤ 0.1 px) foreach (var def in c.chapters) { string at = "제" + def.no + "장(" + def.map + ")"; var wc = d.FindMap(def.map); Assert.IsNotNull(wc, at + " world.json 길"); var plan = WorldData.AnchorStages(def.map); Assert.IsNotNull(plan, at + " Data/" + def.map + ".json stages"); var list = c.StagesOfChapter(def.no); Assert.AreEqual(list.Count, plan.Length, at + " 노드 수 = 스테이지 표"); Vector2? prev = null; for (int i = 0; i < list.Count; i++) { var s = c.stages[list[i]]; var p = System.Array.Find(plan, x => x.stage == s.stage); Assert.IsNotNull(p, at + " " + s.stage + "번 노드"); Assert.AreEqual(s.kind == StageKind.Boss, p.boss, at + " " + s.stage + " 보스 표시 = 스테이지 표 type"); Assert.AreEqual(s.stage % 5 == 0, p.boss, at + " " + s.stage + " 보스 = 5의 배수"); Assert.AreEqual(s.place, p.place, at + " " + s.stage + " 지명"); var pos = new Vector2(p.at[0], p.at[1]); Assert.IsTrue(pos.x >= 0f && pos.x <= d.mapSize[0] && pos.y >= 0f && pos.y <= d.mapSize[1], at + " " + s.stage + " 맵 안 " + pos); Assert.LessOrEqual(DistanceToRoad(wc, pos), onRoad, at + " " + s.stage + " 노드가 길 위 " + pos); if (prev.HasValue) Assert.Greater(Vector2.Distance(prev.Value, pos), nodeKit.refSize[0], at + " " + s.stage + " 앞 노드와 깃발 겹침"); prev = pos; } } } [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("ChapterMap/Maps/" + def.map), def.map + " 맵 그림"); NodeSource source; var slots = wm.NodeSlots(ch, out source); Assert.AreEqual(NodeSource.Stages, source, "제" + def.no + "장 노드 자리 = 기획 확정판 맵 stages[].at"); 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)); } } }