Project_WL/AgentScripts/WL813i2_Probe.cs

874 lines
48 KiB
C#
Raw Normal View History

// WL813i2_Probe.cs — #813i2(보스 연타 대비 피격 후 무적 창 · 보스 예고 중 자동 회피 스텝) 에디트 모드 검증 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 클론은 즉시 파괴)
//
// 상주 에디터: unity command run_script --project-path <wt> --file AgentScripts/WL813i2_Probe.cs --entry WL813i2_Probe.RunAll --timeout 300
// 결과: Console + AgentScripts/WL813i2_PROBE.txt (RESULT PASS / RESULT FAIL n)
//
// 검사 (발주서 WL-813i2 §1-4)
// ⓐ 에셋·SOT: WLSurvivalSettings 신규 6필드 + autoPotionHpRatio 0.55 · WLRunGuideSettings 신규 9필드
// ⓑ 🔴 가짜 보스 5연타(0.52 s 등간격 · Q10 §6 실측 재현) → 실제로 들어간 타수 · HP 궤적
// ⓒ 무적 창 ms 실측(열림 → 경계 직전 → 경계 직후)
// ⓓ 잡몹 5연타 → 무적 0(mobHitInvincible=0) · 옵션을 켜면 창이 열린다
// ⓔ 최소 실피해 게이트(hitInvincibleMinHpLoss) · 부활 창 우선순위 · 런 경계에서 창이 닫힌다
// ⓕ 예고 감지: 실제 Anubis 컨트롤러의 상태 이름 대조 + Animator 상태 판정 + 안전 폴백(null·컨트롤러 없음)
// ⓖ 회피 스텝 판정 6분기 + 「예고 1건에 1회」(쿨다운) · NavMesh 밖 = 스텝 0
// ⓗ GC 0 (TakeDamage ×200k · IsHitInvincible ×200k · DodgeDecision ×200k)
// ⓘ C8: bossHitInvincibleSeconds=0 → 원래(5연타 5타 전부) · dodgeStepEnabled=0 → 스텝 판정 kDodgeOff
// ⓙ 정리(임시 오브젝트 0 · MyPC 원복 · 에셋 캐시 원복 · 씬 dirty 불변)
//
// 에디트 모드 한계(완료보고 「미확인」)
// · NavMesh 가 없어 NavMeshAgent.Move 실이동(3 m)은 못 잰다 → SampleStep 이 실패해 DodgeFailedNavMesh 로 떨어진다.
// 스텝 「판정」과 「1회만」은 순수 함수(DodgeDecision)로, 방향 계산은 별도 산술로 검증한다.
// · 보스 차징 코루틴은 Play 에서만 돌아 「예고 → 발동 ms」는 인게임 QA 몫이다.
using System;
using System.IO;
using System.Reflection;
using System.Text;
using UnityEngine;
using WL.Combat.Run;
using WL.Combat.Survival;
public static class WL813i2_Probe
{
const string OutDir = "Screenshots_WL/WL813i2";
const string OutCommit = "AgentScripts/WL813i2_PROBE.txt";
const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance;
const BindingFlags NS = BindingFlags.NonPublic | BindingFlags.Static;
// Q10 §6 실측 = 0.52 s 등간격 5연타 · 원 피해 222(death_200823_6.txt 14~16행)
const float ComboInterval = 0.52f;
const double BossRawDamage = 222d;
const double PcMaxHp = 310d;
const string AnubisController =
"Assets/ResWork/Suriyun/Monster Pack Pyramid/Animations/Anubis/Anubis_Animations_Check.controller";
static WLSurvivalSettings s_clone;
static WLRunGuideSettings s_rgClone;
public static object RunAll()
{
var sb = new StringBuilder();
sb.AppendLine("# WL813i2 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)");
sb.AppendLine("playMode=" + Application.isPlaying);
int fail = 0;
var pc0 = MyValue.MyPC;
bool dirty0 = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty;
try { fail += Asset(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓐ 예외 " + ex); }
try { fail += BossCombo(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓑ 예외 " + ex); }
try { fail += WindowMs(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓒ 예외 " + ex); }
try { fail += MobCombo(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓓ 예외 " + ex); }
try { fail += Gates(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓔ 예외 " + ex); }
try { fail += Telegraph(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓕ 예외 " + ex); }
try { fail += DodgeLogic(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓖ 예외 " + ex); }
try { fail += Gc(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓗ 예외 " + ex); }
try { fail += C8(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓘ 예외 " + ex); }
RestoreAssets();
MyValue.MyPC = pc0;
Survival.ResetDiagnostics();
try { fail += Cleanup(sb, dirty0); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓙ 예외 " + ex); }
string result = fail == 0 ? "RESULT PASS" : ("RESULT FAIL " + fail);
sb.AppendLine(); sb.AppendLine(result);
string body = sb.ToString();
Write(body);
Debug.Log(body);
return result;
}
// ───────────────────────────────── ⓐ 에셋 (디스크 값 실측)
static int Asset(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓐ 에셋 실측 (디스크 값 · 클론 아님)");
var st = WLSurvivalSettings.Instance;
bool a1 = st != null;
sb.AppendLine(P(a1) + " WLSurvivalSettings 로드 " + (a1 ? "OK" : "null")); if (!a1) return f + 1;
bool a2 = st.hitInvincibleEnabled && Math.Abs(st.bossHitInvincibleSeconds - 0.6f) < 1e-4
&& !st.mobHitInvincible && Math.Abs(st.mobHitInvincibleSeconds - 0.2f) < 1e-4
&& Math.Abs(st.hitInvincibleMinHpLoss - 1f) < 1e-4 && !st.hitInvincibleLog;
sb.AppendLine(P(a2) + " 신규 6필드 hitInv=" + st.hitInvincibleEnabled
+ " boss=" + st.bossHitInvincibleSeconds + "s mob=" + st.mobHitInvincible + "/" + st.mobHitInvincibleSeconds
+ "s minLoss=" + st.hitInvincibleMinHpLoss + " log=" + st.hitInvincibleLog); if (!a2) f++;
bool a3 = Math.Abs(st.autoPotionHpRatio - 0.55f) < 1e-4;
sb.AppendLine(P(a3) + " §1-3 값 autoPotionHpRatio = " + st.autoPotionHpRatio + " (0.45 → 0.55)"); if (!a3) f++;
// 813m2 §4 병목 근거 — 회복량과 임계의 합이 1 을 넘으면 그만큼 회복이 낭비된다(값 판단은 Lead).
float waste = st.autoPotionHpRatio + st.potionHealRate - 1f;
sb.AppendLine(" · 참고: 임계 " + st.autoPotionHpRatio + " + 회복 " + st.potionHealRate
+ " = " + (st.autoPotionHpRatio + st.potionHealRate).ToString("F2")
+ (waste > 0f ? " → 만충 초과 " + (waste * 100f).ToString("F0") + " % 낭비(실측)" : " → 낭비 0"));
bool a4 = Math.Abs(st.bossDamageMultiplier - 0.1f) < 1e-4 && Math.Abs(st.mobDamageMultiplier - 0.1f) < 1e-4;
sb.AppendLine(P(a4) + " 기존 배율(F-17) boss=" + st.bossDamageMultiplier + " mob=" + st.mobDamageMultiplier); if (!a4) f++;
var cfg = WLRunGuideSettings.Instance;
bool a5 = cfg != null;
sb.AppendLine(P(a5) + " WLRunGuideSettings 로드 " + (a5 ? "OK" : "null")); if (!a5) return f + 1;
var names = cfg.dodgeTelegraphStates;
bool a6 = cfg.dodgeStepEnabled && names != null && names.Length == 2
&& names[0] == "boss_skill1charging" && names[1] == "boss_skill2charging"
&& Math.Abs(cfg.dodgeStepDistance - 3f) < 1e-4 && Math.Abs(cfg.dodgeStepSeconds - 0.3f) < 1e-4
&& Math.Abs(cfg.dodgeStepSideBias) < 1e-4 && Math.Abs(cfg.dodgeStepBossRadius - 14f) < 1e-4
&& Math.Abs(cfg.dodgeStepCooldownSeconds - 1.5f) < 1e-4
&& Math.Abs(cfg.dodgeStepNavSampleRadius - 2f) < 1e-4 && cfg.dodgeStepLog;
sb.AppendLine(P(a6) + " 신규 9필드 dodge=" + cfg.dodgeStepEnabled
+ " states=" + (names == null ? "null" : string.Join("|", names))
+ " dist=" + cfg.dodgeStepDistance + "m/" + cfg.dodgeStepSeconds + "s side=" + cfg.dodgeStepSideBias
+ " radius=" + cfg.dodgeStepBossRadius + " cool=" + cfg.dodgeStepCooldownSeconds
+ " nav=" + cfg.dodgeStepNavSampleRadius + " log=" + cfg.dodgeStepLog); if (!a6) f++;
return f;
}
// ───────────────────────────────── ⓑ 🔴 보스 5연타(0.52 s) 재현
static int BossCombo(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓑ 🔴 보스 5연타 0.52 s (Q10 §6 재현 · 원 피해 " + BossRawDamage + " ×0.10)");
var st = UseClone(); if (st == null) return f + 1;
st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1;
st.takeMobDamage = true; st.takeBossDamage = true;
st.mobDamageMultiplier = 0.1f; st.bossDamageMultiplier = 0.1f;
st.unscaledDamageGuardEnabled = false; st.deathDiagEnabled = true;
st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f;
st.mobHitInvincible = false; st.hitInvincibleMinHpLoss = 1f;
var pc = MakePC("i2_pc", PcMaxHp, PcMaxHp);
var boss = MakeBeater("i2_boss", eSubRol.Boss);
var pc0 = MyValue.MyPC; MyValue.MyPC = pc;
try
{
Survival.ResetDiagnostics(); DeathDiag.ResetDiagnostics();
sb.AppendLine("| # | t(s) | 원 피해 | 결과 | HP after | via |");
sb.AppendLine("|---|---|---|---|---|---|");
int landed = 0;
for (int i = 0; i < 5; i++)
{
float t = i * ComboInterval;
Survival.Tick(t);
double before = pc.Get_HP();
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc));
double after = pc.Get_HP();
bool hit = before - after > 1e-6;
if (hit) landed++;
var rec = DeathDiag.Get(i);
sb.AppendLine("| " + (i + 1) + " | " + t.ToString("F2") + " | " + BossRawDamage.ToString("F0")
+ " | " + (hit ? "🔴 피격 " + (before - after).ToString("F1") : "무적(흘림)")
+ " | " + after.ToString("F0") + "/" + PcMaxHp.ToString("F0")
+ " | " + rec.via + " |");
}
double hpEnd = pc.Get_HP();
bool b1 = landed < 5 && landed >= 1;
sb.AppendLine(P(b1) + " 🔴 5연타 중 **실제 피격 " + landed + "타** (창 " + st.bossHitInvincibleSeconds
+ "s · 발주서 §1-1 목표 「1~2타」 · 아래 스윕 표 참조)"); if (!b1) f++;
bool b2 = Survival.BlockedHitInvincible == 5 - landed;
sb.AppendLine(P(b2) + " 무적이 막은 타수 = " + Survival.BlockedHitInvincible + " (5 피격 " + landed + ")"); if (!b2) f++;
bool b3 = Survival.HitInvincibleOpened == landed && Survival.HitInvincibleOpenedByBoss == landed;
sb.AppendLine(P(b3) + " 창 연 횟수 " + Survival.HitInvincibleOpened
+ "(보스 " + Survival.HitInvincibleOpenedByBoss + ") = 피격 타수"); if (!b3) f++;
bool b4 = hpEnd > 0d;
sb.AppendLine(P(b4) + " 🔴 5연타 후 생존 HP = " + hpEnd.ToString("F0") + "/" + PcMaxHp.ToString("F0")
+ " (Q10 실측 = 5대에 사망)"); if (!b4) f++;
// 대조군 — 무적 창을 끄면 5타 전부 들어간다(= Q10 실측 상황)
st.hitInvincibleEnabled = false;
var pcB = MakePC("i2_pcB", PcMaxHp, PcMaxHp);
MyValue.MyPC = pcB;
Survival.ResetDiagnostics();
int landedOff = 0;
for (int i = 0; i < 5; i++)
{
Survival.Tick(i * ComboInterval);
double before = pcB.Get_HP();
Survival.TakeDamage(pcB, Dmg(boss, BossRawDamage), ApplyToPc(pcB));
if (before - pcB.Get_HP() > 1e-6) landedOff++;
}
bool b5 = landedOff == 5;
sb.AppendLine(P(b5) + " 대조군(창 off) = " + landedOff + "타 전부 피격 · HP " + pcB.Get_HP().ToString("F0")
+ "/" + PcMaxHp.ToString("F0") + " → 창이 만든 차이 " + (landedOff - landed) + "타"); if (!b5) f++;
Kill(pcB);
st.hitInvincibleEnabled = true;
// 🔴 값 스윕 — 「1~2타」를 만드는 창 길이가 얼마인가(값 결정은 Lead · 여기서는 실측만).
// 간격 0.52 s 등차 5타에서 창 W 는 W/0.52 타를 흘린다 → 필요한 W 는 0.52 의 배수 경계에 걸린다.
sb.AppendLine();
sb.AppendLine("| 창(초) | 피격 타수 | 5연타 후 HP | 흘린 타 |");
sb.AppendLine("|---|---|---|---|");
float[] sweep = { 0f, 0.30f, 0.53f, 0.60f, 0.80f, 1.05f, 1.30f, 1.60f, 2.10f };
for (int k = 0; k < sweep.Length; k++)
{
st.hitInvincibleEnabled = sweep[k] > 0f;
st.bossHitInvincibleSeconds = sweep[k];
var pcS = MakePC("i2_sw" + k, PcMaxHp, PcMaxHp);
MyValue.MyPC = pcS;
Survival.ResetDiagnostics();
int L = 0;
for (int i = 0; i < 5; i++)
{
Survival.Tick(i * ComboInterval);
double before = pcS.Get_HP();
Survival.TakeDamage(pcS, Dmg(boss, BossRawDamage), ApplyToPc(pcS));
if (before - pcS.Get_HP() > 1e-6) L++;
}
sb.AppendLine("| " + sweep[k].ToString("F2") + " | " + L + " | " + pcS.Get_HP().ToString("F0")
+ "/310 | " + Survival.BlockedHitInvincible + " |"
+ (L >= 1 && L <= 2 ? " ← 발주서 목표 1~2타" : ""));
Kill(pcS);
}
st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f;
sb.AppendLine(" · 🔴 에셋 값은 발주서 제안대로 **0.6 s** 로 두었다. 목표(1~2타)를 만드는 최소 창은 위 표의 실측값이다 — **값 결정은 Lead**(C3).");
}
finally { MyValue.MyPC = pc0; Kill(pc, boss); }
return f;
}
// ───────────────────────────────── ⓒ 무적 창 ms 실측
static int WindowMs(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓒ 무적 창 ms 실측");
var st = UseClone(); if (st == null) return f + 1;
st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1;
st.takeBossDamage = true; st.bossDamageMultiplier = 0.1f;
st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f; st.hitInvincibleMinHpLoss = 1f;
st.unscaledDamageGuardEnabled = false;
var pc = MakePC("i2_w_pc", PcMaxHp, PcMaxHp);
var boss = MakeBeater("i2_w_boss", eSubRol.Boss);
var pc0 = MyValue.MyPC; MyValue.MyPC = pc;
try
{
Survival.ResetDiagnostics();
Survival.Tick(100f);
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc));
float until = Survival.HitInvincibleUntil;
bool c1 = Math.Abs(until - 100.6f) < 1e-3 && Math.Abs(Survival.LastHitInvincibleSeconds - 0.6f) < 1e-4;
sb.AppendLine(P(c1) + " 창 열림 t=100.000 → until=" + until.ToString("F3")
+ " · 길이 " + (Survival.LastHitInvincibleSeconds * 1000f).ToString("F0") + " ms"); if (!c1) f++;
Survival.Tick(100.599f);
bool c2 = Survival.IsHitInvincible(pc) && Survival.HitInvincibleRemaining > 0f;
sb.AppendLine(P(c2) + " 경계 직전 t=100.599(+599 ms) → 무적 " + c2
+ " · 잔여 " + (Survival.HitInvincibleRemaining * 1000f).ToString("F1") + " ms"); if (!c2) f++;
double h1 = pc.Get_HP();
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc));
bool c3 = Math.Abs(pc.Get_HP() - h1) < 1e-6;
sb.AppendLine(P(c3) + " 경계 직전 피격 = 피해 0 (HP " + pc.Get_HP().ToString("F0") + " 유지)"); if (!c3) f++;
Survival.Tick(100.601f);
bool c4 = !Survival.IsHitInvincible(pc) && Survival.HitInvincibleRemaining <= 0f;
sb.AppendLine(P(c4) + " 경계 직후 t=100.601(+601 ms) → 무적 " + Survival.IsHitInvincible(pc)); if (!c4) f++;
double h2 = pc.Get_HP();
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc));
double lost = h2 - pc.Get_HP();
bool c5 = Math.Abs(lost - BossRawDamage * 0.1d) < 1e-3;
sb.AppendLine(P(c5) + " 경계 직후 피격 = 정상 피해 " + lost.ToString("F1")
+ " (원 " + BossRawDamage + " ×0.10)"); if (!c5) f++;
// 창 안에서 다시 맞아도 창이 연장되지 않는다(창은 실피해로만 열린다)
Survival.Tick(100.700f);
float untilB = Survival.HitInvincibleUntil;
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc));
bool c6 = Math.Abs(Survival.HitInvincibleUntil - untilB) < 1e-4;
sb.AppendLine(P(c6) + " 창 안 피격은 창을 연장하지 않는다 until=" + Survival.HitInvincibleUntil.ToString("F3")); if (!c6) f++;
}
finally { MyValue.MyPC = pc0; Kill(pc, boss); }
return f;
}
// ───────────────────────────────── ⓓ 잡몹 = 무적 0
static int MobCombo(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓓ 잡몹 피해 무적 0 (mobHitInvincible=0)");
var st = UseClone(); if (st == null) return f + 1;
st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1;
st.takeMobDamage = true; st.mobDamageMultiplier = 0.1f;
st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f;
st.mobHitInvincible = false; st.mobHitInvincibleSeconds = 0.2f; st.hitInvincibleMinHpLoss = 1f;
st.unscaledDamageGuardEnabled = false;
var pc = MakePC("i2_m_pc", PcMaxHp, PcMaxHp);
var mob = MakeBeater("i2_m_mob", eSubRol.None);
var pc0 = MyValue.MyPC; MyValue.MyPC = pc;
try
{
Survival.ResetDiagnostics();
int landed = 0;
for (int i = 0; i < 5; i++)
{
Survival.Tick(i * ComboInterval);
double before = pc.Get_HP();
Survival.TakeDamage(pc, Dmg(mob, 120d), ApplyToPc(pc));
if (before - pc.Get_HP() > 1e-6) landed++;
}
bool d1 = landed == 5 && Survival.BlockedHitInvincible == 0 && Survival.HitInvincibleOpened == 0;
sb.AppendLine(P(d1) + " 잡몹 5연타 = " + landed + "타 전부 피격 · 막은타 " + Survival.BlockedHitInvincible
+ " · 연 창 " + Survival.HitInvincibleOpened + " (실측상 잡몹은 한 방에 0.3~0.4 %)"); if (!d1) f++;
// 옵션을 켜면 잡몹도 창이 열린다(SO 하나로 뒤집힌다 = C45)
st.mobHitInvincible = true;
var pcB = MakePC("i2_m_pcB", PcMaxHp, PcMaxHp);
MyValue.MyPC = pcB;
Survival.ResetDiagnostics();
int landedOn = 0;
for (int i = 0; i < 5; i++)
{
Survival.Tick(i * 0.1f); // 0.2 s 창보다 촘촘하게
double before = pcB.Get_HP();
Survival.TakeDamage(pcB, Dmg(mob, 120d), ApplyToPc(pcB));
if (before - pcB.Get_HP() > 1e-6) landedOn++;
}
bool d2 = landedOn < 5 && Survival.HitInvincibleOpened == landedOn;
sb.AppendLine(P(d2) + " mobHitInvincible=1 로 뒤집으면 0.1 s 간격 5타 중 " + landedOn
+ "타만 피격(창 0.2 s)"); if (!d2) f++;
Kill(pcB);
st.mobHitInvincible = false;
}
finally { MyValue.MyPC = pc0; Kill(pc, mob); }
return f;
}
// ───────────────────────────────── ⓔ 게이트·우선순위·런 경계
static int Gates(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓔ 최소 피해 게이트 · 부활 창 우선 · 런 경계");
var st = UseClone(); if (st == null) return f + 1;
st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1;
st.takeBossDamage = true; st.bossDamageMultiplier = 0.1f;
st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f; st.hitInvincibleMinHpLoss = 1f;
st.unscaledDamageGuardEnabled = false;
var pc = MakePC("i2_g_pc", PcMaxHp, PcMaxHp);
var boss = MakeBeater("i2_g_boss", eSubRol.Boss);
var pc0 = MyValue.MyPC; MyValue.MyPC = pc;
try
{
// ① 순수 함수 판정표 (Actor 접근 0 · C8 증거)
sb.AppendLine("| 입력(boss · hpLost) | 창(초) |");
sb.AppendLine("|---|---|");
float[] w = new float[5];
w[0] = Survival.HitInvincibleWindow(st, true, 30d);
w[1] = Survival.HitInvincibleWindow(st, true, 0.5d);
w[2] = Survival.HitInvincibleWindow(st, false, 30d);
st.mobHitInvincible = true;
w[3] = Survival.HitInvincibleWindow(st, false, 30d);
st.mobHitInvincible = false;
w[4] = Survival.HitInvincibleWindow(null, true, 30d);
sb.AppendLine("| boss · 30 HP | " + w[0] + " |");
sb.AppendLine("| boss · 0.5 HP(<minLoss 1) | " + w[1] + " |");
sb.AppendLine("| mob · 30 HP (mobHitInv=0) | " + w[2] + " |");
sb.AppendLine("| mob · 30 HP (mobHitInv=1) | " + w[3] + " |");
sb.AppendLine("| SO null | " + w[4] + " |");
bool e1 = Math.Abs(w[0] - 0.6f) < 1e-4 && w[1] == 0f && w[2] == 0f
&& Math.Abs(w[3] - 0.2f) < 1e-4 && w[4] == 0f;
sb.AppendLine(P(e1) + " HitInvincibleWindow 5분기 전부 기대값"); if (!e1) f++;
// ② 스침 타격(실피해 0)은 창을 열지 않는다 — 방어로 0 이 된 타격
Survival.ResetDiagnostics();
Survival.Tick(0f);
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), NoDamage()); // base 가 HP 를 안 깎는다
bool e2 = Survival.HitInvincibleOpened == 0 && !Survival.IsHitInvincible(pc);
sb.AppendLine(P(e2) + " 실피해 0 인 타격은 창을 열지 않는다(연 창 " + Survival.HitInvincibleOpened + ")"); if (!e2) f++;
// ③ 부활 창이 우선(via=ReviveInv) — 두 창이 겹쳐도 진단이 섞이지 않는다
Survival.ResetDiagnostics(); DeathDiag.ResetDiagnostics();
Survival.Tick(10f);
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc)); // 창 열림
Survival.SetReviveInvincible(pc, 5f); // 부활 = 피격 창도 닫는다
bool e3 = !Survival.IsHitInvincible(pc) && Survival.IsReviveInvincible(pc);
sb.AppendLine(P(e3) + " 부활 창이 열리면 피격 창은 닫힌다(hitInv=" + Survival.IsHitInvincible(pc)
+ " reviveInv=" + Survival.IsReviveInvincible(pc) + ")"); if (!e3) f++;
int n0 = DeathDiag.RecordCount;
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), NoDamage());
var r = DeathDiag.Get(n0);
bool e4 = r.via == DamageVia.ReviveInv && Survival.BlockedReviveInvincible == 1 && Survival.BlockedHitInvincible == 0;
sb.AppendLine(P(e4) + " 겹칠 때 via=" + r.via + " (부활 " + Survival.BlockedReviveInvincible
+ " / 피격 " + Survival.BlockedHitInvincible + ")"); if (!e4) f++;
Survival.SetReviveInvincible(null, 0f);
// ④ via=HitInv 가 사망 파일에 남는다
Survival.ResetDiagnostics(); DeathDiag.ResetDiagnostics();
Survival.Tick(20f);
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc));
Survival.Tick(20.2f);
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), NoDamage());
var r2 = DeathDiag.Get(1);
bool e5 = r2.via == DamageVia.HitInv && Survival.BlockedHitInvincible == 1;
sb.AppendLine(P(e5) + " 흘린 타의 진단 via=" + r2.via + " (새 값 HitInv=5)"); if (!e5) f++;
// ⑤ 런 경계(OnRunStart → SetReviveInvincible(null,0))가 창을 닫는다
Survival.SetHitInvincible(pc, 5f);
Survival.SetReviveInvincible(null, 0f);
bool e6 = !Survival.IsHitInvincible(pc);
sb.AppendLine(P(e6) + " 런 경계에서 피격 창이 닫힌다"); if (!e6) f++;
}
finally { MyValue.MyPC = pc0; Kill(pc, boss); }
return f;
}
// ───────────────────────────────── ⓕ 예고 감지
static int Telegraph(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓕ 예고(차징) 감지 — 공개 경로 = Actor.m_animation");
var cfg = UseRgClone(); if (cfg == null) return f + 1;
cfg.enabled = true; WLRunGuideSettings.RuntimeDisabled = false;
cfg.dodgeStepEnabled = true;
// ① SO 의 상태 이름이 **실제 컨트롤러에 있는가**(813b/813h 실측 대조)
var rac = UnityEditor.AssetDatabase.LoadAssetAtPath<RuntimeAnimatorController>(AnubisController);
bool t1 = rac != null;
sb.AppendLine(P(t1) + " Anubis 컨트롤러 로드 " + (t1 ? rac.name : "null") + " (" + AnubisController + ")");
if (!t1) return f + 1;
var go = new GameObject("__WL813i2_boss");
var actor = go.AddComponent<Actor>();
actor.m_Role = eRole.Mob; actor.m_SubRole = eSubRol.Boss;
var anim = go.AddComponent<Animator>();
anim.runtimeAnimatorController = rac;
actor.m_animation = anim;
try
{
var names = cfg.dodgeTelegraphStates;
sb.AppendLine(" · 컨트롤러 레이어 수 = " + anim.layerCount);
bool t2 = true;
for (int i = 0; i < names.Length; i++)
{
int hash = Animator.StringToHash(names[i]);
int found = -1;
for (int L = 0; L < anim.layerCount; L++)
if (anim.HasState(L, hash)) { found = L; break; }
sb.AppendLine(" · \"" + names[i] + "\" → 레이어 " + (found < 0 ? "없음" : found.ToString())
+ " (" + (found < 0 ? "❌" : anim.GetLayerName(found)) + ")");
if (found < 0) t2 = false;
}
sb.AppendLine(P(t2) + " 🔴 SO 상태 이름 " + string.Join(" · ", names)
+ " 가 실제 Anubis 컨트롤러에 **존재**(Animator.HasState · 전 레이어)"); if (!t2) f++;
// ② 그 상태로 재생하면 IsBossTelegraphing 이 true 인가(에디트 모드 Animator 평가)
anim.Play(names[0], -1, 0f);
anim.Update(0f);
bool onCharging = RunGuide.IsBossTelegraphing(cfg, actor);
anim.Play("idle", -1, 0f);
anim.Update(0f);
bool onIdle = RunGuide.IsBossTelegraphing(cfg, actor);
bool t3 = onCharging && !onIdle;
sb.AppendLine((t3 ? "[PASS]" : "[WARN]") + " Animator 상태 판정: charging=" + onCharging + " idle=" + onIdle
+ (t3 ? "" : " ← 에디트 모드 Animator 평가 한계 가능(인게임 QA 로 확인 · 「미확인」)"));
// ②-b GC 0 — 예고 판정은 매 프레임 돈다(실제 Animator 로 잰다)
anim.Play(names[0], -1, 0f); anim.Update(0f);
for (int i = 0; i < 1000; i++) RunGuide.IsBossTelegraphing(cfg, actor);
GC.Collect(); long g0 = GC.GetTotalMemory(true);
for (int i = 0; i < 200000; i++) RunGuide.IsBossTelegraphing(cfg, actor);
long g1 = GC.GetTotalMemory(false);
bool tg = g1 - g0 == 0;
sb.AppendLine(P(tg) + " IsBossTelegraphing ×200,000(실 Animator · 전 레이어) = " + (g1 - g0) + " byte"); if (!tg) f++;
// ③ 안전 폴백 — 이 셋은 어떤 경우에도 예외 없이 false 여야 한다
bool t4 = !RunGuide.IsBossTelegraphing(cfg, null);
var bare = new GameObject("__WL813i2_bare");
var bareActor = bare.AddComponent<Actor>();
bool t5 = !RunGuide.IsBossTelegraphing(cfg, bareActor); // m_animation = null
var noCtrlGo = new GameObject("__WL813i2_noctrl");
var noCtrlActor = noCtrlGo.AddComponent<Actor>();
noCtrlActor.m_animation = noCtrlGo.AddComponent<Animator>(); // 컨트롤러 없음
bool t6 = !RunGuide.IsBossTelegraphing(cfg, noCtrlActor);
var empty = cfg.dodgeTelegraphStates;
cfg.dodgeTelegraphStates = new string[0];
anim.Play(names[0], -1, 0f); anim.Update(0f);
bool t7 = !RunGuide.IsBossTelegraphing(cfg, actor); // 목록이 비면 언제나 false(C8)
cfg.dodgeTelegraphStates = empty;
bool tOk = t4 && t5 && t6 && t7;
sb.AppendLine(P(tOk) + " 안전 폴백 null=" + t4 + " 애니메이터 없음=" + t5
+ " 컨트롤러 없음=" + t6 + " 이름목록 빔=" + t7); if (!tOk) f++;
Kill(bare, noCtrlGo);
// ④ 12보스 컨트롤러 전수 — 어느 보스에 예고 상태가 있는가(값 판단은 Lead · 여기서는 실측만)
sb.AppendLine();
sb.AppendLine("| 컨트롤러 | boss_skill1charging | boss_skill2charging |");
sb.AppendLine("|---|---|---|");
var guids = UnityEditor.AssetDatabase.FindAssets("t:AnimatorController", new[] { "Assets/ResWork" });
int withAny = 0, total = 0;
for (int i = 0; i < guids.Length; i++)
{
string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]);
var c = UnityEditor.AssetDatabase.LoadAssetAtPath<RuntimeAnimatorController>(path);
if (c == null) continue;
var probeGo = new GameObject("__WL813i2_scan");
var pa = probeGo.AddComponent<Animator>();
pa.runtimeAnimatorController = c;
bool h1 = false, h2 = false;
for (int L = 0; L < pa.layerCount; L++)
{
if (pa.HasState(L, Animator.StringToHash("boss_skill1charging"))) h1 = true;
if (pa.HasState(L, Animator.StringToHash("boss_skill2charging"))) h2 = true;
}
Kill(probeGo);
if (!h1 && !h2) continue;
total++; withAny++;
sb.AppendLine("| " + c.name + " | " + (h1 ? "✅" : "—") + " | " + (h2 ? "✅" : "—") + " |");
}
sb.AppendLine(" · 예고 상태를 가진 컨트롤러 " + withAny + "종 · Anubis(10006 · Q10 사망 14/14 의 보스)는 2종 다 보유");
}
finally { Kill(go); }
return f;
}
// ───────────────────────────────── ⓖ 회피 스텝 판정
static int DodgeLogic(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓖ 회피 스텝 판정 (순수 함수 · 「예고 1건에 1회」)");
var cfg = UseRgClone(); if (cfg == null) return f + 1;
cfg.enabled = true; WLRunGuideSettings.RuntimeDisabled = false;
cfg.dodgeStepEnabled = true; cfg.dodgeStepDistance = 3f; cfg.dodgeStepSeconds = 0.3f;
cfg.dodgeStepCooldownSeconds = 1.5f;
sb.AppendLine("| 입력(보스반경 · 예고 · PC준비 · 마지막스텝후) | 판정 |");
sb.AppendLine("|---|---|");
int g0 = RunGuide.DodgeDecision(cfg, true, true, true, 99f);
int g1 = RunGuide.DodgeDecision(cfg, true, true, true, 0.5f);
int g2 = RunGuide.DodgeDecision(cfg, false, true, true, 99f);
int g3 = RunGuide.DodgeDecision(cfg, true, false, true, 99f);
int g4 = RunGuide.DodgeDecision(cfg, true, true, false, 99f);
cfg.dodgeStepEnabled = false;
int g5 = RunGuide.DodgeDecision(cfg, true, true, true, 99f);
cfg.dodgeStepEnabled = true;
int g6 = RunGuide.DodgeDecision(null, true, true, true, 99f);
sb.AppendLine("| 안 · 예고 · 준비 · 99 s | " + Name(g0) + " |");
sb.AppendLine("| 안 · 예고 · 준비 · 0.5 s(<쿨 1.5) | " + Name(g1) + " |");
sb.AppendLine("| 밖 · 예고 · 준비 · 99 s | " + Name(g2) + " |");
sb.AppendLine("| 안 · 예고아님 · 준비 · 99 s | " + Name(g3) + " |");
sb.AppendLine("| 안 · 예고 · PC바쁨 · 99 s | " + Name(g4) + " |");
sb.AppendLine("| dodgeStepEnabled=0 | " + Name(g5) + " |");
sb.AppendLine("| cfg=null | " + Name(g6) + " |");
bool ok = g0 == RunGuide.kDodgeGo && g1 == RunGuide.kDodgeCooldown && g2 == RunGuide.kDodgeNoBoss
&& g3 == RunGuide.kDodgeNoTelegraph && g4 == RunGuide.kDodgePcBusy
&& g5 == RunGuide.kDodgeOff && g6 == RunGuide.kDodgeOff;
sb.AppendLine(P(ok) + " 판정 7분기 전부 기대값"); if (!ok) f++;
// 「예고 1건에 1회」 — 실측 차징 1.2 s(813h JSON) < 쿨 1.5 s 이므로 한 예고 안에서는 1회뿐이다
bool g7 = cfg.dodgeStepCooldownSeconds > 1.2f;
sb.AppendLine(P(g7) + " 🔴 쿨 " + cfg.dodgeStepCooldownSeconds + "s > 실측 차징 1.2 s → **예고 1건에 스텝 1회**"); if (!g7) f++;
int within = RunGuide.DodgeDecision(cfg, true, true, true, 1.2f);
bool g8 = within == RunGuide.kDodgeCooldown;
sb.AppendLine(P(g8) + " 같은 예고 안(1.2 s 경과)의 두 번째 요청 = " + Name(within)); if (!g8) f++;
// 방향 산술 — 「예고 방향과 반대」(보스→PC 의 반대쪽) · sideBias 0/1 을 직접 계산해 대조
Vector3 bossPos = new Vector3(10f, 0f, 0f), pcPos = new Vector3(13f, 0f, 0f);
Vector3 away = (pcPos - bossPos).normalized;
Vector3 side = Vector3.Cross(Vector3.up, away);
bool g9 = Mathf.Abs(away.x - 1f) < 1e-4 && Mathf.Abs(Vector3.Dot(away, side)) < 1e-4;
sb.AppendLine(P(g9) + " 방향: away=" + away.ToString("F2") + " (보스 반대) · side=" + side.ToString("F2")
+ " (직교 · dot=" + Vector3.Dot(away, side).ToString("F3") + ") · 스텝 지점 = PC + away×"
+ cfg.dodgeStepDistance + "m = " + (pcPos + away * cfg.dodgeStepDistance).ToString("F1")); if (!g9) f++;
sb.AppendLine(" · 계획 속도 = " + cfg.dodgeStepDistance + "m / " + cfg.dodgeStepSeconds + "s = "
+ (cfg.dodgeStepDistance / cfg.dodgeStepSeconds).ToString("F1") + " m/s (NavMeshAgent.Move 등속)");
// NavMesh 없음 = 스텝 0(지형 뚫기 방지) — 에디트 모드에서 실제로 확인되는 값
RunGuide.ResetDiagnostics();
RunGuide.ResetDodgeStateForProbe();
bool moved = RunGuide.ForceDodgeTick(1000f, 0.016f);
bool g10 = !moved && !RunGuide.Dodging && RunGuide.DodgeSteps == 0;
sb.AppendLine(P(g10) + " NavMesh/PC 없음 → 스텝 0(Dodging=" + RunGuide.Dodging
+ " DodgeSteps=" + RunGuide.DodgeSteps + ") · 실이동 3 m 는 인게임 QA 몫(「미확인」)"); if (!g10) f++;
return f;
}
static string Name(int d)
{
if (d == RunGuide.kDodgeGo) return "kDodgeGo";
if (d == RunGuide.kDodgeOff) return "kDodgeOff";
if (d == RunGuide.kDodgeCooldown) return "kDodgeCooldown";
if (d == RunGuide.kDodgeNoBoss) return "kDodgeNoBoss";
if (d == RunGuide.kDodgeNoTelegraph) return "kDodgeNoTelegraph";
return "kDodgePcBusy";
}
// ───────────────────────────────── ⓗ GC 0
static int Gc(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓗ GC 0 (핫 경로)");
var st = UseClone(); if (st == null) return f + 1;
st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1;
st.takeBossDamage = true; st.bossDamageMultiplier = 0.1f;
st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f;
st.unscaledDamageGuardEnabled = false; st.deathDiagEnabled = true; st.hitInvincibleLog = false;
var cfg = UseRgClone(); if (cfg == null) return f + 1;
var pc = MakePC("i2_gc_pc", 1e12, 1e12);
var boss = MakeBeater("i2_gc_boss", eSubRol.Boss);
var pc0 = MyValue.MyPC; MyValue.MyPC = pc;
var apply = ApplyToPc(pc);
var dinfo = Dmg(boss, 10d);
try
{
Survival.ResetDiagnostics();
Survival.Tick(0f);
// 예열
for (int i = 0; i < 1000; i++) { dinfo.Damage = 10d; Survival.TakeDamage(pc, dinfo, apply); }
for (int i = 0; i < 1000; i++) Survival.IsHitInvincible(pc);
for (int i = 0; i < 1000; i++) RunGuide.DodgeDecision(cfg, true, true, true, 99f);
GC.Collect(); long m0 = GC.GetTotalMemory(true);
for (int i = 0; i < 200000; i++) { dinfo.Damage = 10d; Survival.TakeDamage(pc, dinfo, apply); }
long m1 = GC.GetTotalMemory(false);
for (int i = 0; i < 200000; i++) Survival.IsHitInvincible(pc);
long m2 = GC.GetTotalMemory(false);
for (int i = 0; i < 200000; i++) RunGuide.DodgeDecision(cfg, true, true, true, 99f);
long m3 = GC.GetTotalMemory(false);
bool h1 = m1 - m0 == 0;
bool h2 = m2 - m1 == 0;
bool h3 = m3 - m2 == 0;
sb.AppendLine(P(h1) + " TakeDamage(무적 창 경로 포함) ×200,000 = " + (m1 - m0) + " byte"); if (!h1) f++;
sb.AppendLine(P(h2) + " IsHitInvincible ×200,000 = " + (m2 - m1) + " byte"); if (!h2) f++;
sb.AppendLine(P(h3) + " DodgeDecision ×200,000 = " + (m3 - m2) + " byte"); if (!h3) f++;
// 대조군 — 의도적 할당이 잡히는지(계측 자체의 신뢰성)
long m4 = GC.GetTotalMemory(false);
var junk = new byte[65536];
junk[0] = 1;
long m5 = GC.GetTotalMemory(false);
bool h4 = m5 - m4 > 0;
sb.AppendLine(P(h4) + " 대조군(64 KB 의도 할당) = " + (m5 - m4) + " byte(> 0 이어야 계측이 유효하다)"); if (!h4) f++;
}
finally { MyValue.MyPC = pc0; Kill(pc, boss); }
return f;
}
// ───────────────────────────────── ⓘ C8
static int C8(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓘ C8 롤백");
var st = UseClone(); if (st == null) return f + 1;
st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1;
st.takeBossDamage = true; st.bossDamageMultiplier = 0.1f;
st.unscaledDamageGuardEnabled = false;
var pc = MakePC("i2_c8_pc", PcMaxHp, PcMaxHp);
var boss = MakeBeater("i2_c8_boss", eSubRol.Boss);
var pc0 = MyValue.MyPC; MyValue.MyPC = pc;
try
{
// ① bossHitInvincibleSeconds = 0 → 813i 동작 100 %(5연타 5타 전부)
st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0f;
Survival.ResetDiagnostics();
int landed = 0;
for (int i = 0; i < 5; i++)
{
Survival.Tick(i * ComboInterval);
double before = pc.Get_HP();
Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc));
if (before - pc.Get_HP() > 1e-6) landed++;
}
bool i1 = landed == 5 && Survival.HitInvincibleOpened == 0 && Survival.BlockedHitInvincible == 0;
sb.AppendLine(P(i1) + " bossHitInvincibleSeconds=0 → 5연타 " + landed + "타 전부 피격 · 창 0 = 813i 동작"); if (!i1) f++;
// ② hitInvincibleEnabled = 0 → 같은 결과
st.bossHitInvincibleSeconds = 0.6f; st.hitInvincibleEnabled = false;
var pcB = MakePC("i2_c8_pcB", PcMaxHp, PcMaxHp);
MyValue.MyPC = pcB;
Survival.ResetDiagnostics();
int landedB = 0;
for (int i = 0; i < 5; i++)
{
Survival.Tick(i * ComboInterval);
double before = pcB.Get_HP();
Survival.TakeDamage(pcB, Dmg(boss, BossRawDamage), ApplyToPc(pcB));
if (before - pcB.Get_HP() > 1e-6) landedB++;
}
bool i2 = landedB == 5 && Survival.HitInvincibleOpened == 0;
sb.AppendLine(P(i2) + " hitInvincibleEnabled=0 → " + landedB + "타 전부 피격 · 창 0"); if (!i2) f++;
Kill(pcB);
// ③ 전체 스위치 off → 원본 100 %(배율조차 없다)
WLSurvivalSettings.RuntimeOverride = -1;
var pcC = MakePC("i2_c8_pcC", PcMaxHp, PcMaxHp);
MyValue.MyPC = pcC;
Survival.ResetDiagnostics();
Survival.Tick(0f);
double h0 = pcC.Get_HP();
Survival.TakeDamage(pcC, Dmg(boss, 100d), ApplyAlways(pcC)); // SO off = 훅 무동작 → base 그대로 통과했는지만 본다
bool i3 = Math.Abs(h0 - pcC.Get_HP() - 100d) < 1e-6 && Survival.PassDisabled == 1
&& Survival.HitInvincibleOpened == 0;
sb.AppendLine(P(i3) + " SO off → 배율·창 전부 무동작(원 피해 100 그대로 · PassDisabled="
+ Survival.PassDisabled + ")"); if (!i3) f++;
Kill(pcC);
WLSurvivalSettings.RuntimeOverride = 1;
// ④ dodgeStepEnabled = 0 → 판정 자체가 kDodgeOff
var cfg = UseRgClone(); if (cfg == null) return f + 1;
cfg.dodgeStepEnabled = false;
int d = RunGuide.DodgeDecision(cfg, true, true, true, 99f);
RunGuide.ResetDodgeStateForProbe();
bool i4 = d == RunGuide.kDodgeOff && !RunGuide.ForceDodgeTick(2000f, 0.016f) && RunGuide.DodgeSteps == 0;
sb.AppendLine(P(i4) + " dodgeStepEnabled=0 → 판정 " + Name(d) + " · 스텝 " + RunGuide.DodgeSteps); if (!i4) f++;
cfg.dodgeStepEnabled = true;
}
finally { MyValue.MyPC = pc0; Kill(pc, boss); }
return f;
}
// ───────────────────────────────── ⓙ 정리
static int Cleanup(StringBuilder sb, bool dirty0)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓙ 정리");
int leftovers = 0;
var all = UnityEngine.Object.FindObjectsByType<GameObject>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < all.Length; i++)
if (all[i] != null && all[i].name.StartsWith("__WL813i2")) leftovers++;
var st = WLSurvivalSettings.Instance;
bool assetBack = st != null && Math.Abs(st.bossHitInvincibleSeconds - 0.6f) < 1e-4
&& Math.Abs(st.autoPotionHpRatio - 0.55f) < 1e-4 && st.hitInvincibleEnabled;
var cfg = WLRunGuideSettings.Instance;
bool cfgBack = cfg != null && cfg.dodgeStepEnabled && Math.Abs(cfg.dodgeStepDistance - 3f) < 1e-4;
bool dirty = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty;
bool ok = leftovers == 0 && assetBack && cfgBack && MyValue.MyPC == null && dirty == dirty0
&& WLSurvivalSettings.RuntimeOverride == 0 && !WLRunGuideSettings.RuntimeDisabled;
sb.AppendLine(P(ok) + " 임시 오브젝트 " + leftovers + " · 에셋 원복(Survival " + assetBack + " · RunGuide " + cfgBack
+ ") · MyPC=null " + (MyValue.MyPC == null) + " · 씬 dirty " + dirty + "(시작 " + dirty0 + ")"
+ " · Override " + WLSurvivalSettings.RuntimeOverride);
if (!ok) f++;
return f;
}
// ───────────────────────────────── 유틸
static string P(bool ok) { return ok ? "[PASS]" : "[FAIL]"; }
static WLSurvivalSettings UseClone()
{
var orig = WLSurvivalSettings.Instance;
if (orig == null) return null;
if (s_clone == null)
{
s_clone = UnityEngine.Object.Instantiate(orig);
s_clone.hideFlags = HideFlags.HideAndDontSave; s_clone.name = "WLSurvivalSettings(probe clone)";
}
typeof(WLSurvivalSettings).GetField("s_cached", NS).SetValue(null, s_clone);
typeof(WLSurvivalSettings).GetField("s_lookupDone", NS).SetValue(null, true);
return s_clone;
}
static WLRunGuideSettings UseRgClone()
{
var orig = WLRunGuideSettings.Instance;
if (orig == null) return null;
if (s_rgClone == null)
{
s_rgClone = UnityEngine.Object.Instantiate(orig);
s_rgClone.hideFlags = HideFlags.HideAndDontSave; s_rgClone.name = "WLRunGuideSettings(probe clone)";
}
typeof(WLRunGuideSettings).GetField("s_cached", NS).SetValue(null, s_rgClone);
typeof(WLRunGuideSettings).GetField("s_lookupDone", NS).SetValue(null, true);
return s_rgClone;
}
static void RestoreAssets()
{
WLSurvivalSettings.ClearCache(); WLSurvivalSettings.RuntimeOverride = 0;
WLRunGuideSettings.ClearCache(); WLRunGuideSettings.RuntimeDisabled = false;
RunGuide.ResetDodgeStateForProbe();
RunGuide.ResetDiagnostics();
if (s_clone != null) { UnityEngine.Object.DestroyImmediate(s_clone); s_clone = null; }
if (s_rgClone != null) { UnityEngine.Object.DestroyImmediate(s_rgClone); s_rgClone = null; }
}
static PCActor MakePC(string name, double maxHp, double hp)
{
var go = new GameObject("__WL813i2_" + name);
var pc = go.AddComponent<PCActor>();
pc.m_Role = eRole.PC; pc.m_SubRole = eSubRol.None;
var stat = new ActorStatInfo(eRole.PC);
stat.Set_Stat(eStat.MaxHP, maxHp); stat.Set_Stat(eStat.HP, hp);
typeof(Actor).GetField("m_Stat", NP).SetValue(pc, stat);
typeof(Actor).GetField("m_Enemy", NP).SetValue(pc, false);
return pc;
}
static Actor MakeBeater(string name, eSubRol sub)
{
var go = new GameObject("__WL813i2_" + name);
var a = go.AddComponent<Actor>();
a.m_Role = eRole.Mob; a.m_SubRole = sub;
typeof(Actor).GetField("m_Enemy", NP).SetValue(a, true);
return a;
}
static DamageInfo Dmg(Actor beater, double dmg)
{
var d = new DamageInfo();
d.Beater = beater; d.Damage = dmg;
return d;
}
/// <summary>
/// 가짜 base.Get_Damage = **원본 MyActor.Get_Damage(:301-309) 그대로**.
/// `wlInvincible = IsRole(PC) && WLGameplaySettings.PlayerInvincible` 이면 base 를 건너뛰고(피해 0)
/// 아니면 넘어온(배율 적용된) 피해만큼 PC HP 를 깎는다.
/// 🔴 이 재현이 없으면 무적 창 경로를 검증할 수 없다 — 훅은 「플래그를 세운 채 base 를 부르는」 방식이라
/// base 를 모사한 쪽이 그 플래그를 안 보면 무적이 무적이 아니게 된다(1차 프로브에서 실제로 잡혔다).
/// </summary>
static Action<DamageInfo> ApplyToPc(PCActor pc)
{
return d =>
{
if (WL.Settings.WLGameplaySettings.PlayerInvincible) return; // 원본 무적 경로 = 피해 0
if (d != null) pc.Get_StatInfo().Set_Stat(false, eStat.HP, d.Damage);
};
}
/// <summary>플래그를 보지 않고 무조건 깎는다(플래그 자체를 검증하지 않는 절에서만 쓴다).</summary>
static Action<DamageInfo> ApplyAlways(PCActor pc)
{
return d => { if (d != null) pc.Get_StatInfo().Set_Stat(false, eStat.HP, d.Damage); };
}
/// <summary>가짜 base.Get_Damage — 원본 무적 경로(피해 0).</summary>
static Action<DamageInfo> NoDamage() { return d => { }; }
static void Kill(params UnityEngine.Object[] objs)
{
foreach (var o in objs)
{
if (o == null) continue;
var c = o as Component;
UnityEngine.Object.DestroyImmediate(c != null ? c.gameObject : o);
}
}
static void Write(string body)
{
try
{
string root = Path.GetDirectoryName(Application.dataPath);
string dir = Path.Combine(root, OutDir);
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "probe_" + DateTime.Now.ToString("HHmmss") + ".txt"), body);
File.WriteAllText(Path.Combine(root, OutCommit), body);
}
catch (Exception ex) { Debug.Log("[WL813i2] probe 파일 기록 실패: " + ex.Message); }
}
}