code:
if (!_progressDict.ContainsKey(achievementId))
            return;

        var progress = _progressDict[achievementId];

        // 이미 해금됨
        if (progress.IsUnlocked)
            return;

        // 해금 처리
        progress.IsUnlocked = true;
        progress.UnlockTime = DateTime.UtcNow.Ticks;

        var achievement = GetAchievement(achievementId);
        if (achievement != null)
        {
            Log($"🏆 Achievement Unlocked: {achievement.Title}");

            // 보상 지급
            GiveReward(achievement);

            // SaveManager에 기록
            if (SaveManager.Instance != null)
            {
                SaveManager.Instance.UnlockAchievement(achievementId);
            }

            // ✅ [R787] 업적 달성 SE
            SoundManager.Instance?.SoundPlay(UI_SoundType.AchieveGet);
            // 이벤트 발동
            OnAchievementUnlocked?.Invoke(achievement);
        }

bug: UnlockAchievement에서 보상 지급을 검증하기 전에 progress.IsUnlocked = true로 해금 완료 플래그를 먼저 확정한다. 이후 GiveReward()의 토큰 지급이 실패하거나 SaveManager/CurrentSave가 null인 경계 상태여도 롤백하지 않으므로, 업적은 해금 완료로 고정된 채 1회성 토큰 보상이 영구 유실된다.

severity: high


code:
public void GrantVictoryBonus()
    {
        if (!IsRiskBattle) return;

        GameFlow.Instance?.AddGold(BalanceConfig.RISK_PATH_GOLD_BONUS);
        SaveManager.Instance?.AddTokens(BalanceConfig.RISK_PATH_TOKEN_BONUS);

        MsgManager.Instance?.InputInstantMsg(
            Loc.Fmt("System/MapRiskPathSystem/L85", BalanceConfig.RISK_PATH_GOLD_BONUS, BalanceConfig.RISK_PATH_TOKEN_BONUS), // 🏆 [위험 경로 클리어] +{BalanceConfig.RISK_PATH_GOLD_BONUS}G / +{...
            InstantMsgType.Good);
        Debug.Log($"[RiskPath] 보상 지급 +{BalanceConfig.RISK_PATH_GOLD_BONUS}G / +{BalanceConfig.RISK_PATH_TOKEN_BONUS}T");

        DeactivateBattle();
    }

    /// <summary>전투 종료(승리·패배·도주) 후 상태 초기화.</summary>
    public void DeactivateBattle()
    {
        IsRiskBattle = false;
    }
}

bug: GrantVictoryBonus()가 GameFlow.Instance?.AddGold(...)와 SaveManager.Instance?.AddTokens(...)를 null 조건부로 호출한 뒤, 골드·토큰 지급의 성공 여부를 검증하지 않고 곧바로 성공 메시지와 로그를 출력한다. 두 보상이 서로 다른 저장 경로로 분리되어 부분 커밋(예: 토큰 미반영)이 발생해도 사용자에게는 보상 완료로 안내되는 정합성 불일치 결함이다.


code:
public override void Apply(CharacterBase c, StatRulesSO rules = null)
    {
        if (!Item) return;

        // ✅ null 가드 + AddItem 결과 확인 — 인벤토리 꽉 또는 시스템 없을 때 조용히 실패 방지
        if (InventorySystem_TRPG.Instance == null)
        {
            Debug.LogWarning($"[Reward_Item] InventorySystem_TRPG 없음 — 아이템 지급 불가: {Item.ItemName}");
            return;
        }
        bool added = InventorySystem_TRPG.Instance.AddItem(Item, Amount);
        if (!added)
        {
            Debug.LogWarning($"[Reward_Item] 아이템 지급 실패 (인벤토리 꽉 또는 미지원 타입): {Item.ItemName}");
            return;
        }

        // ✅ 도감 등록 — Reward 경로는 MapIntegration을 거치지 않으므로 직접 호출
        CollectionManager.Instance?.RegisterItem(Item.ItemId);

        if (AutoApplyEffect)
            ItemEffectExecutor.Apply(Item, c, rules);

        var part = Resources.Load<TextPartSO>("Text/reward_gain_item");
        NarrativeLogger.WriteUnique(part, NarrativeLogger.Vars(
            characterName: c ? c.DisplayName : "Player",
            itemName: Item.ItemName
        ));
    }

bug: Reward_Item.Apply()가 아이템 지급 실패(InventorySystem 부재 또는 AddItem이 false 반환 — 인벤토리 가득참/미지원 타입) 시 경고 로그만 남기고 예외를 던지지 않은 채 return한다. RewardUI.TryApply()는 Apply()에서 예외가 없으면 성공(true)으로 간주해 보상 선택을 확정·소비하므로, 실제로는 아이템이 지급되지 않았는데도 보상이 소모되어 복구 불가능한 손실이 발생한다(fail-open).


code:
public void ClearFormation()
    {
        _rows.Clear();
        _currentParty = null;
        _enemyParty = null;
        _compDeltas.Clear();
    }

bug: ClearFormation()이 동료 메타 진형 보너스의 차감용 값을 담은 _compDeltas를 _compDeltas.Clear()로 바로 비우기만 하고, 저장된 델타(PhyPower, MagPower, PhyDef, CritChance 등)를 각 캐릭터 스탯에서 먼저 차감하지 않는다. 그 결과 전투 한정 진형 보너스가 전투 종료 후에도 스탯에 남아 영구 스탯처럼 누적/오염된다.


code:
void ApplySubclassDefaults()
    {
        // 전부 해제
        foreach (var e in _skillEntries) e.Toggle.isOn = false;
        foreach (var e in _passiveEntries) e.Toggle.isOn = false;

        if (_subclass == null && _classSO != null)
        {
            // 서브클래스 없으면 기본 StartSkills 선택
            var defaults = _classSO.StartSkills;
            if (defaults != null)
            {
                foreach (var defSkill in defaults)
                {
                    var match = _skillEntries.FirstOrDefault(e =>
                        e.Skill != null && e.Skill.name == defSkill.name);
                    if (match != null) match.Toggle.isOn = true;
                }
            }
            return;
        }

        // 서브클래스 StartSkillOverrides
        if (_subclass.StartSkillOverrides != null && _subclass.StartSkillOverrides.Count > 0)
        {
            foreach (var defSkill in _subclass.StartSkillOverrides)
            {
                if (defSkill == null) continue;
                var match = _skillEntries.FirstOrDefault(e =>
                    e.Skill != null && e.Skill.name == defSkill.name);
                if (match != null) match.Toggle.isOn = true;
            }
        }
        else if (_classSO != null && _classSO.StartSkills != null)
        {
            // Override 없으면 ClassSO 기본 StartSkills
            foreach (var defSkill in _classSO.StartSkills)
            {
                var match = _skillEntries.FirstOrDefault(e =>
                    e.Skill != null && e.Skill.name == defSkill.name);
                if (match != null) match.Toggle.isOn = true;
            }
        }

        // 서브클래스 StartPassiveOverrides
        if (_subclass.StartPassiveOverrides != null)
        {
            foreach (var defPassive in _subclass.StartPassiveOverrides)
            {
                if (defPassive == null) continue;
                var match = _passiveEntries.FirstOrDefault(e =>
                    e.Passive != null && e.Passive.name == defPassive.name);
                if (match != null) match.Toggle.isOn = true;
            }
        }

        UpdateBudgetDisplay();
    }

