Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/rules/balance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 실행
Expand Down
15 changes: 10 additions & 5 deletions .claude/scripts/gacha_sim.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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로 변경 가능)")
Expand Down
16 changes: 15 additions & 1 deletion .claude/scripts/game_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}, ...} 등급별 확률(%).

Expand Down Expand Up @@ -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",
)
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions Assets/Scripts/Core/CloudSaveManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
};
}
Expand Down Expand Up @@ -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 미러만으로는 게임플레이에 안 잡힘.
Expand Down Expand Up @@ -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("}}");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
71 changes: 49 additions & 22 deletions Assets/Scripts/Core/GachaBoxManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public class GachaResult
public string displayName;
public InsectRarity rarity;
public bool isExclusive;
public bool isShiny;
public int bonusCandy;
}

Expand All @@ -25,6 +26,36 @@ public class GachaBoxManager : MonoBehaviour
public GachaResult LastResult { get; private set; }
public event Action<GachaResult> BoxOpened;

/// <summary>
/// 천장 — 같은 상자를 이 횟수째 열면 Legendary가 확정된다(그 사이 Legendary가 나오면 리셋).
/// 없을 때 브론즈 100연에 Legendary 0개일 확률이 60%였다(gacha_sim FAIL, 산업 관행 50~100연).
/// 카운터는 상자별·계정 스코프 PlayerPrefs이고 클라우드에도 올라간다(charSkin과 같은 4점).
/// </summary>
public const int PityLegendaryPulls = 80;

/// <summary>
/// 가챠 샤이니 확률. 필드(<c>InsectEntity</c> 1%)보다 높다 — 상자만의 매력이 있어야 한다.
/// 옛은 지정이 없어 <c>CreateWithIV</c>의 필드와 같은 1% 롤을 그대로 썼고, 시뮬은 "미구현"으로 읽었다.
/// </summary>
public const float ShinyChance = 0.02f;

private static string PityKey(string boxId) => SaveScope.PrefsKey(PityKeyBase(boxId));
/// <summary>SaveScope.ScopedIntPrefsKeys와 CloudSaveManager가 같은 문자열을 쓴다.</summary>
public static string PityKeyBase(string boxId) => "InsectGame.GachaPity." + boxId;

/// <summary>이 상자를 Legendary 없이 연 횟수. UI가 "천장까지 N회"를 그린다.</summary>
public int GetPityCount(string boxId) => PlayerPrefs.GetInt(PityKey(boxId), 0);

/// <summary>
/// 순수 판정 — 이번이 <paramref name="pullsSinceLegendary"/>+1번째 뽑기일 때 등급.
/// 천장에 닿았으면 roll과 무관하게 Legendary.
/// </summary>
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<InsectRarity, string[]> gachaExclusives = new Dictionary<InsectRarity, string[]>
{
Expand Down Expand Up @@ -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가 어긋나도 팝업·도감·컬렉션이
Expand All @@ -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}"); }

Expand Down Expand Up @@ -201,6 +233,7 @@ public void OpenBox(string boxId)
displayName = GetInsectDisplayName(insectId),
rarity = resultRarity,
isExclusive = isExclusive,
isShiny = isShiny,
bonusCandy = bonusCandy
};

Expand Down Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion Assets/Scripts/Core/SaveScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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회 재이전.
Expand All @@ -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 =
{
Expand Down
8 changes: 6 additions & 2 deletions Assets/Scripts/Data/InsectLevelCurve.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Loading
Loading