2026-10-29 13:21:25 +00:00
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 ) ;
2026-10-31 10:29:10 +00:00
if ( Math . Abs ( unit . pos . y - c . riverY ) < c . riverHalf ) { crossed = true ; if ( ! b5 . Map . OnBridge ( unit . pos . x ) ) crossedOnBridge = false ; }
2026-10-29 13:21:25 +00:00
}
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 _ ) ;
2026-10-31 10:29:10 +00:00
var coin0 = g10 . Coin ; var reward = g10 . StageWinReward ( 1 , true ) ; // 첫 클리어 = 기본 × 등급 firstMul
Check ( "B10.reward" , g10 . WinBattle ( wb ) = = reward & & reward > g10 . BattleReward ( 1 ) & & g10 . Coin = = coin0 + reward & & g10 . BattleStage = = 2 & & g10 . NewBattle ( ) . CastleMaxHp > wb . CastleMaxHp , $"reward={reward} base={g10.BattleReward(1)}" ) ;
2026-10-29 13:58:23 +00:00
// 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 ) ;
2026-10-29 14:42:47 +00:00
// B12 덱 · 등급 — 처음 덱 = 유닛 순서 · 빼기 = 빈 칸 · 넣기 = 열린 빈 칸 먼저 · 놓기 = 맞바꿈 · 프리셋끼리 따로 · 저장 왕복 · 등급 7개 순서 · 유닛마다 등급
var g12 = NewGame ( t , out _ ) ; var dk = g12 . ActiveDeck ; var us = c . units ;
bool inOrder = dk . Length = = c . slotMax ;
for ( int i = 0 ; i < dk . Length & & i < us . Count ; i + + ) inOrder & = dk [ i ] = = us [ i ] . id ;
Check ( "B12.deck_default" , inOrder & & g12 . BattleUnit ( 0 ) = = us [ 0 ] & & g12 . DeckReady & & g12 . DeckPresets = = c . deckPresets & & c . deckPresets > = 2 , $"presets={c.deckPresets}" ) ;
g12 . DeckRemove ( us [ 0 ] . id ) ; g12 . DeckRemove ( us [ 1 ] . id ) ;
bool emptied = g12 . BattleUnit ( 0 ) = = null & & ! g12 . DeckReady & & g12 . DeckPos ( us [ 0 ] . id ) < 0 ;
bool addOpen = g12 . DeckAdd ( us [ 1 ] . id ) & & g12 . DeckPos ( us [ 1 ] . id ) = = 0 ; // 열린 칸(0)이 먼저 — 잠긴 빈 칸(1)보다
g12 . DeckPut ( 3 , us [ 0 ] . id ) ; g12 . DeckPut ( 0 , us [ 4 ] . id ) ; // 3번 칸 덮어쓰기 · 덱 안 유닛끼리 맞바꿈
bool put = g12 . ActiveDeck [ 3 ] = = us [ 0 ] . id & & g12 . DeckPos ( us [ 3 ] . id ) < 0 & & g12 . ActiveDeck [ 0 ] = = us [ 4 ] . id & & g12 . ActiveDeck [ 4 ] = = us [ 1 ] . id & & Mathf . Approximately ( g12 . DeckAvgCost , us [ 4 ] . cost ) ;
Check ( "B12.deck_edit" , emptied & & addOpen & & put , string . Join ( "," , g12 . ActiveDeck ) ) ;
g12 . SelectDeck ( 1 ) ; bool other = g12 . ActiveDeck [ 0 ] = = us [ 0 ] . id & & g12 . DeckSel = = 1 ;
g12 . SelectDeck ( 0 ) ;
var sv = JsonUtility . FromJson < SaveData > ( JsonUtility . ToJson ( g12 . S ) ) ;
Check ( "B12.preset_save" , other & & g12 . ActiveDeck [ 0 ] = = us [ 4 ] . id & & sv . decks ! = null & & sv . decks . Count = = c . deckPresets & & sv . decks [ 0 ] . ids [ 0 ] = = us [ 4 ] . id & & sv . decks [ 1 ] . ids [ 0 ] = = us [ 0 ] . id ) ;
string [ ] gradeNames = { "일반" , "고급" , "희귀" , "에픽" , "전설" , "불멸" , "신화" } ;
bool grades = c . grades ! = null & & c . grades . Count = = gradeNames . Length ;
for ( int i = 0 ; grades & & i < gradeNames . Length ; i + + ) grades & = c . grades [ i ] . id = = i + 1 & & c . grades [ i ] . name = = gradeNames [ i ] & & ! string . IsNullOrEmpty ( c . grades [ i ] . color ) ;
foreach ( var u in us ) grades & = c . Grade ( u . grade ) ! = null ;
Check ( "B12.grades" , grades & & c . Grade ( 7 ) . color = = "rainbow" ) ;
2026-10-29 15:31:24 +00:00
// B13 유닛 목록 = PD 가 고른 유닛 그림(battle.json roster) · 번호 겹침 없음 · 이름 · 그림 파일 있음 · 보유 유닛 그림도 그 안(고른 그림만 씀)
var artFiles = System . IO . Directory . GetFiles ( "Assets/Resources/Art/Units" , "px_*.png" ) ;
var rosterArts = new HashSet < int > ( ) ; bool rosterOk = c . roster ! = null & & c . roster . Count > 0 & & c . roster . Count < = artFiles . Length ;
if ( c . roster ! = null ) foreach ( var r in c . roster ) rosterOk & = rosterArts . Add ( r . art ) & & ! string . IsNullOrEmpty ( r . name ) & & System . IO . File . Exists ( $"Assets/Resources/Art/Units/px_{r.art}.png" ) & & VS . View . UnitArt . Get ( r . art ) ! = null ;
int ownedArts = 0 ; foreach ( var u in us ) { int a = VS . View . UnitArt . ArtOf ( u . id ) ; if ( a > 0 ) { ownedArts + + ; rosterOk & = rosterArts . Contains ( a ) ; } }
Check ( "B13.roster" , rosterOk & & ownedArts > 0 , $"files={artFiles.Length} roster={c.roster?.Count} owned art={ownedArts}" ) ;
2026-10-29 13:21:25 +00:00
MapTests ( c ) ;
CastleTests ( c ) ;
FlagTests ( c ) ;
StrengthRegenTests ( c ) ;
2026-10-31 10:29:10 +00:00
Stage50Tests ( c , t ) ; // 스테이지 50 — 표 · 지형 · 필드 · 새 기술 · 새 소환 · 성 능력 · 보상(HeadlessRun.Stage50.cs)
2026-10-29 13:21:25 +00:00
}
// ---------------- 깃발: 맵을 누른 곳 → 내 유닛이 깃발에 먼저 닿은 뒤 성으로 ----------------
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}" ) ;
2026-10-31 10:29:10 +00:00
// 싸우는 소환수(근접 · 원거리)만 — 스테이지 50 특수 소환(수호병 · 폭탄병 · 토템 = kind)은 「먼저 잡으라고」 만든 유닛이라 기획서 수치(체력 4000 · 800 · 3000) 그대로(확정 전)
bool sOk = true ; foreach ( var s in c . summons ) if ( string . IsNullOrEmpty ( s . kind ) & & ( s . hp < 2f * maxHp | | s . damage < 2f * maxDmg ) ) sOk = false ;
Check ( "E1.summon_strong" , sOk , string . Join ( " · " , c . summons . FindAll ( s = > string . IsNullOrEmpty ( s . kind ) ) . ConvertAll ( s = > $"{s.name} 체력 {s.hp} 공격 {s.damage}" ) ) ) ;
2026-10-29 13:21:25 +00:00
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 )
{
2026-10-31 10:29:10 +00:00
int laneCells = c . BridgeCells ( null ) ; // 무작위 맵 다리 = 보통 다리(지나갈 칸 수)
2026-10-29 13:21:25 +00:00
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 + + ;
2026-10-31 10:29:10 +00:00
Check ( "M1.type" + k , mp . Type = = k & & mp . Bridges . Length = = k & & mp . River = = ( k > 0 ) & & lanes = = ( k > 0 ? k * laneCells : mp . W ) & & mp . Reachable ( mb . SpawnPoint ) , $"lanes={lanes} bridges={string.Join(" , ", mp.Bridges)}" ) ;
2026-10-29 13:21:25 +00:00
}
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 + + )
{
2026-10-31 10:29:10 +00:00
var mb = new Battle ( c , 1 , 10 , 1000 + s , new BattleSetup { mapType = BattleSetup . RandomMap } ) ; var mp = mb . Map ;
2026-10-29 13:21:25 +00:00
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 + + )
{
2026-10-31 10:29:10 +00:00
var rb = new Battle ( c , 1 , 10 , 500 + s , new BattleSetup { mapType = BattleSetup . RandomMap , obstacles = 1 , shots = L ( ) , gimmicks = L ( ) } ) ; rb . Start ( ) ;
2026-10-29 13:21:25 +00:00
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 ;
2026-10-31 10:29:10 +00:00
if ( rb . Map . River & & Math . Abs ( ru . pos . y - c . riverY ) < c . riverHalf & & ! rb . Map . OnBridge ( ru . pos . x ) ) bridgeOnly = false ;
2026-10-29 13:21:25 +00:00
}
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 ) ) ;
2026-10-31 10:29:10 +00:00
bool loOk = true ; var loBad = new List < int > ( ) ;
for ( int st = 1 ; st < = c . StageCount ; st + + )
2026-10-29 13:21:25 +00:00
{
var sb = new Battle ( c , st , 10 , st ) ; var l = c . Loadout ( st ) ;
2026-10-31 10:29:10 +00:00
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 ) ) ; gids . UnionWith ( sb . Powers . ConvertAll ( p = > p . def . id ) ) ;
if ( string . Join ( "," , ids ) ! = string . Join ( "," , l . shots ) | | ! gids . SetEquals ( l . gimmicks ) | | sb . IsTest ) { loOk = false ; loBad . Add ( st ) ; }
2026-10-29 13:21:25 +00:00
}
2026-10-31 10:29:10 +00:00
Check ( "X2.stage_loadout" , loOk , string . Join ( "," , loBad ) ) ;
2026-10-29 13:21:25 +00:00
}
}
}