bug: ApplySubclassDefaults()의 조기 반환 가드가 `_subclass == null && _classSO != null` 조건으로만 처리하기 때문에, _subclass와 _classSO가 모두 null인 경우(ClassData가 없는 프리셋) 이 가드를 통과하지 못하고 곧바로 `_subclass.StartSkillOverrides`에 접근하여 NullReferenceException이 발생한다.


code:
private static void HideSceneInstancePrefabsInitial()
        {
            HideSceneInstancePrefabs();
            // 매 frame polling 헬퍼 부착 — Singleton GO
            var go = new GameObject("[DamageNumberSceneCleaner_Watcher]");
            Object.DontDestroyOnLoad(go);
            go.hideFlags = HideFlags.HideInHierarchy | HideFlags.DontSave;
            go.AddComponent<Watcher>();
        }

bug: 씬 로드마다 호출되는 초기화 메서드가 기존 워처 존재 여부를 확인/재사용하지 않고 매번 new GameObject("[DamageNumberSceneCleaner_Watcher]")를 생성한 뒤 DontDestroyOnLoad로 보존한다. 그 결과 씬 전환·재로딩을 반복할수록 워처 인스턴스가 누적되며, 각 워처가 주기적으로 HideSceneInstancePrefabs()를 호출해 장기 실행 시 불필요한 반복 스캔 비용과 프레임 타임 저하를 유발한다.


code:
public void OnRewardSelected()
    {
        // ✅ [AUD-Anti] 1회용 완료 콜백 실행 (SkillScholar 등에서 OnNodeCompleted 지연)
        if (OnRewardDoneCallback != null)
        {
            var done = OnRewardDoneCallback;
            OnRewardDoneCallback = null;
            done.Invoke();
        }

        // ✅ [Round 120] 보스 격파 특별 보상 후 처리 — OnPostBossRewardComplete 콜백 우선
        // 보스전 후 특별 보상을 선택했으면 콜백(OnRunComplete 등)을 실행하고 종료
        var tbc = BattleController.Instance;
        if (tbc?.OnPostBossRewardComplete != null)
        {
            var cb = tbc.OnPostBossRewardComplete;
            tbc.OnPostBossRewardComplete = null; // 1회 소비
            cb.Invoke();
            return; // 맵 복귀 생략 — 런 완료 화면으로 이동
        }

        // ✅ 레벨업 큐 — 대기 중인 레벨업이 있으면 맵 복귀 대신 다음 레벨업 처리
        if (_isLevelUpReward && PlayerLevel.Instance != null)
        {
            PlayerLevel.Instance.OnLevelUpRewardComplete();
            // ProcessNextLevelUp()이 ShowRewards를 다시 호출했으면 여기서 리턴
            // 큐가 비었으면 맵 복귀로 진행
            if (gameObject.activeSelf) return; // RewardUI가 다시 열렸으면 리턴
        }

        // ✅ [AUD-CDX-88001] NodeExecutor null 시 완료 라이프사이클 보장
        if (NodeExecutor.Instance != null)
        {
            NodeExecutor.Instance.ReturnToMap();
            // ReturnToMap이 OnEncounterCompleted + ShowMapPanel/ShowMap까지 처리하므로 이후 호출 없음
        }
        else
        {
            GameFlow.Instance?.OnEncounterCompleted(
                MapIntegration.Instance?.NodesCompleted ?? 0);
            if (GameFlow.Instance != null)
                GameFlow.Instance.ShowMapPanel();
            else
            {
                // ✅ [AUD-CDX-20602 Fix] GameFlow null 폴백에서 pause 해제 보장
                MapManager.Instance?.EndNodeInteractionPause();
                MapUI.Instance?.ShowMap();
            }
        }
    }

bug: RewardUI.OnRewardSelected()의 레벨업 보상 완료 처리에서 `if (gameObject.activeSelf) return;`로 'RewardUI 재오픈' 여부를 판단하는데, 이 씬은 RewardUI 호스트 오브젝트는 계속 활성 상태로 두고 RootPanel만 토글한다. 따라서 마지막 레벨업 보상 후 추가 대기 레벨업이 없어도 gameObject.activeSelf가 항상 true라 조기 return되어 NodeExecutor.ReturnToMap()/GameFlow 폴백이 실행되지 않고, 화면에 패널 없이 맵 복귀도 안 되는 소프트락이 발생한다.


code:
void OnStartRun()
    {
        // [AUD-CDX-31034] 재진입 방지 — 빠른 이중 클릭 시 환전/소모품 중복 실행 차단
        if (_isStarting) return;
        _isStarting = true;

        // 환전 처리 — [AUD-CDX-31027] 명시적 확인(Confirm) 시에만 실행
        if (_exchangeConfirmed && tokensToExchange > 0 && ExchangeSystem != null)
        {
            ExchangeSystem.ExchangeForRun(tokensToExchange);
        }
        tokensToExchange = 0;
        _exchangeConfirmed = false;

        // UI 닫기
        gameObject.SetActive(false);

        // ✅ [Round 46] 소모품 즉시 효과 적용 (StartLevel3 XP, ShieldStart 실드, SkipToBoss 등)
        ConsumableSystem?.ApplyConsumables();

#if UNITY_EDITOR || DEVELOPMENT_BUILD
        // ✅ 기지 전략 디버그 프리셋 (BaseLobbyUI 미구현 기간 테스트용)
        //    BaseStrategyManager Inspector에서 DebugPresetEnabled = true 설정 시 동작
        BaseStrategyManager.Instance?.ApplyDebugPreset();
#endif

        // ✅ RunStartFlow에 알림
        var runFlow = RunStartFlow.Instance;
        if (runFlow != null)
        {
            runFlow.OnRunStartComplete();
        }
        else
        {
            // Fallback: 직접 게임 시작
            var gameFlow = GameFlow.Instance;
            if (gameFlow != null)
            {
                gameFlow.StartRun();
            }
        }
    }

bug: RunStartUI.OnStartRun()에서 ConsumableSystem.ApplyConsumables()를 GameFlow.StartRun()(런 진입) 호출보다 먼저 실행하는 순서 결함이다. StartLevel3 같은 소모품이 즉시 레벨업을 부여하지만, 직후 시작되는 런 초기화(PlayerLevel.RunInit)가 Level=1·XP=0으로 되돌리고 런이 아직 활성화되지 않아 사전 부여된 레벨업 보상이 폐기되므로, 유료 소모품 효과가 매번 무효화된다.


