VSClone/Assets/Editor/HeadlessRun.Battle.cs

437 lines
34 KiB
C#
Raw 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;
using System.Collections.Generic;
using System.Numerics;
using UnityEngine;
using VS.Sim;
using Vector2 = UnityEngine.Vector2;
namespace VS.EditorTools
{
// 전투 모드 검사 (battle.json 설계값 기준)
public static partial class HeadlessRun
{
/// 자동 플레이: 열린 칸을 차례로 돌며 마나가 그 카드 값 이상(+ threshold 까지 모은 뒤)이면 바로 소환(아래 가운데) — 여러 종류가 섞인 무리
static Battle AutoBattle(Game g, int stage, int slots, int maxMana, float threshold, out float time, int seed = 3, BattleSetup setup = null)
{
var b = new Battle(g.BC, stage, maxMana, seed, setup);
b.Start();
time = 0; int next = 0;
while (b.State == BattleState.Fighting && time < 400)
{
var d = g.BattleUnit(next % slots);
if (d != null && b.Mana >= Mathf.Max(d.cost, Mathf.Min(threshold, maxMana))) { b.Deploy(d); next++; }
b.Tick(0.05f); time += 0.05f;
}
return b;
}
static List<string> L(params string[] s) => new List<string>(s);
/// 맵 고정: 강 없는 평지(또는 mapType) · 장애물 없음 · 성 x = 9
static BattleSetup Fixed(List<string> shots, List<string> gimmicks, int mapType = 0) => new BattleSetup { mapType = mapType, obstacles = 0, castleX = 9f, shots = shots, gimmicks = gimmicks };
/// 시험 유닛: 체력 100000 · 공격 0 · speed 0 = 제자리
static BUnitDef Dummy(float speed = 0f) => new BUnitDef { id = 1, name = "시험", cost = 0, hp = 100000f, damage = 0f, interval = 99f, range = 0.1f, speed = speed, radius = 0.45f };
static BUnit Put(Battle b, BUnitDef d, float x, float y) { b.Deploy(d); var u = b.Units[b.Units.Count - 1]; u.pos = new Vector2(x, y); return u; }
static bool TickUntil(Battle b, Func<bool> cond, float sec) { for (float t = 0; t < sec; t += 0.05f) { b.Tick(0.05f); if (cond()) return true; } return false; }
/// 성(x = 9) 바로 아래 줄에서 시험 유닛(반지름 0.45)이 성 가장자리로부터 edge 칸 떨어지는 y
static float InY(BattleCfg c, float edge) => c.castle.y - c.castle.radius - 0.45f - edge;
static void BattleTests(Tables t)
{
var c = t.Battle;
Check("B1.config", c != null && c.units.Count == 6 && c.units[0].cost == 1 && c.slotStart == 1 && c.slotMax == 6 && c.manaMaxBase == 10);
var g = NewGame(t, out _);
Check("B2.initial", g.BattleSlots == 1 && g.BattleMaxMana == 10 && g.BattleStage == 1);
var b = g.NewBattle();
Check("B2.castle", Math.Abs(b.CastleMaxHp - c.castle.hp) < 0.5f && Math.Abs(b.Mana - c.manaStart) < 0.001f && b.State == BattleState.Ready);
// B3 마나 충전 · 상한 · 마지막 1분 2배
b.Start();
for (int i = 0; i < 20; i++) b.Tick(0.05f);
Check("B3.regen", Math.Abs(b.Mana - (c.manaStart + 1f / c.manaRegenSeconds)) < 0.02f, b.Mana.ToString("0.000"));
for (int i = 0; i < 400; i++) b.Tick(0.05f);
Check("B3.cap", Math.Abs(b.Mana - 10f) < 0.001f);
var b3 = g.NewBattle(); b3.Start(); b3.Mana = 0; b3.TimeLeft = c.lastMinuteSeconds - 1;
b3.Tick(1f);
Check("B3.lastminute", Math.Abs(b3.Mana - c.lastMinuteRegenMul / c.manaRegenSeconds) < 0.01f);
// B4 소환 = 카드를 누르면 아래 가운데에서 즉시 (위치 선택 없음)
var b4 = g.NewBattle(); b4.Start();
var u1 = g.BattleUnit(0);
Check("B4.deploy", b4.Deploy(u1) && b4.Units.Count == 1 && Math.Abs(b4.Mana - (c.manaStart - 1)) < 0.001f);
var sp = b4.SpawnPoint;
Check("B4.spawn", Math.Abs(sp.x - c.arenaW * 0.5f) < 1e-4f && sp.y < c.arenaH * 0.1f && Vector2.Distance(b4.Units[0].pos, sp) <= c.spawnJitter + 1e-4f, $"spawn={sp} pos={b4.Units[0].pos}");
b4.Mana = 0.5f;
Check("B4.nomana", !b4.Deploy(u1));
// D1 카드 1장 = 1마리 (모든 카드)
var b1 = new Battle(c, 1, 100, 3); b1.Start(); b1.Mana = 100f;
bool oneEach = true;
foreach (var d in c.units) { int n0 = b1.Units.Count; if (!b1.Deploy(d) || b1.Units.Count != n0 + 1 || Vector2.Distance(b1.Units[n0].pos, b1.SpawnPoint) > c.spawnJitter + 1e-4f) oneEach = false; }
Check("D1.one_per_card", oneEach && b1.Spawned == c.units.Count, $"spawned={b1.Spawned}/{c.units.Count}");
// B5 다리로 건넘 (지상) · 곧장 (비행) — 다리 2개 맵 · 성 공격 끔
var b5s = Fixed(L(), L(), 2);
var b5 = new Battle(c, 1, 10, 7, b5s); b5.Start();
b5.Deploy(u1);
var unit = b5.Units[0]; bool crossedOnBridge = true, crossed = false;
for (int i = 0; i < 1200 && unit.alive && !unit.attacking; i++)
{
b5.Tick(0.05f);
if (Math.Abs(unit.pos.y - c.riverY) < c.riverHalf) { crossed = true; bool on = false; foreach (var bx in b5.Map.Bridges) if (Math.Abs(unit.pos.x - bx) <= c.bridgeHalf) on = true; if (!on) crossedOnBridge = false; }
}
Check("B5.bridge", crossed && crossedOnBridge);
var drag = c.units.Find(u => u.flying);
var b5f = new Battle(c, 1, 15, 7, b5s); b5f.Start(); b5f.Mana = 15; b5f.Deploy(drag);
var dr = b5f.Units[0]; var d0 = dr.pos; var toCastle = (b5f.CastlePos - d0).normalized;
for (int i = 0; i < 60; i++) b5f.Tick(0.05f);
var moved = dr.pos - d0;
Check("B5.flying_straight", Vector2.Angle(moved, toCastle) < 2f && moved.magnitude > 4f, $"angle={Vector2.Angle(moved, toCastle):0.0} moved={moved.magnitude:0.00}");
// B6 성 방어: 검사 1마리만 보내면 성에 거의 피해 못 주고 쓰러진다
var b6 = g.NewBattle(); b6.Start(); b6.Deploy(u1);
for (int i = 0; i < 1200; i++) b6.Tick(0.05f);
Check("B6.defend", b6.Units.Count == 0 && b6.Killed == 1 && b6.CastleHp >= b6.CastleMaxHp - u1.damage * 2, $"castle={b6.CastleHp} killed={b6.Killed}");
// B7 자동 플레이: 1칸(검사)만으로 1단계 승리 — 물량으로 (많이 쓰러지면서 이긴다)
var w1 = AutoBattle(g, 1, 1, 10, 5f, out float tw1);
Check("B7.stage1_win", w1.State == BattleState.Won, $"state={w1.State} t={tw1:0.0} castle={w1.CastleHp:0}");
Check("E2.swarm_win", w1.State == BattleState.Won && w1.Spawned >= 30 && w1.Killed >= 10, $"spawned={w1.Spawned} killed={w1.Killed}");
Debug.Log($"[VS] BATTLE stage1 slots1 → {w1.State} t={tw1:0.0}s spawned={w1.Spawned} killed={w1.Killed} 성 회복 {w1.CastleHeals}회 map={w1.MapName}");
for (int st = 2; st <= 8; st++)
{
var r1 = AutoBattle(g, st, 1, 10, 5f, out float ta);
var r6 = AutoBattle(g, st, 6, 15, 7f, out float tb);
Debug.Log($"[VS] BATTLE stage{st} castleHP={r1.CastleMaxHp:0} atk={r1.Atk:0} 성={string.Join("+", r1.ShotNames())}/{string.Join("+", r1.GimmickNames())} · 1칸/10마나 → {r1.State} {ta:0}s 성 남은 {r1.CastleHp / r1.CastleMaxHp:P0} 소환 {r1.Spawned} · 6칸/15마나 → {r6.State} {tb:0}s 성 남은 {r6.CastleHp / r6.CastleMaxHp:P0} 소환 {r6.Spawned} 소환수 처치 {r6.MinionsKilled}");
// 사거리 해석 비교(시험 배율) — 성 사거리 ×0.5 · ×0.25(= 예전 화살 7칸의 절반 3.5칸)
foreach (var mul in c.testRangeMuls)
{
if (mul == 1f) continue;
var q1 = AutoBattle(g, st, 1, 10, 5f, out float qa, 3, new BattleSetup { rangeMul = mul });
var q6 = AutoBattle(g, st, 6, 15, 7f, out float qb, 3, new BattleSetup { rangeMul = mul });
Debug.Log($"[VS] BATTLE └ 사거리 ×{mul} · 1칸 → {q1.State} {qa:0}s 성 남은 {q1.CastleHp / q1.CastleMaxHp:P0} · 6칸 → {q6.State} {qb:0}s 성 남은 {q6.CastleHp / q6.CastleMaxHp:P0}");
}
}
// B9 강화
var g9 = NewGame(t, out _);
g9.Coin = BigInteger.Parse("100000000000");
var p1 = g9.SlotUnlockPrice();
Check("B9.slot", g9.UnlockBattleSlot() == BuyResult.Ok && g9.BattleSlots == 2 && g9.Coin == BigInteger.Parse("100000000000") - p1);
while (g9.UnlockBattleSlot() == BuyResult.Ok) { }
Check("B9.slot_max", g9.BattleSlots == c.slotMax && g9.UnlockBattleSlot() == BuyResult.Max);
Check("B9.mana", g9.UpgradeBattleMana() == BuyResult.Ok && g9.BattleMaxMana == 11);
while (g9.UpgradeBattleMana() == BuyResult.Ok) { }
Check("B9.mana_cap", g9.BattleMaxMana == c.manaMaxCap && g9.NewBattle().MaxMana == c.manaMaxCap);
// B10 승리 보상 · 다음 단계
var g10 = NewGame(t, out _);
var wb = AutoBattle(g10, 1, 1, 10, 5f, out _);
var coin0 = g10.Coin; var reward = g10.BattleReward(1);
Check("B10.reward", g10.WinBattle(wb) == reward && g10.Coin == coin0 + reward && g10.BattleStage == 2 && g10.NewBattle().CastleMaxHp > wb.CastleMaxHp);
// B11 스테이지 — 줄마다 이름 · 다음에 깰 스테이지까지 열림 · 고른 스테이지로 전투 · 지난 스테이지·시험 승리는 진행 그대로 · 마지막을 깨면 전부 클리어
int stN = c.StageCount; bool stNamed = stN > 0;
for (int st = 1; st <= stN; st++) stNamed &= !string.IsNullOrEmpty(c.StageName(st));
Check("B11.stages", stNamed && stN == c.stageLoadouts.Count, $"stages={stN}");
var g11 = NewGame(t, out _);
Check("B11.open", g11.StageOpen(1) && !g11.StageOpen(2) && !g11.StageCleared(1) && g11.StageFocus == 1);
var stPick = g11.NewBattle(3, 7, null);
Check("B11.pick", stPick.Stage == 3 && stPick.CastleMaxHp == c.CastleHpAt(3) && Mathf.Approximately(stPick.Atk, c.CastleAtkAt(3)), $"hp={stPick.CastleMaxHp}");
Battle WonAt(Game gg, int st, BattleSetup set = null) { var w = gg.NewBattle(st, 7, set); w.Start(); w.CastleHpDebugKill(); w.Tick(0.05f); w.Tick(0.05f); return w; }
g11.S.battleStage = 3; // 1 · 2 클리어
var stReplay = WonAt(g11, 1); var stReward = g11.WinBattle(stReplay);
g11.WinBattle(WonAt(g11, 3, Fixed(L("normal"), L()))); // 시험 설정으로 이김 = 기록 안 함
Check("B11.replay_test", stReplay.State == BattleState.Won && stReward == g11.BattleReward(1) && g11.BattleStage == 3 && g11.StageCleared(2) && g11.StageOpen(3) && !g11.StageOpen(4), $"stage={g11.BattleStage}");
g11.S.battleStage = stN; g11.WinBattle(WonAt(g11, stN));
Check("B11.all_clear", g11.BattleStage == stN + 1 && g11.StageCleared(stN) && g11.StageOpen(stN) && g11.StageFocus == stN);
MapTests(c);
CastleTests(c);
FlagTests(c);
StrengthRegenTests(c);
}
// ---------------- 깃발: 맵을 누른 곳 → 내 유닛이 깃발에 먼저 닿은 뒤 성으로 ----------------
static void FlagTests(BattleCfg c)
{
var walker = Dummy(1.2f); walker.range = 0.8f;
var fly = Dummy(1.5f); fly.range = 0.8f; fly.flying = true;
var ready = new Battle(c, 1, 10, 3, Fixed(L(), L()));
Check("F1.not_before_start", !ready.SetFlag(new Vector2(3f, 12f)) && !ready.HasFlag);
var fb = new Battle(c, 1, 15, 3, Fixed(L(), L())); fb.Start();
var before = Put(fb, walker, fb.SpawnPoint.x, fb.SpawnPoint.y);
for (int i = 0; i < 20; i++) fb.Tick(0.05f); // 깃발 전에 이미 성 쪽으로 걷던 유닛
var flagAt = new Vector2(3f, 12f);
Check("F1.flag_set", fb.SetFlag(flagAt) && fb.HasFlag && Vector2.Distance(fb.FlagPos, flagAt) < 1e-4f);
var after = Put(fb, walker, fb.SpawnPoint.x, fb.SpawnPoint.y); // 깃발 뒤에 소환한 유닛
var flyer = Put(fb, fly, fb.SpawnPoint.x, fb.SpawnPoint.y);
var probe = new[] { before, after, flyer }; var touched = new bool[3]; var early = new bool[3];
for (int i = 0; i < 2400 && !(before.attacking && after.attacking && flyer.attacking); i++)
{
fb.Tick(0.05f);
for (int k = 0; k < 3; k++) { if (Vector2.Distance(probe[k].pos, flagAt) <= c.flagReach + 1e-3f) touched[k] = true; if (probe[k].attacking && !touched[k]) early[k] = true; }
}
Check("F2.existing_unit", touched[0] && before.attacking && !early[0], $"pos={before.pos}");
Check("F3.new_unit", touched[1] && after.attacking && !early[1], $"pos={after.pos}");
Check("F4.flying", touched[2] && flyer.attacking && !early[2], $"pos={flyer.pos}");
// F5 새 깃발 = 성을 치던 유닛도 다시 새 깃발로 → 닿은 뒤 다시 성
var flag2 = new Vector2(15f, 20f); fb.SetFlag(flag2);
bool left = false, touched2 = false;
for (int i = 0; i < 2400 && !(touched2 && before.attacking); i++)
{
fb.Tick(0.05f);
if (!before.attacking) left = true;
if (Vector2.Distance(before.pos, flag2) <= c.flagReach + 1e-3f) touched2 = true;
}
Check("F5.reflag", left && touched2 && before.attacking && fb.FlagVer == 2, $"pos={before.pos}");
// F6 강물·바위를 누르면 가장 가까운 빈 칸에 꽂힘
var fr = new Battle(c, 1, 10, 3, Fixed(L(), L(), 2)); fr.Start();
var water = new Vector2(9f, c.riverY);
Check("F6.snap_free", fr.Map.Blocked(water) && fr.SetFlag(water) && !fr.Map.Blocked(fr.FlagPos) && Vector2.Distance(fr.FlagPos, water) < 2f, $"flag={fr.FlagPos}");
}
// ---------------- 적은 내 유닛보다 매우 강하게(물량 컨셉) · 성벽 회복 ----------------
static void StrengthRegenTests(BattleCfg c)
{
// E1 성·소환수의 공격력·체력 ≥ 내 가장 강한 유닛의 2배 (1단계 기준 · 단계마다 더 커짐)
float maxHp = 0f, maxDmg = 0f; foreach (var u in c.units) { maxHp = Mathf.Max(maxHp, u.hp); maxDmg = Mathf.Max(maxDmg, u.damage); }
Check("E1.castle_strong", c.castle.hp >= 2f * maxHp && c.castle.atk >= 2f * maxDmg, $"성 체력 {c.castle.hp} 공격 {c.castle.atk} · 내 최고 체력 {maxHp} 공격 {maxDmg}");
bool sOk = true; foreach (var s in c.summons) if (s.hp < 2f * maxHp || s.damage < 2f * maxDmg) sOk = false;
Check("E1.summon_strong", sOk, string.Join(" · ", c.summons.ConvertAll(s => $"{s.name} 체력 {s.hp} 공격 {s.damage}")));
var basic = c.units[0];
Check("E1.one_shot", c.castle.atk * c.Shot("normal").dmgPct / 100f >= basic.hp, $"일반 {c.castle.atk * c.Shot("normal").dmgPct / 100f} vs {basic.name} 체력 {basic.hp}");
// C 성벽 회복: 맞은 뒤 regenDelay 초 동안 그대로 → 그때부터 regenInterval 초마다 최대 체력의 regenPct % · 최대 체력까지
var k = c.castle;
var rb = new Battle(c, 1, 10, 1, Fixed(L(), L())); rb.Start();
rb.HitCastle(rb.CastleMaxHp * 0.5f);
float hp0 = rb.CastleHp, step = rb.CastleMaxHp * k.regenPct / 100f;
for (int i = 0; i < Mathf.FloorToInt((k.regenDelay - 0.1f) / 0.05f); i++) rb.Tick(0.05f);
Check("C1.regen_wait", rb.CastleHp == hp0 && rb.CastleHeals == 0, $"hp={rb.CastleHp}");
for (int i = 0; i < 4; i++) rb.Tick(0.05f);
Check("C2.regen_first", Near(rb.CastleHp - hp0, step, 0.5) && rb.CastleHeals == 1 && rb.Fx.Exists(f => f.type == "heal"), $"+{rb.CastleHp - hp0} step={step}");
int n10 = Mathf.RoundToInt(10f / k.regenInterval);
for (int i = 0; i < Mathf.RoundToInt(10f / 0.05f); i++) rb.Tick(0.05f);
Check("C2.regen_rate", Math.Abs(rb.CastleHeals - 1 - n10) <= 1 && Near(rb.CastleHp - hp0, step * rb.CastleHeals, 1.0), $"10초 회복 {rb.CastleHeals - 1}회");
rb.CastleHp = rb.CastleMaxHp - step * 0.5f;
for (int i = 0; i < 60; i++) rb.Tick(0.05f);
Check("C3.regen_cap", rb.CastleHp == rb.CastleMaxHp, $"hp={rb.CastleHp}/{rb.CastleMaxHp}");
// C4 계속 맞는 동안은 회복 없음
var hitter = Dummy(); hitter.damage = 1f; hitter.interval = 1f; hitter.range = 0.8f;
var rc = new Battle(c, 1, 10, 1, Fixed(L(), L())); rc.Start();
rc.HitCastle(rc.CastleMaxHp * 0.5f);
Put(rc, hitter, 9f, InY(c, 0.3f));
for (int i = 0; i < Mathf.RoundToInt((k.regenDelay * 3f) / 0.05f); i++) rc.Tick(0.05f);
Check("C4.regen_reset", rc.CastleHeals == 0 && rc.CastleHp < rc.CastleMaxHp * 0.5f, $"heals={rc.CastleHeals} hp={rc.CastleHp}");
}
// ---------------- 맵 4종 · 무작위 생성 · 장애물 우회 ----------------
static void MapTests(BattleCfg c)
{
for (int k = 0; k <= 3; k++)
{
var mb = new Battle(c, 1, 10, 100 + k, new BattleSetup { mapType = k });
var mp = mb.Map; int row = Mathf.FloorToInt(c.riverY - 0.5f), lanes = 0;
for (int x = 0; x < mp.W; x++) if (mp.Walk[row * mp.W + x]) lanes++;
Check("M1.type" + k, mp.Type == k && mp.Bridges.Length == k && mp.River == (k > 0) && lanes == (k > 0 ? k : mp.W) && mp.Reachable(mb.SpawnPoint), $"lanes={lanes} bridges={string.Join(",", mp.Bridges)}");
}
int n = 400, reach = 0, top = 0, near = 0, withObs = 0, blocking = 0; var cnt = new int[4];
float far = c.obstacleDistMax + c.obstacleRadiusMax * c.obstacleSpacing * c.obstaclePiecesMax * 0.5f + c.obstacleWobble + 0.01f;
for (int s = 0; s < n; s++)
{
var mb = new Battle(c, 1, 10, 1000 + s); var mp = mb.Map;
cnt[mp.Type]++;
if (mp.Reachable(mb.SpawnPoint)) reach++;
if (Math.Abs(mp.Castle.y - c.castle.y) < 1e-4f && mp.Castle.y >= c.arenaH * 0.75f && mp.Castle.x >= c.castle.xMin - 1e-4f && mp.Castle.x <= c.castle.xMax + 1e-4f) top++;
bool ok = true; foreach (var o in mp.Obstacles) if (o.pos.y <= c.riverY + c.riverHalf || Vector2.Distance(o.pos, mp.Castle) > far) ok = false;
if (ok) near++;
if (mp.Obstacles.Count > 0) withObs++;
if (!mp.Clear(new Vector2(mp.Castle.x, mp.Castle.y - c.castle.radius - c.castleSeedPad - 0.3f), new Vector2(mp.Castle.x, c.riverY + c.riverHalf + 0.5f))) blocking++;
}
Check("M2.random_types", cnt[0] > n / 10 && cnt[1] > n / 10 && cnt[2] > n / 10 && cnt[3] > n / 10, string.Join(",", cnt));
Check("M2.reachable", reach == n, $"{reach}/{n}");
Check("M2.castle_top", top == n, $"{top}/{n}");
Check("M2.obstacles_near_castle", near == n, $"{near}/{n}");
Check("M2.obstacle_rate", withObs > n / 2 && withObs < n, $"{withObs}/{n}");
Check("M3.blocking", blocking > 0, $"성 바로 앞 직선이 막힌 맵 {blocking}/{n}");
Debug.Log($"[VS] MAP 400판: 평지/다리1/다리2/다리3 = {string.Join("/", cnt)} · 바위·언덕 있음 {withObs} · 성 앞 직선 막힘 {blocking}");
// P1 성 앞을 가로막는 바위 벽 → 끝을 돌아서 도착 · 막힌 칸 진입 0
var wall = new List<BObstacle>(); for (float x = 5f; x <= 12.6f; x += 1.5f) wall.Add(new BObstacle { pos = new Vector2(x, 22f), r = 1f, kind = 0 });
var ps = Fixed(L(), L()); ps.customObstacles = wall;
var pb = new Battle(c, 1, 10, 3, ps); pb.Start();
var walker = Dummy(1.2f); walker.range = 0.8f;
var pu = Put(pb, walker, 9f, c.spawnY);
bool noBlock = true; float maxDx = 0f, pathLen = 0f; var prev = pu.pos;
for (int i = 0; i < 1600 && !pu.attacking; i++)
{
pb.Tick(0.05f);
if (pb.Map.Blocked(pu.pos)) noBlock = false;
maxDx = Mathf.Max(maxDx, Mathf.Abs(pu.pos.x - 9f)); pathLen += (pu.pos - prev).magnitude; prev = pu.pos;
}
float straight = Vector2.Distance(new Vector2(9f, c.spawnY), pb.CastlePos) - c.castle.radius - walker.radius - walker.range;
Check("P1.detour_reach", pu.attacking, $"pos={pu.pos}");
Check("P1.detour_noblock", noBlock);
Check("P1.detour_around", maxDx > 4.2f && pathLen > straight * 1.05f, $"maxDx={maxDx:0.00} len={pathLen:0.0} straight={straight:0.0}");
// P2 무작위 맵 12판(바위·언덕 켬): 전부 도착 · 막힌 칸 진입 0 · 강은 다리 위로만
bool allReach = true, noBlock2 = true, bridgeOnly = true; int obsMaps = 0;
for (int s = 1; s <= 12; s++)
{
var rb = new Battle(c, 1, 10, 500 + s, new BattleSetup { obstacles = 1, shots = L(), gimmicks = L() }); rb.Start();
if (rb.Map.Obstacles.Count > 0) obsMaps++;
var ru = Put(rb, walker, rb.SpawnPoint.x, rb.SpawnPoint.y);
for (int i = 0; i < 2400 && !ru.attacking; i++)
{
rb.Tick(0.05f);
if (rb.Map.Blocked(ru.pos)) noBlock2 = false;
if (rb.Map.River && Math.Abs(ru.pos.y - c.riverY) < c.riverHalf) { bool on = false; foreach (var bx in rb.Map.Bridges) if (Math.Abs(ru.pos.x - bx) <= c.bridgeHalf) on = true; if (!on) bridgeOnly = false; }
}
if (!ru.attacking) { allReach = false; Debug.Log($"[VS] P2 seed {500 + s} 도착 못 함 type={rb.Map.Type} pos={ru.pos}"); }
}
Check("P2.reach_all", allReach && obsMaps == 12, $"obsMaps={obsMaps}");
Check("P2.no_block", noBlock2);
Check("P2.bridge_only", bridgeOnly);
}
// ---------------- 성 기믹 3종 · 투사체 5종 · 사거리 · 시험 설정 ----------------
static void CastleTests(BattleCfg c)
{
float atk = c.castle.atk; // 1단계 공격력
var dm = Dummy();
// G1 파이어 볼: 맞은 유닛 dmgPct · 둘레 splashRadius 안 splashPct · 밖 0
var fb = c.Skill("fireball");
var g1 = new Battle(c, 1, 10, 1, Fixed(L(), L("fireball"))); g1.Start();
float fy = InY(c, fb.range - 1.2f);
var ua = Put(g1, dm, 9f, fy); var ub = Put(g1, dm, 10.2f, fy); var ud = Put(g1, dm, 7.8f, fy); var uc = Put(g1, dm, 9f + fb.splashRadius + 2f, fy);
g1.CastleSkills[0].cd = 0f;
TickUntil(g1, () => ua.hp < dm.hp, 5f);
Check("G1.fireball_hit", Near(dm.hp - ua.hp, atk * fb.dmgPct / 100f, 0.01), $"{dm.hp - ua.hp}");
Check("G1.fireball_splash", Near(dm.hp - ub.hp, atk * fb.splashPct / 100f, 0.01) && Near(dm.hp - ud.hp, atk * fb.splashPct / 100f, 0.01), $"{dm.hp - ub.hp} {dm.hp - ud.hp}");
Check("G1.fireball_outside", uc.hp == dm.hp && g1.Fx.Exists(f => f.type == "blast"));
// G2 라이트닝: 즉시(탄 없이) dmgPct + 기절 동안 이동·공격 멈춤 → 풀리면 다시 이동
var lt = c.Skill("lightning");
var g2 = new Battle(c, 1, 10, 1, Fixed(L(), L("lightning"))); g2.Start();
var wk = Dummy(1.2f);
var ux = Put(g2, wk, 9f, InY(c, lt.range + 0.8f)); // 사거리 밖에서 걸어 들어옴
g2.CastleSkills[0].cd = 0f;
bool shotSeen = false;
TickUntil(g2, () => { if (g2.Shots.Count > 0) shotSeen = true; return ux.hp < wk.hp; }, 5f);
Check("G2.lightning_instant", Near(wk.hp - ux.hp, atk * lt.dmgPct / 100f, 0.01) && !shotSeen && g2.Fx.Exists(f => f.type == "lightning"), $"{wk.hp - ux.hp}");
var pz = ux.pos; for (int i = 0; i < 20; i++) g2.Tick(0.05f);
Check("G2.stun_freeze", (ux.pos - pz).magnitude < 1e-4f && ux.stun > 0f && !ux.attacking, $"stun={ux.stun:0.00}");
for (int i = 0; i < 20; i++) g2.Tick(0.05f);
Check("G2.stun_recover", ux.stun <= 0f && (ux.pos - pz).magnitude > 0.3f, $"moved={(ux.pos - pz).magnitude:0.00}");
// G3 소환(근접): interval 마다 · 최대 max · 성 둘레 집에 주둔
var sm = c.Summon("summonMelee");
var g3 = new Battle(c, 1, 10, 1, Fixed(L(), L("summonMelee"))); g3.Start();
int maxSeen = 0;
for (float tt = 0; tt < sm.interval * (sm.max + 3); tt += 0.05f) { g3.Tick(0.05f); maxSeen = Math.Max(maxSeen, g3.Minions.Count); }
Check("G3.summon_cap", g3.Minions.Count == sm.max && maxSeen == sm.max && g3.MinionsSpawned == sm.max, $"now={g3.Minions.Count} max={maxSeen}");
bool atHome = true; foreach (var m in g3.Minions) if ((m.pos - m.home).magnitude > 0.1f || Vector2.Distance(m.home, g3.CastlePos) > c.castle.radius + sm.homeDist + 1f) atHome = false;
Check("G3.summon_home", atHome);
// G4 사거리 밖 유닛 = 가만히 · 사거리 안 = 공격 · 내 진영(강 아래)으로는 안 내려감
var farU = Put(g3, Dummy(), 9f, 8f);
var homes = g3.Minions.ConvertAll(m => m.pos);
for (int i = 0; i < 100; i++) g3.Tick(0.05f);
bool idle = farU.hp == 100000f; for (int i = 0; i < homes.Count; i++) if ((g3.Minions[i].pos - homes[i]).magnitude > 0.1f) idle = false;
Check("G4.guard_idle", idle);
var h0 = g3.Minions[0].home;
var nearU = Put(g3, Dummy(), h0.x, h0.y - 3.5f);
float minY = float.MaxValue; bool leash = true;
for (int i = 0; i < 100; i++) { g3.Tick(0.05f); foreach (var m in g3.Minions) { minY = Mathf.Min(minY, m.pos.y); if (Vector2.Distance(m.pos, m.home) > g3.Guard(m.def) + 1f) leash = false; } }
Check("G4.guard_attack", nearU.hp < 100000f && farU.hp == 100000f, $"near={nearU.hp} far={farU.hp}");
Check("G4.no_base", leash && minY > c.riverY, $"minY={minY:0.0}");
// G5 소환(원거리): 제자리에서 탄으로 공격
var sr = c.Summon("summonRanged");
var g5 = new Battle(c, 1, 10, 1, Fixed(L(), L("summonRanged"))); g5.Start();
g5.Summoners[0].cd = 0f;
TickUntil(g5, () => g5.Minions.Count > 0 && (g5.Minions[0].pos - g5.Minions[0].home).magnitude < 0.05f, 10f);
var rm = g5.Minions[0]; var rh = rm.home;
var tgt = Put(g5, Dummy(), rh.x, rh.y - (sr.attackRange - 0.5f));
bool minionShot = false; for (int i = 0; i < 80; i++) { g5.Tick(0.05f); if (g5.Shots.Exists(s => s.owner == 2)) minionShot = true; }
Check("G5.ranged_shot", minionShot && tgt.hp < 100000f && (rm.pos - rh).magnitude < 0.05f, $"hp={tgt.hp}");
// G6 내 유닛도 소환수를 공격해 쓰러뜨린다
var g6 = new Battle(c, 1, 15, 1, Fixed(L(), L("summonMelee"))); g6.Start();
g6.Summoners[0].cd = 0f;
TickUntil(g6, () => g6.Minions.Count > 0 && (g6.Minions[0].pos - g6.Minions[0].home).magnitude < 0.05f, 10f);
var kd = c.units[2];
var strong = new BUnitDef { id = kd.id, name = kd.name, cost = 0, hp = 100000f, damage = kd.damage, interval = kd.interval, range = kd.range, speed = kd.speed, radius = kd.radius };
Put(g6, strong, g6.Minions[0].home.x, g6.Minions[0].home.y - 4f);
TickUntil(g6, () => g6.MinionsKilled > 0, g6.Minions[0].maxHp / (kd.damage / kd.interval) + 15f);
Check("G6.fight_back", g6.MinionsKilled >= 1);
// S1 일반: 단일 dmgPct
var sn = c.Shot("normal");
var s1 = new Battle(c, 1, 10, 1, Fixed(L("normal"), L())); s1.Start();
var t1 = Put(s1, dm, 9f, InY(c, sn.range - 1f)); s1.CastleShots[0].cd = 0f;
TickUntil(s1, () => t1.hp < dm.hp, 5f);
Check("S1.normal", Near(dm.hp - t1.hp, atk * sn.dmgPct / 100f, 0.01), $"{dm.hp - t1.hp}");
// S2 범위: 맞은 유닛 dmgPct + 둘레 splashPct · 밖 0
var ss = c.Shot("splash");
var s2 = new Battle(c, 1, 10, 1, Fixed(L("splash"), L())); s2.Start();
float sy2 = InY(c, ss.range - 1f);
var a2 = Put(s2, dm, 9f, sy2); var b2 = Put(s2, dm, 10.2f, sy2); var c2 = Put(s2, dm, 9f + ss.splashRadius + 1.5f, sy2); s2.CastleShots[0].cd = 0f;
TickUntil(s2, () => a2.hp < dm.hp, 5f);
Check("S2.splash", Near(dm.hp - a2.hp, atk * ss.dmgPct / 100f, 0.01) && Near(dm.hp - b2.hp, atk * ss.splashPct / 100f, 0.01) && c2.hp == dm.hp, $"{dm.hp - a2.hp} {dm.hp - b2.hp} {dm.hp - c2.hp}");
// S3 멀티샷: 부채꼴 count 발 → 각 방향 유닛 1명씩 동시에
var sml = c.Shot("multi");
var s3 = new Battle(c, 1, 10, 1, Fixed(L("multi"), L())); s3.Start();
var fan = new List<BUnit>();
for (int i = 0; i < sml.count; i++)
{
float ang = (270f - sml.spreadDeg * 0.5f + sml.spreadDeg * i / (sml.count - 1)) * Mathf.Deg2Rad;
float dist = c.castle.radius + 0.45f + sml.range - 1.5f - (i == sml.count / 2 ? 0.1f : 0f); // 가운데 유닛이 가장 가까움 = 조준 방향
fan.Add(Put(s3, dm, 9f + Mathf.Cos(ang) * dist, c.castle.y + Mathf.Sin(ang) * dist));
}
s3.CastleShots[0].cd = 0f;
s3.Tick(0.05f);
int bolts = s3.Shots.FindAll(s => s.type == "multi").Count;
TickUntil(s3, () => !s3.Shots.Exists(s => s.type == "multi"), 3f);
int hitN = 0; foreach (var u in fan) if (Near(dm.hp - u.hp, atk * sml.dmgPct / 100f, 0.01)) hitN++;
Check("S3.multi", bolts == sml.count && hitN == sml.count, $"bolts={bolts} hit={hitN}");
// S4 감속: 맞으면 이동 속도 −slowPct% · slowSeconds 뒤 원래대로
var sl = c.Shot("slow");
var s4 = new Battle(c, 1, 10, 1, Fixed(L("slow"), L())); s4.Start();
var su = Put(s4, wk, 9f, InY(c, sl.range + 0.5f)); s4.CastleShots[0].cd = 0f;
TickUntil(s4, () => su.slow > 0f, 5f);
Check("S4.slow_hit", Near(wk.hp - su.hp, atk * sl.dmgPct / 100f, 0.01) && Near(su.slowMul, 1f - sl.slowPct / 100f, 1e-4), $"dmg={wk.hp - su.hp} mul={su.slowMul}");
var q0 = su.pos; for (int i = 0; i < 10; i++) s4.Tick(0.05f);
float slowDist = (su.pos - q0).magnitude;
s4.CastleShots.Clear(); s4.Shots.Clear();
su.pos = new Vector2(9f, c.spawnY + 2f); // 성에 닿아 멈추지 않게 멀리 옮겨 감속이 풀리길 기다림
for (int i = 0; i < 70; i++) s4.Tick(0.05f);
var q1 = su.pos; for (int i = 0; i < 10; i++) s4.Tick(0.05f);
float normDist = (su.pos - q1).magnitude;
Check("S4.slow_speed", Near(slowDist, wk.speed * 0.5f * (1f - sl.slowPct / 100f), 0.05), $"slow={slowDist:0.000}");
Check("S4.slow_recover", su.slow <= 0f && Near(normDist, wk.speed * 0.5f, 0.05), $"normal={normDist:0.000}");
// S5 레이저: 선 위 전원 같은 순간 · 선 밖 0
var sz = c.Shot("laser");
var s5 = new Battle(c, 1, 10, 1, Fixed(L("laser"), L())); s5.Start();
var la = Put(s5, dm, 9f, InY(c, 0.5f)); var lb = Put(s5, dm, 9f, InY(c, sz.range * 0.5f)); var lc = Put(s5, dm, 9f, InY(c, sz.range - 0.2f)); var lo = Put(s5, dm, 12.5f, InY(c, sz.range * 0.5f));
s5.CastleShots[0].cd = 0f; s5.Tick(0.05f);
float lz = atk * sz.dmgPct / 100f;
Check("S5.laser_line", Near(dm.hp - la.hp, lz, 0.01) && Near(dm.hp - lb.hp, lz, 0.01) && Near(dm.hp - lc.hp, lz, 0.01) && s5.Fx.Exists(f => f.type == "laser"), $"{dm.hp - la.hp} {dm.hp - lb.hp} {dm.hp - lc.hp}");
Check("S5.laser_off", lo.hp == dm.hp);
// R 사거리: 기본(일반) = 예전 화살 7칸의 절반 · 스킬마다 다름 · 밖이면 안 쏨 · 시험 배율로 늘림
Check("R1.default_half", Near(sn.range, 7f * 0.5f, 0.01) && Near(c.Skill("fireball").range, 10f * 0.5f, 0.01), $"일반 {sn.range} · 파이어 볼 {c.Skill("fireball").range}");
var ranges = new HashSet<float>(); foreach (var d in c.shots) ranges.Add(d.range); foreach (var d in c.skills) ranges.Add(d.range);
Check("R2.per_skill", ranges.Count >= 3, string.Join(",", ranges));
var r3 = new Battle(c, 1, 10, 1, Fixed(L("normal"), L())); r3.Start();
var ro = Put(r3, dm, 9f, InY(c, sn.range + 0.6f)); r3.CastleShots[0].cd = 0f; // 사거리 밖 = 안 맞음
for (int i = 0; i < 60; i++) r3.Tick(0.05f);
float mul = c.testRangeMuls[c.testRangeMuls.Length - 1];
var rs = Fixed(L("normal"), L()); rs.rangeMul = mul;
var r4 = new Battle(c, 1, 10, 1, rs); r4.Start();
var ri = Put(r4, dm, 9f, InY(c, sn.range + 0.6f)); r4.CastleShots[0].cd = 0f; // 같은 자리 · 시험 배율이면 맞음
for (int i = 0; i < 60; i++) r4.Tick(0.05f);
Check("R3.out_of_range", ro.hp == dm.hp && ri.hp < dm.hp && Near(r4.Range(sn), sn.range * mul, 1e-4), $"×{mul}");
// X 시험 설정 · 단계 기본 구성
var xs = new BattleSetup { mapType = 3, obstacles = 0, shots = L("multi", "laser"), gimmicks = L("lightning", "summonRanged"), rangeMul = 0.5f };
var xb = new Battle(c, 1, 10, 9, xs);
Check("X1.setup", xb.IsTest && xb.Map.Type == 3 && xb.Map.Obstacles.Count == 0 && xb.CastleShots.Count == 2 && xb.CastleShots[0].def.id == "multi" && xb.CastleShots[1].def.id == "laser"
&& xb.CastleSkills.Count == 1 && xb.CastleSkills[0].def.id == "lightning" && xb.Summoners.Count == 1 && xb.Summoners[0].def.id == "summonRanged" && Near(xb.Range(c.Shot("laser")), c.Shot("laser").range * 0.5f, 1e-4));
bool loOk = true;
for (int st = 1; st <= 9; st++)
{
var sb = new Battle(c, st, 10, st); var l = c.Loadout(st);
var ids = sb.CastleShots.ConvertAll(w => w.def.id); var gids = new HashSet<string>(sb.CastleSkills.ConvertAll(w => w.def.id)); gids.UnionWith(sb.Summoners.ConvertAll(s => s.def.id));
if (string.Join(",", ids) != string.Join(",", l.shots) || !gids.SetEquals(l.gimmicks) || sb.IsTest) loOk = false;
}
Check("X2.stage_loadout", loOk);
}
}
}