using System.Collections; using System.Collections.Generic; using System.IO; using System.Text; using NUnit.Framework; using TKS.OutGame; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace TKS.Tests { /// /// 지형 기믹 레이어(PD #1097 · 지형기믹_기획서) — 기믹 16 × 핵심 병종 조합 · 샘플 5 스테이지 로드 · 임포트 검증 · 조준 미리보기 · /// 병종 규칙 개정 2건(보병 연쇄 · 창병 반작용 · PD #1098). 수치 = 임포트 자산(GimmickDb · BalanceConfig)에서 읽는다(C45). /// public class GimmickTests { BattleManager bm; BalanceConfig cfg; GimmickLayer L; [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); L = GimmickLayer.Ensure(bm); L.Clear(); yield return null; } static GimmickDef Def(string id) { var d = GimmickTables.Db.Get(id); Assert.IsNotNull(d, "GimmickDb 에 " + id + " (TKS/Import Gimmicks)"); return d; } static int Pct(Unit u, float pct) { return Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * pct)); } float Contact(Unit mover, float obstacleEdgeY) { return obstacleEdgeY - mover.Radius * Combat.CastRadiusScale; } IEnumerator Act(Unit u, Vector2 target, ActionOutcome o, bool moveOnly = false) { yield return bm.ResolveForTest(u, target, moveOnly, o); } // ---------- 목책 ---------- [UnityTest] public IEnumerator Palisade_Infantry2HitsBreak_Bounce_ArrowPasses_SpearShoves_CavalrySelfDamage() { var d = Def("palisade"); yield return Fresh(); var p = L.Spawn("palisade", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.4f))); Assert.AreEqual(d.hp, p.Hp); var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병"); var o = new ActionOutcome(); yield return Act(inf, new Vector2(0f, 1.5f), o); Assert.AreEqual(d.hp - d.dmgInf, p.Hp, "보병 = dmg_inf 피해"); float contact = Contact(inf, -0.2f); float remaining = 4.5f - (contact + 3f); Assert.AreEqual(contact - remaining * cfg.collisionSelfBounceRatio, inf.Position.y, 0.12f, "알까기 자기 튕김(남은 거리 × 비율)"); Assert.AreEqual(inf.MaxHp, inf.Hp, "목책 bounce_pct 0 = 자기 피해 없음"); yield return Act(inf, inf.Position + new Vector2(0f, 4.5f), new ActionOutcome()); Assert.IsTrue(p.Broken, "보병 2회 = 파괴(잔해)"); Assert.IsFalse(p.BlocksMove); yield return Act(inf, inf.Position + new Vector2(0f, 4.5f), new ActionOutcome()); Assert.Greater(inf.Position.y, 0.5f, "잔해 = 통과 가능"); // 화살 = 목책 통과 yield return Fresh(); L.Spawn("palisade", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.4f))); var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.5f), "적"); var oa = new ActionOutcome(); yield return Act(archer, foe.Position, oa); Assert.AreEqual(foe, oa.ShotHit, "화살은 목책을 넘어간다"); Assert.Less(foe.Hp, foe.MaxHp); // 창병 = 1 피해 + spear_push 밀기(밀린 끝 유닛도 밀림) · 정지 yield return Fresh(); var p2 = L.Spawn("palisade", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.4f))); var spear = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, -3f), "창병"); var behind = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0.8f), "목책 뒤 적"); yield return Act(spear, new Vector2(0f, 1.5f), new ActionOutcome()); Assert.AreEqual(d.hp - d.dmgSpear, p2.Hp); Assert.AreEqual(d.spearPush, p2.Shape.Center.y, 0.05f, "목책이 spear_push 만큼 밀림"); Assert.GreaterOrEqual(behind.Position.y - behind.Radius, p2.Shape.Center.y + 0.2f - 0.03f, "밀린 끝 유닛도 밀림(겹치지 않음)"); Assert.AreEqual(Contact(spear, -0.2f), spear.Position.y, 0.08f, "창병은 튕기지 않고 정지"); // 기병 = 1 피해 + 튕김 + 자기 HP cav_self_pct · 복귀 없음 yield return Fresh(); var p3 = L.Spawn("palisade", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.4f))); var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -4f), "기병"); var oc = new ActionOutcome(); yield return Act(cav, new Vector2(0f, 2.5f), oc); Assert.AreEqual(d.hp - d.dmgCav, p3.Hp); Assert.AreEqual(cav.MaxHp - Pct(cav, Mathf.Max(d.cavSelfPct, d.bouncePct)), cav.Hp, "기병 자기 피해 5 %"); Assert.IsFalse(oc.Returned, "막히면 복귀 없음"); Assert.Less(cav.Position.y, Contact(cav, -0.2f) - 0.3f, "튕김"); } // ---------- 가시덤불 ---------- [UnityTest] public IEnumerator ThornBush_ArrowBlocked_BounceSelf5Pct_StrategistSplashClears() { var d = Def("thorn_bush"); yield return Fresh(); var t = L.Spawn("thorn_bush", GimmickShape.Box(Vector2.zero, new Vector2(3f, 0.6f))); var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.5f), "적"); var o = new ActionOutcome(); yield return Act(archer, foe.Position, o); Assert.IsNull(o.ShotHit, "화살은 덤불에 막힌다"); Assert.AreEqual(foe.MaxHp, foe.Hp); Assert.IsTrue(t.BlocksMove); var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(1f, -3f), "보병"); yield return Act(inf, new Vector2(1f, 1.5f), new ActionOutcome()); Assert.AreEqual(inf.MaxHp - Pct(inf, d.bouncePct), inf.Hp, "덤불 = 튕김 + 최대 HP bounce_pct"); Assert.Less(inf.Position.y, Contact(inf, -0.3f) - 0.3f, "튕김"); var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(-1f, -3f), "책사"); var foe2 = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(-1f, 1.2f), "적2"); var os = new ActionOutcome(); yield return Act(st, foe2.Position, os); Assert.IsTrue(t.Removed, "책사 광역 = 덤불 제거"); Assert.IsFalse(t.BlocksMove); var o2 = new ActionOutcome(); archer.ResetForNewTurn(); yield return Act(archer, foe.Position, o2); Assert.AreEqual(foe, o2.ShotHit, "제거 뒤 사선이 열림"); } // ---------- 바위 ---------- [UnityTest] public IEnumerator Boulder_InfantryRollsIntoEnemy_DamageAndPush_CavalryStops() { var d = Def("boulder"); yield return Fresh(); var b = L.Spawn("boulder", GimmickShape.Circle(Vector2.zero, 0.8f)); var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.8f), "적"); yield return Act(inf, new Vector2(0f, 1.5f), new ActionOutcome()); Assert.Greater(b.Shape.Center.y, 0.3f, "보병이 밀면 바위가 구른다"); Assert.Less(foe.Hp, foe.MaxHp, "구른 바위가 적에게 피해(공 × p1)"); Assert.Greater(foe.Position.y, 1.8f + d.p2 * 0.6f, "그리고 p2 만큼 밀기"); Assert.AreEqual(Contact(inf, -0.8f), inf.Position.y, 0.08f, "보병은 정지"); yield return Fresh(); L.Spawn("boulder", GimmickShape.Circle(Vector2.zero, 0.8f)); var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -4f), "기병"); yield return Act(cav, new Vector2(0f, 2.5f), new ActionOutcome()); Assert.AreEqual(cav.MaxHp - Pct(cav, d.cavSelfPct), cav.Hp, "기병 = 충돌 정지 + cav_self_pct"); Assert.AreEqual(Contact(cav, -0.8f), cav.Position.y, 0.08f, "튕기지 않음"); } // ---------- 폭약 수레 ---------- [UnityTest] public IEnumerator PowderCart_ArrowDestroys_ExplosionHitsAlliesToo_PushAndFire() { var d = Def("powder_cart"); yield return Fresh(); var cart = L.Spawn("powder_cart", GimmickShape.Circle(Vector2.zero, 0.6f)); var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.4f), "적"); var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(1.3f, 0f), "아군"); var o = new ActionOutcome(); yield return Act(archer, foe.Position, o); Assert.IsTrue(cart.Broken, "어떤 피격에도 파괴"); Assert.Less(foe.Hp, foe.MaxHp, "폭발 = 반경 p1 광역"); Assert.Less(ally.Hp, ally.MaxHp, "아군 포함"); Assert.Contains(ally, o.FriendlyHit); Assert.Greater(ally.Position.x, 1.3f + d.p3 * 0.6f, "바깥으로 p3 밀기"); bool fire = false; foreach (var g in L.Items) fire |= g.Spawned && g.Zone == GimmickZone.Burn && g.ZoneActive; Assert.IsTrue(fire, "불타는 잔해 생성"); Assert.Less(archer.Position.y, -2.9f, "쏜 궁병은 반경 밖"); Assert.AreEqual(archer.MaxHp, archer.Hp); } // ---------- 독늪 · 늪지 · 불 ---------- [UnityTest] public IEnumerator PoisonMarsh_1_2_3Pct_ThenEnds_ReentryRefreshes() { var d = Def("poison_marsh"); yield return Fresh(); L.Spawn("poison_marsh", GimmickShape.Circle(Vector2.zero, 1f)); var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병"); u.MaxHp = u.Hp = 1000; yield return Act(u, new Vector2(0f, 1.4f), new ActionOutcome(), true); var s = L.StateOf(u); Assert.IsNotNull(s); Assert.AreEqual(Mathf.RoundToInt(d.p1), s.PoisonTurns, "지나면 중독 p1 턴"); Assert.AreEqual(1000, u.Hp, "지나는 순간은 피해 없음"); int hp = 1000; for (int k = 0; k < 3; k++) { yield return L.TurnStart(Faction.Player); int expect = Mathf.Max(1, Mathf.RoundToInt(1000 * (d.p2 + d.p3 * k))); Assert.AreEqual(hp - expect, u.Hp, "턴 " + (k + 1) + " = 최대 HP " + (d.p2 + d.p3 * k) * 100f + " %"); hp = u.Hp; } yield return L.TurnStart(Faction.Player); Assert.AreEqual(hp, u.Hp, "p1 턴 뒤 끝"); Assert.AreEqual(0, s.PoisonTurns); } [UnityTest] public IEnumerator Swamp_HalvesInsideSegment_PreviewBandAndStopPoint() { var d = Def("swamp"); yield return Fresh(); L.Spawn("swamp", GimmickShape.Box(Vector2.zero, new Vector2(20f, 6f))); var a = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -2f), "늪 안"); yield return Act(a, new Vector2(0f, 2f), new ActionOutcome()); Assert.AreEqual(-2f + 4f / d.p1, a.Position.y, 0.1f, "늪 안 구간 이동량 ×p1 = 반감"); var b = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(4f, -6f), "늪 밖"); Vector2 reach = bm.Resolver.ReachPoint(b, new Vector2(4f, -2f)); Assert.AreEqual(-3f + 1f / d.p1, reach.y, 0.1f, "밖 3 → 안 1 × 1/p1"); bm.Sling.ShowPreview(b, new Vector2(4f, -2f), null); Assert.Contains(GimmickPreview.BandColor(GimmickZone.Slow), L.Preview.LastBandColors, "조준선 = 늪 구간 색 띠"); bm.Sling.HidePreview(); yield return Act(b, new Vector2(4f, -2f), new ActionOutcome()); Assert.AreEqual(reach.y, b.Position.y, 0.08f, "미리보기 정지점 = 실제 정지점"); } [UnityTest] public IEnumerator BurningDebris_10PctOncePerAction_StandingEveryTurn() { var d = Def("burning_debris"); yield return Fresh(); L.Spawn("burning_debris", GimmickShape.Circle(Vector2.zero, 1f)); var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병"); yield return Act(u, new Vector2(0f, 1.6f), new ActionOutcome(), true); Assert.AreEqual(u.MaxHp - Pct(u, d.p1), u.Hp, "지나면 최대 HP p1(행동당 1회)"); var v = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0.2f, 0f), "불 위"); yield return L.TurnStart(Faction.Player); Assert.AreEqual(v.MaxHp - Pct(v, d.p1), v.Hp, "위에서 멈추면 매 턴 p1"); Assert.AreEqual(u.MaxHp - Pct(u, d.p1), u.Hp, "밖은 그대로"); } // ---------- 빙판 · 고지 · 안개 · 군기 ---------- [UnityTest] public IEnumerator Ice_SlidesToEdgeMaxP1_CollisionRulesApply() { var d = Def("ice"); yield return Fresh(); L.Spawn("ice", GimmickShape.Box(Vector2.zero, new Vector2(6f, 6f))); var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-1f, -4f), "보병"); var o = new ActionOutcome(); yield return Act(u, new Vector2(-1f, -1f), o); Assert.AreEqual(-1f + d.p1, u.Position.y, 0.12f, "정지점이 빙판 = 같은 방향 최대 p1 미끄러짐"); Assert.IsTrue(o.Notes.Exists(n => n.Contains("미끄러짐"))); yield return Fresh(); L.Spawn("ice", GimmickShape.Box(Vector2.zero, new Vector2(6f, 6f))); var w = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -4f), "보병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1f), "적"); var o2 = new ActionOutcome(); yield return Act(w, new Vector2(0f, -1f), o2); Assert.Contains(foe, o2.Attacked, "미끄러지다 적에 닿으면 알까기(충돌 규칙 그대로)"); Assert.Less(foe.Hp, foe.MaxHp); Assert.IsFalse(o2.BonusMove, "미끄러짐은 명중 보상 없음"); } [UnityTest] public IEnumerator HighGround_RangeAdd_MeleeTakenCut_ClimbCost() { var d = Def("high_ground"); yield return Fresh(); L.Spawn("high_ground", GimmickShape.Box(new Vector2(0f, 5f), new Vector2(20f, 3f))); var a = bm.SpawnUnit(UnitType.Archer, Faction.Enemy, new Vector2(0f, 5f), "고지 궁병"); var b = bm.SpawnUnit(UnitType.Archer, Faction.Enemy, new Vector2(3f, 0f), "평지 궁병"); Assert.AreEqual(a.AttackRange + d.p1, SkillMods.Range(a), 1e-4f, "고지 궁·책 사거리 +p1"); Assert.AreEqual(b.AttackRange, SkillMods.Range(b), 1e-4f); var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, 2f), "보병"); Assert.AreEqual(1f - d.p2, GimmickLayer.MeleeTakenMultOf(inf, a), 1e-4f, "아래에서 오는 근접 피해 −p2"); Assert.AreEqual(1f, GimmickLayer.MeleeTakenMultOf(inf, b), 1e-4f); Vector2 reach = bm.Resolver.ReachPoint(inf, new Vector2(0f, 6f)); Assert.AreEqual(3.5f + (4f - 1.5f) / d.p3, reach.y, 0.1f, "오르는 구간 이동량 ×p3(밖 1.5 → 안 남은 2.5 ÷ p3)"); } [UnityTest] public IEnumerator Fog_RangedDamageXp1_TargetableNormally_StrategistSplashClearsFog() { var d = Def("fog"); yield return Fresh(); L.Spawn("fog", GimmickShape.Circle(new Vector2(0f, 2f), 3.5f)); var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병"); var hidden = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.5f), "안개 속 적"); var far = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 4.6f), "안개 깊은 적"); hidden.MaxHp = hidden.Hp = far.MaxHp = far.Hp = 5000; Assert.AreEqual(d.p1, GimmickLayer.RangedTakenMultOf(hidden), 1e-4f, "안개 안 = 받는 원거리 피해 ×p1(PD 2026-11-06 개정)"); Assert.AreEqual(1f, GimmickLayer.RangedTakenMultOf(archer), 1e-4f); var plan = bm.Resolver.PreviewShot(archer, hidden.Position, false); Assert.AreEqual(hidden, plan.Target, "표적·사선은 정상"); Sim.Active = true; // 분산 0 으로 같은 공식 비교 int inFog = Combat.Damage(cfg, archer, hidden, false, bm.Rng, false); int melee = Combat.Damage(cfg, archer, hidden, false, bm.Rng, true); Vector2 keep = hidden.Position; hidden.Position = new Vector2(6f, -6f); int open = Combat.Damage(cfg, archer, hidden, false, bm.Rng, false); int meleeOpen = Combat.Damage(cfg, archer, hidden, false, bm.Rng, true); hidden.Position = keep; Sim.Active = false; Assert.AreEqual(open * d.p1, inFog, 1.01f, "원거리 피해 ×p1"); Assert.AreEqual(meleeOpen, melee, "근접은 그대로"); var o = new ActionOutcome(); yield return Act(archer, hidden.Position, o); Assert.AreEqual(hidden, o.ShotHit, "안개 속 적도 화살에 맞는다"); Assert.Less(hidden.Hp, hidden.MaxHp); var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(1.5f, -3f), "책사"); var os = new ActionOutcome(); yield return Act(st, hidden.Position, os); Assert.IsTrue(os.Fired); Assert.AreEqual(1, L.FogHoleCount, "책사 광역 = 그 반경만큼 안개 걷힘"); Assert.IsFalse(L.Fogged(hidden), "광역 반경 안 = 안개 효과 없음"); Assert.AreEqual(1f, GimmickLayer.RangedTakenMultOf(hidden), 1e-4f); Assert.IsTrue(L.Fogged(far), "반경 밖 안개는 그대로"); } [UnityTest] public IEnumerator Banner_CaptureAtTurnStart_AtkBonus_NeutralWhenContested() { var d = Def("banner"); yield return Fresh(); var g = L.Spawn("banner", GimmickShape.Circle(Vector2.zero, d.p1)); var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0.5f, 0f), "아군"); var e = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 8f), "적"); yield return L.TurnStart(Faction.Player); Assert.AreEqual((int)Faction.Player, g.Owner, "반경 안에 한쪽만 = 점령"); Assert.AreEqual(1f + d.p2, GimmickLayer.AttackMultOf(p), 1e-4f, "점령 측 공 +p2"); Assert.AreEqual(1f, GimmickLayer.AttackMultOf(e), 1e-4f); e.Position = new Vector2(-0.6f, -0.9f); yield return L.TurnStart(Faction.Enemy); Assert.AreEqual(-1, g.Owner, "양쪽 = 중립"); Assert.AreEqual(1f, GimmickLayer.AttackMultOf(p), 1e-4f); } // ---------- 성문 · 성벽 · 강 · 다리 · 함정 ---------- [UnityTest] public IEnumerator Gate_Hp6_Infantry3Hits_ArrowBlocked_WallUnbreakable() { var d = Def("gate"); yield return Fresh(); var gate = L.Spawn("gate", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.8f))); var wall = L.Spawn("wall", GimmickShape.Box(new Vector2(7f, 0f), new Vector2(6f, 0.8f))); Assert.AreEqual(d.hp, gate.Hp); var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.3f), "적"); var oa = new ActionOutcome(); yield return Act(archer, foe.Position, oa); Assert.IsNull(oa.ShotHit, "화살 막힘"); Assert.AreEqual(d.hp, gate.Hp, "화살로는 부서지지 않음"); var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(1.5f, -3f), "보병"); int hits = 0; while (!gate.Broken && hits < 6) { yield return Act(inf, inf.Position + new Vector2(0f, 4.5f), new ActionOutcome()); hits++; } Assert.AreEqual(Mathf.CeilToInt((float)d.hp / d.dmgInf), hits, "보병 2 피해 × 3회 = 파괴"); var w = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(7f, -3f), "벽 보병"); yield return Act(w, new Vector2(7f, 1.5f), new ActionOutcome()); Assert.IsTrue(wall.BlocksMove, "성벽 = 파괴 불가"); Assert.Less(w.Position.y, -0.4f, "성벽에 막혀 튕김"); } [UnityTest] public IEnumerator River_LikeSwamp_WalkInOut_PushedInFromBankOrBridgeFalls_ArrowPasses() { var river = Def("river"); yield return Fresh(); L.Spawn("river", GimmickShape.Box(new Vector2(0f, 3f), new Vector2(20f, 2f))); L.Spawn("bridge", GimmickShape.Box(new Vector2(0f, 3f), new Vector2(1.2f, 2f))); // 걸어 들어가기 = 늪처럼 강 안 구간 이동량 ×p3 · 빠짐 없음 · 다시 걸어 나올 수 있음(PD 2026-11-06 개정) var walker = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-5f, 0.5f), "보병"); Vector2 reach = bm.Resolver.ReachPoint(walker, new Vector2(-5f, 4.5f)); Assert.AreEqual(2f + (4f - 1.5f) / river.p3, reach.y, 0.1f, "강 안 구간 ×p3"); yield return Act(walker, new Vector2(-5f, 4.5f), new ActionOutcome(), true); Assert.AreEqual(reach.y, walker.Position.y, 0.08f); var ws = L.StateOf(walker); Assert.IsTrue(ws == null || ws.Stun == 0, "걸어 들어가면 빠짐 없음"); yield return Act(walker, walker.Position + new Vector2(0f, 4.5f), new ActionOutcome(), true); Assert.Greater(walker.Position.y, 4.5f, "강에서 걸어 나온다"); var onFoot = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, 0.5f), "다리 보병"); Assert.AreEqual(4.5f, bm.Resolver.ReachPoint(onFoot, new Vector2(0f, 4.5f)).y, 0.08f, "다리 위 = 강 효과 없음"); var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(-7f, 0.5f), "궁병"); var across = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(-7f, 4.5f), "건너편 적"); var oa = new ActionOutcome(); yield return Act(archer, across.Position, oa); Assert.AreEqual(across, oa.ShotHit, "투사체는 강 통과"); // 강가에서 밀려 강물에 = 빠짐 var pusher = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(6f, -1.5f), "밀치는 보병"); var bank = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(6f, 1f), "강가 적"); bank.MaxHp = bank.Hp = 5000; var ob = new ActionOutcome(); yield return Act(pusher, new Vector2(6f, 3f), ob); Assert.Greater(bank.Position.y, 2f, "강물로 밀려 들어감"); Assert.AreEqual(Mathf.RoundToInt(river.p2), L.StateOf(bank).Stun, "강가에서 밀려도 빠짐 = p2 턴 행동 불가"); Assert.IsTrue(ob.Notes.Exists(n => n.Contains("강에 빠짐"))); Assert.AreEqual(0f, L.KnockMult(bank), 1e-4f, "강 안 = 기병 넉백 0(늪과 같음)"); // 다리에서 옆으로 밀려 강물에 = 빠짐 var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -2f), "기병"); var onBridge = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.4f, 3f), "다리 위 적"); onBridge.MaxHp = onBridge.Hp = 5000; onFoot.Position = new Vector2(-8f, -8f); var o = new ActionOutcome(); yield return Act(cav, new Vector2(0f, 4.4f), o); Assert.Contains(onBridge, o.Attacked, "다리 = 통행(관통)"); Assert.AreEqual(Mathf.RoundToInt(river.p2), L.StateOf(onBridge).Stun, "다리에서 옆으로 밀려 강 = 빠짐"); yield return L.TurnStart(Faction.Enemy); Assert.IsTrue(GimmickLayer.Skips(onBridge), "그 진영 턴을 쉰다"); } [UnityTest] public IEnumerator PitTrap_HiddenFromPreview_StopsMover_StunsNextTurn_Removed() { var d = Def("pit_trap"); yield return Fresh(); var trap = L.Spawn("pit_trap", GimmickShape.Circle(Vector2.zero, 0.6f)); Assert.IsFalse(trap.View.Visible, "숨김"); var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병"); bm.Sling.ShowPreview(u, new Vector2(0f, 1.5f), null); Assert.Greater(L.Preview.LastMoverEnd.y, 1f, "아군 조준선은 함정을 모른다(그대로 통과해 그림)"); bm.Sling.HidePreview(); Assert.IsFalse(trap.Removed, "미리보기는 상태를 바꾸지 않는다"); var o = new ActionOutcome(); yield return Act(u, new Vector2(0f, 1.5f), o); Assert.AreEqual(-0.6f, u.Position.y, 0.1f, "밟으면 그 자리 정지"); Assert.IsTrue(trap.Removed && trap.Revealed, "발동 후 제거"); Assert.AreEqual(Mathf.RoundToInt(d.p1), L.StateOf(u).Stun); u.ResetForNewTurn(); yield return L.TurnStart(Faction.Player); Assert.IsTrue(u.ActedThisTurn, "다음 턴 행동 불가"); u.ResetForNewTurn(); yield return L.TurnStart(Faction.Player); Assert.IsFalse(u.ActedThisTurn, "그다음 턴 = 정상"); } // ---------- 조준 미리보기 ---------- [UnityTest] public IEnumerator AimPreview_BlockCutRed_ResultIcon_TracksForPushAndRecoil() { yield return Fresh(); var p = L.Spawn("palisade", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.4f))); var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병"); bm.Sling.ShowPreview(inf, new Vector2(0f, 1.5f), null); Assert.IsTrue(L.Preview.LastCut, "장애물 지점에서 붉게 끊김"); Assert.IsTrue(L.Preview.LastIcons.Exists(t => t.Contains("목책 −")), "결과 글자(내구 −n)"); Assert.IsTrue(L.Preview.LastIcons.Exists(t => t.Contains("튕김")), "튕김 표시"); Assert.AreEqual(p.MaxHp, p.Hp, "미리보기는 상태를 바꾸지 않는다"); Assert.AreEqual(-3f, inf.Position.y, 1e-4f); bm.Sling.HidePreview(); Assert.IsFalse(L.Preview.Visible); // 창병 반작용 · 밀릴 상대 궤적(PD #1098 §3) yield return Fresh(); var spear = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, -3f), "창병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0f), "적"); bm.Sling.ShowPreview(spear, foe.Position, null); Assert.GreaterOrEqual(L.Preview.LastTracks, 2, "행동 유닛 경로(반작용 포함) + 밀릴 상대 궤적"); Assert.Less(L.Preview.LastMoverEnd.y, -0.9f, "창병 반작용 = 접촉점보다 뒤"); bm.Sling.HidePreview(); Assert.AreEqual(foe.MaxHp, foe.Hp); } // ---------- 샘플 5 스테이지 · 임포트 ---------- [UnityTest] public IEnumerator SampleStages_5_LoadPlacements_RiverCutAtBridge_TemplateFire() { var table = GimmickTables.Table; Assert.IsNotNull(table); foreach (var id in new[] { "1-3", "2-5", "4-13", "8-15", "9-11" }) { var parts = id.Split('-'); int ch = int.Parse(parts[0]), no = int.Parse(parts[1]); int idx = -1; for (int i = 0; i < cfg.stages.Length; i++) if (cfg.stages[i].chapter == ch && cfg.stages[i].stage == no) idx = i; Assert.GreaterOrEqual(idx, 0, id + " 스테이지 표에 있음"); bm.StartStage(idx); yield return null; var layer = bm.Gimmicks; Assert.IsNotNull(layer); Assert.AreEqual(table.For(ch, no).Count, layer.Items.Count, id + " 배치 행 수"); foreach (var g in layer.Items) { if (g.IsBlock && !g.Template) Assert.Greater(g.Colliders.Count, 0, id + " " + g.Id + " 콜라이더"); } var items = new List(layer.Items); if (id == "8-15") { Assert.IsTrue(items.Exists(g => g.Template), "8-15 화공 = 책사 광역 자리 불(생성 규칙)"); Assert.IsNotNull(layer.OnSplash(new Vector2(0f, 6f), 1.5f), "광역 자리 불 생성"); } if (id == "9-11") { var river = items.Find(g => g.Zone == GimmickZone.River); Assert.AreEqual(0, river.Colliders.Count, "강 = 늪처럼 통과 가능한 지대(콜라이더 없음 · PD 2026-11-06 개정)"); Assert.IsTrue(layer.OnBridge(new Vector2(0f, 3f)) && layer.RiverAt(new Vector2(0f, 3f)) == null, "다리 구간 = 강 효과 없음"); } foreach (var u in bm.Units) { if (u == null || !u.IsAlive) continue; foreach (var g in items) if (g.BlocksMove) Assert.GreaterOrEqual(g.Shape.SignedDistance(u.Position), u.Radius, id + " " + u.DisplayName + " 이 " + g.Id + " 과 겹치지 않음(배치 보정)"); } } } [Test] public void Import_Tables_Valid_And_ErrorsReported() { var dir = Path.Combine(Application.dataPath, GimmickTables.Folder); var texts = new string[GimmickTables.Files.Length]; for (int i = 0; i < texts.Length; i++) texts[i] = File.ReadAllText(Path.Combine(dir, GimmickTables.Files[i]), Encoding.UTF8); var r = GimmickTables.Parse(texts); Assert.AreEqual(0, r.errors.Count, r.errors.Count > 0 ? r.errors[0] : ""); Assert.AreEqual(16, r.defs.Count, "gimmicks.csv 16 행(PD 6 + 제안 8 + 보조 2)"); Assert.AreEqual(GimmickTables.Db.defs.Count, r.defs.Count, "자산 = CSV"); Assert.AreEqual(GimmickTables.Table.rows.Count, r.rows.Count); var bad = (string[])texts.Clone(); bad[1] = texts[1].TrimEnd('\n', '\r') + "\n1-3,nothing,rect,0,0,1,1,0,,,\n1-3,palisade,hex,0,0,1,1,0,,,\n99-1,palisade,rect,0,0,1,1,0,,,\n1-3,ice,rect,0,0,1,1,0,,,foo=1\n"; var cfgLocal = Resources.Load("BalanceConfig"); var rb = GimmickTables.Parse(bad, (c, s) => { foreach (var st in cfgLocal.stages) if (st.chapter == c && st.stage == s) return true; return false; }); Assert.IsTrue(rb.errors.Exists(e => e.Contains("gimmick_id")), "없는 기믹 id"); Assert.IsTrue(rb.errors.Exists(e => e.Contains("shape")), "모양 오류"); Assert.IsTrue(rb.errors.Exists(e => e.Contains("스테이지 표에 없는")), "없는 스테이지"); Assert.IsTrue(rb.errors.Exists(e => e.Contains("params 키")), "params 키"); var w = new List(); AiWeights.Parse("key,value,desc_ko\ndmg_player,1,x\n", w); Assert.IsTrue(w.Exists(e => e.Contains("kill_base")), "가중치 키 누락 = 오류"); } // ---------- 병종 규칙 개정(PD 2026-11-06 #1098) ---------- [UnityTest] public IEnumerator Rule_InfantryBounceChain_OnceWithOtherEnemy_AllyOnlyPushed() { yield return Fresh(); var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병"); var a = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.6f, 0f), "적A"); float cc = inf.Radius * Combat.CastRadiusScale + a.Radius; float cy = -Mathf.Sqrt(cc * cc - 0.36f); Vector2 contact = new Vector2(0f, cy); Vector2 n = (a.Position - contact).normalized; var b = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, contact - n * (cc + 0.6f), "적B"); a.MaxHp = a.Hp = b.MaxHp = b.Hp = 5000; var o = new ActionOutcome(); yield return Act(inf, new Vector2(0f, 1.5f), o); Assert.Contains(a, o.Attacked); Assert.AreEqual(1, o.Chains, "튕겨나는 도중 다른 적 = 연쇄 1회"); Assert.Contains(b, o.Attacked); Assert.Less(b.Hp, b.MaxHp, "연쇄 = 피해·반격 교환"); yield return Fresh(); var inf2 = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병"); var a2 = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.6f, 0f), "적A"); var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, contact - n * (cc + 0.6f), "아군"); a2.MaxHp = a2.Hp = 5000; Vector2 before = ally.Position; var o2 = new ActionOutcome(); yield return Act(inf2, new Vector2(0f, 1.5f), o2); Assert.AreEqual(0, o2.Chains, "아군과 부딪히면 연쇄 없음"); Assert.AreEqual(ally.MaxHp, ally.Hp, "아군 = 밀기만"); Assert.Greater(Vector2.Distance(before, ally.Position), 0.1f, "아군이 밀림"); } [UnityTest] public IEnumerator Rule_SpearPushesTargetFar_RecoilsLittle_NoOverwatch() { yield return Fresh(); var spear = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, -3f), "창병"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0f), "적"); foe.MaxHp = foe.Hp = 5000; float cc = spear.Radius * Combat.CastRadiusScale + foe.Radius; var o = new ActionOutcome(); yield return Act(spear, foe.Position, o); float push = cc * cfg.spearPushRatio; // 남은 거리(당긴 3 − 접촉까지 = cc) × 창병 밀치기 배율(PD 2026-11-10 「크게 밀친다」) Assert.AreEqual(push, foe.Position.y, 0.08f, "상대 = 남은 거리 × spearPushRatio 만큼 밀림"); Assert.AreEqual(-cc - push * cfg.spearRecoilRatio, spear.Position.y, 0.08f, "창병 = 그 × spearRecoilRatio 만큼 반대로(함께 전진 없음)"); Assert.Greater(cfg.spearPushRatio, cfg.collisionPushRatio, "창병은 보병보다 크게 민다(PD 2026-11-10)"); Assert.Less(cfg.spearPushRatio * cfg.spearRecoilRatio, cfg.collisionSelfBounceRatio, "창병 자신은 보병 자기 튕김보다 적게 밀린다(PD 2026-11-10)"); Assert.Less(foe.Hp, foe.MaxHp, "접촉 순간 공격"); Assert.IsFalse(o.Overwatch || spear.Overwatch, "목표점 명중해도 대기(견제) 없음(PD 2026-11-10 제거)"); } } }