code:
private string ApplyLuckyBlessing()
        {
            // 3가지 중 랜덤 선택
            int choice = Random.Range(0, 3);

            string result = Loc.Get("Map/Fortune/BlessingDescend") + "\n\n"; // 별들의 축복이 내려옵니다...
            result += "━━━━━━━━━━━━━━━━━━━━━\n\n";

            switch (choice)
            {
                case 0:
                    // 즉시 골드 획득
                    int goldBonus = Random.Range(80, 151);
                    // ✅ [AUD-CDX-21602 Fix] GameFlow null 폴백 추가 — 보상 소실 방지
                    if (GameFlow.Instance != null) GameFlow.Instance.AddGold(goldBonus);
                    else GoldManager.Instance?.AddGoldDirect(goldBonus);
                    result += Loc.Fmt("Map/Fortune/BlessingGold", goldBonus) + "\n\n"; // ✨ 황금빛 빛이 쏟아집니다! 💰 골드 +{0}
                    result += Loc.Get("Map/Fortune/BlessingGoldWish"); // 행운이 함께 하길...
                    break;

                case 1:
                    // ✅ 다음 전투 보상 2배 — BlessingManager 연동 시도
                    var rewardBlessing = Resources.Load<BlessingSO>("Blessings/Bless_EventReward2x");
                    if (rewardBlessing != null && BlessingManager.Instance != null)
                    {
                        BlessingManager.Instance.Grant(rewardBlessing);
                        result += Loc.Get("Map/Fortune/BlessingReward2x") + "\n\n"; // 🎁 행운의 부적을 받았습니다! 다음 전투 보상이 2배가 됩니다!
                    }
                    else
                    {
                        // Fallback: BlessingSO 에셋 없으면 즉시 골드 보너스
                        int bonusGold = Random.Range(80, 161);
                        // ✅ [AUD-CDX-21602 Fix] GameFlow null 폴백 추가 — 보상 소실 방지
                        if (GameFlow.Instance != null) GameFlow.Instance.AddGold(bonusGold);
                        else GoldManager.Instance?.AddGoldDirect(bonusGold);
                        result += Loc.Fmt("Map/Fortune/BlessingBonusGold", bonusGold) + "\n\n"; // 🎁 행운의 기운이 넘칩니다! 💰 골드 +{0}
                    }
                    result += Loc.Get("Map/Fortune/UseWisely"); // 현명하게 사용하세요...
                    break;

                case 2:
                    // ✅ 다음 상점 할인 30% — BlessingManager 연동 시도
                    var shopBlessing = Resources.Load<BlessingSO>("Blessings/Bless_ShopDiscount30");
                    if (shopBlessing != null && BlessingManager.Instance != null)
                    {
                        BlessingManager.Instance.Grant(shopBlessing);
                        result += Loc.Get("Map/Fortune/BlessingShopDiscount") + "\n\n"; // 💸 상인의 호의를 얻었습니다! 다음 상점에서 30% 할인!
                    }
                    else
                    {
                        // Fallback: BlessingSO 에셋 없으면 즉시 골드 보너스
                        int discountGold = Random.Range(60, 121);
                        // ✅ [AUD-CDX-21602 Fix] GameFlow null 폴백 추가 — 보상 소실 방지
                        if (GameFlow.Instance != null) GameFlow.Instance.AddGold(discountGold);
                        else GoldManager.Instance?.AddGoldDirect(discountGold);
                        result += Loc.Fmt("Map/Fortune/BlessingDiscountGold", discountGold) + "\n\n"; // 💸 상인의 축복이 내립니다! 💰 골드 +{0}
                    }
                    result += Loc.Get("Map/Fortune/GoodDeal"); // 좋은 거래가 되길...
                    break;
            }

            result += "\n━━━━━━━━━━━━━━━━━━━━━";

            return result;
        }

bug: 유료 운세 노드의 보상 지급에서 GameFlow.Instance.AddGold(...) 호출의 bool 반환값(커밋 성공 여부)을 확인하지 않은 채, 그 직후 "골드 +{0}" 등 보상 성공 안내 텍스트를 무조건 덧붙인다. 따라서 골드 커밋이 실패(예: AddGold가 false 반환)해도 사용자는 비용만 지불하고 보상 손실이 발생하면서 성공 메시지를 보게 된다.


code:
private static bool UpgradeSkill(CharacterBase c)
    {
        var skillbook = c.GetComponent<PlayerSkillbook>();
        if (skillbook == null) skillbook = PlayerSkillbook.Instance;

        if (skillbook != null && skillbook.Skills != null && skillbook.Skills.Count > 0)
        {
            // ✅ [Fix] 실제 스킬 강화 구현 — 랜덤 스킬에 DamageMultiplier +15% 적용
            var randomSkill = skillbook.Skills[Random.Range(0, skillbook.Skills.Count)];
            // [Fix] DamageMultiplier 퍼센트 단위: 15 = +15% (내부에서 /100)
            skillbook.EnhanceSkill(randomSkill, SkillEnhancementType.DamageMultiplier, 15f);

            ShowFeedback($"'{randomSkill.name}' Enhanced! +15%", Color.cyan);
            Debug.Log($"[ItemEffect] UpgradeSkill → {randomSkill.name} DamageMultiplier +15%");
            return true;
        }

        ShowFeedback("No skills to upgrade", Color.gray);
        return false;
    }

bug: UpgradeSkill()이 EnhanceSkill()의 bool 반환값을 검증하지 않고 무조건 성공 피드백을 표시하고 true를 반환한다. 선택된 스킬이 강화 상한(SP_ENHANCE_MAX_PER_SKILL)에 도달해 EnhanceSkill()이 false를 반환(강화 미적용)하더라도, 아이템은 소비되고 "성공"으로 처리되어 실제 효과와 결과가 불일치한다.


code:
private void ShowResumeRunPopup(int slotIndex, string playerName)
    {
        _pendingConfirmAction = () =>
        {
            // [이어하기] → 런 즉시 복귀 (스냅샷 1회 사용 후 GameFlow에서 자동 무효화)
            DLog.D($"[TitleScreen] 중단된 런 복귀: {playerName}");
            StartCoroutine(TransitionToResumeRun());
        };
        _pendingCancelAction = () =>
        {
            // [로비로] → 스냅샷 폐기 + 로비
            DLog.D($"[TitleScreen] 중단된 런 폐기 → 로비");
            var sm = SaveManager.Instance;
            if (sm?.CurrentSave?.MidRunSnapshot != null)
            {
                sm.CurrentSave.MidRunSnapshot.HasSnapshot = false;
                sm.CurrentSave.MidRunSnapshot.MapSeed = 0;
                // ✅ [AUD-CDX-131002 Fix] RunPrep 저장 필드도 함께 정리
                //   스냅샷 폐기 시 RunPrep 잔여 구매 상태가 다음 런에 잔류하는 것 방지
                sm.CurrentSave.RunPrepPurchasedCategoryIds?.Clear();
                sm.CurrentSave.RunPrepActiveBuffIds?.Clear();
                sm.CurrentSave.RunPrepShopGuaranteed = false;
                sm.CurrentSave.RunPrepGuaranteedCompanionId = null;
                sm.CurrentSave.RunPrepAnomalyDisableCount = 0;
                sm.SaveGame();
            }
            StartCoroutine(TransitionToLobby(resetSave: false));
        };

        ShowConfirmDialog(Loc.Fmt("UI/Title/MidRunDetected", playerName)
            ?? $"{playerName}의 중단된 모험이 있습니다.\n\n이어서 플레이하시겠습니까?\n\n(이어하기 시 다음에 다시 같은 시점으로 돌아갈 수 없습니다)");

        // 버튼 텍스트 변경
        if (_confirmYesButton != null)
        {
            var txt = _confirmYesButton.GetComponentInChildren<TextMeshProUGUI>();
            if (txt != null) txt.text = Loc.Get("UI/Title/ResumeRun", "이어하기");
        }
        if (_confirmNoButton != null)
        {
            var txt = _confirmNoButton.GetComponentInChildren<TextMeshProUGUI>();
            if (txt != null) txt.text = Loc.Get("UI/Title/GoToLobby", "로비로");
        }
    }

