ThreeKingdomsSlingshot/Assets/Tests/PlayMode/BattleFlowTests.cs

1238 lines
74 KiB
C#
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections;
using System.Collections.Generic;
using System.IO;
using NUnit.Framework;
using TKS.OutGame;
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();
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; // 파괴된 콜라이더가 물리 월드에서 빠질 시간
}
/// <summary>경로 질의가 쓰는 접촉 거리(행동 유닛 캐스트 반지름 + 상대 반지름).</summary>
static float CastContact(Unit mover, Unit other)
{
return mover.Radius * Combat.CastRadiusScale + other.Radius;
}
/// <summary>신규 유저 시작 영웅 i(economy.csv start_heroes · HeroDb · 구 BalanceConfig.heroes 대체).</summary>
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>("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 ⑤⑥) ----------
/// <summary>사격 피해가 공식 범위 안인지(공 × 상성 × 크리 − 방 × 계수 · 난수 ± damageVariance · 그 뒤 × ratio · 반올림 여유 1).</summary>
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>("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("\"", "\"\"") + "\"";
}
/// <summary>stages.csv 를 다시 쓴다(dropKey 열 빼기 · setRow 행의 setKey 칸 바꾸기 · 모든 칸 따옴표).</summary>
static string RebuildStages(string csv, string dropKey, int setRow, string setKey, string setValue)
{
var errs = new List<string>();
var rows = StageTable.ReadCsv(csv, "stages.csv", errs);
Assert.IsEmpty(errs);
var head = new List<string>(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<string>();
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>("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<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, 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<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" };
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<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), 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<TextAsset>(UnitArt.Root + UnitArt.SizeTable);
Assert.IsNotNull(ta, "크기 표 Resources/Units/art_size.csv");
var lines = ta.text.TrimStart('').Split('\n');
var head = new List<string>(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<FrameAnimator>();
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<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, 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;
}
/// <summary>점 p 와 길(world.json 맵 항목의 꺾은선) 사이 최단 거리(맵 px).</summary>
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>("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<Sprite>("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));
}
}
}