using System; using System.Collections.Generic; using System.Globalization; using UnityEngine; namespace TKS.OutGame { /// 스테이지 1회 결과(보상 · 별 · 조건) — 결과 화면·소탕 팝업 표시용. public class StageReward { public bool win; public bool sweep; public bool first; public bool boss; public int chapter; public int stage; public int gold; public int exp; public int gem; public string gearId; /// 보물 상자(stage_chest · stage_chest_pct · PD #1093 C) — 상자 등급 개발 반영 뒤 id. public string chest; public int stamina; public int stars; /// 별 조건 ① 클리어 ② 아군 전원 생존 ③ star_turns 턴 이내. public bool[] conds = new bool[3]; public int starTurns; public int turns; public int bestStars; public List expTo = new List(); } /// 군량(§2) · 일일 횟수 · 스테이지 보상(§6) · 소탕 · 골드 장비 상점(S15). 수치 = economy.csv · stage_rewards.csv · gear_items.csv. public static class EconomyRules { // ---------- 일일 ---------- public static string DayKey(DateTime nowUtc) { return nowUtc.ToLocalTime().ToString("yyyyMMdd", CultureInfo.InvariantCulture); } /// 날짜가 바뀌었으면 광고·보옥 구매 횟수를 초기화. public static void RollDaily(SaveData d, DateTime nowUtc) { var day = DayKey(nowUtc); if (d.adsUsed.day == day) return; d.adsUsed = new AdsUsed { day = day }; } /// 다음 날 0시(현지)까지 남은 시간. public static TimeSpan UntilNextDay(DateTime nowUtc) { var local = nowUtc.ToLocalTime(); return local.Date.AddDays(1) - local; } // ---------- 군량 ---------- /// 군량 최대 = stamina_max + 농업 「군량 상한」(PD #1093 C). public static int StaminaMax(OutGameData db, SaveData d) { return db.Economy.StaminaMax + Mathf.RoundToInt(DevRules.Mod(db, d, DevRules.FoodCap)); } /// 군량 1 회복 초 = stamina_regen_sec − 농업 「군량 회복」(최소 1). public static int RegenSec(OutGameData db, SaveData d) { return Mathf.Max(1, db.Economy.StaminaRegenSec - Mathf.RoundToInt(DevRules.Mod(db, d, DevRules.FoodRegen))); } /// 회복 계산(오프라인 포함): 최대 미만이면 stamina_regen_sec 마다 1 · 최대 이상(구매·광고로 넘침)이면 멈추고 기준 시각만 당긴다. public static void TickStamina(OutGameData db, SaveData d, DateTime nowUtc) { int max = StaminaMax(db, d); int regen = RegenSec(db, d); var at = SaveStore.ParseIso(d.staminaAt, nowUtc); if (at > nowUtc) at = nowUtc; // 기기 시각을 되돌린 경우 if (d.stamina >= max) { d.staminaAt = SaveStore.Iso(nowUtc); return; } long elapsed = (long)(nowUtc - at).TotalSeconds; long gained = elapsed / Mathf.Max(1, regen); if (gained <= 0) { d.staminaAt = SaveStore.Iso(at); return; } long next = d.stamina + gained; if (next >= max) { d.stamina = max; d.staminaAt = SaveStore.Iso(nowUtc); } else { d.stamina = (int)next; d.staminaAt = SaveStore.Iso(at.AddSeconds(gained * regen)); } } /// 다음 1 회복까지 초(최대 이상 = 0). public static int SecondsToNext(OutGameData db, SaveData d, DateTime nowUtc) { if (d.stamina >= StaminaMax(db, d)) return 0; int regen = RegenSec(db, d); var at = SaveStore.ParseIso(d.staminaAt, nowUtc); int left = regen - (int)(nowUtc - at).TotalSeconds; return Mathf.Clamp(left, 0, regen); } public static int StageCost(OutGameData db, BalanceConfig.StageDef s) { return s.kind == StageKind.Boss ? db.Economy.BossCost : db.Economy.StageCost; } /// 입장 군량 = 표 비용 − 농업 「군량 비용 할인」(누적 값의 정수 부분 · 최소 1 · PD 확인 대상). public static int StageCost(OutGameData db, SaveData d, BalanceConfig.StageDef s) { return Mathf.Max(1, StageCost(db, s) - Mathf.FloorToInt(DevRules.Mod(db, d, DevRules.FoodCost) + 1e-4f)); } public static bool SpendStamina(OutGameData db, SaveData d, int n, DateTime nowUtc) { TickStamina(db, d, nowUtc); if (d.stamina < n) return false; int max = StaminaMax(db, d); bool wasFull = d.stamina >= max; d.stamina -= n; if (wasFull && d.stamina < max) d.staminaAt = SaveStore.Iso(nowUtc); // 가득 찬 상태에서 쓰면 그때부터 회복 return true; } public static void AddStamina(OutGameData db, SaveData d, int n, DateTime nowUtc) { TickStamina(db, d, nowUtc); d.stamina += n; if (d.stamina >= StaminaMax(db, d)) d.staminaAt = SaveStore.Iso(nowUtc); } /// 보옥 gem_stamina_cost → 군량 gem_stamina_amount (gem_stamina_daily 회/일). null = 성공. public static string BuyStaminaWithGem(OutGameData db, SaveData d, DateTime nowUtc) { var e = db.Economy; RollDaily(d, nowUtc); if (d.adsUsed.gemStamina >= e.GemStaminaDaily) return "오늘 구매 " + e.GemStaminaDaily + "회를 모두 썼습니다"; if (d.gem < e.GemStaminaCost) return "보옥 부족 " + d.gem + " / " + e.GemStaminaCost; d.gem -= e.GemStaminaCost; d.adsUsed.gemStamina++; AddStamina(db, d, e.GemStaminaAmount, nowUtc); return null; } /// 광고 보상 군량 +ad_stamina (ad_stamina_daily 회/일) — 광고 SDK 미연동 스텁 = 즉시 지급. public static string AdStamina(OutGameData db, SaveData d, DateTime nowUtc) { var e = db.Economy; RollDaily(d, nowUtc); if (d.adsUsed.stamina >= e.AdStaminaDaily) return "오늘 광고 " + e.AdStaminaDaily + "회를 모두 봤습니다"; d.adsUsed.stamina++; AddStamina(db, d, e.AdStamina + Mathf.RoundToInt(DevRules.Mod(db, d, DevRules.AdFood)), nowUtc); return null; } // ---------- 스테이지 보상(§6) ---------- /// /// 골드 = gold_base + stage × gold_per_stage(첫 클리어 × first_clear_gold_mult · 반복 × repeat_reward_mult) · 경험 = 출전 전원 exp_base(반복 ×0.5) · /// 보옥 = 첫 클리어 gem_first + 보스 gem_boss · 장비 1개 = gear_drop_pct(보스 boss_gear_drop_pct) · 등급 ≤ gear_grade_max · drop_from_chapter ≤ 챕터 · /// 별 = 클리어 · 전원 생존 · star_turns 턴 이내. 소탕 = 반복 보상(별 조건 없음). /// public static StageReward Compute(OutGameData db, SaveData d, BalanceConfig.StageDef s, bool win, bool allAlive, int turns, bool sweep, System.Random rng) { var e = db.Economy; var row = db.Rewards.For(s.chapter); var st = d.Stage(s.chapter, s.stage, false); var r = new StageReward { win = win, sweep = sweep, chapter = s.chapter, stage = s.stage, boss = s.kind == StageKind.Boss, first = !sweep && (st == null || st.clears == 0), turns = turns, starTurns = row != null ? row.starTurns : 0, bestStars = st != null ? st.stars : 0, }; if (!win || row == null) return r; float baseGold = row.goldBase + s.stage * e.GoldPerStage; float mult = r.first ? e.FirstClearGoldMult : e.RepeatRewardMult; // 개발 효과(PD #1093 C) = 골드 획득 · 경험 획득 · 첫 클리어 보옥 · 보옥 보너스 · 장비 드랍(%p) — 개발 0 이면 A 단계 값 그대로 r.gold = Mathf.RoundToInt(baseGold * mult * DevRules.Mult(db, d, DevRules.GoldGain)); r.exp = Mathf.RoundToInt(row.expBase * (r.first ? 1f : e.RepeatRewardMult) * DevRules.Mult(db, d, DevRules.ExpGain)); r.gem = r.first ? Mathf.RoundToInt((row.gemFirst + (r.boss ? row.gemBoss : 0)) * DevRules.Mult(db, d, DevRules.FirstClearGem) * DevRules.Mult(db, d, DevRules.GemBonus)) : 0; float drop = (r.boss ? e.BossGearDropPct : row.gearDropPct) + DevRules.Mod(db, d, DevRules.GearDrop) / 100f; if (rng.NextDouble() < drop) r.gearId = RollGear(db, s.chapter, row.gearGradeMax, rng); if (db.Chests != null && rng.NextDouble() < e.StageChestPct) r.chest = TreasureRules.RollTier(db, d, e.StageChest, rng); if (!sweep) { r.conds[0] = true; r.conds[1] = allAlive; r.conds[2] = turns <= row.starTurns; foreach (var c in r.conds) if (c) r.stars++; } return r; } static string RollGear(OutGameData db, int chapter, string gradeMax, System.Random rng) { int maxOrder = db.Gear.OrderOf(gradeMax); var pool = new List(); foreach (var it in db.Gear.items) if (it.order <= maxOrder && it.dropFromChapter <= chapter) pool.Add(it.id); return pool.Count > 0 ? pool[rng.Next(pool.Count)] : null; } /// 보상을 세이브에 넣는다(재화 · 경험 = expTo 전원 · 장비 · 스테이지 기록 · 클리어 최고 챕터). public static void Apply(SaveData d, StageReward r, IEnumerable expTo) { if (!r.win) return; d.gold += r.gold; d.gem += r.gem; if (expTo != null) foreach (var id in expTo) { var s = d.Level(id); if (s == null || r.expTo.Contains(id)) continue; HeroRules.GrantExp(s, r.exp); r.expTo.Add(id); } if (!string.IsNullOrEmpty(r.gearId)) d.inv.AddGear(r.gearId, 1); if (!string.IsNullOrEmpty(r.chest)) Counts.Add(d.inv.chests, r.chest, 1); var st = d.Stage(r.chapter, r.stage, true); st.clears++; st.stars = Mathf.Max(st.stars, r.stars); r.bestStars = st.stars; d.chapterMax = Mathf.Max(d.chapterMax, r.chapter); } public static int StarsOf(SaveData d, BalanceConfig.StageDef s) { var st = d.Stage(s.chapter, s.stage, false); return st != null ? st.stars : 0; } /// 소탕 = 별 3 스테이지만(최대 별 = 조건 수). public static bool CanSweep(SaveData d, BalanceConfig.StageDef s) { return StarsOf(d, s) >= 3; } // ---------- 골드 장비 상점 ---------- /// 날짜가 바뀌었거나 비었으면 shop_grades 장비 shop_slots 종(겹침 없음 · 가격 = sell_gold × shop_price_mult)을 새로 깐다. public static void EnsureShop(OutGameData db, SaveData d, DateTime nowUtc, System.Random rng) { var day = DayKey(nowUtc); if (d.shop.day == day && d.shop.items.Count > 0) return; d.shop.day = day; d.shop.refreshes = 0; Restock(db, d, rng); } static void Restock(OutGameData db, SaveData d, System.Random rng) { var e = db.Economy; var grades = new HashSet(e.ShopGrades); var pool = new List(); foreach (var it in db.Gear.items) if (grades.Contains(it.grade)) pool.Add(it); d.shop.items.Clear(); for (int i = 0; i < e.ShopSlots && pool.Count > 0; i++) { int k = rng.Next(pool.Count); var it = pool[k]; pool.RemoveAt(k); float discount = Mathf.Max(0f, 1f - DevRules.Mod(db, d, DevRules.TradeDiscount) / 100f); // 상업 「거래 할인」(PD #1093 C) d.shop.items.Add(new ShopItem { gearId = it.id, price = Mathf.RoundToInt(it.sellGold * e.ShopPriceMult * discount), sold = false }); } } /// 보옥 shop_refresh_gem 으로 다시 깔기. null = 성공. public static string RefreshShop(OutGameData db, SaveData d, System.Random rng) { int cost = FreeRefreshesLeft(db, d) > 0 ? 0 : db.Economy.ShopRefreshGem; // 시장 개설 = 하루 무료 새로고침(PD #1093 C) if (d.gem < cost) return "보옥 부족 " + d.gem + " / " + cost; d.gem -= cost; d.shop.refreshes++; Restock(db, d, rng); return null; } /// 오늘 남은 무료 새로고침(상업 「시장 개설」 Lv · 상점 refreshes = 그날 새로고침 수). public static int FreeRefreshesLeft(OutGameData db, SaveData d) { return Mathf.Max(0, Mathf.RoundToInt(DevRules.Mod(db, d, DevRules.TradeFreeRefresh)) - d.shop.refreshes); } public static string BuyShop(SaveData d, int index) { if (index < 0 || index >= d.shop.items.Count) return "없는 칸"; var it = d.shop.items[index]; if (it.sold) return "품절"; if (d.gold < it.price) return "골드 부족 " + d.gold + " / " + it.price; d.gold -= it.price; it.sold = true; d.inv.AddGear(it.gearId, 1); return null; } } }