bug: 타이틀 화면의 중단 런 '로비로/포기' 경로(_pendingCancelAction)는 스냅샷 무효화(HasSnapshot=false, MapSeed=0)와 RunPrep 상태 초기화 같은 파괴적 세이브 변경을 가한 뒤 기본 sm.SaveGame()으로만 영속화한다. 이 기본 호출은 SaveManager의 디바운스(UseSaveDebounce) 조건에 걸려 즉시 디스크에 기록되지 않을 수 있어, 플러시 전에 앱이 종료/크래시되면 폐기한 런이 다시 이어하기로 나타나는 등 상태가 원복될 수 있다(forceImmediate:true 미사용).

severity: medium


code:
private static bool RerollSkill(CharacterBase c)
    {
        var skillbook = c.GetComponent<PlayerSkillbook>();
        if (skillbook == null) skillbook = PlayerSkillbook.Instance;

        if (skillbook != null && skillbook.Skills != null && skillbook.Skills.Count > 0)
        {
            // ✅ [Fix] 실제 리롤 구현 — 랜덤 스킬 1개 제거 후 새 스킬 습득 기회 부여
            var removeTarget = skillbook.Skills[Random.Range(0, skillbook.Skills.Count)];
            string removedName = removeTarget.name;

            // 스킬 제거
            skillbook.Skills.Remove(removeTarget);

            // 로드아웃에서도 제거
            if (skillbook.ActiveLoadout != null)
            {
                for (int i = 0; i < skillbook.ActiveLoadout.Count; i++)
                {
                    if (skillbook.ActiveLoadout[i] == removeTarget)
                        skillbook.ActiveLoadout[i] = null;
                }
            }

            // KnownSkills에서도 제거
            if (skillbook.KnownSkills != null)
                skillbook.KnownSkills.Remove(removeTarget);

            // 새 랜덤 스킬 추가 시도 (풀에서 미보유 스킬)
            // ✅ [AUD-022-L003] 정적 캐시 사용 — Resources.LoadAll 반복 호출 방지
            if (_cachedAllSkills == null || _cachedAllSkills.Length == 0)
                _cachedAllSkills = Resources.LoadAll<SkillDefinitionSO>("Skills");
            var allSkills = _cachedAllSkills;
            var candidates = new System.Collections.Generic.List<SkillDefinitionSO>();
            foreach (var s in allSkills)
            {
                if (!skillbook.Skills.Contains(s) && s != removeTarget)
                    candidates.Add(s);
            }

            if (candidates.Count > 0)
            {
                var newSkill = candidates[Random.Range(0, candidates.Count)];
                skillbook.Skills.Add(newSkill);
                if (skillbook.KnownSkills != null)
                    skillbook.KnownSkills.Add(newSkill);

                // 빈 로드아웃 슬롯에 자동 배치
                if (skillbook.ActiveLoadout != null)
                {
                    for (int i = 0; i < skillbook.ActiveLoadout.Count; i++)
                    {
                        if (skillbook.ActiveLoadout[i] == null)
                        {
                            skillbook.ActiveLoadout[i] = newSkill;
                            break;
                        }
                    }
                }

                ShowFeedback($"'{removedName}' → '{newSkill.name}'", Color.cyan);
                Debug.Log($"[ItemEffect] RerollSkill → removed '{removedName}', added '{newSkill.name}'");
            }
            else
            {
                ShowFeedback($"'{removedName}' removed (no replacement)", Color.yellow);
                Debug.Log($"[ItemEffect] RerollSkill → removed '{removedName}', no candidates for replacement");
            }

            return true;
        }

        ShowFeedback("No skills to reroll", Color.gray);
        return false;
    }

bug: RerollSkill이 두 가지 결함을 가진다: (1) 진행도 우회 — 후보를 `Resources/Skills` 전체 풀에서 뽑으면서 보유 여부만 검사하고 클래스/해금 레벨 제약을 검사하지 않아, 저레벨·타클래스 상태에서도 상위·이질 스킬을 획득할 수 있다. (2) 파괴적 성공 반환 — 먼저 기존 스킬을 제거한 뒤 대체 후보가 0건이어도 스킬을 복원하지 않고 "no replacement"만 표시한 채 true를 반환하여, 호출부가 아이템을 차감해 아이템과 스킬을 동시에 잃게 만든다.


code:
public void ApplyPendingBaseDish()
    {
        if (PendingBaseDish == null) return;

        var cs = CookingSystem.Instance;
        if (cs == null)
        {
            // ★ [2026-06-14 Codex P2] CookingSystem 미초기화 시 ★삭제하지 말고 보류★ — 기존엔 ClearPendingBaseDish 로
            //   대기 요리를 적용 못 한 채 영구 삭제(직접 손실)했다. 다음 시작에서 재시도하도록 그대로 둔다.
            Debug.LogWarning("[CookingSynthesizer] CookingSystem not found — PendingBaseDish 적용 보류(다음 시작 재시도, 삭제 안 함)");
            return;
        }

        cs.AddDurationGroupedBuffsAndNotify("SYNTH_BASE", PendingBaseDish.Name, PendingBaseDish.Effects);
        Debug.Log($"[CookingSynthesizer] PendingBaseDish applied: {PendingBaseDish.Name}");

        // 즉시 효과 적용
        ApplyImmediateEffects(PendingBaseDish);

        // ★ [2026-06-14 Codex P2] 적용된 버프를 미드런 스냅샷에 ★커밋한 뒤★ 대기요리 삭제 — 기존엔 삭제(영속 커밋) 직후 force
        //   스냅샷이 없어, 런 시작 직후 종료 시 '대기요리 삭제됨 + 버프 미스냅샷 = 영구 손실'. force 스냅샷이 ExportMidRunBuffs 로
        //   버프를 영속(맵은 GenerateMap 이 CommonRunInit 전이라 이미 존재 → 조기 스냅샷 아님). 그 ★뒤★ 대기요리 소비.
        GameFlow.Instance?.RequestMidRunSnapshot("PendingBaseDishApplied", force: true);

        ClearPendingBaseDish();
    }

bug: ApplyPendingBaseDish()가 버프를 적용하고 force 스냅샷으로 커밋한 뒤 대기요리를 소비할 때 ClearPendingBaseDish()를 즉시저장 옵션 없이 호출한다. 이로 인한 pending 삭제 저장(WritePendingBaseDishJson→SaveGame())이 직전 force 스냅샷 때문에 디바운스 윈도우에 걸려 지연되며, 플러시 전 크래시/종료 시 디스크에 PendingBaseDishJson이 잔존해 다음 신규 런에서 1회성 합성 요리 버프가 다시 적용된다(소비 삭제가 즉시 커밋되지 않는 결함).


