ThreeKingdomsSlingshot/Assets/Scripts/OutGame/EconomyRules.cs

310 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
namespace TKS.OutGame
{
/// <summary>스테이지 1회 결과(보상 · 별 · 조건) — 결과 화면·소탕 팝업 표시용.</summary>
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;
/// <summary>보물 상자(stage_chest · stage_chest_pct · PD #1093 C) — 상자 등급 개발 반영 뒤 id.</summary>
public string chest;
public int stamina;
public int stars;
/// <summary>별 조건 ① 클리어 ② 아군 전원 생존 ③ star_turns 턴 이내.</summary>
public bool[] conds = new bool[3];
public int starTurns;
public int turns;
public int bestStars;
public List<string> expTo = new List<string>();
}
/// <summary>군량(§2) · 일일 횟수 · 스테이지 보상(§6) · 소탕 · 골드 장비 상점(S15). 수치 = economy.csv · stage_rewards.csv · gear_items.csv.</summary>
public static class EconomyRules
{
// ---------- 일일 ----------
public static string DayKey(DateTime nowUtc)
{
return nowUtc.ToLocalTime().ToString("yyyyMMdd", CultureInfo.InvariantCulture);
}
/// <summary>날짜가 바뀌었으면 광고·보옥 구매 횟수를 초기화.</summary>
public static void RollDaily(SaveData d, DateTime nowUtc)
{
var day = DayKey(nowUtc);
if (d.adsUsed.day == day) return;
d.adsUsed = new AdsUsed { day = day };
}
/// <summary>다음 날 0시(현지)까지 남은 시간.</summary>
public static TimeSpan UntilNextDay(DateTime nowUtc)
{
var local = nowUtc.ToLocalTime();
return local.Date.AddDays(1) - local;
}
// ---------- 군량 ----------
/// <summary>군량 최대 = stamina_max + 농업 「군량 상한」(PD #1093 C).</summary>
public static int StaminaMax(OutGameData db, SaveData d)
{
return db.Economy.StaminaMax + Mathf.RoundToInt(DevRules.Mod(db, d, DevRules.FoodCap));
}
/// <summary>군량 1 회복 초 = stamina_regen_sec − 농업 「군량 회복」(최소 1).</summary>
public static int RegenSec(OutGameData db, SaveData d)
{
return Mathf.Max(1, db.Economy.StaminaRegenSec - Mathf.RoundToInt(DevRules.Mod(db, d, DevRules.FoodRegen)));
}
/// <summary>회복 계산(오프라인 포함): 최대 미만이면 stamina_regen_sec 마다 1 · 최대 이상(구매·광고로 넘침)이면 멈추고 기준 시각만 당긴다.</summary>
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));
}
}
/// <summary>다음 1 회복까지 초(최대 이상 = 0).</summary>
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;
}
/// <summary>입장 군량 = 표 비용 − 농업 「군량 비용 할인」(누적 값의 정수 부분 · 최소 1 · PD 확인 대상).</summary>
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);
}
/// <summary>보옥 gem_stamina_cost → 군량 gem_stamina_amount (gem_stamina_daily 회/일). null = 성공.</summary>
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;
}
/// <summary>광고 보상 군량 +ad_stamina (ad_stamina_daily 회/일) — 광고 SDK 미연동 스텁 = 즉시 지급.</summary>
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) ----------
/// <summary>
/// 골드 = 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 턴 이내. 소탕 = 반복 보상(별 조건 없음).
/// </summary>
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<string>();
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;
}
/// <summary>보상을 세이브에 넣는다(재화 · 경험 = expTo 전원 · 장비 · 스테이지 기록 · 클리어 최고 챕터).</summary>
public static void Apply(SaveData d, StageReward r, IEnumerable<string> 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;
}
/// <summary>소탕 = 별 3 스테이지만(최대 별 = 조건 수).</summary>
public static bool CanSweep(SaveData d, BalanceConfig.StageDef s)
{
return StarsOf(d, s) >= 3;
}
// ---------- 골드 장비 상점 ----------
/// <summary>날짜가 바뀌었거나 비었으면 shop_grades 장비 shop_slots 종(겹침 없음 · 가격 = sell_gold × shop_price_mult)을 새로 깐다.</summary>
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<string>(e.ShopGrades);
var pool = new List<GearDb.Item>();
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 });
}
}
/// <summary>보옥 shop_refresh_gem 으로 다시 깔기. null = 성공.</summary>
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;
}
/// <summary>오늘 남은 무료 새로고침(상업 「시장 개설」 Lv · 상점 refreshes = 그날 새로고침 수).</summary>
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;
}
}
}