ThreeKingdomsSlingshot/Assets/Scripts/OutGame/EconomyRules.cs

278 lines
12 KiB
C#
Raw Normal View History

아웃게임 1차(PD #1093 세션 A) — 본영 로비 · 세이브 · 군량 · 영웅(레벨·승급 6칸·진화·스킬 표시) · 편성(전열 3/후열 2 · 프리셋 3) · 거래(뽑기·골드 장비 상점) · 스테이지 보상·별 3·소탕 - 데이터: 기획 tables/ 12장 → Assets/Data/OutGame/*.csv · 메뉴 TKS/Import OutGame Data(Editor/DataImporter) → Resources/OutGame 9 자산(HeroDb·GearDb·GachaConfig·EconomyConfig·RewardTable·LevelTable·PromotionTable·EvolveTable·SkillInfoDb) · 검증(행 수·필수 열·id 유일·hero↔skill↔gear·확률 합) 오류 시 자산 불변 · BalanceConfig.heroes[]/HeroDef 삭제 → HeroDb(기존 5인 id 유지) · economy.csv 키 17 + gacha.csv 키 2 추가(기획서 본문 값 · C45) - 세이브: persistentDataPath/save.json · ver 1 · 즉시 저장 · 오프라인 군량 회복 · 일일 초기화 · 신규 유저 = 유비·관우·장비·조운·황충 Lv1 녹 ★=희귀도 · 골드 1000 · 보옥 300 · 군량 30 · 테스트·캡처 = 메모리 전용 - 화면(uGUI 와이어프레임 · img03·06·08·11): 시작 = 본영 로비(출정 → 월드맵 · 편성 · 연무장/보물/내정 준비 중 · 동맹 추후) · 재화 바(월드맵 포함 · 군량 타이머) · 하단 탭 5 · ◀/Esc 일관 · 영웅 목록·상세·상세정보·도감 · 편성(탭·드래그) · 뽑기(천장 n/80 · 확률 공개 · 첫 10연 무료 · 10연 ★★ 보장 · 카드 순차 공개) · 장비 상점(흰/녹 6종 일일 · sell×5 · 새로고침 보옥 20) - 전투 연결: BattleManager.LineupProvider/Deployed(LineupEntry · §3-2 스탯 · 방어 배율 생성 뒤) · playerSpawnPoints = 전열 (−4/0/4, −12) · 후열 (−2/2, −14) · WorldMapScreen 훅(팝업 군량·보상·별 · 입장 군량 · 노드 별 star_on/off · 소탕 · ◀ 본영 · TopInset) · 결과 패널(별 3 조건 · 골드·경험·보옥·장비) · GameBootstrap/AutoPlayDriver 로비 시작 - B 세션 파일(ActionResolver·Combat·UnitView·BattleHUD·SlingshotInput·EnemyAI) 미변경 - 테스트 56/56(기존 41 유지 · 신규 OutGameTests 15) · console error 0 · 사본 GUI 캡처 11장(DataImporter.CaptureOutGame) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:11:10 +00:00
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;
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_regen_sec 마다 1 · 최대 이상(구매·광고로 넘침)이면 멈추고 기준 시각만 당긴다.</summary>
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));
}
}
/// <summary>다음 1 회복까지 초(최대 이상 = 0).</summary>
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);
}
/// <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, 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;
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<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);
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);
d.shop.items.Add(new ShopItem { gearId = it.id, price = Mathf.RoundToInt(it.sellGold * e.ShopPriceMult), sold = false });
}
}
/// <summary>보옥 shop_refresh_gem 으로 다시 깔기. null = 성공.</summary>
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;
}
}
}