code:
SetChoice(1, Loc.Get("CataTrans/CataTransNodeController/L232"), () => // 장비를 단련한다 (스킬 강화)
            {
                if (hasSkills)
                {
                    var skill = skills[Random.Range(0, skills.Count)];
                    // 50% 쿨감, 50% 위력 증가
                    if (Random.value < 0.5f)
                    {
                        // [Fix] CooldownReduction: value/100 후 >=1.0이면 턴 직감 → 100f = 1턴 감소
                        PlayerSkillbook.Instance?.EnhanceSkill(skill, SkillEnhancementType.CooldownReduction, 100f);
                        ShowResult(Loc.Fmt("CataTrans/CataTransNodeController/L241", GetLocalizedSkillName(skill))); // 화산의 열기로 [{skillName}] 쿨다운 -1!
                    }
                    else
                    {
                        // [Fix] DamageMultiplier는 퍼센트 단위 (15 = +15%)
                        PlayerSkillbook.Instance?.EnhanceSkill(skill, SkillEnhancementType.DamageMultiplier, 15f);
                        ShowResult(Loc.Fmt("CataTrans/CataTransNodeController/L246", GetLocalizedSkillName(skill))); // 화산의 열기로 [{skillName}] 위력 +15%!
                    }
                }
                else
                {
                    // 스킬 없으면 ATK 보너스로 대체
                    Player?.AddRewardStatBonus(BoostTarget.AttackPower, 5);
                    ShowResult(Loc.Get("CataTrans/CataTransNodeController/L253")); // 화산의 열기가 신체를 단련했다! ATK +5
                }
            });

bug: Inferno_VolcanicForge의 스킬 강화 분기에서 PlayerSkillbook.EnhanceSkill()의 bool 반환값을 검사하지 않고, 호출 직후 무조건 성공 메시지("쿨다운 -1!" / "위력 +15%!")를 표시한다. 그 결과 강화 상한 도달 등으로 실제 강화가 실패해도 성공으로 오인되고 노드가 그대로 소모되어 재시도 기회가 사라진다.


code:
private void OnUnlockAISkill(SkillDefinitionSO skill, int price)
        {
            int currentLevel = PlayerLevel.Instance?.Level ?? 1;
            if (skill != null && skill.UnlockLevel > currentLevel)
            {
                ShowMessage(Loc.Fmt("Skill/SkillMasterControllerComplete/L226", skill.UnlockLevel)); // 레벨 부족 (필요: Lv.{skill.UnlockLevel})
                return;
            }

            // ✅ [AUD-09004] GoldManager.CanAfford + MapIntegration.SpendGold 불일치 해소
            // MapIntegration.SpendGold 내부에서 GoldManager.CanAfford를 통해 검증 후 GameFlow.AddGold(-price)로 소비
            // 중복 GoldManager.CanAfford 제거 → SpendGold 단일 경로로 통일
            bool success = MapIntegration.Instance?.SpendGold(price) ?? false;
            if (!success)
            {
                ShowMessage(Loc.Fmt("Skill/SkillMasterControllerComplete/L232", price)); // 골드 부족! (필요: {price}G)
                Debug.LogError("[SkillMaster] AI 스킬 골드 차감 실패 (잔액 부족 또는 MapIntegration 없음)");
                return;
            }

            // 플레이어 스킬에 추가
            MapIntegration.Instance?.AddSkill(skill);

            // 카탈로그에서 제거 (중복 구매 방지)
            _aiSkills?.Remove(skill);

            RefreshSkillList();
            UpdateGoldDisplay();
            ShowMessage(Loc.Fmt("Skill/SkillMasterControllerComplete/L251", ResolveSkillDisplayName(skill))); // ✨ [AI 스킬] {skill.NameKey} 획득!
            GameFlow.Instance?.RequestMidRunSnapshot("SkillMaster.OnUnlockAISkill", true);
            Debug.Log($"[SkillMaster] AI 스킬 언락: {skill.NameKey} (-{price}G)");
        }

bug: OnUnlockAISkill은 SpendGold로 골드를 먼저 차감한 뒤 MapIntegration.AddSkill(skill)을 호출하지만 반환값을 확인하지 않고 즉시 성공 메시지/스냅샷을 진행한다. AddSkill은 내부적으로 조건 불충족 시 조용히 no-op이 가능하므로, 골드만 소비되고 스킬은 실제로 지급되지 않는 경로가 존재하며 실패 시 환불(롤백) 처리도 없다(차감-지급 비원자성).


code:
if (ActiveSynergies.Contains(synergy))
            return;

        ActiveSynergies.Add(synergy);
        ApplySynergyEffects(synergy);

        // [R389] ANOM_ECO_SYNERGY_COUNTER: each synergy trigger drains 5 gold.
        // ✅ [AUD-33301 Fix] 재계산 중(RemoveAll→Recheck)에는 골드 소모 스킵
        if (!_isRecalculating && TRPG.Anomaly.InRunAnomalyManager.IsSynergyCounterActive())
        {
            GameFlow.Instance?.AddGold(-5);
            SaveManager.Instance?.SaveGame(forceImmediate: true); // ✅ [AUD-CDX-100001 Fix] 이변 카운터 골드 드레인 즉시 커밋 — 세이브-스컴 방지
            MsgManager.Instance?.InputInstantMsg(Loc.Get("System/SynergyManager/L235"), InstantMsgType.Error);
        }

bug: 시너지 카운터 이변의 "발동마다 골드 -5" 페널티가 GameFlow.Instance?.AddGold(-5)를 호출한 뒤 반환값(차감 성공 여부)을 확인하지 않고 곧바로 강제 저장과 메시지를 출력한다. AddGold(-5)는 내부적으로 SpendGold(5)를 호출하는데 보유 골드가 1~4G면 잔액 부족으로 전혀 차감하지 않고 false를 반환하므로, 낮은 골드 상태에서는 골드가 그대로 유지된 채 페널티가 무력화된다.


