diff --git a/.claude/rules/balance.md b/.claude/rules/balance.md index fe87511..e8776e6 100644 --- a/.claude/rules/balance.md +++ b/.claude/rules/balance.md @@ -67,6 +67,16 @@ floor를 같이 도입했기 때문에 **단순 복원은 되돌리기가 아니 ## 보상 기준점 - 배율: C1.0/U1.2/R1.5/E2.0/L2.8 - 레이드: ×3 +- 가챠 천장: 같은 상자 `GachaBoxManager.PityLegendaryPulls`(80)연째 Legendary 확정. 상자별 카운터는 + 계정 스코프 PlayerPrefs + 클라우드(기기 간 **max** 병합). 없을 땐 브론즈 100연 전설 0개가 60%였다. +- 가챠 샤이니 `GachaBoxManager.ShinyChance`(2%) — 필드 1%보다 높아야 상자만의 매력이 선다. 골드 보너스 + 캔디 45~80은 Lv30 레벨업비의 절반 이상이어야 한다(gacha_sim 신호 5). **OpenBox의 `case "box_X":` + 세 개를 접지 말 것** — 추출기가 그 뒤의 Random.Range를 읽는다. + +## 성장 기준점 +- 곤충 캔디 곡선: `InsectLevelCurve.GetCandyCost` = base 4 × 1.125^(L-1). **판정은 `progression_sim`** + (Lv50까지 현실 진행 4,000전투 미만). 14%였을 때 5,759전투로 FAIL, 12.5%로 3,339. 13%는 4,028로 + 경계다 — 기울기를 올릴 땐 시뮬을 먼저 돌린다. ## 변경 시 체크리스트 1. GameConstants 상수 변경 → 전체 시스템 영향, `/impact-analysis` 실행 diff --git a/.claude/scripts/gacha_sim.py b/.claude/scripts/gacha_sim.py index f34598e..faa2ee2 100644 --- a/.claude/scripts/gacha_sim.py +++ b/.claude/scripts/gacha_sim.py @@ -55,7 +55,7 @@ def _load_facts(): game_facts.gacha_exclusive_pool_sizes(), game_facts.rarity_multipliers(), game_facts.field_shiny_pct(), - 0.0 if not game_facts.gacha_has_shiny() else float("nan"), + game_facts.gacha_shiny_pct(), ) except game_facts.ExtractorBroken as e: print(f"추출기 고장: {e}\n", file=sys.stderr) @@ -68,7 +68,9 @@ def _load_facts(): BOX_DEFS, EXCLUSIVE_POOL_SIZE, RARITY_MULT, FIELD_SHINY_PCT, GACHA_SHINY_PCT = _load_facts() # Lv30 캔디 비용 (progression_sim과 일치) -LV30_CANDY_COST = int(4 * (1.14 ** 29)) +# 코드가 단일 출처다 — 예전엔 `int(4 * (1.14 ** 29))`를 여기 박아 두어 곡선이 12.5%로 바뀐 뒤에도 178을 썼다. +_CURVE = game_facts.insect_candy_curve() +LV30_CANDY_COST = int(_CURVE["base"] * (_CURVE["growth"] ** 29)) def draw_rarity(box: str, rng: random.Random) -> str: @@ -164,7 +166,10 @@ def epic_exclusive_duplicates(box: str, pulls: int, trials: int, seed: int) -> f def prob_zero_legendary_analytic(box: str, pulls: int) -> float: - """해석적 P(Legendary 0개 in N연차) = (1-p)^N.""" + """해석적 P(Legendary 0개 in N연차) = (1-p)^N — 단, 천장(pity)이 N 안에 있으면 0.""" + pity = game_facts.gacha_pity_pulls() + if pity <= pulls: + return 0.0 p = BOX_DEFS[box]["rarity_pcts"]["Legendary"] / 100.0 return (1 - p) ** pulls @@ -269,7 +274,7 @@ def evaluate_signals(args) -> list: judge = "WARN" if GACHA_SHINY_PCT < FIELD_SHINY_PCT else "PASS" signals.append(("가챠 샤이니 적용", f"{FIELD_SHINY_PCT}%", - f"{GACHA_SHINY_PCT}% (코드 미구현)", judge)) + f"{GACHA_SHINY_PCT}%" + (" (코드 미구현)" if GACHA_SHINY_PCT == 0 else ""), judge)) return signals @@ -336,7 +341,7 @@ def main(): print() print("## 가정 / 한계") - print("- 천장(pity) 미구현 가정 — 실제 코드와 일치") + print(f"- 천장(pity) {game_facts.gacha_pity_pulls()}연 — GachaBoxManager.PityLegendaryPulls에서 읽음") print("- 곤충 풀 균등 무작위 가정 (가중치 데이터 미확인)") print("- IV 미적용 — 곤충별 스탯 편차 무시") print(f"- random.seed={args.seed} (--seed로 변경 가능)") diff --git a/.claude/scripts/game_facts.py b/.claude/scripts/game_facts.py index 965f6f8..f47664a 100644 --- a/.claude/scripts/game_facts.py +++ b/.claude/scripts/game_facts.py @@ -108,6 +108,12 @@ def gacha_thresholds() -> dict: return out +def gacha_pity_pulls() -> int: + """천장 — 이 횟수째 뽑기에 Legendary 확정. 출처: GachaBoxManager.PityLegendaryPulls.""" + src = _read("gacha") + return int(_need(re.search(r"PityLegendaryPulls\s*=\s*(\d+)", src), "PityLegendaryPulls", "gacha").group(1)) + + def gacha_rarity_pcts() -> dict: """{"bronze": {"Common": 55.0, ..., "Legendary": 0.5}, ...} 등급별 확률(%). @@ -155,9 +161,11 @@ def gacha_candy_bonus() -> dict: src = _read("gacha") out = {} for box in BOXES: + # 같은 case 블록 안에서만 찾는다 — 다음 `case "` 전에 Random.Range가 없으면 실패해야지, + # 파일 뒤쪽의 무관한 Random.Range(GetGachaLevel의 (1, 6))를 물고 조용히 틀린 값을 내면 안 된다. m = _need( re.search( - rf'case\s+"box_{box}":.*?Random\.Range\((\d+),\s*(\d+)\)', src, re.DOTALL + rf'case\s+"box_{box}":(?:(?!case\s+")(?!default\s*:).)*?Random\.Range\((\d+),\s*(\d+)\)', src, re.DOTALL ), f'case "box_{box}"의 보너스 캔디 Random.Range', "gacha", ) @@ -989,6 +997,12 @@ def gacha_has_shiny() -> bool: return bool(re.search(r"[Ss]hiny", _read("gacha"))) +def gacha_shiny_pct() -> float: + """가챠 샤이니 확률(%). 출처: GachaBoxManager.ShinyChance. 상수가 없으면 0(미구현).""" + m = re.search(r"ShinyChance\s*=\s*([\d.]+)f", _read("gacha")) + return float(m.group(1)) * 100.0 if m else 0.0 + + if __name__ == "__main__": import io import sys diff --git a/Assets/Scripts/Core/CloudSaveManager.cs b/Assets/Scripts/Core/CloudSaveManager.cs index 2eb294e..907e0ad 100644 --- a/Assets/Scripts/Core/CloudSaveManager.cs +++ b/Assets/Scripts/Core/CloudSaveManager.cs @@ -433,6 +433,9 @@ private GameSaveData CollectSaveData() charHairColor = PlayerPrefs.GetInt(SaveScope.PrefsKey("InsectGame.Character.HairColor"), 0), charFace = PlayerPrefs.GetInt(SaveScope.PrefsKey("InsectGame.Character.FaceType"), 0), charOutfit = PlayerPrefs.GetInt(SaveScope.PrefsKey("InsectGame.Character.OutfitPreset"), 0), + gachaPityBronze = PlayerPrefs.GetInt(SaveScope.PrefsKey(GachaBoxManager.PityKeyBase("box_bronze")), 0), + gachaPitySilver = PlayerPrefs.GetInt(SaveScope.PrefsKey(GachaBoxManager.PityKeyBase("box_silver")), 0), + gachaPityGold = PlayerPrefs.GetInt(SaveScope.PrefsKey(GachaBoxManager.PityKeyBase("box_gold")), 0), lastSaveTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }; } @@ -544,6 +547,11 @@ private void ApplyResolved(GameSaveData data, bool forceReplace = false) if (data.charHairColor >= 0) PlayerPrefs.SetInt(SaveScope.PrefsKey("InsectGame.Character.HairColor"), data.charHairColor); if (data.charFace >= 0) PlayerPrefs.SetInt(SaveScope.PrefsKey("InsectGame.Character.FaceType"), data.charFace); if (data.charOutfit >= 0) PlayerPrefs.SetInt(SaveScope.PrefsKey("InsectGame.Character.OutfitPreset"), data.charOutfit); + // 가챠 천장 — 옛 문서(-1)면 로컬 유지. 기기 간엔 **큰 쪽**을 취한다: 천장은 단조 증가하다 리셋되는 + // 값이라 작은 쪽을 덮으면 다른 기기에서 쌓은 진척이 사라진다(퀘스트 병합의 max와 같은 이유). + if (data.gachaPityBronze >= 0) PlayerPrefs.SetInt(SaveScope.PrefsKey(GachaBoxManager.PityKeyBase("box_bronze")), Math.Max(data.gachaPityBronze, PlayerPrefs.GetInt(SaveScope.PrefsKey(GachaBoxManager.PityKeyBase("box_bronze")), 0))); + if (data.gachaPitySilver >= 0) PlayerPrefs.SetInt(SaveScope.PrefsKey(GachaBoxManager.PityKeyBase("box_silver")), Math.Max(data.gachaPitySilver, PlayerPrefs.GetInt(SaveScope.PrefsKey(GachaBoxManager.PityKeyBase("box_silver")), 0))); + if (data.gachaPityGold >= 0) PlayerPrefs.SetInt(SaveScope.PrefsKey(GachaBoxManager.PityKeyBase("box_gold")), Math.Max(data.gachaPityGold, PlayerPrefs.GetInt(SaveScope.PrefsKey(GachaBoxManager.PityKeyBase("box_gold")), 0))); PlayerPrefs.Save(); // 실제 진행도 시스템(파일 기반)에 반영 — PlayerPrefs 미러만으로는 게임플레이에 안 잡힘. @@ -688,6 +696,9 @@ private string ConvertToFirestoreDocument(GameSaveData data) sb.Append(","); AppendIntField(sb, "charHairColor", data.charHairColor); sb.Append(","); AppendIntField(sb, "charFace", data.charFace); sb.Append(","); AppendIntField(sb, "charOutfit", data.charOutfit); + sb.Append(","); AppendIntField(sb, "gachaPityBronze", data.gachaPityBronze); + sb.Append(","); AppendIntField(sb, "gachaPitySilver", data.gachaPitySilver); + sb.Append(","); AppendIntField(sb, "gachaPityGold", data.gachaPityGold); sb.Append(","); AppendIntField(sb, "lastSaveTimestamp", (int)data.lastSaveTimestamp); sb.Append("}}"); @@ -736,6 +747,9 @@ private GameSaveData ParseFirestoreDocument(string json) data.charHairColor = ExtractIntValueOrDefault(json, "charHairColor", -1); data.charFace = ExtractIntValueOrDefault(json, "charFace", -1); data.charOutfit = ExtractIntValueOrDefault(json, "charOutfit", -1); + data.gachaPityBronze = ExtractIntValueOrDefault(json, "gachaPityBronze", -1); + data.gachaPitySilver = ExtractIntValueOrDefault(json, "gachaPitySilver", -1); + data.gachaPityGold = ExtractIntValueOrDefault(json, "gachaPityGold", -1); data.lastSaveTimestamp = ExtractIntValue(json, "lastSaveTimestamp"); return data; @@ -932,6 +946,10 @@ public class GameSaveData public int charHairColor = -1; public int charFace = -1; public int charOutfit = -1; + // 가챠 천장 카운터(상자별). -1 = 옛 문서(필드 없음) → 로컬 유지. + public int gachaPityBronze = -1; + public int gachaPitySilver = -1; + public int gachaPityGold = -1; public long lastSaveTimestamp; } diff --git a/Assets/Scripts/Core/GachaBoxManager.cs b/Assets/Scripts/Core/GachaBoxManager.cs index 5549904..fde18e6 100644 --- a/Assets/Scripts/Core/GachaBoxManager.cs +++ b/Assets/Scripts/Core/GachaBoxManager.cs @@ -12,6 +12,7 @@ public class GachaResult public string displayName; public InsectRarity rarity; public bool isExclusive; + public bool isShiny; public int bonusCandy; } @@ -25,6 +26,36 @@ public class GachaBoxManager : MonoBehaviour public GachaResult LastResult { get; private set; } public event Action BoxOpened; + /// + /// 천장 — 같은 상자를 이 횟수째 열면 Legendary가 확정된다(그 사이 Legendary가 나오면 리셋). + /// 없을 때 브론즈 100연에 Legendary 0개일 확률이 60%였다(gacha_sim FAIL, 산업 관행 50~100연). + /// 카운터는 상자별·계정 스코프 PlayerPrefs이고 클라우드에도 올라간다(charSkin과 같은 4점). + /// + public const int PityLegendaryPulls = 80; + + /// + /// 가챠 샤이니 확률. 필드(InsectEntity 1%)보다 높다 — 상자만의 매력이 있어야 한다. + /// 옛은 지정이 없어 CreateWithIV의 필드와 같은 1% 롤을 그대로 썼고, 시뮬은 "미구현"으로 읽었다. + /// + public const float ShinyChance = 0.02f; + + private static string PityKey(string boxId) => SaveScope.PrefsKey(PityKeyBase(boxId)); + /// SaveScope.ScopedIntPrefsKeys와 CloudSaveManager가 같은 문자열을 쓴다. + public static string PityKeyBase(string boxId) => "InsectGame.GachaPity." + boxId; + + /// 이 상자를 Legendary 없이 연 횟수. UI가 "천장까지 N회"를 그린다. + public int GetPityCount(string boxId) => PlayerPrefs.GetInt(PityKey(boxId), 0); + + /// + /// 순수 판정 — 이번이 +1번째 뽑기일 때 등급. + /// 천장에 닿았으면 roll과 무관하게 Legendary. + /// + public static InsectRarity RollRarity(float[] thresholds, float roll, int pullsSinceLegendary) + { + if (pullsSinceLegendary + 1 >= PityLegendaryPulls) return InsectRarity.Legendary; + return GetRarityByThresholds(roll, thresholds); + } + // -- 가챠 전용 곤충 풀 -- private static readonly Dictionary gachaExclusives = new Dictionary { @@ -138,27 +169,27 @@ public void OpenBox(string boxId) try { float roll = UnityEngine.Random.value * 100f; - InsectRarity rarity; + float[] thresholds = GetThresholds(boxId); + if (thresholds == null) return; int bonusCandy; + // 세 case를 **명시적으로** 둔다 — game_facts.gacha_candy_bonus가 `case "box_X":` 뒤의 + // Random.Range를 읽는다. 골드를 default:로 접었더니 추출기가 GetGachaLevel의 (1, 6)을 + // 물어 시뮬이 "골드 보너스 평균 3"을 찍었다(2026-09-09). switch (boxId) { - case "box_bronze": - rarity = GetBronzeRarity(roll); - bonusCandy = UnityEngine.Random.Range(5, 16); - break; - case "box_silver": - rarity = GetSilverRarity(roll); - bonusCandy = UnityEngine.Random.Range(10, 31); - break; - case "box_gold": - rarity = GetGoldRarity(roll); - bonusCandy = UnityEngine.Random.Range(20, 51); - break; - default: - return; + case "box_bronze": bonusCandy = UnityEngine.Random.Range(5, 16); break; + case "box_silver": bonusCandy = UnityEngine.Random.Range(10, 31); break; + // 골드는 Lv30 레벨업비(≈122캔디)의 절반은 돼야 "보너스"로 읽힌다(gacha_sim 신호 5). + case "box_gold": bonusCandy = UnityEngine.Random.Range(45, 81); break; + default: return; } + int pity = GetPityCount(boxId); + InsectRarity rarity = RollRarity(thresholds, roll, pity); + // 카운터는 **굴린 등급** 기준이다 — DB 보정(resultRarity)이 상위로 올려 줬다고 천장을 닫지 않는다. + PlayerPrefs.SetInt(PityKey(boxId), rarity == InsectRarity.Legendary ? 0 : pity + 1); + string insectId = ValidateInsectId(PickRandomInsect(rarity, boxId)); bool isExclusive = insectId.StartsWith("gacha_"); // 결과/레벨 등급은 DB 실제 rarity 단일 출처 — 풀 배치와 DB가 어긋나도 팝업·도감·컬렉션이 @@ -167,10 +198,11 @@ public void OpenBox(string boxId) // 각 보상을 독립 try-catch로 감싸 한 단계 실패가 나머지를 막지 않게 함 // (곤충 지급 예외로 캔디/도감 미실행되는 회귀 방지). + bool isShiny = UnityEngine.Random.value < ShinyChance; try { if (insectCollection != null) - insectCollection.AddCapturedInsect(insectId, GetGachaLevel(resultRarity)); + insectCollection.AddCapturedInsect(insectId, GetGachaLevel(resultRarity), isShiny); } catch (System.Exception e) { Debug.LogError($"[Gacha] 곤충 지급 실패: {e.Message}"); } @@ -201,6 +233,7 @@ public void OpenBox(string boxId) displayName = GetInsectDisplayName(insectId), rarity = resultRarity, isExclusive = isExclusive, + isShiny = isShiny, bonusCandy = bonusCandy }; @@ -291,12 +324,6 @@ private static string FormatPercent(float p) : p.ToString("0.#"); } - private InsectRarity GetBronzeRarity(float roll) => GetRarityByThresholds(roll, BronzeThresholds); - - private InsectRarity GetSilverRarity(float roll) => GetRarityByThresholds(roll, SilverThresholds); - - private InsectRarity GetGoldRarity(float roll) => GetRarityByThresholds(roll, GoldThresholds); - // -- 곤충 선택 -- private string PickRandomInsect(InsectRarity rarity, string boxId) diff --git a/Assets/Scripts/Core/SaveScope.cs b/Assets/Scripts/Core/SaveScope.cs index 024ff84..2436e15 100644 --- a/Assets/Scripts/Core/SaveScope.cs +++ b/Assets/Scripts/Core/SaveScope.cs @@ -59,6 +59,10 @@ public static class SaveScope "InsectGame.Character.HairColor", "InsectGame.Character.FaceType", "InsectGame.Character.OutfitPreset", + // 가챠 천장 카운터(상자별). GachaBoxManager.PityKeyBase와 같은 문자열. + "InsectGame.GachaPity.box_bronze", + "InsectGame.GachaPity.box_silver", + "InsectGame.GachaPity.box_gold", }; // 마이그레이션 버전 — 스코핑 대상 키를 늘리거나 복사 로직을 고칠 때마다 +1. 기존 소유자도 1회 재이전. @@ -67,7 +71,8 @@ public static class SaveScope // 5: 스코핑 키 5개 추가(QuestSideProgress/QuestSideRepeat/QuestUnseen/TutorialHidden/ // WeeklyContestClaimed). 올려야 기존 기기의 전역 키가 계정 스코프로 이전된다. // 6: BlightCleansed(신규) + DefeatedLedgerBosses(누락분) 추가. - private const int MigrationVersion = 7; + // 8: 가챠 천장 카운터 3키(int) 추가. + private const int MigrationVersion = 8; private static readonly string[] ScopedFiles = { diff --git a/Assets/Scripts/Data/InsectLevelCurve.cs b/Assets/Scripts/Data/InsectLevelCurve.cs index 0eaebcc..294bef7 100644 --- a/Assets/Scripts/Data/InsectLevelCurve.cs +++ b/Assets/Scripts/Data/InsectLevelCurve.cs @@ -21,9 +21,13 @@ public int GetXpToNextLevel(int level) public int GetCandyCost(int level) { - // 지수 곡선: base × 1.14^(level-1) — 레벨당 14% 복리 증가 + // 지수 곡선: base × 1.125^(level-1) — 레벨당 12.5% 복리 증가. + // 14%였을 때 Lv50까지 현실 진행(리전 동기화 income ≈3캔디/전투)으로 **5,759전투** + // (≈48시간 그라인딩)가 들어 progression_sim 임계 4,000을 넘었다. 12.5%면 총 캔디 + // 17,500 → 10,200으로 줄어 ≈3,400전투. 13%는 4,028로 경계에 걸린다. 지수 자체는 설계다 + // (후반이 비싸야 리전 진행과 맞물린다) — 기울기만 낮췄다(2026-09-09). // Lv1: 4 → Lv10: 13 → Lv20: 49 → Lv30: 181 → Lv40: 669 → Lv50: 2475 - int cost = Mathf.RoundToInt(baseCandyCost * Mathf.Pow(1.14f, level - 1)); + int cost = Mathf.RoundToInt(baseCandyCost * Mathf.Pow(1.125f, level - 1)); return Mathf.Max(1, cost); } } diff --git a/Assets/Scripts/UI/CashShopUI.cs b/Assets/Scripts/UI/CashShopUI.cs index 2125cd2..379f31f 100644 --- a/Assets/Scripts/UI/CashShopUI.cs +++ b/Assets/Scripts/UI/CashShopUI.cs @@ -815,6 +815,17 @@ private void DrawGachaTab() // 확률 표기는 GachaBoxManager(실제 드랍 임계값)에서 파생 — 하드코딩 금지(공시 위반 방지). // OnGUI 매 프레임 재계산 피하려 boxId별 1회 캐싱. Instance 미준비 시 빈 문자열(다음 프레임 재시도). + private readonly Dictionary pityTextCache = new Dictionary(); + + private string GetPityText(string boxId, int count) + { + if (pityTextCache.TryGetValue(boxId, out var cached) && cached.count == count) return cached.text; + int left = Mathf.Max(1, GachaBoxManager.PityLegendaryPulls - count); + string text = left == 1 ? "★ 다음 상자에 전설 확정" : $"전설 확정까지 {left}회"; + pityTextCache[boxId] = (count, text); + return text; + } + private string GetGachaRateText(string boxId) { if (gachaRateTextCache.TryGetValue(boxId, out string cached)) @@ -877,6 +888,10 @@ private void DrawBoxCard(string boxId, string title, Color themeColor, string ra // 확률표 GUILayout.Label(rateText, boxRateStyle); + // 천장 — 카운터는 PlayerPrefs 1회 조회라 프레임마다 읽어도 싸다. 문자열은 값이 바뀔 때만 만든다. + var gacha = GachaBoxManager.Instance; + if (gacha != null) + GUILayout.Label(GetPityText(boxId, gacha.GetPityCount(boxId)), boxRateStyle); GUILayout.FlexibleSpace(); @@ -1054,6 +1069,10 @@ private void DrawGachaResultScreen(float contentW, float contentH) { GUILayout.Label("* 상자 전용 곤충!", gachaExclusiveStyle); } + if (gachaResult.isShiny) + { + GUILayout.Label("★ 색다른 곤충!", gachaExclusiveStyle); + } GUILayout.Space(14); GUILayout.Label($"보너스: 캔디 {gachaResult.bonusCandy}개", gachaCandyBonusStyle); diff --git a/Assets/Tests/EditMode/GachaRatesTests.cs b/Assets/Tests/EditMode/GachaRatesTests.cs index db873c6..1e0c49b 100644 --- a/Assets/Tests/EditMode/GachaRatesTests.cs +++ b/Assets/Tests/EditMode/GachaRatesTests.cs @@ -29,6 +29,24 @@ public void SetUp() mgr = go.AddComponent(); } + [Test] + public void RollRarity_AtPity_ForcesLegendaryRegardlessOfRoll() + { + float[] t = { 55f, 85f, 97f, 99.5f }; // 브론즈 — roll 0은 Common + Assert.AreEqual(InsectRarity.Common, GachaBoxManager.RollRarity(t, 0f, 0)); + Assert.AreEqual(InsectRarity.Common, GachaBoxManager.RollRarity(t, 0f, GachaBoxManager.PityLegendaryPulls - 2), + "천장 직전까지는 확률대로다"); + Assert.AreEqual(InsectRarity.Legendary, GachaBoxManager.RollRarity(t, 0f, GachaBoxManager.PityLegendaryPulls - 1), + "천장째 뽑기는 roll과 무관하게 Legendary"); + } + + [Test] + public void PityLegendaryPulls_IsWithinIndustryRange() + { + // gacha_sim 임계(100연 Legendary 0개 < 50%)를 만족하려면 100 이하여야 하고, 50 아래면 전설이 흔해진다. + Assert.That(GachaBoxManager.PityLegendaryPulls, Is.InRange(50, 100)); + } + [TearDown] public void TearDown() {