2026-11-03 19:10:10 +00:00
using System.Collections ;
using System.Collections.Generic ;
2026-11-05 03:17:19 +00:00
using System.IO ;
2026-11-03 19:10:10 +00:00
using NUnit.Framework ;
2026-11-05 12:11:10 +00:00
using TKS.OutGame ;
2026-11-03 19:10:10 +00:00
using UnityEngine ;
using UnityEngine.SceneManagement ;
using UnityEngine.TestTools ;
namespace TKS.Tests
{
2026-11-04 06:56:02 +00:00
/// <summary>병종 4종 규칙(PD 2026-11-04 #1080 충돌 규칙 포함)·턴 흐름·승리 판정을 실제 씬에서 검증한다(헤드리스 실행 가능).</summary>
2026-11-03 19:10:10 +00:00
public class BattleFlowTests
{
BattleManager bm ;
BalanceConfig cfg ;
[UnitySetUp]
public IEnumerator SetUp ( )
{
2026-11-04 18:38:47 +00:00
WorldProgress . Reset ( ) ;
2026-11-05 12:11:10 +00:00
SaveStore . UseMemory ( true ) ; // 신규 유저 세이브(메모리 전용 · 실제 save.json 불변 · 2026-11-05 #1093)
2026-11-03 19:10:10 +00:00
SceneManager . LoadScene ( "Battle" ) ;
yield return null ;
yield return null ;
bm = BattleManager . Instance ;
Assert . IsNotNull ( bm , "GameBootstrap 이 BattleManager 를 만들어야 한다" ) ;
2026-11-05 12:11:10 +00:00
// 시작 화면 = 본영 로비(#1093) → 전투 테스트는 1-1 노드 출전과 같은 경로(EnterStage · 출전 = 편성 프리셋)로 들어가 시작
2026-11-04 18:38:47 +00:00
var wm = WorldMapScreen . Instance ;
2026-11-05 12:11:10 +00:00
if ( wm ! = null & & wm . HasData ) wm . EnterStage ( 0 , 0 ) ;
2026-11-04 18:38:47 +00:00
yield return null ;
2026-11-03 19:10:10 +00:00
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 ; // 파괴된 콜라이더가 물리 월드에서 빠질 시간
}
2026-11-04 06:56:02 +00:00
/// <summary>경로 질의가 쓰는 접촉 거리(행동 유닛 캐스트 반지름 + 상대 반지름).</summary>
static float CastContact ( Unit mover , Unit other )
{
return mover . Radius * Combat . CastRadiusScale + other . Radius ;
}
2026-11-05 12:11:10 +00:00
/// <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 ] ) ;
}
2026-11-04 06:56:02 +00:00
static float Gap ( Unit a , Unit b )
{
return Vector2 . Distance ( a . Position , b . Position ) - a . Radius - b . Radius ;
}
2026-11-03 19:10:10 +00:00
[UnityTest]
public IEnumerator Bootstrap_SpawnsHeroesAndStageEnemies ( )
{
Assert . AreEqual ( BattlePhase . PlayerTurn , bm . Phase ) ;
2026-11-05 12:11:10 +00:00
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 ) ) ;
2026-11-03 19:10:10 +00:00
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 ) ;
}
2026-11-04 06:56:02 +00:00
// ---------- 보병 ----------
2026-11-03 19:10:10 +00:00
[UnityTest]
2026-11-04 06:56:02 +00:00
public IEnumerator Infantry_HitsEnemy_StopsAtContact_PushesIt_BonusMoveOnFinalHit ( )
2026-11-03 19:10:10 +00:00
{
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 ) , "적창병" ) ;
2026-11-04 06:56:02 +00:00
float contactY = - 3f + ( 3f - CastContact ( inf , foe ) ) ;
2026-11-03 19:10:10 +00:00
var o = new ActionOutcome ( ) ;
yield return bm . ResolveForTest ( inf , foe . Position , false , o ) ;
Assert . Contains ( foe , o . Attacked ) ;
2026-11-04 06:56:02 +00:00
Assert . Less ( foe . Hp , foe . MaxHp , "부딪힌 적을 공격" ) ;
2026-11-03 19:10:10 +00:00
Assert . IsTrue ( o . BonusMove , "최종 목표점 명중 → 추가 이동" ) ;
2026-11-04 08:32:07 +00:00
float remaining = CastContact ( inf , foe ) ; // 당긴 거리 3 − 접촉까지 거리 = 남은 힘
Assert . AreEqual ( contactY - remaining * cfg . collisionSelfBounceRatio , inf . Position . y , 0.1f , "보병 자신도 반대 방향으로 튕긴다(PD #1081 · 남은 힘 × 비율)" ) ;
2026-11-04 06:56:02 +00:00
Assert . Contains ( foe , o . Pushed , "부딪힌 유닛은 밀려난다" ) ;
Assert . Greater ( foe . Position . y , 0.3f , "적이 남은 힘만큼 밀려남" ) ;
2026-11-03 19:10:10 +00:00
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 ) ;
}
2026-11-04 06:56:02 +00:00
[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 는 피해 없음" ) ;
2026-11-04 08:32:07 +00:00
Assert . Less ( a . Position . x , - pushX - 0.2f , "A 도 반대로 튕김(아군과 부딪혀도 알까기)" ) ;
2026-11-04 06:56:02 +00:00
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 ) ;
}
// ---------- 기병 ----------
2026-11-03 19:10:10 +00:00
[UnityTest]
public IEnumerator Cavalry_PiercesAllOnPath_AndReturnsOnFinalHit ( )
{
yield return Fresh ( ) ;
var cav = bm . SpawnUnit ( UnitType . Cavalry , Faction . Player , new Vector2 ( 0f , - 4f ) , "기병" ) ;
var e1 = bm . SpawnUnit ( UnitType . Infantry , Faction . Enemy , new Vector2 ( 0f , - 1.5f ) , "적1" ) ;
var e2 = bm . SpawnUnit ( UnitType . Infantry , Faction . Enemy , new Vector2 ( 0f , 1f ) , "적2" ) ;
var o = new ActionOutcome ( ) ;
yield return bm . ResolveForTest ( cav , e2 . Position , false , o ) ;
Assert . Less ( e1 . Hp , e1 . MaxHp , "경로 위 첫 적 관통 피해" ) ;
Assert . Less ( e2 . Hp , e2 . MaxHp , "경로 위 둘째 적 관통 피해" ) ;
Assert . IsTrue ( o . Returned , "최종 목표점 명중 → 제자리 복귀" ) ;
Assert . Less ( Vector2 . Distance ( cav . Position , new Vector2 ( 0f , - 4f ) ) , 0.05f ) ;
}
[UnityTest]
public IEnumerator Cavalry_StaysAtEnd_WithoutFinalHit ( )
{
yield return Fresh ( ) ;
var cav = bm . SpawnUnit ( UnitType . Cavalry , Faction . Player , new Vector2 ( 0f , - 4f ) , "기병" ) ;
var e1 = bm . SpawnUnit ( UnitType . Infantry , Faction . Enemy , new Vector2 ( 0f , - 1f ) , "적1" ) ;
var o = new ActionOutcome ( ) ;
yield return bm . ResolveForTest ( cav , new Vector2 ( 0f , 2f ) , false , o ) ;
Assert . Less ( e1 . Hp , e1 . MaxHp ) ;
Assert . IsFalse ( o . Returned ) ;
Assert . Greater ( cav . Position . y , 1.5f , "목표점까지 이동" ) ;
Assert . GreaterOrEqual ( Vector2 . Distance ( cav . Position , e1 . Position ) , cav . Radius + e1 . Radius - 0.05f , "적과 겹치지 않음" ) ;
}
[UnityTest]
2026-11-04 06:56:02 +00:00
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 ( )
2026-11-03 19:10:10 +00:00
{
yield return Fresh ( ) ;
var arc = bm . SpawnUnit ( UnitType . Archer , Faction . Player , new Vector2 ( 0f , - 5f ) , "궁병" ) ;
2026-11-04 06:56:02 +00:00
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 , "전제: 적 가장자리가 사거리 안" ) ;
2026-11-03 19:10:10 +00:00
var o = new ActionOutcome ( ) ;
2026-11-04 06:56:02 +00:00
yield return bm . ResolveForTest ( arc , new Vector2 ( 0f , - 2.5f ) , false , o ) ;
Assert . Contains ( foe , o . Attacked , "사거리 안의 적을 사격" ) ;
2026-11-03 19:10:10 +00:00
Assert . Less ( foe . Hp , foe . MaxHp ) ;
2026-11-04 06:56:02 +00:00
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 ) ;
2026-11-03 19:10:10 +00:00
Assert . IsFalse ( o . Crit ) ;
}
2026-11-04 04:05:46 +00:00
[UnityTest]
2026-11-04 06:56:02 +00:00
public IEnumerator Archer_MovesToTarget_WhenNoEnemyWithinReach ( )
2026-11-04 04:05:46 +00:00
{
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 ) , "적보병" ) ;
2026-11-04 06:56:02 +00:00
Assert . Greater ( 6f - CastContact ( arc , foe ) , arc . AttackRange + arc . ArcherMoveAllow , "전제: 공격 가능 범위 밖" ) ;
2026-11-04 04:05:46 +00:00
Assert . IsFalse ( bm . AnyEnemyWithinArcherReach ( arc ) , "공격 가능 범위 안에 적 없음(도넛 회색)" ) ;
var o = new ActionOutcome ( ) ;
2026-11-04 06:16:15 +00:00
var target = new Vector2 ( 0f , - 1f ) ;
yield return bm . ResolveForTest ( arc , target , false , o ) ;
2026-11-04 06:56:02 +00:00
Assert . IsTrue ( o . MovedOnly , "공격 가능 범위에 적이 없으면 목표점까지 이동만 (PD 2026-11-04 2차 지시)" ) ;
2026-11-05 04:06:32 +00:00
Assert . IsFalse ( o . Fired , "타겟이 없으면 쏘지 않는다(#1087 ⑥ 유지)" ) ;
2026-11-04 04:05:46 +00:00
Assert . AreEqual ( 0 , o . Attacked . Count ) ;
Assert . AreEqual ( foe . MaxHp , foe . Hp ) ;
2026-11-04 06:16:15 +00:00
Assert . Less ( Vector2 . Distance ( arc . Position , target ) , 0.05f , "목표 지점까지 이동" ) ;
2026-11-04 04:05:46 +00:00
2026-11-04 06:56:02 +00:00
var near = bm . SpawnUnit ( UnitType . Infantry , Faction . Enemy , new Vector2 ( 2f , - 1f ) , "가까운적" ) ;
2026-11-04 04:05:46 +00:00
Assert . IsTrue ( bm . AnyEnemyWithinArcherReach ( arc ) , "범위 안에 적이 생기면 도넛 붉게" ) ;
Assert . IsTrue ( near . IsAlive ) ;
}
2026-11-03 19:10:10 +00:00
[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 ) ;
2026-11-05 04:06:32 +00:00
Assert . IsTrue ( o . Fired ) ;
Assert . AreEqual ( foe , o . ShotHit , "사선이 비면 화살이 타겟을 맞힌다" ) ;
Assert . IsTrue ( o . Crit , "첫 피격 = 타겟 + 최종 목표점 명중 → 크리티컬" ) ;
2026-11-03 19:10:10 +00:00
Assert . AreEqual ( arc . MaxHp , arc . Hp ) ;
}
2026-11-05 04:06:32 +00:00
// ---------- 궁병 투사체 · 책사 (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 ) ;
}
2026-11-03 19:10:10 +00:00
[UnityTest]
2026-11-04 06:56:02 +00:00
public IEnumerator Archer_MeleePenalty_DealsRatio_TakesRatio_RangedUnaffected ( )
{
yield return Fresh ( ) ;
var arc = bm . SpawnUnit ( UnitType . Archer , Faction . Player , new Vector2 ( 0f , - 5f ) , "궁병" ) ;
var arc2 = bm . SpawnUnit ( UnitType . Archer , Faction . Player , new Vector2 ( 2f , - 5f ) , "궁병2" ) ;
var inf = bm . SpawnUnit ( UnitType . Infantry , Faction . Enemy , new Vector2 ( 4f , - 5f ) , "적보병" ) ;
int ranged = Combat . Damage ( cfg , arc , inf , false , new System . Random ( 7 ) , false ) ;
int melee = Combat . Damage ( cfg , arc , inf , false , new System . Random ( 7 ) , true ) ;
Assert . AreEqual ( ranged * cfg . archerMeleeDealtRatio , melee , 1f , "궁병이 근접 유닛과 근접 전투하면 주는 피해 × 비율(기본 50 %)" ) ;
int takenRanged = Combat . Damage ( cfg , inf , arc , false , new System . Random ( 7 ) , false ) ;
int takenMelee = Combat . Damage ( cfg , inf , arc , false , new System . Random ( 7 ) , true ) ;
Assert . AreEqual ( takenRanged * cfg . archerMeleeTakenRatio , takenMelee , 1f , "근접 유닛에게 근접으로 맞으면 받는 피해 × 비율(기본 +50 %)" ) ;
int aa = Combat . Damage ( cfg , arc , arc2 , false , new System . Random ( 7 ) , true ) ;
int ab = Combat . Damage ( cfg , arc , arc2 , false , new System . Random ( 7 ) , false ) ;
Assert . AreEqual ( ab , aa , "궁병끼리는 근접 패널티 없음(근접 유닛 상대만)" ) ;
yield return null ;
}
// ---------- 창병 ----------
[UnityTest]
public IEnumerator Spear_PlowsEnemyForward_OverwatchOnFinalHit_DamagesMovingEnemy ( )
2026-11-03 19:10:10 +00:00
{
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 ) ;
2026-11-04 06:56:02 +00:00
Assert . Contains ( foe , o . Attacked ) ;
Assert . Less ( foe . Hp , foe . MaxHp , "부딪힌 적에게 피해" ) ;
2026-11-05 16:20:07 +00:00
Assert . Contains ( foe , o . Pushed , "부딪힌 상대를 밀친다" ) ;
2026-11-04 06:56:02 +00:00
Assert . Greater ( foe . Position . y , 0.5f , "적이 남은 거리만큼 앞으로 밀림" ) ;
2026-11-05 16:20:07 +00:00
float remaining = CastContact ( spear , foe ) ; // 당긴 3 − 접촉까지(3 − 접촉 거리)
Assert . AreEqual ( - CastContact ( spear , foe ) - remaining * cfg . spearRecoilRatio , spear . Position . y , 0.1f , "창병 = 밀친 거리 × spearRecoilRatio 만큼 반대로(PD #1098 · 함께 전진 폐기)" ) ;
2026-11-03 19:10:10 +00:00
Assert . IsTrue ( o . Overwatch & & spear . Overwatch , "최종 목표점 명중 → 대기(견제)" ) ;
int hp = foe . Hp ;
var path = new List < Vector2 > { foe . Position , foe . Position + new Vector2 ( 1.5f , 0f ) } ;
yield return bm . ApplyOverwatch ( foe , path ) ;
Assert . Less ( foe . Hp , hp , "반경 안에서 움직인 적에게 피해" ) ;
// 반경 밖에서만 움직인 적은 피해 없음
var far = bm . SpawnUnit ( UnitType . Infantry , Faction . Enemy , new Vector2 ( 5f , 6f ) , "먼적" ) ;
int farHp = far . Hp ;
yield return bm . ApplyOverwatch ( far , new List < Vector2 > { far . Position , far . Position + new Vector2 ( - 1f , 0f ) } ) ;
Assert . AreEqual ( farHp , far . Hp ) ;
}
2026-11-04 06:56:02 +00:00
[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 ) ;
2026-11-05 16:20:07 +00:00
Assert . Greater ( ally . Position . y , 0.4f , "아군을 밀쳐 적진으로(적에 닿을 때까지)" ) ;
2026-11-04 06:56:02 +00:00
Assert . LessOrEqual ( Gap ( ally , foe ) , Combat . ContactTolerance + 0.05f , "밀린 아군이 적과 닿음" ) ;
2026-11-05 16:20:07 +00:00
float contactY = - 2f - CastContact ( spear , ally ) ;
float push = 4f - ( contactY + 4f ) ; // 남은 거리 = 당긴 4 − 접촉까지
Assert . AreEqual ( contactY - push * cfg . spearRecoilRatio , spear . Position . y , 0.1f , "창병 반작용(PD #1098) = 밀친 거리 × 비율만큼 뒤로" ) ;
2026-11-04 06:56:02 +00:00
Assert . Less ( foe . Hp , foe . MaxHp , "밀린 아군이 적을 공격(규칙 ⑤)" ) ;
Assert . AreEqual ( spear . MaxHp , spear . Hp , "창병은 피해 없음" ) ;
Assert . AreEqual ( 1 , o . Notes . Count ) ;
Assert . IsTrue ( allyHp > = ally . Hp , "아군은 밀려서 피해를 받지 않는다(근접 공격 반격만 가능)" ) ;
}
// ---------- 턴 · 승패 · AI · 카메라 · 표시 ----------
2026-11-03 19:10:10 +00:00
[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 ) ;
}
2026-11-03 19:29:16 +00:00
[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 ;
2026-11-03 19:31:52 +00:00
Assert . LessOrEqual ( v . yMax , r . yMax + cc . TopMargin + 0.01f , "전장 위쪽 밖으로 못 나감(HUD 여유만큼만)" ) ;
2026-11-03 19:29:16 +00:00
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 ;
}
2026-11-04 04:05:46 +00:00
[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 ( )
{
2026-11-04 09:49:57 +00:00
// 부트스트랩 스테이지 1: 아군 5인 = 원화 세션 재디자인(PD #1081 · Resources/Units/liubei·guanyu·zhangfei·zhaoyun·huangzhong · 스켈레탈 B단계 전까지 정지 그림)
2026-11-04 04:05:46 +00:00
Unit liubei = null ;
foreach ( var u in bm . AliveUnits ( Faction . Player ) )
{
if ( u . DisplayName = = "유비" ) liubei = u ;
2026-11-04 09:49:57 +00:00
Assert . IsTrue ( u . View . HasArt , "아군 5인 전원 원화(" + u . DisplayName + " · " + u . ArtId + ")" ) ;
Assert . IsTrue ( u . View . Anim . Current . EndsWith ( "_idle" ) , "대기 클립(뒷모습이 없으면 앞모습으로 대체)" ) ;
2026-11-04 04:05:46 +00:00
}
Assert . IsNotNull ( liubei ) ;
Assert . IsNotNull ( liubei . View . Anim . Renderer . sprite ) ;
Assert . Greater ( liubei . View . Anim . Renderer . bounds . size . y , 0.5f , "스프라이트가 월드 크기로 보인다" ) ;
2026-11-04 18:38:47 +00:00
// 클립 동작은 앞·뒤 포즈가 모두 있는 아트(아군 = front/back × idle·ready·action · 2026-11-05 키아트 세션 뒷모습)로 확인
2026-11-05 12:11:10 +00:00
var rigged = bm . SpawnUnit ( UnitType . Infantry , Faction . Player , new Vector2 ( 6f , - 13f ) , "리그확인" , 1f , 1f , false , StarterHero ( 0 ) . art ) ;
2026-11-04 09:49:57 +00:00
Assert . AreEqual ( "back_idle" , rigged . View . Anim . Current , "아군 시작 = 뒷모습 대기" ) ;
rigged . View . PlayAttack ( ) ;
Assert . AreEqual ( "back_action" , rigged . View . Anim . Current , "공격 자세 클립" ) ;
2026-11-04 06:56:02 +00:00
2026-11-04 15:56:38 +00:00
// 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 ) ;
2026-11-04 06:56:02 +00:00
// 원화 세션 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 ) ;
2026-11-05 03:17:19 +00:00
// 원화 2차 납품(2026-11-04): 동탁군 보·창·기·궁 4종 반영 → 스테이지 표의 첫 동탁군 스테이지(2장)
int dzStage = System . Array . FindIndex ( cfg . stages , s = > s . faction = = "dz" ) ;
Assert . GreaterOrEqual ( dzStage , 0 , "동탁군 스테이지" ) ;
bm . StartStage ( dzStage ) ;
2026-11-04 06:56:02 +00:00
yield return null ;
foreach ( var e in bm . AliveUnits ( Faction . Enemy ) )
{
if ( e . IsBoss ) continue ;
2026-11-05 03:17:19 +00:00
Assert . IsTrue ( e . View . HasArt , "동탁군 병종 4종은 원화(" + e . ArtId + ")" ) ;
2026-11-04 06:56:02 +00:00
}
bm . StartStage ( 0 ) ;
2026-11-04 04:05:46 +00:00
yield return null ;
}
2026-11-04 08:58:30 +00:00
[UnityTest]
public IEnumerator Facing_FourWays_FollowsLastMoveDirection_BounceTurnsAround ( )
{
yield return Fresh ( ) ;
// PD #1082: 오른쪽 보기(기본) ↘ · 왼쪽 ↙ · 왼쪽 위 ↖ · 오른쪽 위 ↗ = 앞/뒤 세트 + 좌우 반전 · 보는 방향 = 마지막 이동 방향
2026-11-05 12:11:10 +00:00
var u = bm . SpawnUnit ( UnitType . Infantry , Faction . Player , new Vector2 ( 0f , - 5f ) , "보병" , 1f , 1f , false , StarterHero ( 0 ) . art ) ; // 앞·뒤 포즈가 모두 있는 아트(아군)
2026-11-04 08:58:30 +00:00
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 , "밀린 적은 밀린 방향(위)을 본다" ) ;
}
2026-11-04 18:38:47 +00:00
// ---------- 월드맵 · 스테이지 팝업(PD 2026-11-05) ----------
[UnityTest]
2026-11-05 12:11:10 +00:00
public IEnumerator WorldMap_BootStartsOnLobby_SortieOpensMap ( )
2026-11-04 18:38:47 +00:00
{
WorldProgress . Reset ( ) ;
2026-11-05 12:11:10 +00:00
SaveStore . UseMemory ( true ) ;
2026-11-04 18:38:47 +00:00
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" ) ;
2026-11-05 12:11:10 +00:00
var og = OutGameRoot . Instance ;
Assert . IsNotNull ( og , "GameBootstrap 이 아웃게임(본영 로비)을 만들어야 한다" ) ;
Assert . IsTrue ( og . LobbyVisible , "실행하면 본영 로비에서 시작(#1093)" ) ;
Assert . IsFalse ( wm . IsVisible , "로비 표시 중 = 월드맵 닫힘" ) ;
2026-11-04 18:38:47 +00:00
var b = BattleManager . Instance ;
2026-11-05 12:11:10 +00:00
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 , "월드맵 표시 중 = 로비 화면 닫힘" ) ;
2026-11-04 18:38:47 +00:00
Assert . IsNotNull ( wm . MapSprite , "챕터 1 맵 그림" ) ;
2026-11-05 03:17:19 +00:00
Assert . AreEqual ( wm . NodesIn ( 0 ) , wm . NodeCount , "노드 수 = 스테이지 표 1장 스테이지 수" ) ;
2026-11-04 18:38:47 +00:00
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 ) ;
2026-11-05 03:17:19 +00:00
Assert . AreEqual ( wm . StageIndexOf ( 0 , 0 ) , bm . StageIndex , "1-1 노드 = 스테이지 표 1-1 전투" ) ;
var stage = cfg . stages [ wm . StageIndexOf ( 0 , 0 ) ] ;
2026-11-04 18:38:47 +00:00
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" } ;
2026-11-05 12:11:10 +00:00
for ( int hi = 0 ; hi < OutGameData . Default . Economy . StartHeroes . Length ; hi + + )
2026-11-04 18:38:47 +00:00
{
2026-11-05 12:11:10 +00:00
var h = StarterHero ( hi ) ;
2026-11-04 18:38:47 +00:00
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 ) ;
}
2026-11-05 04:23:01 +00:00
// 병사 = 납품 세력(hgj·dz·cc · 원소·원술·형주는 D3 로 cc 재사용)은 반드시 원화(책사 병사 아트는 미납품) · 장수 = 납품된 것만 원화(나머지는 도형 대체 · StageData_MissingArt 테스트)
2026-11-04 18:38:47 +00:00
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 ) ;
2026-11-05 04:23:01 +00:00
if ( e . type = = UnitType . Strategist ) want = false ;
if ( e . isGeneral ) want = Resources . Load < TextAsset > ( "Units/" + e . art + "/meta" ) ! = null ;
2026-11-04 18:38:47 +00:00
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 ( ) ;
2026-11-05 12:11:10 +00:00
var u = bm . SpawnUnit ( UnitType . Infantry , Faction . Player , new Vector2 ( 0f , - 4f ) , StarterHero ( 0 ) . nameKo , 1f , 1f , false , StarterHero ( 0 ) . art ) ;
2026-11-04 18:38:47 +00:00
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 , "왼쪽 = 좌우 반전" ) ;
}
2026-11-05 03:17:19 +00:00
// ---------- 스테이지 표(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 ] ;
2026-11-05 04:23:01 +00:00
int bosses = 0 , generals = 0 ;
2026-11-05 03:17:19 +00:00
foreach ( var e in s . enemies )
{
2026-11-05 04:23:01 +00:00
if ( ! e . isGeneral ) counts [ ( int ) e . type ] + + ;
else generals + + ;
2026-11-05 03:17:19 +00:00
if ( e . isBoss ) bosses + + ;
2026-11-05 04:23:01 +00:00
Assert . IsTrue ( ! e . isBoss | | e . isGeneral , at + " 보스는 무장" ) ;
2026-11-05 03:17:19 +00:00
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 ) + " 수" ) ;
2026-11-05 04:23:01 +00:00
Assert . AreEqual ( row . generals . Count , generals , at + " 무장 수" ) ;
2026-11-05 03:17:19 +00:00
Assert . AreEqual ( row . generals . FindAll ( g = > g . boss ) . Count , bosses , at + " 보스 수" ) ;
for ( int a = 0 ; a < s . enemies . Count ; a + + )
for ( int b = a + 1 ; b < s . enemies . Count ; b + + )
{
var ea = s . enemies [ a ] ;
var eb = s . enemies [ b ] ;
float ra = c . GetStats ( ea . type ) . radius * ( ea . isBoss ? c . bossRadiusMult : 1f ) ;
float rb = c . GetStats ( eb . type ) . radius * ( eb . isBoss ? c . bossRadiusMult : 1f ) ;
Assert . Greater ( Vector2 . Distance ( ea . position , eb . position ) , ra + rb , at + " " + ea . name + "·" + eb . name + " 겹침" ) ;
}
}
}
[UnityTest]
public IEnumerator StageData_MissingArt_FallsBackToShapes ( )
{
// 아트가 아직 없는 무장(보스 신규 25 · 엘리트 25)·세력(원소·원술·형주 베리에이션) = 도형으로 정상 전투
int idx = - 1 ;
for ( int i = 0 ; i < cfg . stages . Length & & idx < 0 ; i + + )
foreach ( var e in cfg . stages [ i ] . enemies )
if ( e . isBoss & & UnitArt . Load ( e . art ) = = null ) { idx = i ; break ; }
Assert . GreaterOrEqual ( idx , 0 , "아트 없는 보스 스테이지" ) ;
bm . StartStage ( idx ) ;
yield return null ;
Assert . AreEqual ( BattlePhase . PlayerTurn , bm . Phase ) ;
Assert . AreEqual ( cfg . stages [ idx ] . enemies . Count , bm . AliveCount ( Faction . Enemy ) ) ;
int shapes = 0 ;
foreach ( var e in bm . AliveUnits ( Faction . Enemy ) )
{
bool has = UnitArt . Load ( e . ArtId ) ! = null ;
Assert . AreEqual ( has , e . View . HasArt , e . DisplayName + " 아트 있으면 원화 · 없으면 도형" ) ;
if ( ! has ) shapes + + ;
}
Assert . Greater ( shapes , 0 , "도형 대체 유닛" ) ;
bm . StartStage ( 0 ) ;
yield return null ;
}
2026-11-05 09:56:15 +00:00
/// <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 ;
}
}
}
2026-11-05 03:17:19 +00:00
[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 + " 맵 그림" ) ;
2026-11-05 09:56:15 +00:00
NodeSource source ;
var slots = wm . NodeSlots ( ch , out source ) ;
Assert . AreEqual ( NodeSource . Stages , source , "제" + def . no + "장 노드 자리 = 기획 확정판 맵 stages[].at" ) ;
2026-11-05 03:17:19 +00:00
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 ) ) ;
}
2026-11-03 19:10:10 +00:00
}
}