code:
private void ApplyInheritedWills(List<LegacyWillEntry> selected)
        {
            _inheritedThisRun = new List<LegacyWillEntry>(selected);

            var player    = PlayerLevel.Instance?.GetComponent<CharacterBase>();
            var relicSys  = player?.GetComponent<RelicEffectSystem>();
            var skillbook = PlayerSkillbook.Instance;

            foreach (var will in selected)
            {
                if (will.Type == LegacyWillEntry.WillType.Relic && relicSys != null)
                {
                    // 유물 이름으로 SO 탐색 (서브폴더 폴백 포함)
                    var relicSO = ResolveRelicBySourceName(will.SourceName);
                    if (relicSO != null)
                    {
                        relicSys.AddRelic(relicSO);
                        Debug.Log($"[LegacyWillSystem] 유언 유물 상속: {will.DisplayName}");
                    }
                    else
                    {
                        Debug.LogWarning($"[LegacyWillSystem] 유물 SO를 찾을 수 없음: {will.SourceName}");
                    }
                }
                else if (will.Type == LegacyWillEntry.WillType.Skill && skillbook != null)
                {
                    // 스킬 이름으로 SO 탐색 (서브폴더 폴백 포함)
                    var skillSO = ResolveSkillBySourceName(will.SourceName);
                    if (skillSO != null)
                    {
                        skillbook.AddSkill(skillSO);
                        Debug.Log($"[LegacyWillSystem] 유언 스킬 상속: {will.DisplayName}");
                    }
                    else
                    {
                        Debug.LogWarning($"[LegacyWillSystem] 스킬 SO를 찾을 수 없음: {will.SourceName}");
                    }
                }

                MsgManager.Instance?.InputInstantMsg(
                    Loc.Fmt("Antigravity/LegacyWillSystem/L286", ResolveMaybeLocalized(will.DisplayName)), // 📜 [유언 상속] '{will.DisplayName}' 전승됨!
                    InstantMsgType.Good);
            }

            // ✅ [InheritedEffectScale] 상속 유물/스킬 적용 후 전투력 보정 적용
            // InheritedEffectScale = 0.7 → 플레이어 PhyPower/MagPower를 70%로 감쇠
            // MultDamage value = scale - 1f = -0.3 → stat *= (1 + (-0.3)) = stat * 0.7
            if (selected.Count > 0 && player != null && InheritedEffectScale < 1f)
            {
                ApplyInheritedScaleModifier(player);
            }
        }

bug: 유산 상속 루프에서 유물/스킬 부여(relicSys.AddRelic / skillbook.AddSkill)의 성공 여부를 검증하지 않고, "전승됨!" 성공 메시지(MsgManager.InputInstantMsg)를 각 will마다 두 분기 바깥에서 무조건 출력한다. 그 결과 SO를 찾지 못했거나(relicSO/skillSO == null) AddRelic이 중복으로 false를 반환하거나 AddSkill이 이미 보유해 무동작(no-op)인 경우에도 성공 메시지가 표시되어, 실제 부여 상태와 사용자 노출 메시지가 어긋난다.


code:
public void OnNodeMoved(NodeType nodeType)
        {
            // 전투 노드가 아닌 경우에만
            if (nodeType == NodeType.Combat || nodeType == NodeType.Elite ||
                nodeType == NodeType.Boss || nodeType == NodeType.MidBoss ||
                nodeType == NodeType.Seal) return;

            foreach (var skill in _activeSkills)
            {
                if (skill.Data == null) continue;
                if (skill.Data.EffectType == FieldSkillEffect.CompanionPartyHeal)
                {
                    // [Fix] NoHealing 수정자 체크
                    if (RunModifierSystem.Instance != null && !RunModifierSystem.Instance.CanHeal())
                    {
                        Debug.Log($"[CompanionFieldSkill] {skill.CompanionName}: 야전 수리 차단 (NoHealing 수정자)");
                        continue;
                    }

                    float healRate = skill.EffectValue; // e.g., 0.03 = 3%
                    var companions = CompanionManager.Instance?.GetActiveCompanions();
                    if (companions == null) continue;

                    // 플레이어 + 동료 전체 회복 (실제 회복량 추적)
                    bool anyHealed = false;
                    var player = PlayerLevel.Instance?.GetComponent<CharacterBase>();
                    if (player != null && player.Stat != null && !player.IsDead())
                    {
                        float hpBefore = player.Stat.HP;
                        int healAmount = Mathf.Max(1, Mathf.RoundToInt(player.Stat.MaxHP * healRate));
                        player.ApplyDeltaHP(healAmount);
                        if (player.Stat.HP > hpBefore) anyHealed = true;
                    }
                    foreach (var comp in companions)
                    {
                        if (comp != null && comp.Stat != null && !comp.IsDead())
                        {
                            float hpBefore = comp.Stat.HP;
                            int healAmount = Mathf.Max(1, Mathf.RoundToInt(comp.Stat.MaxHP * healRate));
                            comp.ApplyDeltaHP(healAmount);
                            if (comp.Stat.HP > hpBefore) anyHealed = true;
                        }
                    }
                    // [Fix] NoHeal 챌린지 — 실제 HP 증가 시에만 기록
                    if (anyHealed)
                        ChallengeRunSystem.Instance?.NotifyHealUsed();
                    Debug.Log($"[CompanionFieldSkill] {skill.CompanionName}: 야전 수리 발동 ({healRate * 100:F0}% HP, healed={anyHealed})");
                }
            }
        }

bug: 동료 필드 스킬 '야전 수리'가 비전투 노드 이동 시 플레이어와 동료를 회복할 때 중앙 회복 정책인 HealingService.Heal을 거치지 않고 CharacterBase.ApplyDeltaHP를 직접 호출한다. NoHealing 수식자만 직접 체크할 뿐, 맹세 회복 차단(OathSystem.IsHealingBlocked), 맹세 회복 배율(GetHealingMultiplier), 월드지변 회복 배율(WorldMutationEffectApplier)을 모두 우회하여 차단/배율 정책이 이 회복에는 적용되지 않는다.


code:
private void OnClericNodeCompleted(CharacterBase player)
    {
        if (player == null || player.Stat == null) return; // [AUD-709-H002]
        // [Fix] NoHealing 수정자 체크
        if (RunModifierSystem.Instance != null && !RunModifierSystem.Instance.CanHeal()) return;

        float recoverRate = BalanceConfig.INSTINCT_CLERIC_HP_RECOVER * _metaMultiplier;
        int hpRecover = Mathf.Max(1, Mathf.RoundToInt(player.Stat.MaxHP * recoverRate));

        if (player.Stat.HP < player.Stat.MaxHP)
        {
            player.ApplyDeltaHP(hpRecover);
            ChallengeRunSystem.Instance?.NotifyHealUsed();
            MsgManager.Instance?.InputInstantMsg(
                Loc.Fmt("Skill/ClassInstinctSystem/L203", hpRecover), // 💡 [신의 은총] HP +{hpRecover} 회복
                InstantMsgType.Normal);
        }
    }

bug: 사제 클래스 본능 '신의 은총'의 OnClericNodeCompleted가 노드 완료 후 HP 회복을 중앙 회복 정책인 HealingService.Heal을 거치지 않고 player.ApplyDeltaHP(hpRecover)로 직접 호출한다. 이로 인해 NoHealing 수식자만 체크되고 맹세 회복 차단(거식의 맹세), 맹세 회복 배율, 월드지변 회복 배율이 모두 우회된다.


code:
public void AddSkill(SkillDefinitionSO skill)
        {
            if (PlayerSkillbook != null)
            {
                PlayerSkillbook.AddSkill(skill);
                Debug.Log($"[MapIntegration] +Skill: {skill.Dev.NameKo}");

                // 컬렉션 해금 기록 — 즉시 저장 제거 (AUD-145)
                // 조기 SaveGame() 및 RequestMidRunSnapshot(force:true)은 스킬은 저장되지만
                // 노드 방문 상태가 갱신되기 전이라 강제 종료 후 동일 스킬을 반복 획득 가능.
                // 저장은 OnEncounterCompleted → CompleteNode + RequestMidRunSnapshot 경로로만 수행.
                if (SaveManager.Instance?.CurrentSave != null)
                {
                    SaveManager.Instance.CurrentSave.UnlockSkill(skill.name);
                }

                // ✅ Phase 9.3 TASK-4: 스킬 획득 업적 기록
                AchievementManager.Instance?.RecordSkillCollect();

                // 획득 팝업
                AcquirePopupUI.Instance?.ShowSkill(skill);
            }
        }

