ThreeKingdomsSlingshot/Assets/Tests/PlayMode/GimmickTests.cs

647 lines
36 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

using System.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 제거)");
}
}
}