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; 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_regen_sec 마다 1 · 최대 이상(구매·광고로 넘침)이면 멈추고 기준 시각만 당긴다. public static void TickStamina(OutGameData db, SaveData d, DateTime nowUtc) { var e = db.Economy; var at = SaveStore.ParseIso(d.staminaAt, nowUtc); if (at > nowUtc) at = nowUtc; // 기기 시각을 되돌린 경우 if (d.stamina >= e.StaminaMax) { d.staminaAt = SaveStore.Iso(nowUtc); return; } long elapsed = (long)(nowUtc - at).TotalSeconds; long gained = elapsed / Mathf.Max(1, e.StaminaRegenSec); if (gained <= 0) { d.staminaAt = SaveStore.Iso(at); return; } long next = d.stamina + gained; if (next >= e.StaminaMax) { d.stamina = e.StaminaMax; d.staminaAt = SaveStore.Iso(nowUtc); } else { d.stamina = (int)next; d.staminaAt = SaveStore.Iso(at.AddSeconds(gained * e.StaminaRegenSec)); } } /// 다음 1 회복까지 초(최대 이상 = 0). public static int SecondsToNext(OutGameData db, SaveData d, DateTime nowUtc) { var e = db.Economy; if (d.stamina >= e.StaminaMax) return 0; var at = SaveStore.ParseIso(d.staminaAt, nowUtc); int left = e.StaminaRegenSec - (int)(nowUtc - at).TotalSeconds; return Mathf.Clamp(left, 0, e.StaminaRegenSec); } public static int StageCost(OutGameData db, BalanceConfig.StageDef s) { return s.kind == StageKind.Boss ? db.Economy.BossCost : db.Economy.StageCost; } public static bool SpendStamina(OutGameData db, SaveData d, int n, DateTime nowUtc) { TickStamina(db, d, nowUtc); if (d.stamina < n) return false; bool wasFull = d.stamina >= db.Economy.StaminaMax; d.stamina -= n; if (wasFull && d.stamina < db.Economy.StaminaMax) 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 >= db.Economy.StaminaMax) 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, 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; r.gold = Mathf.RoundToInt(baseGold * mult); r.exp = Mathf.RoundToInt(row.expBase * (r.first ? 1f : e.RepeatRewardMult)); r.gem = r.first ? row.gemFirst + (r.boss ? row.gemBoss : 0) : 0; float drop = r.boss ? e.BossGearDropPct : row.gearDropPct; if (rng.NextDouble() < drop) r.gearId = RollGear(db, s.chapter, row.gearGradeMax, 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); 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); d.shop.items.Add(new ShopItem { gearId = it.id, price = Mathf.RoundToInt(it.sellGold * e.ShopPriceMult), sold = false }); } } /// 보옥 shop_refresh_gem 으로 다시 깔기. null = 성공. public static string RefreshShop(OutGameData db, SaveData d, System.Random rng) { int cost = db.Economy.ShopRefreshGem; if (d.gem < cost) return "보옥 부족 " + d.gem + " / " + cost; d.gem -= cost; d.shop.refreshes++; Restock(db, d, rng); return null; } 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; } } }