bug: MapIntegration.AddSkill은 PlayerSkillbook.AddSkill(skill) 호출 후 실제로 스킬이 추가되었는지 검증하지 않고, 해금 저장(UnlockSkill)·업적 기록(RecordSkillCollect)·획득 팝업(ShowSkill) 등 성공 사이드이펙트를 무조건 실행한다. PlayerSkillbook.AddSkill은 이미 보유한 스킬이면 조용히 no-op 처리하므로, 중복 획득 시 실제 스킬 집합은 변하지 않았는데도 성공으로 신호되어 커밋 상태와 사이드이펙트 상태가 어긋난다.


code:
void OnBlessingSelected(BlessingSO blessing)
    {
        if (blessing == null)
        {
            Debug.LogWarning("[Rest] OnBlessingSelected called with null blessing.");
            return;
        }

        if (BlessingPanel != null) BlessingPanel.SetActive(false);
        hasBlessing = true;

        UpdateOptions();

        string rawBK2 = !string.IsNullOrEmpty(blessing.NameKey) ? blessing.NameKey : blessing.name;
        string displayName = Loc.Get(rawBK2);
        if (string.IsNullOrEmpty(displayName)) displayName = rawBK2;

        // Update narration
        if (RestNarratorText != null)
        {
            RestNarratorText.text = Loc.Fmt("UI/Correct_RestSiteUI/L1156", displayName); // {displayName}을(를) 받았다!
        }

        Debug.Log($"[Rest] Blessing selected: {displayName}");

        // ✅ 실제 축복 적용 — MapIntegration.AddBlessing(id) 사용
        string blessingId = !string.IsNullOrEmpty(blessing.BlessingId) ? blessing.BlessingId : blessing.name;
        if (MapIntegration.Instance != null)
        {
            if (!MapIntegration.Instance.TryAddBlessing(blessingId))
            {
                Debug.LogWarning($"[Rest] Blessing apply failed via MapIntegration: {blessingId}");
            }
        }
        else
        {
            // MapIntegration 없으면 BlessingManager에 직접 Grant
            var mgr = BlessingManager.Instance;
            if (mgr != null)
                mgr.Grant(blessing);
            else
                Debug.LogWarning("[Rest] Neither MapIntegration nor BlessingManager found — blessing not applied.");
        }

        // ✅ [RunPrep] prep_karma_surge — 축복 2개 동시 획득
        if (_prepKarmaSurgeActive)
        {
            _prepKarmaSurgeActive = false; // 1회만 발동
            var allBlessingsForKarma = CollectionManager.Instance != null
                ? CollectionManager.Instance.GetAllBlessingsCached()
                : Resources.LoadAll<BlessingSO>("Blessings");
            if (allBlessingsForKarma != null && allBlessingsForKarma.Length > 0)
            {
                var candidates = new System.Collections.Generic.List<BlessingSO>();
                foreach (var b in allBlessingsForKarma)
                {
                    if (b != null && b != blessing) candidates.Add(b);
                }
                if (candidates.Count > 0)
                {
                    var bonus = candidates[Random.Range(0, candidates.Count)];
                    string bonusId = !string.IsNullOrEmpty(bonus.BlessingId) ? bonus.BlessingId : bonus.name;
                    if (MapIntegration.Instance != null)
                        MapIntegration.Instance.TryAddBlessing(bonusId);
                    else
                        BlessingManager.Instance?.Grant(bonus);

                    string bonusName = Loc.Get(!string.IsNullOrEmpty(bonus.NameKey) ? bonus.NameKey : bonus.name);
                    if (string.IsNullOrEmpty(bonusName)) bonusName = bonus.name;
                    MsgManager.Instance?.InputInstantMsg(
                        $"[카르마 폭발] {bonusName} 추가 획득!", InstantMsgType.Good);
                    Debug.Log($"[RunPrep] prep_karma_surge 발동: 보너스 축복 {bonusId}");
                }
            }
        }

        // ✅ [검증 #31] 축복 선택 후 즉시 스냅샷 — 앱 강제종료 시 축복 상태 보존
        GameFlow.Instance?.RequestMidRunSnapshot("RestSiteUI.Blessing");
    }

bug: OnBlessingSelected가 실제 축복 적용보다 먼저 hasBlessing = true와 UpdateOptions()를 호출해 성공 상태를 확정한다. 이후 MapIntegration.TryAddBlessing()이 false를 반환하거나 MapIntegration과 BlessingManager가 모두 null인 폴백 경로에서 축복이 실제로 커밋되지 않아도 롤백되지 않아, 축복이 적용되지 않았는데도 UI/노드 상태가 "축복 소비됨"으로 확정되는 거짓 성공(false success) 결함이다.


code:
private void ApplyPrayEffect()
        {
            float roll = Random.value;
            float enhanceMul = 1f + FieldSkillExecutor.GetAltarEnhanceRate();

            string result;

            if (roll < 0.3f)  // 30% - 좋은 효과
            {
                int goldBonus = Mathf.RoundToInt(Random.Range(50, 101) * enhanceMul);
                // AltarPray Fix: AddGold → GameFlow.AddGold 경유
                bool goldOk;
                if (GameFlow.Instance != null)
                {
                    // ✅ [AUD-CDX-151002 Fix] AddGold 반환값 검증 — 지급 실패 시 메시지 억제
                    goldOk = GameFlow.Instance.AddGold(goldBonus);
                    if (!goldOk)
                        Debug.LogWarning($"[Altar] 골드 지급 실패 — GameFlow.AddGold({goldBonus}) false 반환");
                }
                else
                {
                    // ✅ [AUD-CDX-21502 Fix] GameFlow null 폴백 — 보상 소실 방지 (통계 미기록 감수)
                    goldOk = GoldManager.Instance != null;
                    GoldManager.Instance?.AddGoldDirect(goldBonus);
                    SaveManager.Instance?.SaveGame(forceImmediate: true); // ✅ [AUD-CDX-306001] 폴백 골드 즉시 커밋
                    Debug.LogWarning($"[Altar] GameFlow 없음 — 골드 직접 지급(통계 미기록): {goldBonus}");
                }

                result = goldOk
                    ? Loc.Fmt("Map/Altar/PrayGold", goldBonus) // ✨ 신의 은총 기도가 받아들여졌습니다! 💰 +{0} 골드 축복이 함께 하길...
                    : Loc.Get("Map/Altar/PrayNoEffect"); // 🙏 평온한 기도. 마음이 평온해집니다. 특별한 효과는 없습니다.
            }
            else if (roll < 0.6f)  // 30% - 평범한 효과
            {
                if (Player != null && Player.Stat != null)
                {
                    // ✅ ApplyDeltaHP API 경유 (직접 Stat.HP 수정 금지)
                    // [Fix] NoHealing 수정자 체크
                    if (RunModifierSystem.Instance != null && !RunModifierSystem.Instance.CanHeal())
                    {
                        result = Loc.Get("Map/Altar/PrayNoEffect"); // 🙏 평온한 기도. 마음이 평온해집니다. 특별한 효과는 없습니다.
                    }
                    else
                    {
                        int healAmount = Mathf.RoundToInt(Player.Stat.MaxHP * 0.3f * enhanceMul);
                        float hpBefore = Player.Stat.HP;
                        Player.ApplyDeltaHP(healAmount);
                        if (Player.Stat.HP > hpBefore) ChallengeRunSystem.Instance?.NotifyHealUsed();
                        result = Loc.Get("Map/Altar/PrayHeal"); // 🙏 평온한 기도 마음이 평온해집니다. ❤ HP 30% 회복 신의 평안이 함께 하길...
                    }
                }
                else
                {
                    result = Loc.Get("Map/Altar/PrayNoEffect"); // 🙏 평온한 기도 마음이 평온해집니다. 특별한 효과는 없습니다.
                }
            }
            else  // 40% - 아무 효과 없음
            {
                result = Loc.Get("Map/Altar/PraySilence"); // ... 침묵 ... 신은 대답하지 않습니다. 아무 일도 일어나지 않았습니다.
            }

            ShowResult(Loc.Get("Map/Altar/PrayResultTitle"), result); // 기도의 결과
        }

