1216 lines
68 KiB
C#
1216 lines
68 KiB
C#
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, "발동 턴부터");
|
||
}
|
||
}
|
||
}
|