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 { /// /// 스킬 시스템(PD #1093 B · 시스템 기획 §5·§3-5) — effect_key 51종 처리기(수치·조건·레벨 계수) · 쿨·예약·취소 · 즉시형 · ★3/★5 · 적 무장 패시브 · 스킬 레벨업·스킬서. /// 기대값은 전부 skills.csv(SkillCatalog)·BalanceConfig·표에서 계산한다(C45 · 테스트에 수치 상수 없음 · 스킬 id 만 지정). /// 결정 피해 비교 = 같은 시드의 난수로 Combat.Damage 를 부른다. /// 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); } /// 피해 공식 기대값(같은 시드 난수) = (공 × 상성 − 방 × 계수) × 배율 × 분산. 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(); 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(); 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(); 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++; // 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 전용)"); 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").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(); 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, "발동 턴부터"); } } }