ThreeKingdomsSlingshot/Assets/Tests/PlayMode/SkillTests.cs

1216 lines
68 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using System;
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);
}
// 영웅 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);
// 자산 = 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();
// 비도(spear_hit_two · 축융 · 기령은 영웅 풀 제외 PD #1096): 밀며 전진 끝에 닿은 적까지 최대 n 기 동시 피해
var jl = Spawn(UnitType.Spear, Faction.Player, 0f, -4f, "축융");
Give(jl, "zhurong");
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);
// 대장군(pushed_ally_attack_on_push)은 기령 제외로 표에서 쓰지 않음(처리기만 유지 · PD #1096)
// 철척사모(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++;
Assert.IsNull(es.Active, "적 = 액티브 없음(AI 사용은 2차)");
Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(x));
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;
Assert.IsFalse(og.Trade.SkillBookCellVisible, "스킬서 임시 판매 제거 = 사당 던전(PD #1093 C)");
Assert.AreEqual(og.Session.Db.Economy.ShopSlots, og.Trade.ShopCardCount, "장비 칸 수는 그대로");
}
// ---------- 영웅 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, "발동 턴부터");
}
}
}