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;
}
}
}