Project_WL/AgentScripts/WL813i2_Probe.cs

874 lines
48 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

// 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); }
}
}