bug: AltarController.ApplyPrayEffect()의 HP 회복 분기가 중앙 회복 정책인 HealingService.Heal(...)을 거치지 않고 RunModifierSystem.CanHeal()만 확인한 뒤 Player.ApplyDeltaHP(healAmount)를 직접 호출한다. 그 결과 OathSystem의 회복 차단(거식의 맹세) 및 회복 배율, WorldMutationEffectApplier의 월드 지변 회복 배율이 제단 기도 회복에 적용되지 않고 우회된다.


code:
private void ApplyStatBonus(StatType statType)
        {
            if (Player == null || Player.Ability == null || Player.Stat == null) return;

            int bonus = GetStatBonus(statType);

            switch (statType)
            {
                case StatType.STR:
                    Player.Ability.STR += bonus;
                    break;

                case StatType.DEX:
                    Player.Ability.DEX += bonus;
                    break;

                case StatType.CON:
                    Player.Ability.CON += bonus;
                    // ✅ Fix: StatRulesSO.Recalc() 경유로 MaxHP를 공식에 맞게 재계산
                    // (Stat.MaxHP 직접 수정 대신 Recalc 사용하여 수식 일관성 유지)
                    {
                        var rules = PlayerLevel.Instance?.Rules;
                        if (rules != null)
                        {
                            float prevMaxHP = Player.Stat.MaxHP;
                            rules.Recalc(Player);
                            // 훈련 보너스: MaxHP 증가분만큼 현재 HP도 회복 (ApplyDeltaHP 경유)
                            float delta = Player.Stat.MaxHP - prevMaxHP;
                            if (delta > 0) Player.ApplyDeltaHP(Mathf.RoundToInt(delta));
                        }
                        else
                        {
                            // Rules 없을 시 BalanceConfig 기준 폴백
                            float hpBonus = bonus * BalanceConfig.HP_PER_CON;
                            Player.Stat.MaxHP += hpBonus;
                            Player.ApplyDeltaHP(Mathf.RoundToInt(hpBonus));
                        }
                    }
                    break;

                case StatType.INT:
                    Player.Ability.INT += bonus;
                    break;

                case StatType.WIS:
                    Player.Ability.WIS += bonus;
                    // ✅ Fix: Recalc으로 MagDef/RegenHP 재계산 (WIS는 공식상 MaxMP에 직접 연결 없음)
                    // MaxMP 보너스는 UI 표시 일관성을 위해 수동으로 추가 (3f per WIS 유지)
                    {
                        var rules = PlayerLevel.Instance?.Rules;
                        if (rules != null) rules.Recalc(Player);

                        float mpBonus = bonus * 3f;  // WIS→MaxMP 수동 보너스 (UI: "최대 MP 증가")
                        Player.Stat.RewardBonusMaxMP += mpBonus; // Recalc 영속 레이어에 기록
                        Player.Stat.MaxMP += mpBonus; // Recalc 이후이므로 즉시 반영
                        Player.ApplyDeltaMP(mpBonus);
                    }
                    break;

                case StatType.CHA:
                    Player.Ability.CHA += bonus;
                    break;
            }

            Debug.Log($"[Training] {statType} increased by {bonus}");
        }

bug: TrainingController.ApplyStatBonus(...)에서 능력치 훈련 시 CON과 WIS 분기만 StatRulesSO.Recalc(Player)를 호출하고, STR/DEX/INT/CHA 분기는 Player.Ability.* += bonus로 능력치 값만 올린 뒤 끝납니다. 그 결과 STR/DEX/INT/CHA 훈련 직후 물리·마법 공격력, 명중/회피/치명타/속도, MaxMP/MP재생/쿨다운, 골드획득/상점할인 등 파생 전투 스탯이 현재 세션에 즉시 반영되지 않습니다.


code:
void OnItemSelected(ItemSO item)
    {
        Debug.Log($"[Combat] Item selected: {item.NameKey}");

        // ✅ [Auto_488#F1] BattleController 경유 아이템 사용 — 턴 진행 및 효과 처리 위임
        if (BattleController != null)
        {
            BattleController.UseItemBySelection(item);
            CloseItemPanel();
            return;
        }

        // BattleController 없을 때 직접 처리 (레거시 폴백)
        var player = PlayerLevel.Instance?.GetComponent<CharacterBase>();
        var rules = PlayerLevel.Instance?.Rules;

        if (player != null && rules != null)
        {
            // ✅ [Open-A] 합성 아이템 PowerMultiplier 경유 — 인벤토리 엔트리에서 배율 조회
            var inv = InventorySystem_TRPG.Instance;
            var uiItemEntry = inv?.Items.FirstOrDefault(e => e.Item?.ItemId == item.ItemId);
            float uiItemPowerMul = uiItemEntry?.PowerMultiplier ?? 1f;
            // ✅ [R788] ApplyItem 반환값 검사 — 효과 실패 시 아이템 소모 방지
            bool applied = ItemEffectExecutor.ApplyItem(item, player, rules, uiItemPowerMul);

            // 인벤토리에서 제거 — 효과 성공 시에만 소모
            if (applied)
                inv?.RemoveItem(item.ItemId, 1);
            else
                Debug.LogWarning($"[CombatUI] 아이템 효과 실패 — 소모 취소: {item?.ItemId}");
        }

        CloseItemPanel();
    }

bug: CombatUI_FiveButton의 레거시 폴백 아이템 사용 경로가 백엔드 인벤토리 API(UseItemEntry)를 거치지 않고 ItemEffectExecutor.ApplyItem으로 효과만 적용한 뒤 inv.RemoveItem(item.ItemId, 1)로 차감한다. 이로 인해 아이템 사용 금지 맹세/NoItems 모디파이어/챌린지 사용 추적/미드런 스냅샷·즉시 저장이 모두 우회되고, 효과는 강화 엔트리의 PowerMultiplier로 적용되지만 차감은 ItemId 기준이라 강화되지 않은 다른 엔트리가 소모될 수 있다.