From 48b026ac358059b3ba39ceeed4652c1e9ec14b10 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 20:53:49 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813i2]=20=EB=B3=B4=EC=8A=A4=20=EC=97=B0?= =?UTF-8?q?=ED=83=80=20=ED=94=BC=EA=B2=A9=20=ED=9B=84=20=EB=AC=B4=EC=A0=81?= =?UTF-8?q?=20=C2=B7=20=EC=98=88=EA=B3=A0=20=ED=9A=8C=ED=94=BC=20=EC=8A=A4?= =?UTF-8?q?=ED=85=9D=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AgentScripts/WL813i2_PROBE.txt | 121 +++ AgentScripts/WL813i2_Probe.cs | 873 ++++++++++++++++++ Assets/WL/Combat/Run/RunGuide.cs | 286 +++++- Assets/WL/Combat/Run/WLRunGuideSettings.cs | 41 + .../Resources/WL/WLRunGuideSettings.asset | 11 + .../Resources/WL/WLSurvivalSettings.asset | 8 +- Assets/WL/Combat/Survival/DeathDiag.cs | 22 +- Assets/WL/Combat/Survival/Survival.cs | 113 ++- .../WL/Combat/Survival/WLSurvivalSettings.cs | 34 + 9 files changed, 1501 insertions(+), 8 deletions(-) create mode 100644 AgentScripts/WL813i2_PROBE.txt create mode 100644 AgentScripts/WL813i2_Probe.cs diff --git a/AgentScripts/WL813i2_PROBE.txt b/AgentScripts/WL813i2_PROBE.txt new file mode 100644 index 000000000..22f36e4e7 --- /dev/null +++ b/AgentScripts/WL813i2_PROBE.txt @@ -0,0 +1,121 @@ +# WL813i2 Probe 2026-09-09 21:48:12 (edit mode · Play 0 · 로그인 0) +playMode=False + +## ⓐ 에셋 실측 (디스크 값 · 클론 아님) +[PASS] WLSurvivalSettings 로드 OK +[PASS] 신규 6필드 hitInv=True boss=0.6s mob=False/0.2s minLoss=1 log=False +[PASS] §1-3 값 autoPotionHpRatio = 0.55 (0.45 → 0.55) + · 참고: 임계 0.55 + 회복 0.5 = 1.05 → 만충 초과 5 % 낭비(실측) +[PASS] 기존 배율(F-17) boss=0.1 mob=0.1 +[PASS] WLRunGuideSettings 로드 OK +[PASS] 신규 9필드 dodge=True states=boss_skill1charging|boss_skill2charging dist=3m/0.3s side=0 radius=14 cool=1.5 nav=2 log=True + +## ⓑ 🔴 보스 5연타 0.52 s (Q10 §6 재현 · 원 피해 222 ×0.10) +| # | t(s) | 원 피해 | 결과 | HP after | via | +|---|---|---|---|---|---| +| 1 | 0.00 | 222 | 🔴 피격 −22.2 | 288/310 | Scaled | +| 2 | 0.52 | 222 | 무적(흘림) | 288/310 | HitInv | +| 3 | 1.04 | 222 | 🔴 피격 −22.2 | 266/310 | Scaled | +| 4 | 1.56 | 222 | 무적(흘림) | 266/310 | HitInv | +| 5 | 2.08 | 222 | 🔴 피격 −22.2 | 243/310 | Scaled | +[PASS] 🔴 5연타 중 **실제 피격 3타** (창 0.6s · 발주서 §1-1 목표 「1~2타」 · 아래 스윕 표 참조) +[PASS] 무적이 막은 타수 = 2 (5 − 피격 3) +[PASS] 창 연 횟수 3(보스 3) = 피격 타수 +[PASS] 🔴 5연타 후 생존 HP = 243/310 (Q10 실측 = 5대에 사망) +[PASS] 대조군(창 off) = 5타 전부 피격 · HP 199/310 → 창이 만든 차이 2타 + +| 창(초) | 피격 타수 | 5연타 후 HP | 흘린 타 | +|---|---|---|---| +| 0.00 | 5 | 199/310 | 0 | +| 0.30 | 5 | 199/310 | 0 | +| 0.53 | 3 | 243/310 | 2 | +| 0.60 | 3 | 243/310 | 2 | +| 0.80 | 3 | 243/310 | 2 | +| 1.05 | 2 | 266/310 | 3 | ← 발주서 목표 1~2타 +| 1.30 | 2 | 266/310 | 3 | ← 발주서 목표 1~2타 +| 1.60 | 2 | 266/310 | 3 | ← 발주서 목표 1~2타 +| 2.10 | 1 | 288/310 | 4 | ← 발주서 목표 1~2타 + · 🔴 에셋 값은 발주서 제안대로 **0.6 s** 로 두었다. 목표(1~2타)를 만드는 최소 창은 위 표의 실측값이다 — **값 결정은 Lead**(C3). + +## ⓒ 무적 창 ms 실측 +[PASS] 창 열림 t=100.000 → until=100.600 · 길이 600 ms +[PASS] 경계 직전 t=100.599(+599 ms) → 무적 True · 잔여 1.0 ms +[PASS] 경계 직전 피격 = 피해 0 (HP 288 유지) +[PASS] 경계 직후 t=100.601(+601 ms) → 무적 False +[PASS] 경계 직후 피격 = 정상 피해 −22.2 (원 222 ×0.10) +[PASS] 창 안 피격은 창을 연장하지 않는다 until=101.201 + +## ⓓ 잡몹 피해 무적 0 (mobHitInvincible=0) +[PASS] 잡몹 5연타 = 5타 전부 피격 · 막은타 0 · 연 창 0 (실측상 잡몹은 한 방에 0.3~0.4 %) +[PASS] mobHitInvincible=1 로 뒤집으면 0.1 s 간격 5타 중 3타만 피격(창 0.2 s) + +## ⓔ 최소 피해 게이트 · 부활 창 우선 · 런 경계 +| 입력(boss · hpLost) | 창(초) | +|---|---| +| boss · 30 HP | 0.6 | +| boss · 0.5 HP( 실측 차징 1.2 s → **예고 1건에 스텝 1회** +[PASS] 같은 예고 안(1.2 s 경과)의 두 번째 요청 = kDodgeCooldown +[PASS] 방향: away=(1.00, 0.00, 0.00) (보스 반대) · side=(0.00, 0.00, -1.00) (직교 · dot=0.000) · 스텝 지점 = PC + away×3m = (16.0, 0.0, 0.0) + · 계획 속도 = 3m / 0.3s = 10.0 m/s (NavMeshAgent.Move 등속) +[PASS] NavMesh/PC 없음 → 스텝 0(Dodging=False DodgeSteps=0) · 실이동 3 m 는 인게임 QA 몫(「미확인」) + +## ⓗ GC 0 (핫 경로) +[PASS] TakeDamage(무적 창 경로 포함) ×200,000 = 0 byte +[PASS] IsHitInvincible ×200,000 = 0 byte +[PASS] DodgeDecision ×200,000 = 0 byte +[PASS] 대조군(64 KB 의도 할당) = 69632 byte(> 0 이어야 계측이 유효하다) + +## ⓘ C8 롤백 +[PASS] bossHitInvincibleSeconds=0 → 5연타 5타 전부 피격 · 창 0 = 813i 동작 +[PASS] hitInvincibleEnabled=0 → 5타 전부 피격 · 창 0 +[PASS] SO off → 배율·창 전부 무동작(원 피해 100 그대로 · PassDisabled=1) +[PASS] dodgeStepEnabled=0 → 판정 kDodgeOff · 스텝 0 + +## ⓙ 정리 +[PASS] 임시 오브젝트 0 · 에셋 원복(Survival True · RunGuide True) · MyPC=null True · 씬 dirty False(시작 False) · Override 0 + +RESULT PASS diff --git a/AgentScripts/WL813i2_Probe.cs b/AgentScripts/WL813i2_Probe.cs new file mode 100644 index 000000000..d896c771a --- /dev/null +++ b/AgentScripts/WL813i2_Probe.cs @@ -0,0 +1,873 @@ +// WL813i2_Probe.cs — #813i2(보스 연타 대비 피격 후 무적 창 · 보스 예고 중 자동 회피 스텝) 에디트 모드 검증 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 클론은 즉시 파괴) +// +// 상주 에디터: unity command run_script --project-path --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((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.m_Role = eRole.Mob; actor.m_SubRole = eSubRol.Boss; + var anim = go.AddComponent(); + 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(); + bool t5 = !RunGuide.IsBossTelegraphing(cfg, bareActor); // m_animation = null + var noCtrlGo = new GameObject("__WL813i2_noctrl"); + var noCtrlActor = noCtrlGo.AddComponent(); + noCtrlActor.m_animation = noCtrlGo.AddComponent(); // 컨트롤러 없음 + 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(path); + if (c == null) continue; + var probeGo = new GameObject("__WL813i2_scan"); + var pa = probeGo.AddComponent(); + 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(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(); + 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(); + 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; + } + + /// + /// 가짜 base.Get_Damage = **원본 MyActor.Get_Damage(:301-309) 그대로**. + /// `wlInvincible = IsRole(PC) && WLGameplaySettings.PlayerInvincible` 이면 base 를 건너뛰고(피해 0) + /// 아니면 넘어온(배율 적용된) 피해만큼 PC HP 를 깎는다. + /// 🔴 이 재현이 없으면 무적 창 경로를 검증할 수 없다 — 훅은 「플래그를 세운 채 base 를 부르는」 방식이라 + /// base 를 모사한 쪽이 그 플래그를 안 보면 무적이 무적이 아니게 된다(1차 프로브에서 실제로 잡혔다). + /// + static Action 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); + }; + } + + /// 플래그를 보지 않고 무조건 깎는다(플래그 자체를 검증하지 않는 절에서만 쓴다). + static Action ApplyAlways(PCActor pc) + { + return d => { if (d != null) pc.Get_StatInfo().Set_Stat(false, eStat.HP, d.Damage); }; + } + + /// 가짜 base.Get_Damage — 원본 무적 경로(피해 0). + static Action 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); } + } +} diff --git a/Assets/WL/Combat/Run/RunGuide.cs b/Assets/WL/Combat/Run/RunGuide.cs index cfc755c70..54b0e999b 100644 --- a/Assets/WL/Combat/Run/RunGuide.cs +++ b/Assets/WL/Combat/Run/RunGuide.cs @@ -122,6 +122,21 @@ // // ■ C8 롤백 — `holdPcOnRunEnd=0` 이면 이 경로가 통째로 죽고 813w6 동작 그대로다. // ───────────────────────────────────────────────────────────────────────────── +// WL-813i2 §1-2 (2026-09-09 · PD 지시 #813) — 보스 예고 중 자동 회피 스텝 +// +// 🔴 근거 = qa/W2-10_재채점_v8.md §6 — 사망 **14/14 가 보스 연타**(0.52 s 간격 5타 · 한 타에 HP 6~26 %). +// 본 방어선은 813i2 의 「피격 후 무적 창」이고, 이 스텝은 그 앞에서 **애초에 덜 맞는** 축이다 +// (813r 회피의 축소판 · 실패해도 무적이 받쳐 준다). +// ■ 예고 감지는 `Actor.m_animation`(public) 의 애니메이터 상태 이름 하나뿐이다 — 아래 회피 스텝 구역의 +// 주석에 그 실측 근거(왜 `Get_CurAnim()`·`MobStatus` 로는 못 읽는지)를 적어 두었다. +// ■ C8 롤백 = `dodgeStepEnabled = 0`(또는 `dodgeTelegraphStates` 를 비운다) → 813w4 동작 100 %. +// +// 🔴 813w7 과의 관계(2026-09-09 rebase 로 두 축이 한 파일에서 만났다) +// 결과창 중 PC 정지(813w7)가 걸려 있는 동안에는 회피 스텝을 **시작하지 않는다** — +// 두 축이 같은 프레임에 PC 를 반대로 밀면 「결과창 동안 PC 를 세운다」가 깨진다. +// 정지가 걸리는 순간 진행 중이던 스텝도 함께 끝낸다(BeginRunEndHold → EndDodgeStep). +// 런이 끝나면 보스도 없으므로 실전에서 겹칠 일은 없고, 이 가드는 순서 보장용이다. +// ───────────────────────────────────────────────────────────────────────────── // // ■ 값(C45) 전부 WLRunGuideSettings. enabled = 0 이면 러너가 뜨지 않고 구독도 하지 않는다(C8). // @@ -367,6 +382,11 @@ namespace WL.Combat.Run s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; s_bossArrivedOnce = false; // 813w4 §1-1: 정지 감시도 런마다 새로 잡는다. s_idleSince = -1f; s_lastStallGuide = -999f; + // 813i2 §1-2: 예고 회피 스텝 집계도 런 단위(QA 가 런 1회 표를 읽는다). + EndDodgeStep("런 시작"); + DodgeSteps = DodgeTelegraphsSeen = 0; + DodgeSkippedCooldown = DodgeSkippedPcBusy = DodgeFailedNavMesh = 0; + s_wasTelegraphing = false; s_lastDodge = -999f; var cfg = Cfg; if (cfg == null) return; @@ -504,6 +524,10 @@ namespace WL.Combat.Run "(틱 " + RunEndHoldTicks + " 타깃 " + RunEndTargetClears + ")" + " · 엘리트 " + EliteCount + "(고정 " + (DSUtil.CheckNull(s_lockedElite) ? "-" : LastEliteName) + " 락 " + EliteLocks + " 재타깃 " + EliteRetargets + " 해제 " + EliteReleases + ")" + + // 813i2: 예고 감지 · 회피 스텝(QA 가 「예고 → 스텝 1회」를 이 줄로 센다) + " · 회피 " + DodgeSteps + "/예고 " + DodgeTelegraphsSeen + + "(쿨 " + DodgeSkippedCooldown + " PC " + DodgeSkippedPcBusy + + " 맵밖 " + DodgeFailedNavMesh + ")" + " · 존 " + e.zonesCleared + "/" + e.zoneCount + " " + e.zoneKills + "/" + e.zoneRequired; Debug.Log("[RunGuide] " + LastTickLog); } @@ -562,6 +586,7 @@ namespace WL.Combat.Run s_stillSince = -1f; s_lastPosTime = -1f; s_outsideSince = -1f; s_nextExitDiag = 0f; // 813w3: 이탈 타이머는 목적지와 함께 끝난다 s_bossArrivedOnce = false; + EndDodgeStep("가이드 종료"); // 813i2: 스텝이 목적지보다 오래 살지 않게 한다 if (had) Log("가이드 종료 — " + reason); } @@ -581,10 +606,23 @@ namespace WL.Combat.Run internal static void Tick() { if (s_faulted) return; - if (!Active) { if (s_runEndHold) EndRunEndHold("가이드 off"); else if (s_steering) ReleaseSteer(); return; } + // 813w7 + 813i2 합류: 가이드가 꺼지면 결과창 정지도 풀고(813w7) 진행 중인 회피 스텝도 끝낸다(813i2). + if (!Active) + { + if (s_runEndHold) EndRunEndHold("가이드 off"); else if (s_steering) ReleaseSteer(); + EndDodgeStep("비활성"); + return; + } var cfg = Cfg; float now = Time.unscaledTime; + + // 🔴 813i2 §1-2 — 예고 회피 스텝은 **매 프레임** 돈다(폴링 0.1 s 로는 0.3 s 스텝을 못 민다). + // 스텝 중에는 폴링을 쉰다 — 폴링이 조종을 놓거나(ReleaseSteer) 목적지를 다시 주면 두 이동이 싸운다. + // 🔴 813w7 결과창 정지 중에는 스텝을 돌리지 않는다 — 「결과창 동안 PC 를 세운다」가 우선이다. + // (정지가 시작될 때 BeginRunEndHold 가 진행 중이던 스텝을 이미 끝냈으므로 s_dodging 이 남지 않는다.) + if (!s_runEndHold && TickDodgeStep(cfg, now, Time.deltaTime)) return; + if (now < s_nextPoll) return; s_nextPoll = now + Mathf.Max(0.02f, cfg.pollSeconds); @@ -712,6 +750,9 @@ namespace WL.Combat.Run if (cfg == null || !cfg.holdPcOnRunEnd) return; if (s_runEndHold) return; // 중복 안전(RunEnded 는 런당 1회지만 Abandon 경로가 겹칠 수 있다) s_runEndHold = true; + // 🔴 rebase 합류(813i2): 정지가 걸리면 진행 중이던 예고 회피 스텝을 끝낸다 — + // 스텝이 살아 있으면 다음 프레임에 PC 를 밀어 「결과창 동안 정지」가 깨진다. + EndDodgeStep("결과창 정지"); s_runEndHoldUntil = cfg.runEndHoldMaxSeconds > 0f ? Time.unscaledTime + cfg.runEndHoldMaxSeconds : -1f; RunEndHolds++; Log("결과창 중 PC 정지 시작(" + reason + ") — 상한 " + @@ -1346,6 +1387,243 @@ namespace WL.Combat.Run /// 캐시된 앵커(진단). public static Vector3 AnchorOf(int i) { return i >= 0 && i < kMaxZones ? s_anchors[i] : Vector3.zero; } + // ───────────────────────────────────────────────────────────────────── + // 813i2 §1-2 — 보스 예고 중 자동 회피 스텝 (Q10 D-21 · 813r 회피의 축소판) + // + // 🔴 예고를 읽는 유일한 공개 경로 (원본 수정 0 · 리플렉션 0 · 실측) + // 보스 차징 코루틴(Boss_Anubis.cs:64 `AnimationPlay("boss_skill1charging")` · + // :117 `"boss_skill2charging"`)은 `Actor.AnimationPlay`(Actor.cs:2029)를 부르는데 + // 그 함수는 `m_animation.Play(...)` 만 하고 **`CurAnim` 을 바꾸지 않는다** → + // `Get_CurAnim()` 으로는 예고를 못 읽는다. `MobActor.MobStatus`(:17)·`UseSkillIndex`· + // `dic_indicator`(BossMobActor:9)는 전부 **protected** 라 밖에서 못 읽는다. + // 남는 공개 경로 = `Actor.m_animation`(public Animator · Actor.cs:30) 하나뿐이라 + // `GetCurrentAnimatorStateInfo(0).IsName(<상태 이름>)` 로 판정한다(상태 이름은 SO 값 · C45). + // + // ■ 스텝은 「예고 → 발동 사이 1회」다 — 쿨다운(`dodgeStepCooldownSeconds` 1.5 s)이 + // 실측 차징 1.2 s 보다 길어 한 예고에 한 번만 나간다. 실패해도 813i2 무적 창이 받쳐 준다. + // ■ 이동은 DashDriver 와 같은 근거로 `NavMeshAgent.Move`(NavMesh 표면 투영 · 내부 상태 갱신)만 쓴다. + // `transform.position` 대입·`Warp` 는 지형을 통과한다(PD 지시 4). + // ───────────────────────────────────────────────────────────────────── + + /// 회피 스텝 판정 결과 — 0 스텝 · 1 꺼짐 · 2 쿨다운 · 3 보스 없음/멀다 · 4 예고 아님 · 5 PC 상태 불가. + public const int kDodgeGo = 0, kDodgeOff = 1, kDodgeCooldown = 2, kDodgeNoBoss = 3, + kDodgeNoTelegraph = 4, kDodgePcBusy = 5; + + // ── 진단(프로브·QA 가 읽는다) + /// 실제로 나간 회피 스텝 수 · 예고 **에피소드** 수(차징 상태의 상승 에지만 센다 · 프레임마다 세지 않는다). + public static int DodgeSteps, DodgeTelegraphsSeen; + /// 예고를 봤는데 못 나간 사유별 수 — 쿨다운 · PC 상태(공격·CC) · NavMesh 지점 없음. + /// 🔴 「보스 없음」·「예고 아님」은 평시 상태라 세지 않는다(세면 프레임 수만큼 늘어 값이 무의미해진다). + public static int DodgeSkippedCooldown, DodgeSkippedPcBusy, DodgeFailedNavMesh; + /// 마지막 스텝의 계획 거리(m) · 실제 이동 거리(m · NavMesh 투영 후) · 걸린 시간(초). + public static float LastDodgePlanned, LastDodgeMoved, LastDodgeSeconds; + /// 마지막 스텝 요약 줄(프로브·QA 가 읽는다). + public static string LastDodgeLog = ""; + + static bool s_dodging, s_wasTelegraphing; + static Vector3 s_dodgeDir, s_dodgeFrom; + static float s_dodgeElapsed, s_dodgeSpeed, s_dodgePlanned, s_lastDodge = -999f; + + /// 지금 회피 스텝 중인가(진단·프로브). + public static bool Dodging { get { return s_dodging; } } + /// 마지막 스텝 시각(unscaled · 쿨다운 기준). + public static float LastDodgeTime { get { return s_lastDodge; } } + + /// + /// 매 프레임 1회. 반환 true = 스텝이 진행 중이니 이번 프레임 폴링은 쉰다. + /// dt 는 게임 시간(Time.deltaTime) — 세계가 멈추면(timeScale 0) 스텝도 멈춘다. + /// + internal static bool TickDodgeStep(WLRunGuideSettings cfg, float now, float dt) + { + if (cfg == null) { EndDodgeStep("설정 없음"); return false; } + if (s_dodging) return DriveDodgeStep(cfg, dt); + TryBeginDodgeStep(cfg, now); + return s_dodging; + } + + /// + /// 스텝 시작 판정(순수 함수 · Actor 접근 0 · 프로브가 가짜 값으로 그대로 부른다 · C8 증거). + /// `sinceLastDodge` = 마지막 스텝 이후 경과(초 · 없으면 큰 값). + /// + public static int DodgeDecision(WLRunGuideSettings cfg, bool bossInRange, bool telegraphing, + bool pcReady, float sinceLastDodge) + { + if (cfg == null || !cfg.dodgeStepEnabled || + cfg.dodgeStepDistance <= 0f || cfg.dodgeStepSeconds <= 0f) return kDodgeOff; + if (sinceLastDodge < cfg.dodgeStepCooldownSeconds) return kDodgeCooldown; + if (!bossInRange) return kDodgeNoBoss; + if (!telegraphing) return kDodgeNoTelegraph; + if (!pcReady) return kDodgePcBusy; + return kDodgeGo; + } + + /// + /// 보스가 지금 예고(차징) 상태인가. `Actor.m_animation`(public) 의 상태 이름만 본다. + /// 🔴 **모든 레이어**를 본다 — 813b 실측: Anubis 컨트롤러는 레이어가 2개고 + /// `boss_skill1charging` 과 `boss_skill2charging` 이 서로 다른 레이어에 있다. + /// 원본 `Actor.AnimationPlay`(:2029)의 `Animator.Play(name)` 도 레이어 −1(= 그 상태가 있는 첫 레이어)로 건다. + /// 이름 목록은 SO 값(C45) — 목록이 비면 언제나 false = 스텝 0(C8). 할당 0(문자열 비교만). + /// + public static bool IsBossTelegraphing(WLRunGuideSettings cfg, Actor boss) + { + if (cfg == null || DSUtil.CheckNull(boss)) return false; + var names = cfg.dodgeTelegraphStates; + if (names == null || names.Length == 0) return false; + var anim = boss.m_animation; + if (anim == null || !anim.isActiveAndEnabled || anim.runtimeAnimatorController == null) return false; + int layers = anim.layerCount; + for (int L = 0; L < layers; L++) + { + var info = anim.GetCurrentAnimatorStateInfo(L); + for (int i = 0; i < names.Length; i++) + { + if (string.IsNullOrEmpty(names[i])) continue; + if (info.IsName(names[i])) return true; + } + } + return false; + } + + /// PC 가 지금 스텝을 받을 수 있는 상태인가(공격·스킬·CC·사망 중이면 원본 우선). + static bool DodgePcReady(PCActor pc) + { + if (DSUtil.CheckNull(pc) || pc.IsDead()) return false; + if (pc.JoystickStatus == 2) return false; // 스킬 이동 잠금(Whirlwind 등) + if (pc.Get_CurAnim() >= eAnim.Attack) return false; // 공격·스킬 중에는 끼어들지 않는다 + try { if (pc.IsCC()) return false; } + catch (System.Exception) { } // 에디트 모드엔 dic_ccData 가 없다 + return true; + } + + static void TryBeginDodgeStep(WLRunGuideSettings cfg, float now) + { + var pc = MyValue.MyPC; + var boss = IsAliveBoss(s_boss) ? s_boss : BossArena.Boss; + if (!IsAliveBoss(boss)) boss = null; + + Vector3 pcPos = DSUtil.CheckNull(pc) ? Vector3.zero : pc.Get_position(); + Vector3 bossPos = boss == null ? Vector3.zero : boss.Get_position(); + bool inRange = boss != null && cfg.dodgeStepBossRadius > 0f && + DistXZ(pcPos, bossPos) <= cfg.dodgeStepBossRadius; + + bool telegraphing = inRange && IsBossTelegraphing(cfg, boss); + // 🔴 상승 에지만 센다 — 프레임마다 세면 차징 1.2 s 당 70여 건이 찍혀 「예고 건수」가 무의미해진다. + if (telegraphing && !s_wasTelegraphing) DodgeTelegraphsSeen++; + s_wasTelegraphing = telegraphing; + + switch (DodgeDecision(cfg, inRange, telegraphing, DodgePcReady(pc), now - s_lastDodge)) + { + case kDodgeCooldown: if (telegraphing) DodgeSkippedCooldown++; return; + case kDodgePcBusy: DodgeSkippedPcBusy++; return; // 예고를 봤는데 공격·CC 중이었다 + case kDodgeGo: break; + default: return; // 꺼짐 · 보스 없음 · 예고 아님 = 조용히 무동작 + } + + var agent = pc.Get_Agent(); + if (agent == null || !agent.enabled || !agent.isOnNavMesh) { DodgeFailedNavMesh++; return; } + + // ── 방향 = 예고 방향(보스→PC)의 **반대** = 보스에서 멀어지는 쪽. 옆 성분은 SO 비율로 섞는다. + Vector3 away = pcPos - bossPos; away.y = 0f; + if (away.sqrMagnitude < 1e-4f) { away = -boss.transform.forward; away.y = 0f; } + if (away.sqrMagnitude < 1e-4f) away = Vector3.forward; + away.Normalize(); + Vector3 side = Vector3.Cross(Vector3.up, away); // XZ 직교(정규 · away 가 단위라 단위) + + float bias = Mathf.Clamp01(cfg.dodgeStepSideBias); + Vector3 dir = (away * (1f - bias) + side * bias); + if (dir.sqrMagnitude < 1e-4f) dir = away; else dir.Normalize(); + + float r = cfg.dodgeStepNavSampleRadius > 0f ? cfg.dodgeStepNavSampleRadius : 1f; + Vector3 target; + if (!SampleStep(pcPos, dir, cfg.dodgeStepDistance, r, out target)) + { + // 옆으로 섞은 방향이 막혔으면 반대쪽 옆으로 한 번 더(벽에 붙어 있을 때). + Vector3 alt = (away * (1f - bias) - side * bias); + if (alt.sqrMagnitude > 1e-4f) alt.Normalize(); else alt = away; + if (!SampleStep(pcPos, alt, cfg.dodgeStepDistance, r, out target)) { DodgeFailedNavMesh++; return; } + dir = alt; + } + + Vector3 real = target - pcPos; real.y = 0f; + float planned = real.magnitude; + if (planned < 0.05f) { DodgeFailedNavMesh++; return; } // 갈 데가 없다 + s_dodgeDir = real / planned; + + // NavMesh 경로 이동을 끊고 직접 민다(DashDriver 와 같은 이유 — 두 이동이 싸우지 않게). + pc.WL_StopNavMove(); + AcquireSteer(pc); // 813w7 이 뽑아 둔 공통 진입점(우리가 0 → 1 로 만든 경우에만 잡는다) + + s_dodging = true; + s_dodgeFrom = pcPos; + s_dodgeElapsed = 0f; + s_dodgePlanned = planned; + s_dodgeSpeed = planned / Mathf.Max(0.05f, cfg.dodgeStepSeconds); + s_lastDodge = now; + DodgeSteps++; + LastDodgePlanned = planned; + + if (cfg.dodgeStepLog) + { + LastDodgeLog = "#" + DodgeSteps + " 예고 회피 — 보스 " + DistXZ(pcPos, bossPos).ToString("F1") + + "m · 반대로 " + planned.ToString("F1") + "m / " + + cfg.dodgeStepSeconds.ToString("F2") + "s · 옆비율 " + bias.ToString("F2"); + Debug.Log("[RunGuide][회피] " + LastDodgeLog); + } + } + + /// 스텝 지점을 NavMesh 위로 스냅한다(밖이면 false = 스텝하지 않는다 · 지형 뚫기 방지). + static bool SampleStep(Vector3 from, Vector3 dir, float dist, float sampleRadius, out Vector3 target) + { + target = from + dir * dist; + NavMeshHit nh; + if (!NavMesh.SamplePosition(target, out nh, sampleRadius, NavMesh.AllAreas)) return false; + target = nh.position; + return true; + } + + /// 진행 중인 스텝을 민다. 반환 true = 아직 스텝 중(폴링은 쉰다). + static bool DriveDodgeStep(WLRunGuideSettings cfg, float dt) + { + var pc = MyValue.MyPC; + if (!DodgePcReady(pc)) { EndDodgeStep("PC 상태"); return false; } + + var agent = pc.Get_Agent(); + if (agent == null || !agent.enabled || !agent.isOnNavMesh) { EndDodgeStep("NavMesh"); return false; } + + if (dt > 0f) + { + float remain = Mathf.Max(0f, s_dodgePlanned - Vector3.Distance(s_dodgeFrom, pc.Get_position())); + float step = Mathf.Min(s_dodgeSpeed * dt, remain); + if (step > 0f) agent.Move(s_dodgeDir * step); + s_dodgeElapsed += dt; + } + + if (s_dodgeElapsed >= Mathf.Max(0.05f, cfg.dodgeStepSeconds)) { EndDodgeStep("완료"); return false; } + return true; + } + + /// 스텝을 끝낸다(조종은 다음 폴링이 정리한다 — 우리가 1 로 만든 경우에만 0 으로 돌아간다). + static void EndDodgeStep(string why) + { + if (!s_dodging) return; + s_dodging = false; + var pc = MyValue.MyPC; + LastDodgeMoved = DSUtil.CheckNull(pc) ? 0f : DistXZ(s_dodgeFrom, pc.Get_position()); + LastDodgeSeconds = s_dodgeElapsed; + LastDodgeLog = "#" + DodgeSteps + " " + why + " — 계획 " + s_dodgePlanned.ToString("F1") + + "m / 실제 " + LastDodgeMoved.ToString("F1") + "m / " + s_dodgeElapsed.ToString("F2") + "s"; + var c = Cfg; + if (c != null && c.dodgeStepLog) Debug.Log("[RunGuide][회피] " + LastDodgeLog); + } + + // ── 813i2 프로브 API + /// 프로브용 — 스텝 상태·쿨다운을 비운다. + public static void ResetDodgeStateForProbe() { s_dodging = false; s_wasTelegraphing = false; s_dodgeElapsed = 0f; s_lastDodge = -999f; } + /// 프로브용 — 마지막 스텝 시각을 밀어 넣는다(쿨다운 시나리오). + public static void SetLastDodgeForProbe(float t) { s_lastDodge = t; } + /// 프로브용 — 러너 없이 회피 스텝 1틱(가짜 dt). + public static bool ForceDodgeTick(float now, float dt) { return TickDodgeStep(Cfg, now, dt); } + // ───────────────────────────────────────────────────────────────────── // 유틸 · 진단 // ───────────────────────────────────────────────────────────────────── @@ -1397,6 +1675,12 @@ namespace WL.Combat.Run EndRunEndHold("진단 초기화"); RunEndHolds = RunEndHoldReleases = RunEndHoldTicks = RunEndTargetClears = 0; LastRunEndHoldReason = ""; + // 813i2 + DodgeSteps = DodgeTelegraphsSeen = 0; + DodgeSkippedCooldown = DodgeSkippedPcBusy = DodgeFailedNavMesh = 0; + LastDodgePlanned = LastDodgeMoved = LastDodgeSeconds = 0f; LastDodgeLog = ""; + s_dodging = false; s_wasTelegraphing = false; s_dodgeElapsed = 0f; s_lastDodge = -999f; + s_dodgeDir = s_dodgeFrom = Vector3.zero; s_dodgeSpeed = s_dodgePlanned = 0f; } // ── 813w7 프로브 API diff --git a/Assets/WL/Combat/Run/WLRunGuideSettings.cs b/Assets/WL/Combat/Run/WLRunGuideSettings.cs index 10f98f6b5..52de4e0df 100644 --- a/Assets/WL/Combat/Run/WLRunGuideSettings.cs +++ b/Assets/WL/Combat/Run/WLRunGuideSettings.cs @@ -296,5 +296,46 @@ namespace WL.Combat.Run [Tooltip("정지 중 NavMeshAgent 의 남은 경로를 지운다(ResetPath · isStopped). 런 종료 순간의 잔여 경로로 " + "PC 가 계속 걸어가는 것을 막는다. 끄면 좌표만 그대로 두고 조종만 잡는다.")] public bool runEndHoldStopsAgent = true; + + // ─────────────────────────────────────────── 813i2 §1-2 보스 예고 중 자동 회피 스텝 + // 🔴 실측 근거 = qa/W2-10_재채점_v8.md §6 — 사망 14/14 가 보스 연타. 813i2 무적 창이 본 방어선이고 + // 이 스텝은 그 앞의 「덜 맞기」다(813r 회피의 축소판 · 실패해도 무적이 받쳐 준다). + // ■ 예고를 어떻게 읽는가 (원본 수정 0 · 리플렉션 0) + // 보스의 차징 코루틴(예: Boss_Anubis.Co_Skill1 :64 `AnimationPlay("boss_skill1charging")`)은 + // `Actor.CurAnim` 을 바꾸지 않고(Actor.cs:2029 는 Animator.Play 만 한다) `MobStatus` 는 protected 다. + // 읽을 수 있는 공개 경로는 **`Actor.m_animation`(public Animator · Actor.cs:30)** 뿐이라 + // `GetCurrentAnimatorStateInfo(0).IsName(<상태 이름>)` 로 판정한다. 상태 이름은 값(C45)이라 아래 배열에 둔다. + [Header("보스 예고 중 자동 회피 스텝 (813i2 · D-21)")] + [Tooltip("보스가 예고(차징) 상태에 들어가면 예고 방향 반대로 짧게 한 걸음 물러난다. " + + "끄면 813w4 동작 그대로(C8 롤백 1순위).")] + public bool dodgeStepEnabled = true; + + [Tooltip("예고로 인정할 보스 애니메이터 상태 이름(0번 레이어 · 대소문자 구분). " + + "813b/813h 실측 = 12보스 전부 `boss_skill1charging` / `boss_skill2charging` 두 상태를 쓴다.")] + public string[] dodgeTelegraphStates = { "boss_skill1charging", "boss_skill2charging" }; + + [Tooltip("한 걸음의 거리(m). 발주서 제안 3 m.")] + public float dodgeStepDistance = 3f; + + [Tooltip("한 걸음에 쓰는 시간(초). 거리/시간 = 스텝 속도. 짧을수록 회피가 날카롭고 원본 이동을 오래 막지 않는다.")] + public float dodgeStepSeconds = 0.3f; + + [Tooltip("스텝 방향의 옆(직교) 성분 비율. 0 = 보스 반대쪽 정직진(예고 방향의 정반대) · " + + "1 = 완전히 옆으로. 돌진형 예고를 옆으로 흘리고 싶으면 올린다.")] + [Range(0f, 1f)] + public float dodgeStepSideBias = 0f; + + [Tooltip("보스가 PC 에서 이 거리(m · XZ) 안일 때만 스텝한다. 멀면 어차피 안 맞는다.")] + public float dodgeStepBossRadius = 14f; + + [Tooltip("한 예고(차징 상태가 이어지는 동안)에 스텝은 1회만. 이 시간(초) 안에는 다시 스텝하지 않는다(쿨다운). " + + "실측 차징 1.2 s 기준 그보다 커야 예고 1건에 1회가 된다.")] + public float dodgeStepCooldownSeconds = 1.5f; + + [Tooltip("스텝 지점을 NavMesh 위로 되돌릴 때 쓰는 탐색 반경(m). NavMesh 밖이면 스텝하지 않는다(지형 뚫기 방지).")] + public float dodgeStepNavSampleRadius = 2f; + + [Tooltip("스텝을 시작할 때 Console 에 1줄 남긴다(verboseLog 와 별개 · QA 가 예고→스텝 1회를 이 줄로 센다).")] + public bool dodgeStepLog = true; } } diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset index 7998c44a0..64a6e0a68 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset @@ -66,3 +66,14 @@ MonoBehaviour: runEndHoldMaxSeconds: 120 runEndHoldClearsTarget: 1 runEndHoldStopsAgent: 1 + dodgeStepEnabled: 1 + dodgeTelegraphStates: + - boss_skill1charging + - boss_skill2charging + dodgeStepDistance: 3 + dodgeStepSeconds: 0.3 + dodgeStepSideBias: 0 + dodgeStepBossRadius: 14 + dodgeStepCooldownSeconds: 1.5 + dodgeStepNavSampleRadius: 2 + dodgeStepLog: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset index f04d697ce..8b81432b1 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset @@ -19,6 +19,12 @@ MonoBehaviour: takeBossDamage: 1 bossDamageMultiplier: 0.1 eliteCountsAsBoss: 0 + hitInvincibleEnabled: 1 + bossHitInvincibleSeconds: 0.6 + mobHitInvincible: 0 + mobHitInvincibleSeconds: 0.2 + hitInvincibleMinHpLoss: 1 + hitInvincibleLog: 0 potionCountPerRun: 4 potionHealRate: 0.5 potionCooldownSeconds: 3 @@ -26,7 +32,7 @@ MonoBehaviour: potionRefuseAtFullHp: 1 potionResetOnMapLoad: 1 autoPotionEnabled: 1 - autoPotionHpRatio: 0.45 + autoPotionHpRatio: 0.55 autoPotionRequireAutoCombat: 1 autoPotionCheckSeconds: 0.25 autoPotionLog: 1 diff --git a/Assets/WL/Combat/Survival/DeathDiag.cs b/Assets/WL/Combat/Survival/DeathDiag.cs index 89969ff7e..9de1890f4 100644 --- a/Assets/WL/Combat/Survival/DeathDiag.cs +++ b/Assets/WL/Combat/Survival/DeathDiag.cs @@ -12,6 +12,7 @@ // SwitchOff = 잡몹/보스 스위치 off(원본 무적 그대로) // Passthru = SO off · 메인 PC 아님(원본 100 %) // 🔴 Unscaled = **훅을 거치지 않고 HP 가 깎였다**(DamageGuard 폴링이 잡은 차액 = 배율 미적용 경로) +// 🔴 HitInv = WL-813i2 피격 후 무적 창이 흘린 타(피해 0) — Q10 D-21 보스 0.52 s 5연타 완화 // // ■ 기록 경로 GC 0 // · 사전 할당 struct 배열 16칸(링) · 문자열 0 · 박싱 0 · 가해자는 참조 1개만 담는다(파괴돼도 null 로 읽힌다). @@ -40,7 +41,8 @@ namespace WL.Combat.Survival ReviveInv = 1, // 부활 무적 창 → 원본 무적 경로(피해 0) SwitchOff = 2, // takeMobDamage/takeBossDamage off → 원본 무적 그대로 Passthru = 3, // SO off · 메인 PC 아님 → 원본 100 % - Unscaled = 4 // 🔴 훅을 거치지 않은 HP 감소(DoT · Cal_Damage 직접 호출 등) + Unscaled = 4, // 🔴 훅을 거치지 않은 HP 감소(DoT · Cal_Damage 직접 호출 등) + HitInv = 5 // 🔴 WL-813i2 피격 후 무적 창 → 원본 무적 경로(피해 0 · 연타에서 흘린 타) } /// 사망 직전 피해 로그. 정적 · 링버퍼 16 · 기록 경로 GC 0. @@ -130,6 +132,7 @@ namespace WL.Combat.Survival int n = Mathf.Min(s_written, Capacity); double sumScaled = 0d, sumUnscaled = 0d; int cntScaled = 0, cntUnscaled = 0; + int cntHitInv = 0; // 813i2 — 무적 창이 흘린 타(최근 16건 중) s_sb.Append("# WL-813m3 사망 직전 피해 로그 (PD 지시 #813 · Q9 D-21)\n"); s_sb.Append("death #").Append(e.deathCount) @@ -148,7 +151,12 @@ namespace WL.Combat.Survival .Append(" potion=").Append(PotionUse.Remaining).Append('/').Append(PotionUse.Max) .Append(" autoPotion=").Append(st.autoPotionEnabled ? 1 : 0) .Append('@').Append(st.autoPotionHpRatio.ToString("F2")) - .Append(" guard=").Append(st.unscaledDamageGuardEnabled ? 1 : 0).Append('\n'); + .Append(" guard=").Append(st.unscaledDamageGuardEnabled ? 1 : 0) + // 813i2 — 피격 후 무적 창(Q10 D-21 보스 0.52 s 5연타) + .Append(" hitInv=").Append(st.hitInvincibleEnabled ? 1 : 0) + .Append('@').Append(st.bossHitInvincibleSeconds.ToString("F2")).Append("s(boss)") + .Append(st.mobHitInvincible ? "/" + st.mobHitInvincibleSeconds.ToString("F2") + "s(mob)" : "/mob off") + .Append('\n'); s_sb.Append("\n# t(s) frame via attacker(kind/id) raw mult lost hp_after/max\n"); // 링을 오래된 것 → 최신 순으로 @@ -159,6 +167,7 @@ namespace WL.Combat.Survival ref Hit h = ref s_ring[i]; if (h.via == DamageVia.Unscaled) { cntUnscaled++; sumUnscaled += h.lost; } else if (h.via == DamageVia.Scaled) { cntScaled++; sumScaled += h.lost; } + else if (h.via == DamageVia.HitInv) cntHitInv++; // 813i2 s_sb.Append(k < 9 ? " " : "").Append(k + 1).Append(" ") .Append(h.time.ToString("F2")).Append(" ") @@ -176,7 +185,12 @@ namespace WL.Combat.Survival s_sb.Append("\n합계(최근 ").Append(n).Append("건): 배율적용 ").Append(cntScaled).Append("건 ").Append(sumScaled.ToString("F0")) .Append(" HP · 🔴배율미적용(Unscaled) ").Append(cntUnscaled).Append("건 ").Append(sumUnscaled.ToString("F0")).Append(" HP\n") .Append("누계: Unscaled ").Append(UnscaledCount).Append("건 ").Append(UnscaledHpTotal.ToString("F0")).Append(" HP · ") - .Append("DamageGuard 환급 ").Append(DamageGuard.RefundCount).Append("건 ").Append(DamageGuard.RefundedHp.ToString("F0")).Append(" HP\n"); + .Append("DamageGuard 환급 ").Append(DamageGuard.RefundCount).Append("건 ").Append(DamageGuard.RefundedHp.ToString("F0")).Append(" HP\n") + // 813i2 — 무적 창이 실제로 몇 타를 흘렸는가(최근 16건 · 세션 누계) + .Append("🔴813i2 무적창: 최근 ").Append(n).Append("건 중 흘린타 ").Append(cntHitInv) + .Append("건 · 세션 누계 막은타 ").Append(Survival.BlockedHitInvincible) + .Append("건 / 연 창 ").Append(Survival.HitInvincibleOpened) + .Append("회(보스 ").Append(Survival.HitInvincibleOpenedByBoss).Append(")\n"); string path = Write(s_sb.ToString(), e.deathCount); @@ -186,6 +200,7 @@ namespace WL.Combat.Survival + " last" + n + " scaled=" + cntScaled + "(" + sumScaled.ToString("F0") + "hp)" + " unscaled=" + cntUnscaled + "(" + sumUnscaled.ToString("F0") + "hp)" + + " hitInv=" + cntHitInv + "/" + Survival.BlockedHitInvincible // 813i2 + " potion=" + PotionUse.Remaining + "/" + PotionUse.Max + " file=" + (string.IsNullOrEmpty(path) ? "-" : path); Debug.Log(LastSummary); @@ -199,6 +214,7 @@ namespace WL.Combat.Survival case DamageVia.ReviveInv: return "ReviveInv"; case DamageVia.SwitchOff: return "SwitchOff"; case DamageVia.Passthru: return "Passthru "; + case DamageVia.HitInv: return "HitInv "; // 813i2 피격 후 무적 창(피해 0) default: return "UNSCALED "; } } diff --git a/Assets/WL/Combat/Survival/Survival.cs b/Assets/WL/Combat/Survival/Survival.cs index ad6aa8d30..fdd1cfee1 100644 --- a/Assets/WL/Combat/Survival/Survival.cs +++ b/Assets/WL/Combat/Survival/Survival.cs @@ -25,6 +25,26 @@ // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── +// WL-813i2 (2026-09-09 · PD 지시 #813) — 보스 연타 대비 **피격 후 짧은 무적 창** +// +// 🔴 실측 근거 = qa/W2-10_재채점_v8.md §6 (`death_*.txt` **14건 전수**) +// · 사망 14/14 전부 `boss/813010 Anubis(Clone)` — 마지막 5~6행이 전부 보스 타격 +// · 보스 1타 원 피해 133.8~547.2 → ×0.15 를 거쳐도 **PC 최대 HP(310)의 6~26 %** +// · 타격 간격 **0.52 s 등간격** · **5대 / 약 2.6 초에 사망**(122→92→63→34→4→사망) +// · 도트(Unscaled) 기여 **0** — 813m3 의 「도트가 범인」 가설은 기각됐다 +// → 배율(F-17 0.15→0.1)은 「한 방에 26 %」를 못 바꾼다. **연타의 횟수**를 줄이는 축이 이 창이다. +// +// ■ 어떻게 (새 코드 경로 0 · 813i 무적 API 재사용) +// 실피해가 들어간 직후 `SetHitInvincible(pc, bossHitInvincibleSeconds)` 로 창을 연다. +// 창 안의 피격은 **부활 무적 창과 똑같은 원본 경로**를 탄다 — `playerInvincible` 을 내리지 않으므로 +// MyActor.Get_Damage(:308)가 `base.Get_Damage` 를 건너뛰고 `RaiseDamaged(invincible=true)` 만 낸다. +// · 피해 0 · 방어 공식 0 · 사망 판정 0 +// · 연출은 **UI 무수정으로 1회만** — `WLSurvivalUiSettings.ignoreInvincibleHits=1`(HitVignette:87)과 +// `WLImpactTierSettings.pcHitSkipWhenInvincible=1`(PcHitReaction:89)이 invincible 타를 스스로 거른다. +// · 잡몹에는 기본 미적용(`mobHitInvincible=0`) — 실측상 잡몹은 한 방에 0.3~0.4 % 라 창이 상시로 열리면 +// 난이도가 통째로 사라진다. +// C8 롤백 = `bossHitInvincibleSeconds = 0` 또는 `hitInvincibleEnabled = 0` → 813i 동작 100 %. +// ───────────────────────────────────────────────────────────────────────────── using System; using UnityEngine; @@ -45,6 +65,14 @@ namespace WL.Combat.Survival public static bool LastWasBoss; public static bool LastFlagDuringCall; // base 호출 직전 PlayerInvincible 값(기대 false) + // ── WL-813i2 진단(프로브가 읽는다) + /// 피격 후 무적 창이 막은 피격 수(= 연타에서 덜 맞은 타수). + public static int BlockedHitInvincible; + /// 피격 후 무적 창을 연 횟수 · 그중 보스 피격이 연 횟수. + public static int HitInvincibleOpened, HitInvincibleOpenedByBoss; + /// 마지막으로 연 창의 길이(초). + public static float LastHitInvincibleSeconds; + /// /// WL-813m3 — 이 훅을 거쳐 실제로 깎인 PC HP 누계(중첩 Cal_Damage 포함 · base 호출 전후 실측). /// DamageGuard 가 "총 감소량 − 이 누계" 로 **훅을 안 거친 피해**(배율 미적용 경로)를 뽑는 기준선이다. @@ -55,6 +83,10 @@ namespace WL.Combat.Survival static float s_reviveInvincibleUntil = -1f; static Actor s_invinciblePc; + // WL-813i2 — 피격 후 무적 창(부활 창과 별도 축: 부활 창은 런/부활 경계가, 이 창은 피격이 연다) + static float s_hitInvincibleUntil = -1f; + static Actor s_hitInvinciblePc; + /// 마지막 틱의 게임 시간(Time.time · 프로브 클록). public static float Now { get { return s_now; } } public static float ReviveInvincibleUntil { get { return s_reviveInvincibleUntil; } } @@ -73,11 +105,47 @@ namespace WL.Combat.Survival public static void SetReviveInvincible(Actor pc, float seconds) { DamageGuard.Rebase(); // WL-813m3 — 부활·런 경계로 HP 가 튀면 폴링 기준선을 다시 잡는다(오검출 0) + SetHitInvincible(null, 0f); // WL-813i2 — 부활·런 경계는 피격 창도 함께 닫는다 if (pc == null || seconds <= 0f) { s_invinciblePc = null; s_reviveInvincibleUntil = -1f; return; } s_invinciblePc = pc; s_reviveInvincibleUntil = s_now + seconds; } + // ───────────────────────────────────────── WL-813i2 피격 후 무적 창 (Q10 D-21) + + /// 피격 후 무적 창 종료 시각(게임 시간 · 창 없으면 −1). + public static float HitInvincibleUntil { get { return s_hitInvincibleUntil; } } + /// 피격 후 무적 창 잔여(초 · 813j UI 가 읽어도 된다). + public static float HitInvincibleRemaining { get { return Mathf.Max(0f, s_hitInvincibleUntil - s_now); } } + + /// 피격 후 무적 창 안인가(부활 창과 별개). + public static bool IsHitInvincible(Actor pc) + { + return pc != null && ReferenceEquals(pc, s_hitInvinciblePc) && s_now < s_hitInvincibleUntil; + } + + /// 피격 후 무적 창을 연다(프로브도 직접 부른다). seconds ≤ 0 이면 창을 닫는다. + public static void SetHitInvincible(Actor pc, float seconds) + { + if (pc == null || seconds <= 0f) { s_hitInvinciblePc = null; s_hitInvincibleUntil = -1f; return; } + s_hitInvinciblePc = pc; + s_hitInvincibleUntil = s_now + seconds; + LastHitInvincibleSeconds = seconds; + } + + /// + /// 이 피격이 창을 열 자격이 있는가(순수 함수 · Actor 접근 0 · 프로브가 가짜 값으로 그대로 부른다 · C8 증거). + /// 반환 = 창 길이(초 · 0 이면 열지 않는다). + /// + public static float HitInvincibleWindow(WLSurvivalSettings st, bool boss, double hpLost) + { + if (st == null || !st.hitInvincibleEnabled) return 0f; + if (hpLost < (double)Mathf.Max(0f, st.hitInvincibleMinHpLoss)) return 0f; + if (boss) return Mathf.Max(0f, st.bossHitInvincibleSeconds); + if (!st.mobHitInvincible) return 0f; + return Mathf.Max(0f, st.mobHitInvincibleSeconds); + } + /// /// PCActor.Get_Damage 훅(1줄). baseCall = base.Get_Damage(MyActor · 비가상). 비활성이면 baseCall 만 = 원본 100%. /// @@ -90,6 +158,24 @@ namespace WL.Combat.Survival if (IsReviveInvincible(pc)) { BlockedReviveInvincible++; CallBase(pc, dinfo, baseCall, DamageVia.ReviveInv, dinfo.Damage, 0d); return; } // 원본 무적 경로(피해 0 · Damaged(invincible=true)) + // 🔴 WL-813i2 (Q10 D-21) — 피격 후 무적 창. 부활 창과 **같은 원본 경로**로 흘려보낸다: + // playerInvincible 을 내리지 않으므로 MyActor.Get_Damage 가 base 를 건너뛰고 + // RaiseDamaged(invincible=true) 만 낸다 → 피해 0 · 비네트/타격반응은 각 SO 의 + // `ignoreInvincibleHits` / `pcHitSkipWhenInvincible` 이 걸러 준다(UI 무수정 · 연출 1회). + // 🔴 그 경로는 `WLGameplaySettings.playerInvincible` 이 **true 일 때만** 성립한다(MyActor.cs:308). + // PD 가 그 값을 해제하면 창이 있어도 원 피해가 그대로 들어간다 → 이 호출 동안만 플래그를 + // 올렸다 되돌린다(Scaled 경로가 내렸다 되돌리는 것의 정확한 대칭 · 디스크 값 무변경). + if (IsHitInvincible(pc)) + { + BlockedHitInvincible++; + var gsi = WL.Settings.WLGameplaySettings.Instance; + bool raise = gsi != null && !gsi.playerInvincible; + if (raise) gsi.playerInvincible = true; + try { CallBase(pc, dinfo, baseCall, DamageVia.HitInv, dinfo.Damage, 0d); } + finally { if (raise) gsi.playerInvincible = false; } + return; + } + var st = St; bool boss = IsBossSource(dinfo, st); if (boss ? !st.takeBossDamage : !st.takeMobDamage) @@ -109,11 +195,25 @@ namespace WL.Combat.Survival bool lift = gs != null && gs.playerInvincible; // 이미 꺼져 있으면(PD 가 에셋에서 해제) 손대지 않는다 if (lift) { gs.playerInvincible = false; Lifted++; } LastFlagDuringCall = WL.Settings.WLGameplaySettings.PlayerInvincible; - try { CallBase(pc, dinfo, baseCall, DamageVia.Scaled, raw, mult); } + double lost = 0d; + try { lost = CallBase(pc, dinfo, baseCall, DamageVia.Scaled, raw, mult); } finally { if (lift) gs.playerInvincible = true; } // 디스크 값 무변경 · 예외가 나도 되돌린다 Applied++; LastFinalDamage = dinfo.Damage; // Actor.Get_Damage 가 dinfo 를 제자리에서 갱신 → 방어 후 최종 피해 + // 🔴 WL-813i2 — **실제로 깎였을 때만** 창을 연다(방어로 0 이 된 타격은 창을 열지 않는다). + float win = HitInvincibleWindow(st, boss, lost); + if (win > 0f) + { + SetHitInvincible(pc, win); + HitInvincibleOpened++; + if (boss) HitInvincibleOpenedByBoss++; + if (st.hitInvincibleLog) + Debug.Log("[WL813i2] " + (boss ? "boss" : "mob") + " hit −" + lost.ToString("F0") + + " hp → 무적 " + win.ToString("F2") + "s (hp=" + pc.Get_HP().ToString("F0") + + "/" + pc.Get_MaxHP().ToString("F0") + " 막은타 " + BlockedHitInvincible + ")"); + } + if (Verbose) Debug.Log("[Survival] " + (boss ? "boss" : "mob") + " dmg " + raw.ToString("F0") + " ×" + mult.ToString("F2") + " → " + LastScaledDamage.ToString("F0") + " → final " + dinfo.Damage.ToString("F0") @@ -125,18 +225,21 @@ namespace WL.Combat.Survival /// (원본 Get_Damage 안의 중첩 Cal_Damage · 스턴 추가타 · Extra 까지 포함한다) 그 누계가 /// DamageGuard 의 기준선이 된다. 같은 자리에서 사망 직전 로그 1건도 남긴다(GC 0). /// - static void CallBase(PCActor pc, DamageInfo dinfo, Action baseCall, DamageVia via, double raw, double mult) + /// WL-813i2 — 이 호출이 실제로 깎은 HP(피격 후 무적 창을 열 자격 판정에 쓴다). + static double CallBase(PCActor pc, DamageInfo dinfo, Action baseCall, DamageVia via, double raw, double mult) { double before = pc.Get_HP(); + double lost = 0d; try { baseCall(dinfo); } finally { double after = pc.Get_HP(); - double lost = before - after; + lost = before - after; if (lost < 0d) lost = 0d; HookHpLoss += lost; DeathDiag.Record(via, dinfo != null ? dinfo.Beater : null, raw, mult, lost, after, pc.Get_MaxHP()); } + return lost; } static bool IsBossSource(DamageInfo dinfo, WLSurvivalSettings st) @@ -215,6 +318,10 @@ namespace WL.Combat.Survival s_reviveInvincibleUntil = -1f; s_invinciblePc = null; RunStarts = 0; LastRunReason = ""; HookHpLoss = 0d; // 813m3 + // 813i2 + BlockedHitInvincible = HitInvincibleOpened = HitInvincibleOpenedByBoss = 0; + LastHitInvincibleSeconds = 0f; + s_hitInvincibleUntil = -1f; s_hitInvinciblePc = null; } } diff --git a/Assets/WL/Combat/Survival/WLSurvivalSettings.cs b/Assets/WL/Combat/Survival/WLSurvivalSettings.cs index 9de03524f..5d337621b 100644 --- a/Assets/WL/Combat/Survival/WLSurvivalSettings.cs +++ b/Assets/WL/Combat/Survival/WLSurvivalSettings.cs @@ -86,6 +86,40 @@ namespace WL.Combat.Survival [Tooltip("켜면 정예(eSubRol.Elite) 가해자를 보스 배율·스위치로 다룬다. 기본은 잡몹 취급.")] public bool eliteCountsAsBoss = false; + // ─────────────────────────────────────────── 보스 연타 대비 피격 후 무적 (Q10 D-21 · WL-813i2) + // 🔴 실측 근거 = qa/W2-10_재채점_v8.md §6(`death_*.txt` 14건 전수) + // · 사망 14/14 가 전부 `boss/813010 Anubis(Clone)` — 마지막 5~6행이 전부 보스 타격 + // · 보스 1타 원 피해 133.8~547.2 → ×0.15 를 거쳐도 **PC 최대 HP(310)의 6~26 %** + // · 타격 간격 🔴 **0.52 s 등간격** · 🔴 **5대 / 약 2.6 초에 사망**(122→92→63→34→4→사망) + // · 도트(Unscaled) 기여 **0**(14건 전부 0건 0 HP) — 813m3 가설은 기각됐다 + // → 배율(F-17 로 0.15→0.1)만으로는 「한 방에 26 %」가 안 바뀐다. 연타의 **횟수**를 줄이는 축이 필요하다. + // 0.6 s 창이면 0.52 s 간격 5연타에서 1·3·5 타만 들어온다(3/5) → 창을 0.6 s 로 두면 실측 2~3타. + // 창 안의 피격은 **부활 무적 창과 같은 원본 경로**(playerInvincible 유지 → base 가 피해를 건너뛰고 + // RaiseDamaged(invincible=true) 만 낸다)로 흘려보낸다 → 피격 비네트(WLSurvivalUiSettings + // `ignoreInvincibleHits=1`)·타격 반응(WLImpactTierSettings `pcHitSkipWhenInvincible=1`)이 + // 스스로 걸러 준다 = **UI 무수정으로 연출 1회만**. + [Header("보스 연타 대비 피격 후 무적 (Q10 D-21 · WL-813i2)")] + [Tooltip("피해를 실제로 입은 직후 짧은 무적 창을 연다(보스 연타 완화). C8 롤백 = 0(813i 동작 100%).")] + public bool hitInvincibleEnabled = true; + + [Tooltip("보스 피격으로 HP 가 실제로 깎인 직후 무적 창 길이(초 · 게임 시간). " + + "0 이면 창 없음 = C8. 실측 보스 타격 간격 0.52 s 기준 0.6 이면 연타 5타 중 2~3타만 들어온다.")] + public float bossHitInvincibleSeconds = 0.6f; + + [Tooltip("잡몹 피격에도 같은 창을 여는가. 실측상 잡몹은 한 방에 0.3~0.4 % 라 기본은 끈다(0) — " + + "켜면 잡몹 밭에서도 무적이 상시로 켜져 난이도가 통째로 사라진다.")] + public bool mobHitInvincible = false; + + [Tooltip("잡몹 피격 후 무적 창 길이(초 · mobHitInvincible 가 켜졌을 때만).")] + public float mobHitInvincibleSeconds = 0.2f; + + [Tooltip("창을 여는 최소 실피해(HP). 이보다 적게 깎였으면 창을 열지 않는다(스침 타격으로 창이 열려 " + + "보스 본타를 통째로 흘리는 것을 막는다). 0 이면 피해가 조금이라도 있으면 연다.")] + public float hitInvincibleMinHpLoss = 1f; + + [Tooltip("창을 열 때 Console 에 1줄 남긴다(verboseLog 와 별개 · 창 1개당 1줄).")] + public bool hitInvincibleLog = false; + // ─────────────────────────────────────────── 물약 [Header("물약 (기준서 §B 요소 8 회복 · 3개/런 · 40 % · 쿨 5 s)")] [Tooltip("런(맵 진입)당 물약 개수.")]