2026-11-05 14:31:27 +00:00
using System ;
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>
/// 스킬 시스템(PD #1093 B · 시스템 기획 §5·§3-5) — effect_key 51종 처리기(수치·조건·레벨 계수) · 쿨·예약·취소 · 즉시형 · ★3/★5 · 적 무장 패시브 · 스킬 레벨업·스킬서.
/// 기대값은 전부 skills.csv(SkillCatalog)·BalanceConfig·표에서 계산한다(C45 · 테스트에 수치 상수 없음 · 스킬 id 만 지정).
/// 결정 피해 비교 = 같은 시드의 난수로 Combat.Damage 를 부른다.
/// </summary>
public class SkillTests
{
const int Seed = 7 ;
static readonly DateTime T0 = new DateTime ( 2026 , 11 , 5 , 3 , 0 , 0 , DateTimeKind . Utc ) ;
BattleManager bm ;
BalanceConfig cfg ;
[UnitySetUp]
public IEnumerator SetUp ( )
{
WorldProgress . Reset ( ) ;
SaveStore . UseMemory ( true ) ;
SceneManager . LoadScene ( "Battle" ) ;
yield return null ;
yield return null ;
bm = BattleManager . Instance ;
Assert . IsNotNull ( bm ) ;
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 ) ;
bm . SetTurnForTest ( 1 ) ;
yield return null ;
}
// ---------- 도우미 ----------
static OutGameData Db
{
get { return OutGameData . Default ; }
}
static SkillEffect Eff ( string skillId , string key , int lv = 1 , float star = 1f )
{
var d = SkillCatalog . Get ( skillId ) ;
Assert . IsNotNull ( d , skillId ) ;
foreach ( var e in d . ScaledEffects ( lv , star ) )
if ( e . Key = = key ) return e ;
Assert . Fail ( skillId + " 에 " + key + " 없음" ) ;
return null ;
}
static int Cd ( string skillId )
{
return SkillCatalog . Get ( skillId ) . Cooldown ;
}
Unit Spawn ( UnitType t , Faction f , float x , float y , string name , bool boss = false )
{
return bm . SpawnUnit ( t , f , new Vector2 ( x , y ) , name , 1f , 1f , boss ) ;
}
UnitSkills Give ( Unit u , string heroId , int alv = 1 , int plv = 1 , float pm = 1f )
{
var s = bm . Skills . AttachHero ( u , heroId , alv , plv , pm ) ;
Assert . IsNotNull ( s , heroId ) ;
return s ;
}
void Reserve ( Unit u )
{
bm . Skills . Of ( u ) . Reserved = true ;
}
void Arm ( Unit u , bool on )
{
bm . Skills . Of ( u ) . Armed = on ;
}
IEnumerator Act ( Unit u , Vector2 target , ActionOutcome o , bool moveOnly = false )
{
yield return bm . ResolveForTest ( u , target , moveOnly , o ) ;
}
static float CastContact ( Unit mover , Unit other )
{
return mover . Radius * Combat . CastRadiusScale + other . Radius ;
}
static float Gap ( Unit a , Unit b )
{
return Vector2 . Distance ( a . Position , b . Position ) - a . Radius - b . Radius ;
}
int Dmg ( Unit a , Unit d , bool melee = true , float scale = 1f )
{
return Combat . Damage ( cfg , a , d , false , new System . Random ( Seed ) , melee , scale ) ;
}
/// <summary>피해 공식 기대값(같은 시드 난수) = (공 × 상성 − 방 × 계수) × 배율 × 분산.</summary>
int Expect ( float atk , float matchup , float def , float scale )
{
var r = new System . Random ( Seed ) ;
float v = 1f + ( ( float ) r . NextDouble ( ) * 2f - 1f ) * cfg . damageVariance ;
return Mathf . Max ( 1 , Mathf . RoundToInt ( ( atk * matchup - def * cfg . defenseFactor ) * scale * v ) ) ;
}
IEnumerator WaitPhase ( BattlePhase p , float sec = 10f )
{
float t0 = Time . realtimeSinceStartup ;
while ( bm . Phase ! = p & & Time . realtimeSinceStartup - t0 < sec ) yield return null ;
Assert . AreEqual ( p , bm . Phase ) ;
}
// ---------- 표 · 임포터 · 레벨 계수 ----------
[Test]
public void Import_EffectKeys_All51_Handled_PColumns_Parsed ( )
{
var keys = new HashSet < string > ( ) ;
foreach ( var s in Db . Skills . skills )
foreach ( var k in s . EffectKeys )
{
Assert . IsTrue ( SkillKeys . Known ( k ) , s . id + " " + k ) ;
keys . Add ( k ) ;
}
2026-11-05 15:22:54 +00:00
// 영웅 56명 확장(PD #1096): 표 키 ⊆ 처리기 55종 · 표에서 안 쓰는 처리기 = 대장군(기령 제외)뿐
var unused = new List < string > ( ) ;
foreach ( var k in SkillKeys . All . Keys )
if ( ! keys . Contains ( k ) ) unused . Add ( k ) ;
CollectionAssert . AreEquivalent ( new [ ] { SkillKeys . PushedAllyAttackOnPush } , unused , "표에서 안 쓰는 처리기" ) ;
foreach ( var k in new [ ] { SkillKeys . TeamDmgPctTurn , SkillKeys . CooldownAdd , SkillKeys . Charm , SkillKeys . HealSelfPct } ) Assert . IsTrue ( keys . Contains ( k ) , "확장 키 " + k ) ;
2026-11-05 14:31:27 +00:00
// 자산 = CSV(effect_key · p1~p3 · lv_scale · needs_impl)
var dir = Path . Combine ( Application . dataPath , OutGameTables . Folder ) ;
var texts = new string [ OutGameTables . Files . Length ] ;
for ( int i = 0 ; i < texts . Length ; i + + ) texts [ i ] = File . ReadAllText ( Path . Combine ( dir , OutGameTables . Files [ i ] ) , System . Text . Encoding . UTF8 ) ;
var r = OutGameTables . Parse ( texts ) ;
try
{
Assert . AreEqual ( 0 , r . errors . Count , string . Join ( "\n" , r . errors . ToArray ( ) ) ) ;
foreach ( var s in r . data . Skills . skills )
{
var a = Db . Skills . Get ( s . id ) ;
Assert . AreEqual ( s . effectKey , a . effectKey , s . id ) ;
Assert . AreEqual ( s . pMask , a . pMask , s . id ) ;
Assert . AreEqual ( s . p1 , a . p1 , 1e-5f , s . id ) ;
Assert . AreEqual ( s . p2 , a . p2 , 1e-5f , s . id ) ;
Assert . AreEqual ( s . p3 , a . p3 , 1e-5f , s . id ) ;
Assert . AreEqual ( s . lvScale , a . lvScale , 1e-5f , s . id ) ;
Assert . AreEqual ( s . needsImpl , a . needsImpl , s . id ) ;
var d = SkillCatalog . Get ( s . id ) ;
Assert . AreEqual ( s . EffectKeys . Length , d . Effects . Count , s . id ) ;
if ( s . EffectKeys . Length > 1 )
for ( int i = 0 ; i < d . Effects . Count ; i + + ) Assert . AreEqual ( s . P ( i ) , d . Effects [ i ] . P , 1e-5f , s . id + " p" + ( i + 1 ) + " = 키 순서" ) ;
}
}
finally
{
OutGameTables . Destroy ( r ) ;
}
// 처리기 없는 키 = 임포트 오류
int si = Array . IndexOf ( OutGameTables . Files , "skills.csv" ) ;
var broken = ( string [ ] ) texts . Clone ( ) ;
broken [ si ] = broken [ si ] . Replace ( "counter_ratio" , "counter_ratio_x" ) ;
var rb = OutGameTables . Parse ( broken ) ;
try
{
Assert . IsTrue ( rb . errors . Exists ( e = > e . Contains ( "처리기 없음" ) ) , "모르는 effect_key → 오류" ) ;
}
finally
{
OutGameTables . Destroy ( rb ) ;
}
}
[Test]
public void LevelScale_Value_Mult_Count_Fixed_AndStar5Passive ( )
{
var cr = SkillCatalog . Get ( "guanyu_p" ) ;
float raw = cr . Effects [ 0 ] . P ;
Assert . AreEqual ( raw * ( 1f + cr . LvScale * 2f ) , Eff ( "guanyu_p" , SkillKeys . CounterRatio , 3 ) . P , 1e-4f , "값 × (1 + lv_scale·(lv− 1))" ) ;
float star = SkillRules . PassiveMult ( Db , Db . Evolve . MaxStar ) ;
Assert . Greater ( star , 1f , "★5 패시브 배율(evolve.csv)" ) ;
Assert . AreEqual ( raw * star , Eff ( "guanyu_p" , SkillKeys . CounterRatio , 1 , star ) . P , 1e-4f , "★5 = 패시브 ×배율" ) ;
var pm = SkillCatalog . Get ( "zhoucang_a" ) ;
float praw = pm . Effects [ 0 ] . P ;
Assert . AreEqual ( 1f + ( praw - 1f ) * ( 1f + pm . LvScale * 4f ) , Eff ( "zhoucang_a" , SkillKeys . PushMult , 5 ) . P , 1e-4f , "배율 = 1 을 넘는 몫만 키움" ) ;
Assert . AreEqual ( praw , Eff ( "zhoucang_a" , SkillKeys . PushMult , 1 , star ) . P , 1e-4f , "액티브에는 ★5 패시브 배율 없음" ) ;
var em = SkillCatalog . Get ( "liubei_a" ) ;
float craw = em . First ( SkillKeys . ExtraMoveCount ) . P ;
Assert . AreEqual ( Mathf . Floor ( craw * ( 1f + em . LvScale * 4f ) + 0.5f ) , Eff ( "liubei_a" , SkillKeys . ExtraMoveCount , 5 ) . P , 1e-4f , "횟수 = 반올림" ) ;
Assert . AreEqual ( SkillCatalog . Get ( "guanyu_a" ) . First ( SkillKeys . OverwatchUnlimited ) . P , Eff ( "guanyu_a" , SkillKeys . OverwatchUnlimited , 5 ) . P , 1e-4f , "켜기 값은 그대로" ) ;
var lb = SkillCatalog . Get ( "lvbu_p" ) . Effects [ 0 ] ;
var lb3 = Eff ( "lvbu_p" , SkillKeys . SlingDistPct , 3 ) ;
Assert . AreEqual ( lb . A2 * ( 1f + SkillCatalog . Get ( "lvbu_p" ) . LvScale * 2f ) , lb3 . A2 , 1e-4f , "절대 가산(p2)도 레벨 계수" ) ;
var hg = SkillCatalog . Get ( "huanggai_a" ) . Effects [ 0 ] ;
Assert . AreEqual ( hg . A2 , Eff ( "huanggai_a" , SkillKeys . HpCostAtk , 5 ) . A2 , 1e-4f , "HP 소모(p2)는 커지지 않음" ) ;
}
// ---------- 보병 ----------
[UnityTest]
public IEnumerator Infantry_Rende_NoSelfBounce_ExtraMoves_CooldownFromTable ( )
{
yield return Fresh ( ) ;
var inf = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 3f , "유비" ) ;
Give ( inf , "liubei" ) ;
Reserve ( inf ) ;
var foe = Spawn ( UnitType . Spear , Faction . Enemy , 0f , 0f , "적창병" ) ;
float contactY = - 3f + ( 3f - CastContact ( inf , foe ) ) ;
var o = new ActionOutcome ( ) ;
yield return Act ( inf , foe . Position , o ) ;
Assert . AreEqual ( "liubei_a" , o . SkillUsed ! = null ? o . SkillUsed . Id : null , "예약 = 다음 행동에 발동" ) ;
Assert . AreEqual ( contactY , inf . Position . y , 0.1f , "self_bounce_mult 0 = 자신은 튕기지 않음" ) ;
Assert . IsTrue ( o . BonusMove ) ;
int n = Mathf . RoundToInt ( Eff ( "liubei_a" , SkillKeys . ExtraMoveCount ) . P ) ;
Assert . AreEqual ( n , o . BonusMoves , "extra_move_count = 추가 이동 n 회" ) ;
var s = bm . Skills . Of ( inf ) ;
Assert . IsFalse ( s . Reserved ) ;
Assert . AreEqual ( bm . TurnNumber + Cd ( "liubei_a" ) , s . ReadyTurn , "쿨 = 사용 턴 + cooldown" ) ;
// 추가 이동 n 회: 첫 명중 → 1 회 부여 + 남은 n− 1
Assert . IsTrue ( bm . Skills . NextBonus ( inf , false , o ) ) ;
for ( int i = 1 ; i < n ; i + + ) Assert . IsTrue ( bm . Skills . NextBonus ( inf , true , new ActionOutcome ( ) ) , "남은 추가 이동 " + i ) ;
Assert . IsFalse ( bm . Skills . NextBonus ( inf , true , new ActionOutcome ( ) ) , "n 회 뒤 끝" ) ;
}
[UnityTest]
public IEnumerator Infantry_PushMult_KnockbackTaken_DmgPct_ExtraMoveAttack ( )
{
yield return Fresh ( ) ;
// 청룡도 지기(push_mult): 밀리는 거리 = 남은 힘 × collisionPushRatio × p
var a = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 3f , "주창" ) ;
Give ( a , "zhoucang" ) ;
Reserve ( a ) ;
var foe = Spawn ( UnitType . Spear , Faction . Enemy , 0f , 0f , "적" ) ;
float remaining = CastContact ( a , foe ) ;
var o = new ActionOutcome ( ) ;
yield return Act ( a , foe . Position , o ) ;
Assert . AreEqual ( remaining * cfg . collisionPushRatio * Eff ( "zhoucang_a" , SkillKeys . PushMult ) . P , foe . Position . y , 0.12f , "push_mult" ) ;
// 거구(knockback_taken_pct): 부딪힌 쪽이 밀리는 거리 × (1 + p)
yield return Fresh ( ) ;
var b = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 3f , "보병" ) ;
var xu = Spawn ( UnitType . Spear , Faction . Enemy , 0f , 0f , "허저" ) ;
Give ( xu , "xuchu" ) ;
float rem2 = CastContact ( b , xu ) ;
var o2 = new ActionOutcome ( ) ;
yield return Act ( b , xu . Position , o2 ) ;
Assert . AreEqual ( rem2 * cfg . collisionPushRatio * ( 1f + Eff ( "xuchu_p" , SkillKeys . KnockbackTakenPct ) . P ) , xu . Position . y , 0.12f , "knockback_taken_pct" ) ;
// 강동의 호랑이(dmg_pct · extra_move_attack): 피해 × (1 + p) · 명중 시 추가 이동에서 공격 가능
yield return Fresh ( ) ;
var sj = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 3f , "손견" ) ;
var plain = Spawn ( UnitType . Infantry , Faction . Player , 6f , - 3f , "보병" ) ;
var d = Spawn ( UnitType . Spear , Faction . Enemy , 0f , 0f , "적" ) ;
Give ( sj , "sunjian" ) ;
Arm ( sj , true ) ;
Assert . AreEqual ( Expect ( plain . Atk , cfg . Matchup ( UnitType . Infantry , UnitType . Spear ) , d . Def , 1f + Eff ( "sunjian_a" , SkillKeys . DmgPct ) . P ) , Dmg ( sj , d ) , 1 , "dmg_pct" ) ;
Arm ( sj , false ) ;
Assert . AreEqual ( Dmg ( plain , d ) , Dmg ( sj , d ) , "예약 효과는 행동 중에만" ) ;
Reserve ( sj ) ;
var o3 = new ActionOutcome ( ) ;
yield return Act ( sj , d . Position , o3 ) ;
Assert . IsTrue ( o3 . BonusMove & & o3 . BonusAttack , "extra_move_attack" ) ;
Assert . IsTrue ( bm . Skills . NextBonus ( sj , false , o3 ) ) ;
Assert . IsTrue ( bm . Skills . BonusCanAttack ( sj ) , "추가 이동 = 공격 가능" ) ;
}
[UnityTest]
public IEnumerator Infantry_Yujin_PushedAllyAttacksTwice_Liubei_PushedAttackBonus_Gaoshun_Collision ( )
{
yield return Fresh ( ) ;
// 군령(pushed_ally_attack_count): 알까기로 밀린 아군이 적과 닿으면 n 회 공격
var a = Spawn ( UnitType . Infantry , Faction . Player , - 3f , - 2f , "우금" ) ;
var b = Spawn ( UnitType . Spear , Faction . Player , 0f , - 2f , "창병B" ) ;
Give ( a , "yujin" ) ;
Reserve ( a ) ;
float pushX = CastContact ( a , b ) ;
var foe = Spawn ( UnitType . Infantry , Faction . Enemy , pushX , - 2f + b . Radius + 0.45f + 0.05f , "적" ) ;
foe . MaxHp = foe . Hp = 10000 ;
var o = new ActionOutcome ( ) ;
yield return Act ( a , new Vector2 ( 0f , - 2f ) , o ) ;
Assert . AreEqual ( Mathf . RoundToInt ( Eff ( "yujin_a" , SkillKeys . PushedAllyAttackCount ) . P ) , o . Notes . Count , "pushed_ally_attack_count" ) ;
// 도원의 맹약(pushed_ally_attack_pct · 같은 편 전원): 밀린 아군 공격 피해 × (1 + p)
yield return Fresh ( ) ;
var lb = Spawn ( UnitType . Infantry , Faction . Player , - 6f , - 8f , "유비" ) ;
Give ( lb , "liubei" ) ;
var ally = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 2f , "보병" ) ;
var en = Spawn ( UnitType . Infantry , Faction . Enemy , 6f , 6f , "적" ) ;
float p = Eff ( "liubei_p" , SkillKeys . PushedAllyAttackPct ) . P ;
Assert . AreEqual ( p , SkillMods . Sum ( ally , SkillKeys . PushedAllyAttackPct ) , 1e-5f , "같은 편 = 적용" ) ;
Assert . AreEqual ( 0f , SkillMods . Sum ( en , SkillKeys . PushedAllyAttackPct ) , 1e-5f , "상대 = 미적용" ) ;
// 함진영(push_mult p2): 밀린 적이 벽·유닛에 막히면 알까기 피해 × p2 추가
yield return Fresh ( ) ;
var gs = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 3f , "고순" ) ;
Give ( gs , "gaoshun" ) ;
Reserve ( gs ) ;
var f1 = Spawn ( UnitType . Spear , Faction . Enemy , 0f , 0f , "적1" ) ;
var f2 = Spawn ( UnitType . Spear , Faction . Enemy , 0f , f1 . Radius * 2f + 0.01f , "적2" ) ;
f1 . MaxHp = f1 . Hp = 10000 ;
var o2 = new ActionOutcome ( ) ;
yield return Act ( gs , f1 . Position , o2 ) ;
float bonus = Eff ( "gaoshun_a" , SkillKeys . PushMult ) . A2 ;
int total = f1 . MaxHp - f1 . Hp ;
Assert . AreEqual ( total , o2 . DamageDealt , "충돌 추가 피해도 집계" ) ;
int impact = Mathf . RoundToInt ( total / ( 1f + bonus ) ) ;
Assert . AreEqual ( Mathf . Max ( 1 , Mathf . RoundToInt ( impact * bonus ) ) , total - impact , 2 , "막혀서 충돌 = 알까기 피해 × p2" ) ;
}
// ---------- 기병 ----------
[UnityTest]
public IEnumerator Cavalry_NoCounter_ReturnOnMiss_Knockback_Repeat_PerHit_MinPierce ( )
{
yield return Fresh ( ) ;
// 천하무쌍(pierce_dmg_mult · pierce_no_counter)
var lv = Spawn ( UnitType . Cavalry , Faction . Player , 0f , - 4f , "여포" ) ;
Give ( lv , "lvbu" ) ;
Reserve ( lv ) ;
var e1 = Spawn ( UnitType . Spear , Faction . Enemy , 0f , - 1.5f , "적창병" ) ;
e1 . MaxHp = e1 . Hp = 10000 ;
var o = new ActionOutcome ( ) ;
yield return Act ( lv , new Vector2 ( 0f , 1.5f ) , o ) ;
Assert . Less ( e1 . Hp , e1 . MaxHp ) ;
Assert . AreEqual ( 0 , o . CounterTaken , "pierce_no_counter = 살아남은 근접 적도 반격 없음" ) ;
Arm ( lv , true ) ;
Assert . AreEqual ( Eff ( "lvbu_a" , SkillKeys . PierceDmgMult ) . P , SkillMods . PierceDmgMult ( lv , 1 ) , 1e-5f , "pierce_dmg_mult" ) ;
Arm ( lv , false ) ;
// 단기구주(pierce_return_on_miss) + 상산의 창(pierce_knockback_mult)
yield return Fresh ( ) ;
var zy = Spawn ( UnitType . Cavalry , Faction . Player , 0f , - 4f , "조운" ) ;
Give ( zy , "zhaoyun" ) ;
Reserve ( zy ) ;
var e2 = Spawn ( UnitType . Infantry , Faction . Enemy , 0.3f , - 1.5f , "적" ) ;
e2 . MaxHp = e2 . Hp = 10000 ;
var o2 = new ActionOutcome ( ) ;
yield return Act ( zy , new Vector2 ( 0f , 2f ) , o2 ) ;
Assert . IsTrue ( o2 . Returned , "빗나가도 출발점 복귀" ) ;
Assert . Less ( Vector2 . Distance ( zy . Position , new Vector2 ( 0f , - 4f ) ) , 0.05f ) ;
Assert . AreEqual ( 0 , o2 . CounterTaken , "단기구주 = 반격 없음" ) ;
var disp = o2 . Displaced . Find ( x = > x . Unit = = e2 ) ;
Assert . IsNotNull ( disp . Unit ) ;
Assert . AreEqual ( cfg . cavalryKnockback * Eff ( "zhaoyun_p" , SkillKeys . PierceKnockbackMult ) . P , Vector2 . Distance ( disp . From , disp . To ) , 0.05f , "pierce_knockback_mult" ) ;
// 위진소요(pierce_repeat) + 팔백 돌격(pierce_dmg_pct_per_hit)
yield return Fresh ( ) ;
var zl = Spawn ( UnitType . Cavalry , Faction . Player , 0f , - 7f , "장료" ) ;
Give ( zl , "zhangliao" ) ;
Reserve ( zl ) ;
var t1 = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , - 4f , "적1" ) ;
var t2 = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , - 1.2f , "적2" ) ;
t1 . MaxHp = t1 . Hp = t2 . MaxHp = t2 . Hp = 10000 ;
var o3 = new ActionOutcome ( ) ;
yield return Act ( zl , t1 . Position , o3 ) ;
Assert . Less ( t2 . Hp , t2 . MaxHp , "명중 → 같은 방향 한 번 더 관통" ) ;
Assert . IsFalse ( o3 . Returned , "복귀 대신 추가 관통" ) ;
Assert . AreEqual ( t1 . MaxHp - t1 . Hp , o3 . DamageDealt - ( t2 . MaxHp - t2 . Hp ) , "앞서 관통한 적은 다시 맞지 않음" ) ;
var ps = Eff ( "zhangliao_p" , SkillKeys . PierceDmgPctPerHit ) ;
int hits = bm . Skills . Of ( zl ) . PierceHits ;
Assert . AreEqual ( 2 , hits , "관통한 적 누적" ) ;
Assert . AreEqual ( 1f + Mathf . Min ( ps . P * hits , ps . A2 ) , SkillMods . AttackMult ( zl ) , 1e-4f , "pierce_dmg_pct_per_hit(최대 p2)" ) ;
// 만인지적(pierce_dmg_mult p2 = 최소 관통 수)
yield return Fresh ( ) ;
var zf = Spawn ( UnitType . Cavalry , Faction . Player , 0f , - 4f , "장비" ) ;
Give ( zf , "zhangfei" ) ;
var mp = Eff ( "zhangfei_p" , SkillKeys . PierceDmgMult ) ;
int need = Mathf . RoundToInt ( mp . A2 ) ;
Assert . AreEqual ( 1f , SkillMods . PierceDmgMult ( zf , need - 1 ) , 1e-5f , "최소 관통 수 미만" ) ;
Assert . AreEqual ( mp . P , SkillMods . PierceDmgMult ( zf , need ) , 1e-5f , "최소 관통 수 이상" ) ;
}
// ---------- 창병 · 견제 ----------
int StrikeHi ( Unit spear , Unit mover )
{
int one = Combat . OverwatchDamage ( cfg , spear , mover , new System . Random ( Seed ) ) ;
return Mathf . CeilToInt ( one / ( 1f - cfg . damageVariance ) * ( 1f + cfg . damageVariance ) ) + 1 ;
}
int StrikeLo ( Unit spear , Unit mover )
{
int one = Combat . OverwatchDamage ( cfg , spear , mover , new System . Random ( Seed ) ) ;
return Mathf . FloorToInt ( one / ( 1f + cfg . damageVariance ) * ( 1f - cfg . damageVariance ) ) - 1 ;
}
[UnityTest]
public IEnumerator Spear_OverwatchSkills_Radius_Unlimited_Count_DmgMult_HealOnKill ( )
{
yield return Fresh ( ) ;
// 청룡언월(overwatch_radius_mult · overwatch_unlimited): 명중 → 대기 반경 × p · 반경을 스친 구간마다 + 밀린 적도
var gy = Spawn ( UnitType . Spear , Faction . Player , 0f , - 3f , "관우" ) ;
Give ( gy , "guanyu" ) ;
Reserve ( gy ) ;
var foe = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 0f , "적" ) ;
var o = new ActionOutcome ( ) ;
yield return Act ( gy , foe . Position , o ) ;
Assert . IsTrue ( o . Overwatch & & gy . Overwatch ) ;
float r = SkillMods . OverwatchRadius ( gy ) ;
Assert . AreEqual ( gy . AttackRange * Eff ( "guanyu_a" , SkillKeys . OverwatchRadiusMult ) . P , r , 1e-4f , "overwatch_radius_mult" ) ;
Assert . IsTrue ( SkillMods . OverwatchUnlimited ( gy ) ) ;
var mover = Spawn ( UnitType . Infantry , Faction . Enemy , gy . Position . x + r + 3f , gy . Position . y , "이동 적" ) ;
mover . MaxHp = mover . Hp = 10000 ;
var path = new ActionOutcome ( ) ;
Vector2 c = gy . Position ;
path . Path . AddRange ( new [ ] { c + new Vector2 ( r + 2f , 0.3f ) , c + new Vector2 ( 0.5f , 0.3f ) , c + new Vector2 ( r + 2f , 0.6f ) , c + new Vector2 ( 0.5f , 0.6f ) } ) ;
yield return bm . ApplyOverwatch ( mover , path ) ;
int drop = mover . MaxHp - mover . Hp ;
Assert . GreaterOrEqual ( drop , StrikeLo ( gy , mover ) * 2 , "반경을 스친 구간 2 번 = 견제 2 회(무제한)" ) ;
// 밀린 적(Displaced)도 견제
var pushedFoe = Spawn ( UnitType . Infantry , Faction . Enemy , c . x - r - 3f , c . y , "밀린 적" ) ;
pushedFoe . MaxHp = pushedFoe . Hp = 10000 ;
var o2 = new ActionOutcome ( ) ;
o2 . Path . Add ( mover . Position ) ;
o2 . Displaced . Add ( new Displacement { Unit = pushedFoe , From = c + new Vector2 ( - r - 1f , 0f ) , To = c + new Vector2 ( - 0.5f , 0f ) } ) ;
yield return bm . ApplyOverwatch ( mover , o2 ) ;
Assert . Less ( pushedFoe . Hp , pushedFoe . MaxHp , "무제한 = 밀려서 반경을 지난 적도 견제" ) ;
// 후계(overwatch_radius_add) · 삼대 노신(overwatch_count) · 교변(overwatch_dmg_mult)
yield return Fresh ( ) ;
var gp = Spawn ( UnitType . Spear , Faction . Player , - 6f , - 6f , "관평" ) ;
Give ( gp , "guanping" ) ;
gp . SetOverwatch ( true ) ;
Assert . AreEqual ( gp . AttackRange + Eff ( "guanping_p" , SkillKeys . OverwatchRadiusAdd ) . P , SkillMods . OverwatchRadius ( gp ) , 1e-4f , "overwatch_radius_add" ) ;
var cp = Spawn ( UnitType . Spear , Faction . Player , 4f , - 6f , "정보" ) ;
Give ( cp , "chengpu" ) ;
cp . SetOverwatch ( true ) ;
var m2 = Spawn ( UnitType . Infantry , Faction . Enemy , 4f , 2f , "적" ) ;
m2 . MaxHp = m2 . Hp = 10000 ;
var po = new ActionOutcome ( ) ;
po . Path . AddRange ( new [ ] { new Vector2 ( 3f , - 3f ) , new Vector2 ( 3f , - 8f ) } ) ;
yield return bm . ApplyOverwatch ( m2 , po ) ;
int strikes = Mathf . RoundToInt ( Eff ( "chengpu_p" , SkillKeys . OverwatchCount ) . P ) ;
Assert . GreaterOrEqual ( m2 . MaxHp - m2 . Hp , StrikeLo ( cp , m2 ) * strikes , "overwatch_count = 지나간 적 1기당 n 회" ) ;
Assert . LessOrEqual ( m2 . MaxHp - m2 . Hp , StrikeHi ( cp , m2 ) * strikes ) ;
var zh = Spawn ( UnitType . Spear , Faction . Player , 8f , 8f , "장합" ) ;
Give ( zh , "zhanghe" ) ;
var plainSpear = Spawn ( UnitType . Spear , Faction . Player , - 8f , 8f , "창병" ) ;
int baseOw = Combat . OverwatchDamage ( cfg , plainSpear , m2 , new System . Random ( Seed ) ) ;
int zhOw = Combat . OverwatchDamage ( cfg , zh , m2 , new System . Random ( Seed ) ) ;
Assert . AreEqual ( baseOw * Eff ( "zhanghe_p" , SkillKeys . OverwatchDmgMult ) . P , zhOw , 1.01f , "overwatch_dmg_mult" ) ;
// 하북 명장(heal_on_overwatch_kill): 견제로 쓰러뜨리면 최대 HP × p 회복
yield return Fresh ( ) ;
var yl = Spawn ( UnitType . Spear , Faction . Player , 0f , - 3f , "안량" ) ;
Give ( yl , "yanliang" ) ;
yl . SetOverwatch ( true ) ;
yl . Hp = yl . MaxHp / 2 ;
int hp0 = yl . Hp ;
var weak = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 1f , "적" ) ;
weak . Hp = 1 ;
var ko = new ActionOutcome ( ) ;
ko . Path . AddRange ( new [ ] { new Vector2 ( - 1f , - 1.5f ) , new Vector2 ( 1f , - 1.5f ) } ) ;
yield return bm . ApplyOverwatch ( weak , ko ) ;
Assert . IsFalse ( weak . IsAlive ) ;
Assert . AreEqual ( Mathf . Min ( yl . MaxHp , hp0 + Mathf . Max ( 1 , Mathf . RoundToInt ( yl . MaxHp * Eff ( "yanliang_p" , SkillKeys . HealOnOverwatchKill ) . P ) ) ) , yl . Hp , "heal_on_overwatch_kill" ) ;
}
[UnityTest]
public IEnumerator Spear_HitTwo_OnPushAllyAttacks_ChengpuAdvance ( )
{
yield return Fresh ( ) ;
2026-11-05 15:22:54 +00:00
// 비도(spear_hit_two · 축융 · 기령은 영웅 풀 제외 PD #1096): 밀며 전진 끝에 닿은 적까지 최대 n 기 동시 피해
var jl = Spawn ( UnitType . Spear , Faction . Player , 0f , - 4f , "축융" ) ;
Give ( jl , "zhurong" ) ;
2026-11-05 14:31:27 +00:00
Reserve ( jl ) ;
var f1 = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , - 1f , "적1" ) ;
var f2 = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , - 1f + f1 . Radius * 2f + 0.03f , "적2" ) ;
f1 . MaxHp = f1 . Hp = f2 . MaxHp = f2 . Hp = 10000 ;
var o = new ActionOutcome ( ) ;
yield return Act ( jl , new Vector2 ( 0f , - 1f ) , o ) ;
Assert . Less ( f1 . Hp , f1 . MaxHp ) ;
Assert . Less ( f2 . Hp , f2 . MaxHp , "spear_hit_two = 닿은 둘째 적도 피해" ) ;
Assert . Contains ( f2 , o . Attacked ) ;
2026-11-05 15:22:54 +00:00
// 대장군(pushed_ally_attack_on_push)은 기령 제외로 표에서 쓰지 않음(처리기만 유지 · PD #1096)
2026-11-05 14:31:27 +00:00
// 철척사모(push_mult · 창병): 밀며 전진 거리 × p
yield return Fresh ( ) ;
var ch = Spawn ( UnitType . Spear , Faction . Player , 0f , - 3f , "정보" ) ;
Give ( ch , "chengpu" ) ;
Reserve ( ch ) ;
var t = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 0f , "적" ) ;
t . MaxHp = t . Hp = 10000 ;
float rem = CastContact ( ch , t ) ;
var o3 = new ActionOutcome ( ) ;
yield return Act ( ch , t . Position , o3 ) ;
Assert . AreEqual ( rem * Eff ( "chengpu_a" , SkillKeys . PushMult ) . P , t . Position . y , 0.12f , "push_mult(창병 = 밀며 전진 거리)" ) ;
}
// ---------- 궁병 · 책사 ----------
[UnityTest]
public IEnumerator Ranged_Range_TeamRange_MeleePenaltyOff_PassAllies_CritAlways ( )
{
yield return Fresh ( ) ;
var ts = Spawn ( UnitType . Archer , Faction . Player , - 6f , - 8f , "태사자" ) ;
Give ( ts , "taishici" ) ;
Arm ( ts , true ) ;
Assert . AreEqual ( ts . AttackRange * Eff ( "taishici_a" , SkillKeys . RangeMult ) . P , SkillMods . Range ( ts ) , 1e-4f , "range_mult" ) ;
Arm ( ts , false ) ;
var xs = Spawn ( UnitType . Strategist , Faction . Player , - 3f , - 8f , "서서" ) ;
Give ( xs , "xushu" ) ;
Assert . AreEqual ( xs . AttackRange + Eff ( "xushu_p" , SkillKeys . RangeAdd ) . P , SkillMods . Range ( xs ) , 1e-4f , "range_add" ) ;
var zy = Spawn ( UnitType . Strategist , Faction . Player , 0f , - 8f , "주유" ) ;
Give ( zy , "zhouyu" ) ;
var archer = Spawn ( UnitType . Archer , Faction . Player , 3f , - 8f , "궁병" ) ;
var inf = Spawn ( UnitType . Infantry , Faction . Player , 6f , - 8f , "보병" ) ;
var enemyArcher = Spawn ( UnitType . Archer , Faction . Enemy , 6f , 8f , "적궁병" ) ;
float tr = Eff ( "zhouyu_p" , SkillKeys . TeamRangeAdd ) . P ;
Assert . AreEqual ( archer . AttackRange + tr , SkillMods . Range ( archer ) , 1e-4f , "team_range_add = 아군 궁병" ) ;
Assert . AreEqual ( 0f , SkillMods . Sum ( inf , SkillKeys . TeamRangeAdd ) , 1e-5f , "보병 제외" ) ;
Assert . AreEqual ( enemyArcher . AttackRange , SkillMods . Range ( enemyArcher ) , 1e-4f , "상대 제외" ) ;
// 돌파(melee_penalty_off): 근접 전투 패널티 없음(주는·받는 피해)
var foe = Spawn ( UnitType . Infantry , Faction . Enemy , 8f , 8f , "적보병" ) ;
float m = cfg . Matchup ( UnitType . Archer , UnitType . Infantry ) ;
Assert . AreEqual ( Expect ( ts . Atk , m , foe . Def , 1f ) , Dmg ( ts , foe , true ) , 1 , "주는 근접 피해 패널티 없음" ) ;
Assert . AreEqual ( Expect ( archer . Atk , m , foe . Def , cfg . archerMeleeDealtRatio ) , Dmg ( archer , foe , true ) , 1 , "일반 궁병은 패널티" ) ;
float m2 = cfg . Matchup ( UnitType . Infantry , UnitType . Archer ) ;
Assert . AreEqual ( Expect ( foe . Atk , m2 , ts . Def , 1f ) , Dmg ( foe , ts , true ) , 1 , "받는 근접 피해 패널티 없음" ) ;
// 백발백중(arrow_pass_allies · arrow_crit_always): 사선의 아군 통과 · 목표점 명중 없이도 크리
yield return Fresh ( ) ;
var hz = Spawn ( UnitType . Archer , Faction . Player , 0f , - 4f , "황충" ) ;
Give ( hz , "huangzhong" ) ;
Reserve ( hz ) ;
var block = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 2.6f , "아군" ) ;
var target = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , - 0.6f , "적" ) ;
target . MaxHp = target . Hp = 10000 ;
var o = new ActionOutcome ( ) ;
yield return Act ( hz , new Vector2 ( 0f , - 3.2f ) , o ) ;
Assert . AreEqual ( block . MaxHp , block . Hp , "arrow_pass_allies = 사선의 아군 무피해" ) ;
Assert . Less ( target . Hp , target . MaxHp ) ;
Assert . IsTrue ( o . Crit , "arrow_crit_always = 목표점 명중 없이 크리" ) ;
}
[UnityTest]
public IEnumerator Strategist_SplashRadiusRatio_AllyZero_Ganning_Teleport ( )
{
yield return Fresh ( ) ;
// 화공(splash_radius_mult · splash_ratio)
var zy = Spawn ( UnitType . Strategist , Faction . Player , 0f , - 4f , "주유" ) ;
Give ( zy , "zhouyu" ) ;
Reserve ( zy ) ;
var c = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , - 1f , "중심" ) ;
float mult = Eff ( "zhouyu_a" , SkillKeys . SplashRadiusMult ) . P ;
float far = cfg . strategistSplashRadius * ( 1f + mult ) * 0.5f + 0.45f ; // 기본 반경 밖 · 화공 반경 안
var side = Spawn ( UnitType . Infantry , Faction . Enemy , far , - 1f , "곁" ) ;
var plan = bm . Resolver . PreviewShot ( zy , c . Position , false ) ;
Assert . IsNotNull ( plan ) ;
Assert . AreEqual ( cfg . strategistSplashRadius * mult , plan . SplashRadius , 1e-4f , "splash_radius_mult" ) ;
Assert . Contains ( side , plan . Splash , "커진 광역에 들어옴" ) ;
Arm ( zy , true ) ;
Assert . AreEqual ( Eff ( "zhouyu_a" , SkillKeys . SplashRatio ) . P , SkillMods . SplashRatio ( zy , cfg ) , 1e-4f , "splash_ratio" ) ;
Arm ( zy , false ) ;
Assert . AreEqual ( cfg . strategistSplashRatio , SkillMods . SplashRatio ( zy , cfg ) , 1e-4f , "예약 없으면 자산 값" ) ;
// 팔문 간파(splash_ally_ratio): 광역의 아군 피해 = 0
yield return Fresh ( ) ;
var xs = Spawn ( UnitType . Strategist , Faction . Player , 0f , - 4f , "서서" ) ;
Give ( xs , "xushu" ) ;
Reserve ( xs ) ;
var en = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , - 0.6f , "적" ) ;
en . MaxHp = en . Hp = 10000 ;
var ally = Spawn ( UnitType . Infantry , Faction . Player , 1.0f , - 0.6f , "아군" ) ;
var o = new ActionOutcome ( ) ;
yield return Act ( xs , en . Position , o ) ;
Assert . Less ( en . Hp , en . MaxHp ) ;
Assert . AreEqual ( ally . MaxHp , ally . Hp , "splash_ally_ratio = 0" ) ;
// 백기습영(teleport · arrow_crit_always): 사거리 밖이어도 목표점까지 이동 후 사격 · 크리
yield return Fresh ( ) ;
var gn = Spawn ( UnitType . Archer , Faction . Player , 0f , - 9f , "감녕" ) ;
Give ( gn , "ganning" ) ;
var far2 = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 0.5f , "먼 적" ) ;
far2 . MaxHp = far2 . Hp = 10000 ;
Assert . Greater ( Gap ( gn , far2 ) , SkillMods . Range ( gn ) + gn . ArcherMoveAllow , "출발점에서는 사거리 밖" ) ;
Reserve ( gn ) ;
Vector2 to = gn . Position + Vector2 . up * SkillMods . SlingMax ( gn ) ;
var o2 = new ActionOutcome ( ) ;
yield return Act ( gn , to , o2 ) ;
Assert . Greater ( gn . Position . y , - 9f + SkillMods . SlingMax ( gn ) - 0.3f , "목표점까지 이동" ) ;
Assert . IsTrue ( o2 . Fired & & far2 . Hp < far2 . MaxHp , "이동 후 사격" ) ;
Assert . IsTrue ( o2 . Crit , "명중 시 크리" ) ;
}
// ---------- 피해 · 반격 · 공격력 ----------
[UnityTest]
public IEnumerator Damage_MatchupFloor_HpBelow_Turn1_General_Taken_Archer_Counter_Death_Full_HpMax ( )
{
yield return Fresh ( ) ;
var cav = Spawn ( UnitType . Cavalry , Faction . Enemy , 6f , 8f , "적기병" ) ;
var xh = Spawn ( UnitType . Infantry , Faction . Player , - 6f , - 8f , "서황" ) ;
var plain = Spawn ( UnitType . Infantry , Faction . Player , - 3f , - 8f , "보병" ) ;
Give ( xh , "xuhuang" ) ;
Arm ( xh , true ) ;
float floor = Eff ( "xuhuang_a" , SkillKeys . MatchupFloor ) . P ;
Assert . AreEqual ( Expect ( xh . Atk , Mathf . Max ( cfg . Matchup ( UnitType . Infantry , UnitType . Cavalry ) , floor ) , cav . Def , 1f ) , Dmg ( xh , cav ) , 1 , "matchup_floor" ) ;
Arm ( xh , false ) ;
Assert . AreEqual ( Dmg ( plain , cav ) , Dmg ( xh , cav ) ) ;
// 노익장(dmg_pct_hp_below): HP 임계 이하 공 +p
var hz = Spawn ( UnitType . Archer , Faction . Player , 0f , - 8f , "황충" ) ;
Give ( hz , "huangzhong" ) ;
var hb = Eff ( "huangzhong_p" , SkillKeys . DmgPctHpBelow ) ;
Assert . AreEqual ( 1f , SkillMods . AttackMult ( hz ) , 1e-5f , "HP 가득 = 없음" ) ;
hz . Hp = Mathf . FloorToInt ( hz . MaxHp * hb . A2 ) ;
Assert . AreEqual ( 1f + hb . P , SkillMods . AttackMult ( hz ) , 1e-5f , "dmg_pct_hp_below" ) ;
// 선봉(dmg_pct_turn1)
var hx = Spawn ( UnitType . Infantry , Faction . Player , 3f , - 8f , "화웅" ) ;
Give ( hx , "huaxiong" ) ;
bm . SetTurnForTest ( 1 ) ;
Assert . AreEqual ( 1f + Eff ( "huaxiong_p" , SkillKeys . DmgPctTurn1 ) . P , SkillMods . AttackMult ( hx ) , 1e-5f , "dmg_pct_turn1" ) ;
bm . SetTurnForTest ( 2 ) ;
Assert . AreEqual ( 1f , SkillMods . AttackMult ( hx ) , 1e-5f , "2턴 = 없음" ) ;
// 파로(dmg_vs_general_mult p2 1 = 보스만) · 참장(p2 0 = 무장 전체)
var sj = Spawn ( UnitType . Infantry , Faction . Player , 6f , - 8f , "손견" ) ;
Give ( sj , "sunjian" ) ;
var boss = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 8f , "보스" , true ) ;
var gen = Spawn ( UnitType . Infantry , Faction . Enemy , 3f , 8f , "장수" ) ;
bm . Skills . MarkGeneral ( gen ) ;
float im = cfg . Matchup ( UnitType . Infantry , UnitType . Infantry ) ;
Assert . AreEqual ( Expect ( sj . Atk , im , boss . Def , Eff ( "sunjian_p" , SkillKeys . DmgVsGeneralMult ) . P ) , Dmg ( sj , boss ) , 1 , "보스 = 배율" ) ;
Assert . AreEqual ( Expect ( sj . Atk , im , gen . Def , 1f ) , Dmg ( sj , gen ) , 1 , "보스만 = 일반 장수 제외" ) ;
Arm ( hx , true ) ;
Assert . AreEqual ( Expect ( hx . Atk , im , gen . Def , Eff ( "huaxiong_a" , SkillKeys . DmgVsGeneralMult ) . P ) , Dmg ( hx , gen ) , 1 , "참장 = 무장 전체" ) ;
Arm ( hx , false ) ;
// 수성(dmg_taken_pct) · 창천이사(dmg_taken_from_archer_pct)
var cr = Spawn ( UnitType . Infantry , Faction . Player , - 6f , - 5f , "조인" ) ;
Give ( cr , "caoren" ) ;
Assert . AreEqual ( Expect ( gen . Atk , im , cr . Def , 1f + Eff ( "caoren_p" , SkillKeys . DmgTakenPct ) . P ) , Dmg ( gen , cr ) , 1 , "dmg_taken_pct" ) ;
var zj = Spawn ( UnitType . Infantry , Faction . Player , - 3f , - 5f , "장각" ) ;
Give ( zj , "zhangjiao" ) ;
var ea = Spawn ( UnitType . Archer , Faction . Enemy , - 3f , 8f , "적궁병" ) ;
float am = cfg . Matchup ( UnitType . Archer , UnitType . Infantry ) ;
Assert . AreEqual ( Expect ( ea . Atk , am , zj . Def , 1f + Eff ( "zhangjiao_p" , SkillKeys . DmgTakenFromArcherPct ) . P ) , Dmg ( ea , zj , false ) , 1 , "적 궁병 피해 − " ) ;
Assert . AreEqual ( Expect ( gen . Atk , im , zj . Def , 1f ) , Dmg ( gen , zj ) , 1 , "궁병 아님 = 그대로" ) ;
// 무성(counter_ratio) · 진중(p2 1 = 밀렸을 때만) · 군율(counter_taken_pct) · 금범적(− 1 = 반격 없음)
var gy = Spawn ( UnitType . Spear , Faction . Player , 0f , - 5f , "관우" ) ;
Give ( gy , "guanyu" ) ;
int baseGy = Combat . Damage ( cfg , gy , gen , false , new System . Random ( Seed ) , true ) ;
Assert . AreEqual ( Mathf . Max ( 1 , Mathf . RoundToInt ( baseGy * Eff ( "guanyu_p" , SkillKeys . CounterRatio ) . P ) ) , Combat . CounterDamage ( cfg , gy , gen , new System . Random ( Seed ) ) , "counter_ratio" ) ;
var yj = Spawn ( UnitType . Infantry , Faction . Player , 3f , - 5f , "우금" ) ;
Give ( yj , "yujin" ) ;
int baseYj = Combat . Damage ( cfg , yj , gen , false , new System . Random ( Seed ) , true ) ;
Assert . AreEqual ( Mathf . Max ( 1 , Mathf . RoundToInt ( baseYj * cfg . counterRatio ) ) , Combat . CounterDamage ( cfg , yj , gen , new System . Random ( Seed ) , false ) , "안 밀림 = 기본 반격" ) ;
Assert . AreEqual ( Mathf . Max ( 1 , Mathf . RoundToInt ( baseYj * Eff ( "yujin_p" , SkillKeys . CounterRatio ) . P ) ) , Combat . CounterDamage ( cfg , yj , gen , new System . Random ( Seed ) , true ) , "밀렸을 때 = 반격 배율" ) ;
int baseC = Combat . Damage ( cfg , gen , xh , false , new System . Random ( Seed ) , true ) ;
Assert . AreEqual ( Mathf . Max ( 1 , Mathf . RoundToInt ( baseC * cfg . counterRatio * ( 1f + Eff ( "xuhuang_p" , SkillKeys . CounterTakenPct ) . P ) ) ) , Combat . CounterDamage ( cfg , gen , xh , new System . Random ( Seed ) ) , "counter_taken_pct" ) ;
var gn = Spawn ( UnitType . Archer , Faction . Player , 6f , - 5f , "감녕" ) ;
Give ( gn , "ganning" ) ;
Assert . AreEqual ( 0 , Combat . CounterDamage ( cfg , gen , gn , new System . Random ( Seed ) ) , "금범적 = 근접 반격 없음" ) ;
// 복수(on_ally_death_atk) · 패자(team_atk_pct_full · 출전 인원 조건)
yield return Fresh ( ) ;
var wc = Spawn ( UnitType . Cavalry , Faction . Player , 0f , - 8f , "문추" ) ;
Give ( wc , "wenchou" ) ;
var dead = Spawn ( UnitType . Infantry , Faction . Player , 3f , - 8f , "전사자" ) ;
Assert . AreEqual ( 1f , SkillMods . AttackMult ( wc ) , 1e-5f ) ;
dead . TakeDamage ( dead . Hp ) ;
Assert . AreEqual ( 1f + Eff ( "wenchou_p" , SkillKeys . OnAllyDeathAtk ) . P , SkillMods . AttackMult ( wc ) , 1e-5f , "on_ally_death_atk" ) ;
yield return Fresh ( ) ;
var cc = Spawn ( UnitType . Infantry , Faction . Player , - 6f , - 8f , "조조" ) ;
Give ( cc , "caocao" ) ;
var fe = Eff ( "caocao_p" , SkillKeys . TeamAtkPctFull ) ;
int needN = Mathf . RoundToInt ( fe . A2 ) ;
var mates = new List < Unit > ( ) ;
for ( int i = 1 ; i < needN ; i + + ) mates . Add ( Spawn ( UnitType . Infantry , Faction . Player , - 6f + i * 2.5f , - 8f , "아군" + i ) ) ;
bm . Skills . BeginBattle ( ) ;
Assert . AreEqual ( 1f + fe . P , SkillMods . AttackMult ( mates [ 0 ] ) , 1e-5f , "출전 n 인 = 아군 전원 공 +p" ) ;
mates [ 0 ] . gameObject . SetActive ( false ) ;
bm . ClearUnits ( ) ;
yield return null ;
var cc2 = Spawn ( UnitType . Infantry , Faction . Player , - 6f , - 8f , "조조" ) ;
Give ( cc2 , "caocao" ) ;
var mate = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 8f , "아군" ) ;
bm . Skills . BeginBattle ( ) ;
Assert . AreEqual ( 1f , SkillMods . AttackMult ( mate ) , 1e-5f , "인원 미달 = 없음" ) ;
// 산채 체력(hp_max_pct)
var zc = Spawn ( UnitType . Infantry , Faction . Player , 6f , - 8f , "주창" ) ;
int baseHp = zc . MaxHp ;
Give ( zc , "zhoucang" ) ;
Assert . AreEqual ( Mathf . RoundToInt ( baseHp * ( 1f + Eff ( "zhoucang_p" , SkillKeys . HpMaxPct ) . P ) ) , zc . MaxHp , "hp_max_pct" ) ;
Assert . AreEqual ( zc . MaxHp , zc . Hp ) ;
}
// ---------- 즉시형(needs_impl 처리기 포함) ----------
[UnityTest]
public IEnumerator Instant_PushFear_Fog_Guard_Aura_TeamTaken_HpCost_SlingTeam_Aggro ( )
{
yield return Fresh ( ) ;
// 장판 일갈(aoe_push_fear): 반경 p2 안 적을 바깥으로 p1 · 이번 적 턴 교전 반응 없음
var zf = Spawn ( UnitType . Cavalry , Faction . Player , 0f , - 3f , "장비" ) ;
Give ( zf , "zhangfei" ) ;
var pf = Eff ( "zhangfei_a" , SkillKeys . AoePushFear ) ;
var near = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , - 3f + 1.5f , "가까운 적" ) ;
var farE = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , - 3f + pf . A2 + 3f , "먼 적" ) ;
float y0 = near . Position . y ;
bm . Sling . Select ( zf ) ;
Assert . IsNotNull ( bm . Hud . PressSkill ( ) ) ;
yield return WaitPhase ( BattlePhase . PlayerTurn ) ;
Assert . AreEqual ( y0 + pf . P , near . Position . y , 0.08f , "바깥으로 p1 밀기" ) ;
Assert . AreEqual ( bm . TurnNumber + Cd ( "zhangfei_a" ) , bm . Skills . Of ( zf ) . ReadyTurn , "즉시형도 쿨" ) ;
Assert . IsFalse ( zf . ActedThisTurn , "행동 소모 없음" ) ;
Assert . IsFalse ( bm . Skills . Suppressed ( near ) , "아군 턴에는 해당 없음" ) ;
bm . ForcePhase ( BattlePhase . EnemyTurn ) ;
Assert . IsTrue ( bm . Skills . Suppressed ( near ) , "이번 적 턴 교전 반응 없음" ) ;
Assert . IsFalse ( bm . Skills . Suppressed ( farE ) , "반경 밖 적은 정상" ) ;
bm . ForcePhase ( BattlePhase . PlayerTurn ) ;
// 태평요술(enemy_aggro_off_turn): 이번 적 턴 적 전원
var zj = Spawn ( UnitType . Infantry , Faction . Player , 4f , - 6f , "장각" ) ;
Give ( zj , "zhangjiao" ) ;
bm . Sling . Select ( zj ) ;
bm . Hud . PressSkill ( ) ;
yield return WaitPhase ( BattlePhase . PlayerTurn ) ;
bm . ForcePhase ( BattlePhase . EnemyTurn ) ;
Assert . IsTrue ( bm . Skills . Suppressed ( farE ) , "안개 = 적 전원" ) ;
bm . ForcePhase ( BattlePhase . PlayerTurn ) ;
// 적 턴을 실제로 돌리면 아무도 움직이지 않고 · 끝나면 효과가 사라진다
zf . MarkActed ( ) ;
zj . MarkActed ( ) ;
Vector2 fp = farE . Position ;
yield return bm . RunEnemyTurn ( ) ;
Assert . AreEqual ( fp , farE . Position , "교전 반응 없음 = 행동 안 함" ) ;
Assert . IsFalse ( bm . Skills . Suppressed ( farE ) , "다음 아군 턴 = 해제" ) ;
// 호위(guard_share) · 철벽(dmg_taken_aura) · 간웅(team_dmg_taken_pct_turn)
yield return Fresh ( ) ;
var gp = Spawn ( UnitType . Spear , Faction . Player , 0f , - 3f , "관평" ) ;
Give ( gp , "guanping" ) ;
var gs = Eff ( "guanping_a" , SkillKeys . GuardShare ) ;
var mate = Spawn ( UnitType . Infantry , Faction . Player , gs . A2 * 0.8f , - 3f , "아군" ) ;
mate . MaxHp = mate . Hp = gp . MaxHp = gp . Hp = 10000 ;
Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 9f , "먼 적" ) ; // 적이 없으면 즉시 발동 뒤 승리 판정
bm . Sling . Select ( gp ) ;
bm . Hud . PressSkill ( ) ;
yield return WaitPhase ( BattlePhase . PlayerTurn ) ;
int share = Mathf . RoundToInt ( 100 * Mathf . Clamp01 ( gs . P ) ) ;
Assert . AreEqual ( 100 , SkillSystem . Deal ( mate , 100 ) , "아군 턴 = 분담 없음" ) ;
bm . ForcePhase ( BattlePhase . EnemyTurn ) ;
int mh = mate . Hp , gh = gp . Hp ;
Assert . AreEqual ( 100 - share , SkillSystem . Deal ( mate , 100 ) , "guard_share = 받는 피해 일부를 관평이" ) ;
Assert . AreEqual ( gh - share , gp . Hp ) ;
Assert . AreEqual ( mh - ( 100 - share ) , mate . Hp ) ;
bm . ForcePhase ( BattlePhase . PlayerTurn ) ;
yield return Fresh ( ) ;
var cr = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 3f , "조인" ) ;
Give ( cr , "caoren" ) ;
var au = Eff ( "caoren_a" , SkillKeys . DmgTakenAura ) ;
var inR = Spawn ( UnitType . Infantry , Faction . Player , au . A2 * 0.8f , - 3f , "곁" ) ;
var outR = Spawn ( UnitType . Infantry , Faction . Player , au . A2 + 3f , - 3f , "먼 아군" ) ;
var hitter = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 8f , "적" ) ;
bm . Sling . Select ( cr ) ;
bm . Hud . PressSkill ( ) ;
yield return WaitPhase ( BattlePhase . PlayerTurn ) ;
Assert . AreEqual ( 1f , SkillMods . TakenMult ( inR , hitter ) , 1e-5f , "아군 턴 = 아직" ) ;
bm . ForcePhase ( BattlePhase . EnemyTurn ) ;
Assert . AreEqual ( 1f + au . P , SkillMods . TakenMult ( inR , hitter ) , 1e-5f , "dmg_taken_aura 반경 안" ) ;
Assert . AreEqual ( 1f , SkillMods . TakenMult ( outR , hitter ) , 1e-5f , "반경 밖" ) ;
bm . ForcePhase ( BattlePhase . PlayerTurn ) ;
var cc = Spawn ( UnitType . Infantry , Faction . Player , - 6f , - 8f , "조조" ) ;
Give ( cc , "caocao" ) ;
bm . Sling . Select ( cc ) ;
bm . Hud . PressSkill ( ) ;
yield return WaitPhase ( BattlePhase . PlayerTurn ) ;
bm . ForcePhase ( BattlePhase . EnemyTurn ) ;
Assert . AreEqual ( 1f + Eff ( "caocao_a" , SkillKeys . TeamDmgTakenPctTurn ) . P , SkillMods . TakenMult ( outR , hitter ) , 1e-5f , "team_dmg_taken_pct_turn" ) ;
bm . ForcePhase ( BattlePhase . PlayerTurn ) ;
// 고육계(hp_cost_atk): HP p2 소모 · 이번 턴 공 +p1 · 적 턴 시작에 끝
yield return Fresh ( ) ;
var hg = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 3f , "황개" ) ;
Give ( hg , "huanggai" ) ;
var hc = Eff ( "huanggai_a" , SkillKeys . HpCostAtk ) ;
Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 9f , "먼 적" ) ;
int hp0 = hg . Hp ;
bm . Sling . Select ( hg ) ;
bm . Hud . PressSkill ( ) ;
yield return WaitPhase ( BattlePhase . PlayerTurn ) ;
Assert . AreEqual ( Mathf . Max ( 1 , hp0 - Mathf . RoundToInt ( hg . MaxHp * hc . A2 ) ) , hg . Hp , "HP 소모" ) ;
Assert . AreEqual ( 1f + hc . P , SkillMods . AttackMult ( hg ) , 1e-5f , "이번 턴 공 +" ) ;
bm . Skills . OnEnemyTurnStart ( ) ;
Assert . AreEqual ( 1f , SkillMods . AttackMult ( hg ) , 1e-5f , "적 턴 시작 = 끝" ) ;
// 동남풍(sling_dist_pct · p3 1 = 아군 전원 · 이번 턴) · 적토마(p2 절대 가산) · 신산(enemy_aggro_add)
yield return Fresh ( ) ;
var zg = Spawn ( UnitType . Strategist , Faction . Player , 0f , - 6f , "제갈량" ) ;
Give ( zg , "zhugeliang" ) ;
var lb = Spawn ( UnitType . Cavalry , Faction . Player , 4f , - 6f , "여포" ) ;
Give ( lb , "lvbu" ) ;
var foot = Spawn ( UnitType . Infantry , Faction . Player , - 4f , - 6f , "보병" ) ;
var en = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 8f , "적" ) ;
var lbp = Eff ( "lvbu_p" , SkillKeys . SlingDistPct ) ;
Assert . AreEqual ( lb . SlingMaxDistance * ( 1f + lbp . P ) + lbp . A2 , SkillMods . SlingMax ( lb ) , 1e-4f , "적토마 = 절대 가산" ) ;
Assert . AreEqual ( Eff ( "zhugeliang_p" , SkillKeys . EnemyAggroAdd ) . P , bm . Skills . AggroAdd ( Faction . Enemy ) , 1e-5f , "신산 = 적 교전 범위 +p" ) ;
Assert . AreEqual ( 0f , bm . Skills . AggroAdd ( Faction . Player ) , 1e-5f ) ;
bm . Sling . Select ( zg ) ;
bm . Hud . PressSkill ( ) ;
yield return WaitPhase ( BattlePhase . PlayerTurn ) ;
var dn = Eff ( "zhugeliang_a" , SkillKeys . SlingDistPct ) ;
Assert . AreEqual ( foot . SlingMaxDistance * ( 1f + dn . P ) + dn . A2 , SkillMods . SlingMax ( foot ) , 1e-4f , "동남풍 = 아군 전원" ) ;
Assert . AreEqual ( en . SlingMaxDistance , SkillMods . SlingMax ( en ) , 1e-4f , "상대 제외" ) ;
bm . Skills . OnEnemyTurnStart ( ) ;
Assert . AreEqual ( foot . SlingMaxDistance , SkillMods . SlingMax ( foot ) , 1e-4f , "이번 턴만" ) ;
}
[UnityTest]
public IEnumerator Instant_Teleport_TapInsideRadius_NoActionUsed_OutsideCancels ( )
{
yield return Fresh ( ) ;
var zh = Spawn ( UnitType . Spear , Faction . Player , 0f , - 5f , "장합" ) ;
Give ( zh , "zhanghe" ) ;
Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 8f , "적" ) ;
float r = Eff ( "zhanghe_a" , SkillKeys . Teleport ) . P ;
bm . Sling . Select ( zh ) ;
bm . Hud . PressSkill ( ) ;
Assert . AreEqual ( zh , bm . Skills . PendingTeleport , "변진 = 지점 탭 대기" ) ;
yield return null ;
Assert . IsTrue ( bm . Sling . TeleportAreaVisible , "반경 원 표시" ) ;
string msg ;
Assert . IsTrue ( bm . Skills . TryTeleportTap ( zh . Position + new Vector2 ( r + 1f , 0f ) , out msg ) ) ;
Assert . IsNull ( bm . Skills . PendingTeleport ) ;
Assert . AreEqual ( new Vector2 ( 0f , - 5f ) , zh . Position , "반경 밖 = 취소" ) ;
Assert . AreEqual ( SkillSystem . ActiveState . Ready , bm . Skills . StateOf ( zh ) , "취소 = 쿨 소모 없음" ) ;
bm . Hud . PressSkill ( ) ;
Vector2 dest = zh . Position + new Vector2 ( r * 0.7f , 0f ) ;
Assert . IsTrue ( bm . Skills . TryTeleportTap ( dest , out msg ) ) ;
yield return WaitPhase ( BattlePhase . PlayerTurn ) ;
Assert . Less ( Vector2 . Distance ( dest , zh . Position ) , 0.01f , "teleport = 반경 안 빈 곳으로 이동" ) ;
Assert . IsFalse ( zh . ActedThisTurn , "행동 소모 없음" ) ;
Assert . AreEqual ( bm . TurnNumber + Cd ( "zhanghe_a" ) , bm . Skills . Of ( zh ) . ReadyTurn ) ;
}
// ---------- 쿨 · 예약 · 취소 · HUD ----------
[UnityTest]
public IEnumerator Active_Reserve_Cancel_UseCooldown_HudButton_PassiveLog ( )
{
yield return Fresh ( ) ;
var gy = Spawn ( UnitType . Spear , Faction . Player , 0f , - 3f , "관우" ) ;
Give ( gy , "guanyu" ) ;
var other = Spawn ( UnitType . Infantry , Faction . Player , - 6f , - 8f , "보병" ) ;
var foe = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 0f , "적" ) ;
foe . MaxHp = foe . Hp = 10000 ;
bm . Sling . Select ( gy ) ;
yield return null ;
Assert . IsTrue ( bm . Hud . SkillButtonVisible , "유닛 선택 → 스킬 버튼" ) ;
StringAssert . Contains ( SkillCatalog . Get ( "guanyu_a" ) . NameKo , bm . Hud . SkillButtonText , "이름" ) ;
Assert . AreEqual ( SkillSystem . ActiveState . Ready , bm . Skills . StateOf ( gy ) , "전투 시작 = 사용 가능" ) ;
bm . Hud . PressSkill ( ) ;
Assert . IsTrue ( bm . Skills . Of ( gy ) . Reserved , "탭 = 예약" ) ;
yield return null ;
Assert . IsTrue ( gy . View . SkillBadgeVisible , "예약 표시" ) ;
bm . Hud . PressSkill ( ) ;
Assert . IsFalse ( bm . Skills . Of ( gy ) . Reserved , "다시 탭 = 취소" ) ;
Assert . AreEqual ( 1 , bm . Skills . Of ( gy ) . ReadyTurn , "취소 = 쿨 소모 없음" ) ;
// 예약 후 쓰지 않고 적 턴 = 예약만 사라짐
bm . Hud . PressSkill ( ) ;
bm . Skills . OnEnemyTurnStart ( ) ;
Assert . IsFalse ( bm . Skills . Of ( gy ) . Reserved ) ;
Assert . AreEqual ( 1 , bm . Skills . Of ( gy ) . ReadyTurn ) ;
// 예약 → 실제 행동(RequestPlayerAction) → 쿨
bm . Sling . Select ( gy ) ;
bm . Hud . PressSkill ( ) ;
Assert . IsTrue ( bm . RequestPlayerAction ( gy , foe . Position ) ) ;
yield return WaitPhase ( BattlePhase . PlayerTurn ) ;
int cd = Cd ( "guanyu_a" ) ;
Assert . AreEqual ( 1 + cd , bm . Skills . Of ( gy ) . ReadyTurn , "사용한 턴 + 쿨" ) ;
StringAssert . Contains ( SkillCatalog . Get ( "guanyu_a" ) . NameKo , bm . LastMessage , "행동 문구에 스킬 이름" ) ;
StringAssert . Contains ( "액티브" , string . Join ( " " , bm . Skills . RecentLog ( 30f , 8 ) . ToArray ( ) ) , "HUD 액티브 한 줄" ) ;
gy . ResetForNewTurn ( ) ;
bm . SetTurnForTest ( cd ) ;
Assert . AreEqual ( SkillSystem . ActiveState . Cooldown , bm . Skills . StateOf ( gy ) , "쿨 중 = 사용 불가" ) ;
Assert . AreEqual ( 1 , bm . Skills . Of ( gy ) . CooldownLeft ( bm . TurnNumber ) ) ;
bm . Hud . PressSkill ( ) ;
Assert . IsFalse ( bm . Skills . Of ( gy ) . Reserved , "쿨 중 탭 = 예약 안 됨" ) ;
bm . SetTurnForTest ( 1 + cd ) ;
Assert . AreEqual ( SkillSystem . ActiveState . Ready , bm . Skills . StateOf ( gy ) , "N 턴 뒤 재사용" ) ;
// 행동 끝난 유닛 = next_action 예약 불가 · 적 유닛 = 버튼은 패시브만
gy . MarkActed ( ) ;
Assert . AreEqual ( SkillSystem . ActiveState . Acted , bm . Skills . StateOf ( gy ) ) ;
var eb = Spawn ( UnitType . Infantry , Faction . Enemy , 6f , 6f , "적 무장" ) ;
Give ( eb , "caoren" ) ;
Assert . AreEqual ( SkillSystem . ActiveState . None , bm . Skills . StateOf ( eb ) , "적 = 액티브 사용 없음" ) ;
// 패시브 발동 = HUD 한 줄
Combat . Damage ( cfg , foe , eb , false , new System . Random ( Seed ) , true ) ;
StringAssert . Contains ( SkillCatalog . Get ( "caoren_p" ) . NameKo , bm . Skills . Log [ bm . Skills . Log . Count - 1 ] . Text , "패시브 발동 한 줄" ) ;
StringAssert . Contains ( "패시브" , bm . Skills . Log [ bm . Skills . Log . Count - 1 ] . Text ) ;
bm . Sling . Select ( eb ) ;
yield return null ;
StringAssert . Contains ( "패시브" , bm . Hud . SkillButtonText ) ;
}
// ---------- ★3 · ★5 · 편성 · 적 무장 ----------
[UnityTest]
public IEnumerator Star3ActiveCap_Star5PassiveMult_Lineup_EnemyGeneralPassiveOnly ( )
{
var db = Db ;
int capStar = 0 , multStar = 0 ;
foreach ( var r in db . Evolve . rows )
{
if ( capStar = = 0 & & SkillRules . ActiveCapBonus ( db , r . star ) > 0 ) capStar = r . star ;
if ( multStar = = 0 & & SkillRules . PassiveMult ( db , r . star ) > 1f ) multStar = r . star ;
}
Assert . Greater ( capStar , 1 , "evolve.csv 액티브 상한 해금 ★" ) ;
Assert . Greater ( multStar , 1 , "evolve.csv 패시브 배율 ★" ) ;
var save = HeroRules . NewSave ( db , cfg . playerSpawnPoints . Length , T0 ) ;
var h = db . Heroes . Get ( db . Economy . StartHeroes [ 0 ] ) ;
var hs = save . Hero ( h . id ) ;
hs . star = capStar - 1 ;
int capBefore = SkillRules . Cap ( db , h , hs , 0 ) ;
hs . star = capStar ;
Assert . AreEqual ( Mathf . Min ( capBefore + SkillRules . ActiveCapBonus ( db , capStar ) , db . Skills . Get ( h . skillActive ) . lvMax ) , SkillRules . Cap ( db , h , hs , 0 ) , "★3 = 액티브 상한 +" ) ;
Assert . AreEqual ( HeroRules . SkillCap ( db , hs ) , SkillRules . Cap ( db , h , hs , 1 ) , "패시브 상한 = 등급" ) ;
var e = new LineupEntry ( ) ;
hs . star = multStar - 1 ;
SkillRules . FillLineup ( db , save , h , e ) ;
Assert . AreEqual ( 1f , e . passiveMult , 1e-5f ) ;
hs . star = multStar ;
SkillRules . FillLineup ( db , save , h , e ) ;
Assert . AreEqual ( SkillRules . PassiveMult ( db , multStar ) , e . passiveMult , 1e-5f , "★5 = 패시브 배율" ) ;
Assert . AreEqual ( h . skillActive , e . skillActive ) ;
Assert . IsTrue ( e . general ) ;
// 편성 → 전투 유닛에 스킬(시작 영웅 = 액티브·패시브) · ★5 패시브 수치 ×
yield return Fresh ( ) ;
var u = Spawn ( h . type , Faction . Player , 0f , - 6f , h . nameKo ) ;
bm . Skills . AttachLineup ( u , e ) ;
var s = bm . Skills . Of ( u ) ;
Assert . AreEqual ( h . skillActive , s . Active . Id ) ;
var raw = SkillCatalog . Get ( h . skillPassive ) . Effects [ 0 ] ;
SkillKeys . Info info = SkillKeys . All [ raw . Key ] ;
Assert . AreEqual ( SkillKeys . Apply ( info . Main , raw . P , e . passiveMult ) , s . PassiveEffects [ 0 ] . P , 1e-4f , "★5 패시브 효과 적용" ) ;
// 적 무장(heroes.csv 에 있는 보스·엘리트) = 패시브만
int idx = - 1 ;
for ( int i = 0 ; i < cfg . stages . Length & & idx < 0 ; i + + )
foreach ( var en in cfg . stages [ i ] . enemies )
if ( ( en . isBoss | | en . isGeneral ) & & SkillCatalog . HeroForEnemy ( en . art , en . name ) ! = null )
{
idx = i ;
break ;
}
Assert . GreaterOrEqual ( idx , 0 , "영웅 표에 있는 적 무장 스테이지" ) ;
bm . StartStage ( idx ) ;
yield return null ;
int withPassive = 0 ;
foreach ( var x in bm . Units )
{
if ( x = = null | | x . Faction ! = Faction . Enemy ) continue ;
var es = bm . Skills . Of ( x ) ;
if ( es = = null | | es . Passive = = null ) continue ;
withPassive + + ;
2026-11-05 16:20:07 +00:00
// PD #1098 확정: 적 액티브 = 1부(1~10장) 보스만(적 AI 가 다음 행동형을 예약해 씀) · 엘리트 무장 = 패시브만
if ( x . IsBoss & & TacticalAI . BossUsesActive ( bm . Stage , new BalanceConfig . EnemySpawn { isBoss = true } ) ) Assert . IsNotNull ( es . Active , "1부 보스 = 액티브도" ) ;
else Assert . IsNull ( es . Active , "엘리트 무장 = 액티브 없음" ) ;
Assert . AreEqual ( SkillSystem . ActiveState . None , bm . Skills . StateOf ( x ) , "적 액티브는 HUD 버튼 상태 없음(AI 전용)" ) ;
2026-11-05 14:31:27 +00:00
Assert . IsTrue ( es . IsGeneral ) ;
}
Assert . Greater ( withPassive , 0 , "적 무장 패시브 부착" ) ;
int playersWithActive = 0 ;
foreach ( var x in bm . Units )
if ( x ! = null & & x . Faction = = Faction . Player & & bm . Skills . Of ( x ) ! = null & & bm . Skills . Of ( x ) . Active ! = null ) playersWithActive + + ;
Assert . AreEqual ( bm . Deployed . Count , playersWithActive , "출전 영웅 = 액티브 부착" ) ;
}
// ---------- 스킬 레벨업 · 스킬서 ----------
[Test]
public void SkillLevelUp_BooksGold_Cap_Max_BookShopDaily ( )
{
var db = Db ;
var save = HeroRules . NewSave ( db , Resources . Load < BalanceConfig > ( "BalanceConfig" ) . playerSpawnPoints . Length , T0 ) ;
Assert . AreEqual ( db . Economy . StartSkillBooks , save . inv . books . skill , "신규 유저 스킬서" ) ;
var h = db . Heroes . Get ( db . Economy . StartHeroes [ 0 ] ) ;
var hs = save . Hero ( h . id ) ;
int cap = SkillRules . Cap ( db , h , hs , 1 ) ;
Assert . AreEqual ( 1 , SkillRules . Level ( hs , 1 ) ) ;
if ( cap < = 1 ) StringAssert . Contains ( "상한" , SkillRules . CanLevelUp ( db , save , h , hs , 1 ) , "상한 = 등급" ) ;
var cost = db . Skills . CostTo ( 2 ) ;
int books0 = save . inv . books . skill , gold0 = save . gold ;
hs . grade = db . Promotion . Get ( hs . grade ) . nextGrade ; // 승급 → 상한 +1
Assert . IsNull ( SkillRules . LevelUp ( db , save , h , hs , 1 ) ) ;
Assert . AreEqual ( 2 , SkillRules . Level ( hs , 1 ) ) ;
Assert . AreEqual ( books0 - cost . books , save . inv . books . skill , "스킬서 소모" ) ;
Assert . AreEqual ( gold0 - cost . gold , save . gold , "골드 소모" ) ;
save . inv . books . skill = 0 ;
hs . grade = db . Promotion . Get ( hs . grade ) . nextGrade ;
StringAssert . Contains ( "스킬서" , SkillRules . CanLevelUp ( db , save , h , hs , 1 ) , "스킬서 부족" ) ;
hs . skillLv [ 0 ] = db . Skills . Get ( h . skillActive ) . lvMax ;
Assert . AreEqual ( "최고 레벨" , SkillRules . CanLevelUp ( db , save , h , hs , 0 ) ) ;
// 스킬서 임시 판매: 하루 n 개 · 골드 · 다음 날 다시
var e = db . Economy ;
save . gold = e . SkillBookPrice * ( e . SkillBookShopDaily + 2 ) ;
int g = save . gold ;
for ( int i = 0 ; i < e . SkillBookShopDaily ; i + + ) Assert . IsNull ( SkillRules . BuyBook ( db , save , T0 ) , "구매 " + i ) ;
Assert . IsNotNull ( SkillRules . BuyBook ( db , save , T0 ) , "하루 한도" ) ;
Assert . AreEqual ( e . SkillBookShopDaily , save . inv . books . skill ) ;
Assert . AreEqual ( g - e . SkillBookPrice * e . SkillBookShopDaily , save . gold ) ;
Assert . IsNull ( SkillRules . BuyBook ( db , save , T0 . AddDays ( 1 ) ) , "다음 날 다시" ) ;
}
[UnityTest]
public IEnumerator HeroDetail_SkillUpButton_OpensModal_LevelsUp_ShopHasBookCell ( )
{
var og = OutGameRoot . Instance ;
Assert . IsNotNull ( og ) ;
og . ShowLobby ( ) ;
var id = og . Session . Db . Economy . StartHeroes [ 0 ] ;
og . ShowHero ( id ) ;
yield return null ;
og . HeroDetail . OnSkillUp ( ) ;
Assert . IsTrue ( og . ModalVisible , "스킬 레벨업 = 고르기 팝업" ) ;
og . CloseModal ( ) ;
var h = og . Session . Def ( id ) ;
var hs = og . Session . Save . Hero ( id ) ;
int lv0 = SkillRules . Level ( hs , 0 ) ;
if ( SkillRules . CanLevelUp ( og . Session . Db , og . Session . Save , h , hs , 0 ) = = null )
{
Assert . IsNull ( SkillRules . LevelUp ( og . Session . Db , og . Session . Save , h , hs , 0 ) ) ;
og . Session . Commit ( ) ;
Assert . AreEqual ( lv0 + 1 , og . Session . Save . Hero ( id ) . skillLv [ 0 ] , "세이브 반영" ) ;
}
og . Trade . OpenShop ( ) ;
yield return null ;
2026-11-05 15:22:54 +00:00
Assert . IsFalse ( og . Trade . SkillBookCellVisible , "스킬서 임시 판매 제거 = 사당 던전(PD #1093 C)" ) ;
2026-11-05 14:31:27 +00:00
Assert . AreEqual ( og . Session . Db . Economy . ShopSlots , og . Trade . ShopCardCount , "장비 칸 수는 그대로" ) ;
}
2026-11-05 15:22:54 +00:00
// ---------- 영웅 56명 확장(PD #1096) — 신규 키 4종 · 기존 키 조건 열 ----------
[UnityTest]
public IEnumerator Expansion_NewKeys_TeamDmg_Cooldown_Heal_Charm ( )
{
// 십승십패(team_dmg_pct_turn · 즉시): 이번 아군 턴 아군 전원 가하는 피해 × (1 + p) · 귀재(cooldown_add 패시브 · 자기) = 쿨 + p(최소 1)
yield return Fresh ( ) ;
var gj = Spawn ( UnitType . Strategist , Faction . Player , - 6f , - 8f , "곽가" ) ;
var gs = Give ( gj , "guojia" ) ;
var ally = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 2f , "보병" ) ;
var foe = Spawn ( UnitType . Infantry , Faction . Enemy , 6f , 6f , "적" ) ;
float before = SkillMods . DealtMult ( ally , foe ) ;
yield return bm . Skills . Instant ( gs ) ;
Assert . AreEqual ( before * ( 1f + Eff ( "guojia_a" , SkillKeys . TeamDmgPctTurn ) . P ) , SkillMods . DealtMult ( ally , foe ) , 1e-4f , "team_dmg_pct_turn = 아군 전원" ) ;
Assert . AreEqual ( 1f , SkillMods . DealtMult ( foe , ally ) , 1e-4f , "상대 = 미적용" ) ;
Assert . AreEqual ( Mathf . Max ( 1 , Cd ( "guojia_a" ) + Mathf . RoundToInt ( Eff ( "guojia_p" , SkillKeys . CooldownAdd ) . P ) ) , gs . ReadyTurn - bm . TurnNumber , "cooldown_add 자기 쿨" ) ;
bm . Skills . OnEnemyTurnStart ( ) ;
Assert . AreEqual ( before , SkillMods . DealtMult ( ally , foe ) , 1e-4f , "적 턴 시작 = 해제" ) ;
// 금슬(cooldown_add 즉시 · p2 1 = 아군 전원): 다른 아군의 남은 쿨 + p(이번 턴 아래로는 안 감 · 시전자 제외)
yield return Fresh ( ) ;
var xq = Spawn ( UnitType . Strategist , Faction . Player , - 6f , - 8f , "소교" ) ;
var xs = Give ( xq , "xiaoqiao" ) ;
var zy = Spawn ( UnitType . Cavalry , Faction . Player , 0f , - 2f , "조운" ) ;
var zs = Give ( zy , "zhaoyun" ) ;
zs . ReadyTurn = bm . TurnNumber + 3 ;
yield return bm . Skills . Instant ( xs ) ;
Assert . AreEqual ( bm . TurnNumber + 3 + Mathf . RoundToInt ( Eff ( "xiaoqiao_a" , SkillKeys . CooldownAdd ) . P ) , zs . ReadyTurn , "아군 쿨 감소" ) ;
Assert . AreEqual ( bm . TurnNumber + Cd ( "xiaoqiao_a" ) , xs . ReadyTurn , "시전자 쿨 = 표 그대로" ) ;
// 꿀물(heal_self_pct)
yield return Fresh ( ) ;
var ys = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 4f , "원술" ) ;
var yss = Give ( ys , "yuanshu" ) ;
ys . Hp = 1 ;
yield return bm . Skills . Instant ( yss ) ;
Assert . AreEqual ( Mathf . Min ( ys . MaxHp , 1 + Mathf . RoundToInt ( ys . MaxHp * Eff ( "yuanshu_a" , SkillKeys . HealSelfPct ) . P ) ) , ys . Hp , "heal_self_pct" ) ;
// 연환계(charm): 반경 p1 안 상대 무장 우선 1기 → p2 적 턴 동안 교전 반응 없음
yield return Fresh ( ) ;
var dc = Spawn ( UnitType . Strategist , Faction . Player , 0f , - 4f , "초선" ) ;
var dcs = Give ( dc , "diaochan" ) ;
var ce = Eff ( "diaochan_a" , SkillKeys . Charm ) ;
var soldier = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , - 4f + ce . P * 0.4f , "병사" ) ;
var general = Spawn ( UnitType . Infantry , Faction . Enemy , 1f , - 4f + ce . P * 0.8f , "무장" ) ;
bm . Skills . MarkGeneral ( general ) ;
yield return bm . Skills . Instant ( dcs ) ;
bm . ForcePhase ( BattlePhase . EnemyTurn ) ;
Assert . IsTrue ( bm . Skills . Suppressed ( general ) , "무장 우선 매혹" ) ;
Assert . IsFalse ( bm . Skills . Suppressed ( soldier ) , "1기만" ) ;
for ( int i = 0 ; i < Mathf . RoundToInt ( ce . A2 ) ; i + + ) bm . Skills . OnPlayerTurnStart ( ) ;
Assert . IsFalse ( bm . Skills . Suppressed ( general ) , "p2 적 턴 뒤 해제" ) ;
}
[UnityTest]
public IEnumerator Expansion_ConditionColumns_TakenHpTurnCounterTeam ( )
{
// 받는 피해 조건: 위연(p2 = HP 임계) · 원술(p2 2 = 보스에게서만) · 초선(p2 3 = 근접에게서만)
yield return Fresh ( ) ;
var wy = Spawn ( UnitType . Infantry , Faction . Player , - 4f , - 8f , "위연" ) ;
Give ( wy , "weiyan" ) ;
var atk = Spawn ( UnitType . Infantry , Faction . Enemy , 4f , 8f , "적" ) ;
Assert . AreEqual ( 1f , SkillMods . TakenMult ( wy , atk ) , 1e-5f , "HP 가득 = 미적용" ) ;
var we = Eff ( "weiyan_p" , SkillKeys . DmgTakenPct ) ;
wy . Hp = Mathf . FloorToInt ( wy . MaxHp * we . A2 ) ;
Assert . AreEqual ( 1f + we . P , SkillMods . TakenMult ( wy , atk ) , 1e-5f , "HP 임계 이하 = 적용" ) ;
yield return Fresh ( ) ;
var yu = Spawn ( UnitType . Infantry , Faction . Player , - 4f , - 8f , "원술" ) ;
Give ( yu , "yuanshu" ) ;
var boss = Spawn ( UnitType . Infantry , Faction . Enemy , 4f , 8f , "보스" , true ) ;
var grunt = Spawn ( UnitType . Infantry , Faction . Enemy , - 4f , 8f , "병사" ) ;
Assert . AreEqual ( 1f + Eff ( "yuanshu_p" , SkillKeys . DmgTakenPct ) . P , SkillMods . TakenMult ( yu , boss ) , 1e-5f , "보스에게" ) ;
Assert . AreEqual ( 1f , SkillMods . TakenMult ( yu , grunt ) , 1e-5f , "병사에게 = 미적용" ) ;
yield return Fresh ( ) ;
var dch = Spawn ( UnitType . Strategist , Faction . Player , - 4f , - 8f , "초선" ) ;
Give ( dch , "diaochan" ) ;
var mel = Spawn ( UnitType . Cavalry , Faction . Enemy , 4f , 8f , "기병" ) ;
var arc = Spawn ( UnitType . Archer , Faction . Enemy , - 4f , 8f , "궁병" ) ;
Assert . AreEqual ( 1f + Eff ( "diaochan_p" , SkillKeys . DmgTakenPct ) . P , SkillMods . TakenMult ( dch , mel ) , 1e-5f , "근접에게" ) ;
Assert . AreEqual ( 1f , SkillMods . TakenMult ( dch , arc ) , 1e-5f , "궁병에게 = 미적용" ) ;
// 원소(p3 = 출전 n 인 이상 · 아군 전원)
var yp = Eff ( "yuanshao_p" , SkillKeys . DmgTakenPct ) ;
int need = Mathf . RoundToInt ( yp . A3 ) ;
yield return Fresh ( ) ;
var ysh = Spawn ( UnitType . Archer , Faction . Player , - 6f , - 8f , "원소" ) ;
Give ( ysh , "yuanshao" ) ;
var mates = new List < Unit > ( ) ;
for ( int i = 1 ; i < need ; i + + ) mates . Add ( Spawn ( UnitType . Infantry , Faction . Player , - 6f + i * 2.5f , - 10f , "병" + i ) ) ;
var en = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 8f , "적" ) ;
bm . Skills . BeginBattle ( ) ;
Assert . AreEqual ( 1f + yp . P , SkillMods . TakenMult ( mates [ 0 ] , en ) , 1e-5f , "출전 " + need + "인 = 아군 전원" ) ;
Assert . AreEqual ( 1f , SkillMods . TakenMult ( en , mates [ 0 ] ) , 1e-5f , "상대 = 미적용" ) ;
yield return Fresh ( ) ;
var en2 = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 8f , "적" ) ;
var solo = Spawn ( UnitType . Archer , Faction . Player , - 6f , - 8f , "원소" ) ;
Give ( solo , "yuanshao" ) ;
bm . Skills . BeginBattle ( ) ;
Assert . AreEqual ( 1f , SkillMods . TakenMult ( solo , en2 ) , 1e-5f , "출전 인원 미달 = 미적용" ) ;
// 순욱 패시브(hp_max_pct p2 1 = 아군 전원 · 배치 뒤) · 액티브(counter_ratio p3 1 = 아군 전원 · 이번 적 턴)
yield return Fresh ( ) ;
var xy = Spawn ( UnitType . Strategist , Faction . Player , - 6f , - 8f , "순욱" ) ;
var xys = Give ( xy , "xunyu" ) ;
var mate = Spawn ( UnitType . Infantry , Faction . Player , 0f , - 8f , "보병" ) ;
var foe = Spawn ( UnitType . Infantry , Faction . Enemy , 0f , 8f , "적" ) ;
int m0 = mate . MaxHp , x0 = xy . MaxHp , f0 = foe . MaxHp ;
bm . Skills . BeginBattle ( ) ;
float hp = Eff ( "xunyu_p" , SkillKeys . HpMaxPct ) . P ;
Assert . AreEqual ( Mathf . RoundToInt ( m0 * ( 1f + hp ) ) , mate . MaxHp , "아군 전원 최대 HP" ) ;
Assert . AreEqual ( Mathf . RoundToInt ( x0 * ( 1f + hp ) ) , xy . MaxHp , "자기 포함" ) ;
Assert . AreEqual ( f0 , foe . MaxHp , "상대 = 미적용" ) ;
yield return bm . Skills . Instant ( xys ) ;
bm . ForcePhase ( BattlePhase . EnemyTurn ) ;
Assert . AreEqual ( Eff ( "xunyu_a" , SkillKeys . CounterRatio ) . P , SkillMods . CounterRatio ( mate , cfg . counterRatio , false ) , 1e-5f , "아군 전원 반격 배율" ) ;
Assert . AreEqual ( cfg . counterRatio , SkillMods . CounterRatio ( foe , cfg . counterRatio , false ) , 1e-5f , "상대 = 기본" ) ;
// 여몽 패시브(dmg_pct p2 = 발동 턴 이상)
yield return Fresh ( ) ;
var lm = Spawn ( UnitType . Infantry , Faction . Player , - 4f , - 8f , "여몽" ) ;
Give ( lm , "lvmeng" ) ;
var tg = Spawn ( UnitType . Infantry , Faction . Enemy , 4f , 8f , "적" ) ;
var lp = Eff ( "lvmeng_p" , SkillKeys . DmgPct ) ;
bm . SetTurnForTest ( Mathf . RoundToInt ( lp . A2 ) - 1 ) ;
Assert . AreEqual ( 1f , SkillMods . DealtMult ( lm , tg ) , 1e-5f , "발동 턴 전" ) ;
bm . SetTurnForTest ( Mathf . RoundToInt ( lp . A2 ) ) ;
Assert . AreEqual ( 1f + lp . P , SkillMods . DealtMult ( lm , tg ) , 1e-5f , "발동 턴부터" ) ;
}
2026-11-05 14:31:27 +00:00
}
}