647 lines
36 KiB
C#
647 lines
36 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// 지형 기믹 레이어(PD #1097 · 지형기믹_기획서) — 기믹 16 × 핵심 병종 조합 · 샘플 5 스테이지 로드 · 임포트 검증 · 조준 미리보기 ·
|
||
/// 병종 규칙 개정 2건(보병 연쇄 · 창병 반작용 · PD #1098). 수치 = 임포트 자산(GimmickDb · BalanceConfig)에서 읽는다(C45).
|
||
/// </summary>
|
||
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<GimmickInstance>(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>("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<string>();
|
||
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 제거)");
|
||
}
|
||
}
|
||
}
|