Project_WL/AgentScripts/WL813w8_Probe.cs

646 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

// WL813w8_Probe.cs — #813w8(결과창 정지 중 PC 무적 D-46 · 게이트 전 보스 UI 숨김 D-23 재발) 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수)
//
// 상주 에디터:
// unity command run_script --project-path <wt> --file AgentScripts/WL813w8_Probe.cs --entry WL813w8_Probe.RunAll --timeout 300
// 결과: Console + AgentScripts/WL813w8_PROBE.txt (RESULT PASS / RESULT FAIL n)
//
// 무엇을 증명하나 (발주서 WL-813w8 §1 · 근거 = qa/W2-11_재채점_v9.md D-46 · D-23)
// ① 에셋 — 신규 2필드(invincibleWhileRunEndHold · hideBossUiBeforeGate) + D-46 이 성립하던 조건 값
// ② D-46 — 가짜 RunEnded → 정지 + 무적 래치 → 잡몹 연타 피해 0(대조군은 깎인다) → RunStarted 해제
// · 사망 중 진입은 부활 우선(래치 0) · 부활 뒤 틱에서 래치 · 상한 만료에서 함께 해제
// ③ D-46 보조 — 훅을 안 거치는 경로(도트)도 정지 중에는 전액 환급(DamageGuard 배율 0)
// ④ D-23 — 게이트 전 보스 UI 숨김 판정(순수 함수 진리표 + 실제 게이트 상태)
// ⑤ D-23 — 바·배너 4진입점(늦은 구독 · Spawned · 런 시작 재바인드 · 폴링) 전부 숨김 →
// 게이트 개방(RunEvents.BossGateOpened) 순간 표시
// ⑥ GC 0 — 정지 틱 · 무적 판정 · 피해 차단 · UI 숨김 판정 각 ×100
// ⑦ C8 롤백 — 신규 2필드 0 → 813w7/813q2·813y 동작 그대로(= D-46 · D-23 재현)
//
// 에디트 모드 한계(보고서 「미확인」)
// · Time 이 흐르지 않는다 → 시간 판정은 전부 인자로 먹인다(RunEndHoldTick(now) · SetRunEndHoldUntilForProbe).
// · 사망은 GameObject 비활성으로 만든다(Actor.IsDead() = DeadStatus || !activeInHierarchy · Actor.cs:975).
// · PC 스탯(m_Stat)은 protected 라 **프로브에서만** 리플렉션으로 주입한다(제품 코드 리플렉션 0).
// · 화면 픽셀은 못 본다 — 바는 CanvasGroup.alpha(=BossHpBar.Visible)로 판정한다.
using System;
using System.IO;
using System.Reflection;
using System.Text;
using UnityEngine;
using WL.Combat.Boss;
using WL.Combat.Core;
using WL.Combat.Run;
using WL.Combat.Survival;
using WL.UI;
public static class WL813w8_Probe
{
const string OutCommit = "AgentScripts/WL813w8_PROBE.txt";
const BindingFlags NP = BindingFlags.Instance | BindingFlags.NonPublic;
const double PcMaxHp = 310d; // Q11 실측 PC 최대 HP
const double MobRaw = 6d; // Q11 death_*.txt: 잡몹 원 피해 6.0 → ×0.10 = 0.6~1.0 HP
const int MobHitsPerResultScreen = 240; // 초당 4대 × 60 s (Q11: 55~80 s 에 사망)
static StringBuilder sb;
static int s_fail;
static GameObject s_pcGo, s_fbdGo, s_bossGo, s_barGo, s_bannerGo;
static PCActor s_pc, s_prevMyPc;
static MobActor s_boss;
static FieldBossData s_fbd;
static BossHpBar s_bar;
static BossBanner s_banner;
public static object RunAll()
{
sb = new StringBuilder();
s_fail = 0;
sb.AppendLine("# WL813w8 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)");
sb.AppendLine("playMode=" + Application.isPlaying);
try
{
Setup();
Sec1_Asset();
Sec2_RunEndInvincible();
Sec3_DamageGuard();
Sec4_GateDecision();
Sec5_BossUi();
Sec6_GC();
Sec7_C8();
}
catch (Exception ex) { L("[FAIL] 예외 — " + ex.GetType().Name + ": " + ex.Message); L(ex.StackTrace); s_fail++; }
finally { Teardown(); }
L("");
L(s_fail == 0 ? "RESULT PASS" : "RESULT FAIL " + s_fail);
var text = sb.ToString();
try { File.WriteAllText(OutCommit, text, new UTF8Encoding(false)); } catch { }
Debug.Log(text);
return text;
}
// ───────────────────────────────────────── 유틸
static void L(string m) { sb.AppendLine(m); }
static string P(bool ok) { if (!ok) s_fail++; return ok ? "[PASS]" : "[FAIL]"; }
static long Alloc() { return GC.GetTotalMemory(false); }
static WLRunGuideSettings TmpG()
{
var t = UnityEngine.Object.Instantiate(WLRunGuideSettings.Instance);
t.hideFlags = HideFlags.HideAndDontSave;
return t;
}
static WLBossArenaSettings TmpB()
{
var t = UnityEngine.Object.Instantiate(WLBossArenaSettings.Instance);
t.hideFlags = HideFlags.HideAndDontSave;
return t;
}
static void UseTmpG(WLRunGuideSettings t) { WLRunGuideSettings.SetInstanceForProbe(t); }
static void UseRealG() { WLRunGuideSettings.SetInstanceForProbe(null); }
static void UseTmpB(WLBossArenaSettings t) { WLBossArenaSettings.SetInstanceForProbe(t); }
static void UseRealB() { WLBossArenaSettings.SetInstanceForProbe(null); }
/// <summary>가짜 base.Get_Damage = 원본 MyActor.Get_Damage(:301-309) 그대로(무적 플래그를 본다).</summary>
static Action<DamageInfo> ApplyToPc(PCActor pc)
{
return d =>
{
s_flagDuringBase = WL.Settings.WLGameplaySettings.PlayerInvincible;
if (s_flagDuringBase) return; // 원본 무적 경로 = 피해 0
if (d != null) pc.Get_StatInfo().Set_Stat(false, eStat.HP, d.Damage);
};
}
static bool s_flagDuringBase;
static DamageInfo Dmg(Actor beater, double dmg)
{
var d = new DamageInfo();
d.Beater = beater; d.Damage = dmg;
return d;
}
static void SetHp(Actor a, double hp)
{
var st = a.Get_StatInfo();
st.Set_Stat(eStat.HP, hp);
}
static void Setup()
{
s_prevMyPc = MyValue.MyPC;
s_pcGo = new GameObject("__WL813w8_PC");
s_pc = s_pcGo.AddComponent<PCActor>();
s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None;
var stat = new ActorStatInfo(eRole.PC);
stat.Set_Stat(eStat.MaxHP, PcMaxHp); stat.Set_Stat(eStat.HP, PcMaxHp);
typeof(Actor).GetField("m_Stat", NP).SetValue(s_pc, stat); // 프로브 전용(제품 코드 0)
typeof(Actor).GetField("m_Enemy", NP).SetValue(s_pc, false);
MyValue.MyPC = s_pc;
s_bossGo = new GameObject("__WL813w8_BOSS");
s_boss = s_bossGo.AddComponent<MobActor>();
s_boss.m_Role = eRole.Mob; s_boss.m_SubRole = eSubRol.Boss;
var bstat = new ActorStatInfo(eRole.Mob);
bstat.Set_Stat(eStat.MaxHP, 1800d); bstat.Set_Stat(eStat.HP, 1800d); // Q11 D-23 실측(bhp=1800/1800)
typeof(Actor).GetField("m_Stat", NP).SetValue(s_boss, bstat);
typeof(Actor).GetField("m_Enemy", NP).SetValue(s_boss, true);
WLBossArenaSettings.RuntimeDisabled = false;
WLRunGuideSettings.RuntimeDisabled = false;
BossArena.ResetAll();
BossArena.EnsureSubscribed();
RunDirector.ResetAll();
RunGuide.EnsureSubscribed();
RunGuide.ResetDiagnostics();
Survival.ResetDiagnostics();
DamageGuard.ResetDiagnostics();
DeathDiag.ResetDiagnostics();
// 게이트 무장 — 원본 훅과 같은 진입점(HoldSpawn)
var cfg = WLBossArenaSettings.Instance;
s_fbdGo = new GameObject("__WL813w8_FBD");
s_fbd = s_fbdGo.AddComponent<FieldBossData>();
s_fbd.n_SpawnerId = cfg != null ? cfg.bossSpawnerId : 813010;
s_fbd.n_MonsterID = cfg != null ? cfg.bossMonsterId : 10006;
bool held = BossArena.HoldSpawn(s_fbd, NoopResume);
// 보스 UI 2벌(런타임 OnEnable 과 같은 경로 = Initialize)
s_barGo = new GameObject("__WL813w8_BAR", typeof(RectTransform));
s_bar = s_barGo.AddComponent<BossHpBar>();
string barInit = s_bar.Initialize();
s_bannerGo = new GameObject("__WL813w8_BANNER", typeof(RectTransform));
s_banner = s_bannerGo.AddComponent<BossBanner>();
string bannerInit = s_banner.Initialize();
L("");
L("── ⓪ 준비");
L(" " + P(WLRunGuideSettings.Instance != null) + " WLRunGuideSettings 로드 · Enabled=" + WLRunGuideSettings.Enabled);
L(" " + P(WLBossArenaSettings.Instance != null) + " WLBossArenaSettings 로드 · Active=" + WLBossArenaSettings.Active);
L(" " + P(WLSurvivalSettings.Instance != null && WLSurvivalSettings.Enabled) +
" WLSurvivalSettings 로드 · Enabled=" + WLSurvivalSettings.Enabled + " (이게 켜져 있어야 D-46 이 성립한다)");
L(" " + P(RunGuide.Subscribed) + " RunGuide.Subscribed(RunEvents)");
L(" " + P(held && BossArena.GateArmed && !BossArena.GateOpen) +
" HoldSpawn(FieldBossData) → 게이트 무장(armed=" + BossArena.GateArmed + " open=" + BossArena.GateOpen + ")");
L(" PC hp=" + s_pc.Get_HP().ToString("F0") + "/" + s_pc.Get_MaxHP().ToString("F0") +
" · 보스 hp=" + s_boss.Get_HP().ToString("F0") + "/" + s_boss.Get_MaxHP().ToString("F0"));
L(" 바 초기화: " + barInit);
L(" 배너 초기화: " + bannerInit);
}
static void NoopResume() { }
static void Teardown()
{
try
{
UseRealG(); UseRealB();
RunGuide.EndRunEndHold("teardown");
RunGuide.ClearRunEndInvincible("teardown");
RunGuide.ResetDiagnostics();
Survival.SetHoldInvincible(null, false, "teardown");
Survival.ResetDiagnostics();
DamageGuard.ResetDiagnostics();
DeathDiag.ResetDiagnostics();
BossArena.SetProbeTime(-1f);
BossArena.SetRunActiveForProbe(0);
BossArena.SetBossForProbe(null);
BossArena.ResetAll();
RunDirector.ResetAll();
WLBossArenaSettings.RuntimeDisabled = false;
WLRunGuideSettings.RuntimeDisabled = false;
BossHpBar.ClearFakeHp();
BossHpBar.DebugFallbackMaxHp = 100f;
BossHpBar.RunBossHpRatio = -1f; BossHpBar.RunBossSeen = false; BossHpBar.RunResetCount = 0;
BossHpBar.GateSuppressCount = BossHpBar.GateHideCount = BossHpBar.GateOpenBinds = 0;
BossBanner.GateSuppressCount = BossBanner.GateOpenTriggers = 0;
BossBanner.RunResetCount = 0;
if (s_bar != null) s_bar.Subscribe(false);
if (s_banner != null) s_banner.Subscribe(false);
MyValue.MyPC = s_prevMyPc;
if (s_pcGo != null) UnityEngine.Object.DestroyImmediate(s_pcGo);
if (s_bossGo != null) UnityEngine.Object.DestroyImmediate(s_bossGo);
if (s_fbdGo != null) UnityEngine.Object.DestroyImmediate(s_fbdGo);
if (s_barGo != null) UnityEngine.Object.DestroyImmediate(s_barGo);
if (s_bannerGo != null) UnityEngine.Object.DestroyImmediate(s_bannerGo);
}
catch { }
}
// ─────────────────────────────────────────────────────────────────────
// ① 에셋 (값은 전부 SO = C45)
// ─────────────────────────────────────────────────────────────────────
static void Sec1_Asset()
{
L("");
L("── ① 에셋 (813w8 신규 2필드 + D-46/D-23 이 성립하던 조건 값)");
var g = WLRunGuideSettings.Instance;
var b = WLBossArenaSettings.Instance;
var s = WLSurvivalSettings.Instance;
if (g == null || b == null || s == null) { L(" [FAIL] 에셋 없음"); s_fail++; return; }
L(" " + P(g.invincibleWhileRunEndHold) + " 🔴 신규 invincibleWhileRunEndHold=" + g.invincibleWhileRunEndHold + " (발주서 §1-1 기본 1)");
L(" " + P(b.hideBossUiBeforeGate) + " 🔴 신규 hideBossUiBeforeGate=" + b.hideBossUiBeforeGate + " (발주서 §1-2)");
L(" " + P(g.holdPcOnRunEnd) + " 813w7 holdPcOnRunEnd=" + g.holdPcOnRunEnd + " 유지(무적은 이 정지와 같은 구간)");
L(" " + P(g.runEndHoldMaxSeconds > 0f) + " runEndHoldMaxSeconds=" + g.runEndHoldMaxSeconds +
"s — Q11 사망은 55~80 s 라 상한이 더 늦다(그래서 무적이 필요했다)");
L(" " + P(!s.mobHitInvincible) + " 813i2 mobHitInvincible=" + s.mobHitInvincible +
" (잡몹 연타를 끊는 창이 없다 = D-46 의 조건 · 이 발주는 이 값을 바꾸지 않는다)");
L(" mob 배율=" + s.mobDamageMultiplier + " · unscaledDamageGuard=" + s.unscaledDamageGuardEnabled +
" · deathDiag=" + s.deathDiagEnabled);
L(" " + P(b.parkBossOnRespawn && b.bossHoldUntilGateOpen) +
" 813w6 parkBossOnRespawn=" + b.parkBossOnRespawn + " bossHoldUntilGateOpen=" + b.bossHoldUntilGateOpen +
" 유지 = 게이트 전에 보스가 살아 있는 그림(D-23 재발의 전제)");
// 피격 연출 필터(무적 타는 비네트·타격 반응을 스스로 거른다 = 「비네트/플래시 0」의 근거)
var ui = WL.UI.WLSurvivalUiSettings.Instance;
var tier = WL.Combat.Reaction.WLImpactTierSettings.Instance;
L(" " + P(ui != null && ui.ignoreInvincibleHits) + " WLSurvivalUiSettings.ignoreInvincibleHits=" +
(ui != null ? ui.ignoreInvincibleHits.ToString() : "(에셋 없음)") + " (무적 피격 비네트 0)");
L(" " + P(tier != null && tier.pcHitSkipWhenInvincible) + " WLImpactTierSettings.pcHitSkipWhenInvincible=" +
(tier != null ? tier.pcHitSkipWhenInvincible.ToString() : "(에셋 없음)") + " (무적 피격 타격 반응 0)");
}
// ─────────────────────────────────────────────────────────────────────
// ② D-46 — 결과창 정지 중 무적
// ─────────────────────────────────────────────────────────────────────
static void Sec2_RunEndInvincible()
{
L("");
L("── ② D-46 결과창 정지 중 PC 무적 (Q11: 초당 3.4~4.9 HP · 55~80 s 사망 · 3판 3/3)");
var g = WLRunGuideSettings.Instance;
RunGuide.ResetDiagnostics();
Survival.ResetDiagnostics();
SetHp(s_pc, PcMaxHp);
s_pc.Set_JoystickStatus(3);
s_pc.isTarget = false;
// ⓐ 정지 시작 = 무적 래치
RunGuide.ProbeRunEnded(RunOutcome.Win);
RunGuide.SetRunEndHoldUntilForProbe(-1f);
L(" " + P(RunGuide.RunEndHeld && RunGuide.RunEndInvincible && Survival.IsHoldInvincible(s_pc)) +
" 🔴 가짜 RunEnded(Win) → 정지(" + RunGuide.RunEndHeld + ") + 무적 래치(" + RunGuide.RunEndInvincible +
" · on " + RunGuide.RunEndInvincibleOn + "/off " + RunGuide.RunEndInvincibleOff + ")");
// ⓑ 잡몹 연타 240대(= 초당 4대 × 60 s) → 피해 0
double hp0 = s_pc.Get_HP();
var dinfo = Dmg(s_boss, MobRaw); // Beater 는 보스 서브롤이지만 아래 대조군과 같은 조건이면 된다
var mobGo = new GameObject("__WL813w8_MOB");
var mob = mobGo.AddComponent<Actor>();
mob.m_Role = eRole.Mob; mob.m_SubRole = eSubRol.None;
typeof(Actor).GetField("m_Enemy", NP).SetValue(mob, true);
var baseCall = ApplyToPc(s_pc);
try
{
int blocked0 = Survival.BlockedHoldInvincible;
bool flagSeen = true;
for (int i = 0; i < MobHitsPerResultScreen; i++)
{
Survival.Tick(i * 0.25f);
s_flagDuringBase = false;
Survival.TakeDamage(s_pc, Dmg(mob, MobRaw), baseCall);
if (!s_flagDuringBase) flagSeen = false;
}
double hpAfter = s_pc.Get_HP();
L(" " + P(Math.Abs(hpAfter - hp0) < 1e-6) +
" 🔴 잡몹 " + MobHitsPerResultScreen + "대(초당 4대 × 60 s · 원 피해 " + MobRaw + ") → HP " +
hp0.ToString("F0") + " → " + hpAfter.ToString("F0") + " = **피해 0**");
L(" " + P(Survival.BlockedHoldInvincible == blocked0 + MobHitsPerResultScreen) +
" 전부 정지 무적 경로로 흘렸다(BlockedHoldInvincible " + blocked0 + " → " + Survival.BlockedHoldInvincible + ")");
L(" " + P(flagSeen) +
" 🔴 base 호출 동안 PlayerInvincible=true = 원본 무적 경로(RaiseDamaged(invincible=true) → 비네트·타격 반응 0)");
L(" " + P(WL.Settings.WLGameplaySettings.PlayerInvincible) +
" 호출 뒤 플래그 원복(PlayerInvincible=" + WL.Settings.WLGameplaySettings.PlayerInvincible + " · 디스크 값 무변경)");
bool viaSeen = false;
for (int i = 0; i < DeathDiag.Capacity; i++)
if (DeathDiag.Get(i).via == DamageVia.HoldInv) { viaSeen = true; break; }
L(" " + P(viaSeen) + " 사망 진단(813m3)에 via=HoldInv 로 기록된다(사망 파일에서 바로 구분된다)");
// ⓒ 대조군 — 래치를 풀면 그대로 깎인다(= Q11 그림)
RunGuide.ClearRunEndInvincible("대조군");
SetHp(s_pc, PcMaxHp);
double ctl0 = s_pc.Get_HP();
for (int i = 0; i < MobHitsPerResultScreen; i++)
{
Survival.Tick(1000f + i * 0.25f);
Survival.TakeDamage(s_pc, Dmg(mob, MobRaw), baseCall);
}
double ctl1 = s_pc.Get_HP();
L(" " + P(ctl0 - ctl1 > 1d) +
" 🔴 대조군(무적 없음) — 같은 " + MobHitsPerResultScreen + "대에 HP " + ctl0.ToString("F0") + " → " +
ctl1.ToString("F0") + "(" + (ctl0 - ctl1).ToString("F0") + ") = D-46 그림 재현");
}
finally { UnityEngine.Object.DestroyImmediate(mobGo); }
// ⓓ 사망 중 진입은 부활 우선
RunGuide.ClearRunEndInvincible("사망 시험 준비");
s_pcGo.SetActive(false); // Actor.IsDead() = DeadStatus || !activeInHierarchy
RunGuide.ApplyRunEndInvincible(g, s_pc, "사망 중");
bool deadNoLatch = !RunGuide.RunEndInvincible;
s_pcGo.SetActive(true);
SetHp(s_pc, PcMaxHp);
RunGuide.RunEndHoldTick(g, s_pc, 300f); // 부활 뒤 첫 정지 틱
L(" " + P(deadNoLatch && RunGuide.RunEndInvincible) +
" 🔴 사망 중 진입 = 래치 안 검(부활 우선 · " + deadNoLatch + ") → 부활 뒤 첫 정지 틱에서 걸림(" +
RunGuide.RunEndInvincible + ")");
// ⓔ 해제 = 다음 런 시작
int on0 = RunGuide.RunEndInvincibleOn, off0 = RunGuide.RunEndInvincibleOff;
RunGuide.ProbeRunStarted(2, "restart");
L(" " + P(!RunGuide.RunEndHeld && !RunGuide.RunEndInvincible && RunGuide.RunEndInvincibleOff == off0 + 1) +
" 🔴 가짜 RunStarted(\"restart\") → 정지·무적 동시 해제(무적 on " + on0 + " → off " +
RunGuide.RunEndInvincibleOff + " · 사유 '" + Survival.LastHoldInvincibleReason + "')");
SetHp(s_pc, PcMaxHp);
double h0 = s_pc.Get_HP();
Survival.Tick(2000f);
Survival.TakeDamage(s_pc, Dmg(s_boss, 500d), ApplyToPc(s_pc));
L(" " + P(h0 - s_pc.Get_HP() > 1e-6) +
" 🔴 해제 뒤에는 런 안 피해가 정상 복귀(HP " + h0.ToString("F0") + " → " + s_pc.Get_HP().ToString("F0") + ")");
// ⓕ 상한 만료에서도 함께 해제
RunGuide.ResetDiagnostics();
SetHp(s_pc, PcMaxHp);
RunGuide.BeginRunEndHold("상한 시험");
RunGuide.SetRunEndHoldUntilForProbe(200f);
RunGuide.RunEndHoldTick(g, s_pc, 199f);
bool heldAt199 = RunGuide.RunEndHeld && RunGuide.RunEndInvincible;
RunGuide.RunEndHoldTick(g, s_pc, 201f);
L(" " + P(heldAt199 && !RunGuide.RunEndHeld && !RunGuide.RunEndInvincible) +
" 🔴 상한 만료(199 s 유지 → 201 s) → 정지·무적 함께 해제(사유 '" + RunGuide.LastRunEndHoldReason + "')");
L(" " + P(RunGuide.RunEndInvincibleOn == RunGuide.RunEndInvincibleOff) +
" 건 만큼 풀렸다(on " + RunGuide.RunEndInvincibleOn + " = off " + RunGuide.RunEndInvincibleOff + ") — QA 판정 기준");
RunGuide.ResetDiagnostics();
SetHp(s_pc, PcMaxHp);
}
// ─────────────────────────────────────────────────────────────────────
// ③ D-46 보조 — 훅 밖 경로(도트)도 정지 중에는 전액 환급
// ─────────────────────────────────────────────────────────────────────
static void Sec3_DamageGuard()
{
L("");
L("── ③ 훅을 안 거친 감소(도트 등)도 정지 중에는 전액 환급 (DamageGuard 배율 0)");
var s = WLSurvivalSettings.Instance;
if (s == null || !s.unscaledDamageGuardEnabled)
{
L(" [SKIP] unscaledDamageGuardEnabled=0 — 이 축은 꺼져 있다");
return;
}
const double drop = 20d;
// ⓐ 정지 무적 중
SetHp(s_pc, PcMaxHp);
Survival.SetHoldInvincible(s_pc, true, "프로브 ③");
DamageGuard.ResetDiagnostics();
DamageGuard.ProbeArm(s_pc, s_pc.Get_HP(), Survival.HookHpLoss);
s_pc.Get_StatInfo().Set_Stat(false, eStat.HP, drop); // 훅을 안 거친 감소(도트 재현)
DamageGuard.Poll();
double refundOn = DamageGuard.LastRefund;
double hpOn = s_pc.Get_HP();
L(" " + P(Math.Abs(refundOn - drop) < 0.5d) +
" 🔴 무적 중 " + drop.ToString("F0") + " HP 도트 → 환급 " + refundOn.ToString("F1") +
" (배율 0 = 전액) · HP " + hpOn.ToString("F0") + "/" + PcMaxHp.ToString("F0"));
// ⓑ 대조군 — 래치 없이는 종전대로 잡몹 배율만 환급한다(순 피해 = drop × 배율)
Survival.SetHoldInvincible(null, false, "프로브 ③ 대조군");
SetHp(s_pc, PcMaxHp);
DamageGuard.ResetDiagnostics();
DamageGuard.ProbeArm(s_pc, s_pc.Get_HP(), Survival.HookHpLoss);
s_pc.Get_StatInfo().Set_Stat(false, eStat.HP, drop);
DamageGuard.Poll();
double refundOff = DamageGuard.LastRefund;
double expect = drop * (1d - DamageGuard.Multiplier);
L(" " + P(Math.Abs(refundOff - expect) < 0.5d) +
" 대조군(래치 없음) 환급 " + refundOff.ToString("F1") + " = " + drop.ToString("F0") + " × (1 " +
DamageGuard.Multiplier.ToString("F2") + ") — 813m3 동작 그대로(C8)");
SetHp(s_pc, PcMaxHp);
}
// ─────────────────────────────────────────────────────────────────────
// ④ D-23 — 게이트 전 보스 UI 숨김 판정
// ─────────────────────────────────────────────────────────────────────
static void Sec4_GateDecision()
{
L("");
L("── ④ D-23 재발 — 게이트 전 보스 UI 숨김 판정(ShouldHideBossUi · 순수 함수)");
var b = WLBossArenaSettings.Instance;
L(" " + P(BossArena.ShouldHideBossUi(b, true, false, false)) + " 무장 · 게이트 닫힘 → **숨김**(= 다음 런 존 사냥 구간)");
L(" " + P(!BossArena.ShouldHideBossUi(b, true, true, false)) + " 무장 · BossArena 게이트 열림 → 표시");
L(" " + P(!BossArena.ShouldHideBossUi(b, true, false, true)) + " 무장 · 런 축 게이트 열림(RunDirector) → 표시");
L(" " + P(!BossArena.ShouldHideBossUi(b, false, false, false)) + " 게이트 축 미무장(813d off) → 정책 미적용(원본 그대로)");
var t = TmpB();
try
{
t.hideBossUiBeforeGate = false;
L(" " + P(!BossArena.ShouldHideBossUi(t, true, false, false)) + " C8: hideBossUiBeforeGate=0 → 숨김 0(813q2/813y 그대로)");
}
finally { UnityEngine.Object.DestroyImmediate(t); }
L(" " + P(BossArena.HideBossUiNow && BossHpBar.SuppressedBeforeGate && BossBanner.SuppressedBeforeGate) +
" 🔴 실제 상태(무장=" + BossArena.GateArmed + " open=" + BossArena.GateOpen + "/" + RunDirector.GateOpened +
") → HideBossUiNow=" + BossArena.HideBossUiNow + " (바·배너가 같은 판정을 읽는다)");
}
// ─────────────────────────────────────────────────────────────────────
// ⑤ D-23 — 바·배너 4진입점 + 게이트 개방 표시
// ─────────────────────────────────────────────────────────────────────
static void Sec5_BossUi()
{
L("");
L("── ⑤ D-23 재발 — 게이트 전 4진입점 숨김 → 게이트 개방 순간 표시");
BossHpBar.GateSuppressCount = BossHpBar.GateHideCount = BossHpBar.GateOpenBinds = 0;
BossBanner.GateSuppressCount = BossBanner.GateOpenTriggers = 0;
BossArena.SetBossForProbe(s_boss); // 813w6 이 고정해 둔 「살아 있는 만피 보스」
s_bar.Initialize(); s_banner.Initialize(); // 늦은 구독 경로가 여기서 다시 돈다
L(" " + P(!s_bar.Visible) + " ① 늦은 구독(813y · BindExistingBoss) → 바 안 보임(Visible=" + s_bar.Visible + ")");
L(" " + s_bar.BindExistingBoss());
L(" " + P(!s_banner.Visible) + " ① 늦은 구독(배너) → " + s_banner.TriggerExistingBoss());
// ② Spawned(isBoss) — 813w6 재스폰이 내는 그 이벤트(진짜 디스패치)
BossHpBar.DebugFallbackMaxHp = 1800f;
int sup0 = BossHpBar.GateSuppressCount;
BossHpBar.RaiseFakeBossSpawned(1800f);
L(" " + P(!s_bar.Visible && BossHpBar.GateSuppressCount == sup0 + 1) +
" 🔴 ② 게이트 전 Spawned(isBoss) → 바 안 보임(거절 " + BossHpBar.GateSuppressCount + "회) · 배너 " +
(!s_banner.Visible ? "안 보임" : "🔴보임") + "(거절 " + BossBanner.GateSuppressCount + "회)");
if (s_banner.Visible) s_fail++;
// ③ 런 시작 재바인드(813q2) — 813w6 이 BossSpawned=true 를 유지해도 뜨면 안 된다
// 🔴 813w6 은 재스폰 보스에도 이 플래그를 유지한다(BossArena.cs:1532) = 813c2 조건(`bossBarRebindRequiresArenaSpawn`)이
// 통과되는 상태. Q11 「다음 런」 그림을 그대로 만들기 위해 여기서 같은 값으로 세운다.
BossArena.BossSpawned = true;
L(" BossArena.BossSpawned=" + BossArena.BossSpawned + " (813w6 유지값 재현 → 813c2 조건만으로는 못 막는다)");
string reset = s_bar.ResetForNewRun();
string resetB = s_banner.ResetForNewRun();
L(" " + P(!s_bar.Visible) + " 🔴 ③ 런 시작 리셋 → " + reset);
L(" " + P(!s_banner.Visible) + " 배너 런 시작 리셋 → " + resetB);
// ④ 폴링(bossBarPollFollowsArena)
bool polled = s_bar.PollBoss(true);
L(" " + P(!polled && !s_bar.Visible) + " 🔴 ④ 폴링(force) → 재바인드 0(반환 " + polled + " · 내림 " +
BossHpBar.GateHideCount + "회) · 바 안 보임");
// ⑤ 게이트 개방 → 표시 (진짜 RunEvents.BossGateOpened 경로)
RunDirector.StartRun("probe");
try { BossArena.ForceOpenGate(); } catch (Exception ex) { L(" (참고) ForceOpenGate 예외 — " + ex.GetType().Name + ": " + ex.Message); }
RunDirector.OpenBossGate(RunGateReason.TimeLimit); // gateMinSeconds 를 우회하는 사유(BossArena.cs:582)
L(" " + P(RunDirector.GateOpened) + " 게이트 개방(RunDirector.GateOpened=" + RunDirector.GateOpened +
" BossArena.GateOpen=" + BossArena.GateOpen + " · BossGateOpened 발행 " + RunEvents.BossGateOpened.Raised + "회)");
L(" " + P(!BossHpBar.SuppressedBeforeGate) + " 숨김 정책 해제(SuppressedBeforeGate=" + BossHpBar.SuppressedBeforeGate + ")");
L(" " + P(s_bar.Visible && BossHpBar.GateOpenBinds >= 1) +
" 🔴 ⑤ 게이트 개방 순간 바 표시(Visible=" + s_bar.Visible + " · 개방바인드 " + BossHpBar.GateOpenBinds +
"회 · ratio=" + s_bar.LastRatio.ToString("F3") + ") — " + BossHpBar.LastGateOpenBind);
L(" " + P(BossBanner.GateOpenTriggers >= 1) +
" ⑤ 배너도 개방에서 트리거(" + BossBanner.GateOpenTriggers + "회) — " + BossBanner.LastGateOpenTrigger);
// 원상 복구 — 다음 절이 「게이트 전」 상태를 다시 쓴다
RunDirector.ResetAll();
BossArena.ResetAll();
BossArena.EnsureSubscribed();
BossArena.HoldSpawn(s_fbd, NoopResume);
BossArena.SetBossForProbe(s_boss);
s_bar.Initialize(); s_banner.Initialize();
L(" " + P(BossArena.GateArmed && !BossArena.GateOpen && !s_bar.Visible) +
" 복구: 게이트 재무장 · 바 숨김(다음 절 준비)");
}
// ─────────────────────────────────────────────────────────────────────
// ⑥ GC 0
// ─────────────────────────────────────────────────────────────────────
static void Sec6_GC()
{
L("");
L("── ⑥ GC (각 ×100 · 문자열·박싱 0 이어야 한다)");
var g = WLRunGuideSettings.Instance;
var b = WLBossArenaSettings.Instance;
SetHp(s_pc, PcMaxHp);
RunGuide.ResetDiagnostics();
RunGuide.BeginRunEndHold("GC");
RunGuide.SetRunEndHoldUntilForProbe(-1f);
RunGuide.RunEndHoldTick(g, s_pc, 500f); // 워밍업(첫 호출의 지연 초기화 제외)
long a0 = Alloc();
for (int i = 0; i < 100; i++) RunGuide.RunEndHoldTick(g, s_pc, 500f + i);
long a1 = Alloc();
L(" " + P(a1 - a0 <= 0) + " 정지 틱(무적 재확인 포함) ×100 = " + (a1 - a0) + " B");
a0 = Alloc();
bool acc = false;
for (int i = 0; i < 100; i++) acc |= Survival.IsHoldInvincible(s_pc);
a1 = Alloc();
L(" " + P(a1 - a0 <= 0) + " IsHoldInvincible ×100 = " + (a1 - a0) + " B (acc=" + acc + ")");
var dinfo = Dmg(s_boss, MobRaw);
var call = ApplyToPc(s_pc);
Survival.TakeDamage(s_pc, dinfo, call); // 워밍업
a0 = Alloc();
for (int i = 0; i < 100; i++) { dinfo.Damage = MobRaw; Survival.TakeDamage(s_pc, dinfo, call); }
a1 = Alloc();
L(" " + P(a1 - a0 <= 0) + " 무적 경로 피해 차단 ×100 = " + (a1 - a0) + " B");
a0 = Alloc();
for (int i = 0; i < 100; i++) acc |= BossArena.ShouldHideBossUi(b, true, false, false);
a1 = Alloc();
L(" " + P(a1 - a0 <= 0) + " 보스 UI 숨김 판정 ×100 = " + (a1 - a0) + " B");
a0 = Alloc();
for (int i = 0; i < 100; i++) acc |= BossHpBar.SuppressedBeforeGate;
a1 = Alloc();
L(" " + P(a1 - a0 <= 0) + " BossHpBar.SuppressedBeforeGate ×100 = " + (a1 - a0) + " B");
RunGuide.EndRunEndHold("GC 끝");
RunGuide.ResetDiagnostics();
SetHp(s_pc, PcMaxHp);
}
// ─────────────────────────────────────────────────────────────────────
// ⑦ C8 롤백
// ─────────────────────────────────────────────────────────────────────
static void Sec7_C8()
{
L("");
L("── ⑦ C8 롤백 (신규 2필드 0 → 813w7 · 813q2/813y 동작 그대로 = 결함 재현)");
// ⓐ invincibleWhileRunEndHold = 0
var t = TmpG();
try
{
t.invincibleWhileRunEndHold = false;
UseTmpG(t);
RunGuide.ResetDiagnostics();
SetHp(s_pc, PcMaxHp);
RunGuide.BeginRunEndHold("C8");
RunGuide.SetRunEndHoldUntilForProbe(-1f);
RunGuide.RunEndHoldTick(t, s_pc, 600f);
L(" " + P(RunGuide.RunEndHeld && !RunGuide.RunEndInvincible && RunGuide.RunEndInvincibleOn == 0) +
" ⓐ invincibleWhileRunEndHold=0 → 정지는 그대로(" + RunGuide.RunEndHeld + ") · 무적 0(on " +
RunGuide.RunEndInvincibleOn + ") = 813w7 동작");
double c0 = s_pc.Get_HP();
Survival.Tick(3000f);
Survival.TakeDamage(s_pc, Dmg(s_boss, 200d), ApplyToPc(s_pc));
L(" " + P(c0 - s_pc.Get_HP() > 1e-6) + " → 피해가 그대로 들어간다(HP " + c0.ToString("F0") + " → " +
s_pc.Get_HP().ToString("F0") + ") = D-46 재현");
// 정지 중에 스위치를 끄면 즉시 풀린다
t.invincibleWhileRunEndHold = true;
RunGuide.RunEndHoldTick(t, s_pc, 601f);
bool onAgain = RunGuide.RunEndInvincible;
t.invincibleWhileRunEndHold = false;
RunGuide.RunEndHoldTick(t, s_pc, 602f);
L(" " + P(onAgain && !RunGuide.RunEndInvincible) +
" ⓑ 정지 중 스위치 on→off → 다음 틱에서 무적 즉시 해제(" + onAgain + " → " + RunGuide.RunEndInvincible + ")");
RunGuide.EndRunEndHold("C8 끝");
}
finally { UseRealG(); UnityEngine.Object.DestroyImmediate(t); }
// ⓒ hideBossUiBeforeGate = 0 → 게이트 전에도 종전처럼 물린다(= D-23 재현)
var tb = TmpB();
try
{
tb.hideBossUiBeforeGate = false;
UseTmpB(tb);
s_bar.Initialize();
L(" " + P(!BossHpBar.SuppressedBeforeGate) + " ⓒ hideBossUiBeforeGate=0 → 숨김 정책 off(SuppressedBeforeGate=false)");
L(" " + P(s_bar.Visible) + " → 게이트 전인데도 늦은 구독이 만피 보스를 문다(Visible=" + s_bar.Visible +
" ratio=" + s_bar.LastRatio.ToString("F3") + ") = D-23 재현");
}
finally { UseRealB(); UnityEngine.Object.DestroyImmediate(tb); }
// ⓓ RuntimeDisabled — 가이드 축이 꺼지면 틱이 정지·무적을 원본으로 되돌린다(813w7 계약 그대로)
RunGuide.ResetDiagnostics();
SetHp(s_pc, PcMaxHp);
RunGuide.BeginRunEndHold("RuntimeDisabled 준비");
RunGuide.SetRunEndHoldUntilForProbe(-1f);
RunGuide.RunEndHoldTick(WLRunGuideSettings.Instance, s_pc, 700f);
bool heldBefore = RunGuide.RunEndHeld && RunGuide.RunEndInvincible;
WLRunGuideSettings.RuntimeDisabled = true;
WLBossArenaSettings.RuntimeDisabled = true;
try
{
RunGuide.ForceTick(); // 러너가 매 프레임 부르는 그 경로(!Active → 정지 해제)
L(" " + P(heldBefore && !RunGuide.Active && !RunGuide.RunEndHeld && !RunGuide.RunEndInvincible) +
" ⓓ RuntimeDisabled=1 → 틱이 정지·무적을 함께 푼다(직전 " + heldBefore + " → held " +
RunGuide.RunEndHeld + "/무적 " + RunGuide.RunEndInvincible + " · 예외 0)");
L(" " + P(!BossArena.HideBossUiNow) + " → 보스 UI 숨김 정책도 무동작(HideBossUiNow=" + BossArena.HideBossUiNow + ")");
}
finally
{
WLRunGuideSettings.RuntimeDisabled = false;
WLBossArenaSettings.RuntimeDisabled = false;
s_bar.Initialize();
RunGuide.ResetDiagnostics();
}
}
}