2026-11-05 12:11:10 +00:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using UnityEngine;
|
|
|
|
|
|
|
|
|
|
namespace TKS.OutGame
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 아웃게임 실행 상태 = 표(OutGameData) + 세이브(SaveData). 바꾸는 동작은 전부 여기를 거쳐 즉시 저장(Commit)한다.
|
|
|
|
|
/// 전투 연결 = 출전 편성(BuildLineup → BattleManager.LineupProvider) · 결과 보상(OnBattleEnd) · 군량(TryPayEntry) · 월드맵 진행(SyncWorldProgress).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class OutGameSession
|
|
|
|
|
{
|
|
|
|
|
public static OutGameSession Current { get; private set; }
|
|
|
|
|
|
|
|
|
|
public readonly OutGameData Db;
|
|
|
|
|
public readonly BalanceConfig Cfg;
|
|
|
|
|
public SaveData Save;
|
|
|
|
|
public System.Random Rng = new System.Random(Environment.TickCount);
|
|
|
|
|
public Func<DateTime> Clock = () => DateTime.UtcNow;
|
|
|
|
|
public StageReward LastReward;
|
|
|
|
|
public bool IsNewUser { get; private set; }
|
2026-11-05 15:22:54 +00:00
|
|
|
/// <summary>마지막 연무장 결과(PD #1093 C).</summary>
|
|
|
|
|
public ModeResult LastMode;
|
|
|
|
|
int lastGold;
|
2026-11-05 12:11:10 +00:00
|
|
|
|
|
|
|
|
public event Action Changed;
|
|
|
|
|
|
|
|
|
|
public OutGameSession(OutGameData db, BalanceConfig cfg, SaveData save)
|
|
|
|
|
{
|
|
|
|
|
Db = db;
|
|
|
|
|
Cfg = cfg;
|
|
|
|
|
Save = save;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public DateTime Now
|
|
|
|
|
{
|
|
|
|
|
get { return Clock(); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public int FormationSlots
|
|
|
|
|
{
|
|
|
|
|
get { return Cfg.playerSpawnPoints.Length; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>세이브를 읽어(없으면 신규 유저) 오프라인 군량 회복 · 일일 초기화 · 월드맵 진행 반영.</summary>
|
|
|
|
|
public static OutGameSession Start(OutGameData db, BalanceConfig cfg)
|
|
|
|
|
{
|
|
|
|
|
var now = DateTime.UtcNow;
|
|
|
|
|
var save = SaveStore.Load();
|
|
|
|
|
bool isNew = save == null;
|
|
|
|
|
if (isNew) save = HeroRules.NewSave(db, cfg.playerSpawnPoints.Length, now);
|
|
|
|
|
var s = new OutGameSession(db, cfg, save) { IsNewUser = isNew };
|
|
|
|
|
HeroRules.Sanitize(db, save, s.FormationSlots);
|
|
|
|
|
EconomyRules.TickStamina(db, save, now);
|
|
|
|
|
EconomyRules.RollDaily(save, now);
|
2026-11-05 15:22:54 +00:00
|
|
|
ArenaRules.RollDaily(db, save, now);
|
|
|
|
|
s.lastGold = save.gold;
|
2026-11-05 12:11:10 +00:00
|
|
|
s.SyncWorldProgress();
|
|
|
|
|
s.Commit();
|
|
|
|
|
Current = s;
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Commit()
|
|
|
|
|
{
|
2026-11-05 15:22:54 +00:00
|
|
|
// 보물 조건 효과 「골드 사용」 = 저장 사이에 줄어든 골드(쓰는 동작은 각자 Commit · PD #1093 C)
|
|
|
|
|
if (Save.gold < lastGold) TreasureRules.AddCount(Save, BattleEvents.GoldSpent, lastGold - Save.gold);
|
|
|
|
|
lastGold = Save.gold;
|
2026-11-05 12:11:10 +00:00
|
|
|
SaveStore.Write(Save, Now);
|
|
|
|
|
if (Changed != null) Changed();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>군량 회복만 반영(화면 갱신 주기 · 저장은 값이 바뀔 때만).</summary>
|
|
|
|
|
public void TickStamina()
|
|
|
|
|
{
|
|
|
|
|
int before = Save.stamina;
|
|
|
|
|
EconomyRules.TickStamina(Db, Save, Now);
|
|
|
|
|
if (Save.stamina != before) Commit();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 조회 ----------
|
|
|
|
|
|
|
|
|
|
public HeroDb.Hero Def(string id)
|
|
|
|
|
{
|
|
|
|
|
return Db.Heroes.Get(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public HeroRules.Stats StatsOf(string id)
|
|
|
|
|
{
|
2026-11-05 15:22:54 +00:00
|
|
|
return HeroRules.Compute(Db, Cfg, Def(id), Save.Level(id), Save); // 훈련 · 보물 포함(PD #1093 C)
|
2026-11-05 12:11:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>보유 목록(장수 = 영웅 · 병사 = 병사 유닛) · 표 순서.</summary>
|
|
|
|
|
public List<HeroDb.Hero> Owned(bool soldiers)
|
|
|
|
|
{
|
|
|
|
|
var res = new List<HeroDb.Hero>();
|
|
|
|
|
foreach (var h in Db.Heroes.heroes)
|
|
|
|
|
if (h.isSoldier == soldiers && Save.Owns(h.id)) res.Add(h);
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>지금 할 수 있는 성장(레벨업 · 장착 · 승급 · 진화)이 있는가 — 목록 카드 알림 점.</summary>
|
|
|
|
|
public bool CanGrowAny(string id)
|
|
|
|
|
{
|
|
|
|
|
var h = Def(id);
|
|
|
|
|
var lv = Save.Level(id);
|
|
|
|
|
if (h == null || lv == null) return false;
|
|
|
|
|
if (HeroRules.CanLevelUp(Db, Save, lv) == null) return true;
|
|
|
|
|
var hs = Save.Hero(id);
|
|
|
|
|
if (hs == null) return false;
|
|
|
|
|
if (HeroRules.CanPromote(Db, h, hs) == null || HeroRules.CanEvolve(Db, h, hs) == null) return true;
|
|
|
|
|
for (int i = 0; i < hs.gear.Count; i++)
|
|
|
|
|
if (HeroRules.CanEquip(Db, Save, h, hs, i) == null) return true;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public bool InActivePreset(string id)
|
|
|
|
|
{
|
|
|
|
|
var p = Save.ActivePreset;
|
|
|
|
|
return p != null && p.slots.Contains(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 성장 ----------
|
|
|
|
|
|
|
|
|
|
public string LevelUp(string id)
|
|
|
|
|
{
|
|
|
|
|
var s = Save.Level(id);
|
|
|
|
|
var why = HeroRules.CanLevelUp(Db, Save, s);
|
|
|
|
|
if (why != null) return why;
|
|
|
|
|
HeroRules.LevelUp(Db, Save, s);
|
|
|
|
|
Commit();
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string Equip(string id, int slot)
|
|
|
|
|
{
|
|
|
|
|
var h = Def(id);
|
|
|
|
|
var s = Save.Hero(id);
|
|
|
|
|
var why = HeroRules.CanEquip(Db, Save, h, s, slot);
|
|
|
|
|
if (why != null) return why;
|
|
|
|
|
HeroRules.Equip(Db, Save, h, s, slot);
|
|
|
|
|
Commit();
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string Promote(string id)
|
|
|
|
|
{
|
|
|
|
|
var h = Def(id);
|
|
|
|
|
var s = Save.Hero(id);
|
|
|
|
|
var why = HeroRules.CanPromote(Db, h, s);
|
|
|
|
|
if (why != null) return why;
|
|
|
|
|
HeroRules.Promote(Db, h, s);
|
|
|
|
|
Commit();
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string Evolve(string id)
|
|
|
|
|
{
|
|
|
|
|
var h = Def(id);
|
|
|
|
|
var s = Save.Hero(id);
|
|
|
|
|
var why = HeroRules.CanEvolve(Db, h, s);
|
|
|
|
|
if (why != null) return why;
|
|
|
|
|
HeroRules.Evolve(Db, h, s);
|
|
|
|
|
Commit();
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 편성 ----------
|
|
|
|
|
|
|
|
|
|
/// <summary>프리셋 idx 저장 + 출전 프리셋으로 지정(빈 칸 허용 · 최소 1).</summary>
|
|
|
|
|
public string SavePreset(int idx, List<string> slots)
|
|
|
|
|
{
|
|
|
|
|
if (idx < 0 || idx >= Save.presets.Count) return "없는 프리셋";
|
|
|
|
|
int filled = 0;
|
|
|
|
|
foreach (var id in slots)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(id)) continue;
|
|
|
|
|
if (!Save.Owns(id)) return "미보유 " + id;
|
|
|
|
|
filled++;
|
|
|
|
|
}
|
|
|
|
|
if (filled == 0) return "최소 1명은 출전해야 합니다";
|
|
|
|
|
var p = Save.presets[idx];
|
|
|
|
|
p.slots.Clear();
|
|
|
|
|
for (int i = 0; i < FormationSlots; i++) p.slots.Add(i < slots.Count && slots[i] != null ? slots[i] : "");
|
|
|
|
|
Save.presetIdx = idx;
|
|
|
|
|
Commit();
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>출전 편성 = 출전 프리셋의 자리 순서(0~2 전열 · 3~4 후열 → playerSpawnPoints) · 스탯 = §3-2 공식.</summary>
|
|
|
|
|
public List<LineupEntry> BuildLineup()
|
|
|
|
|
{
|
|
|
|
|
var res = new List<LineupEntry>();
|
|
|
|
|
var p = Save.ActivePreset;
|
|
|
|
|
if (p == null) return res;
|
|
|
|
|
for (int i = 0; i < p.slots.Count && i < FormationSlots; i++)
|
|
|
|
|
{
|
|
|
|
|
var id = p.slots[i];
|
|
|
|
|
var h = Def(id);
|
|
|
|
|
if (h == null || !Save.Owns(id)) continue;
|
|
|
|
|
var st = StatsOf(id);
|
2026-11-05 14:31:27 +00:00
|
|
|
var entry = new LineupEntry
|
2026-11-05 12:11:10 +00:00
|
|
|
{
|
|
|
|
|
slot = i, id = h.id, name = h.nameKo, type = h.type, art = h.art,
|
2026-11-05 15:22:54 +00:00
|
|
|
hpMult = h.hpMult * st.GrowthOf("hp"), atkMult = h.atkMult * st.GrowthOf("atk"), defMult = h.defMult * st.GrowthOf("def"),
|
2026-11-05 14:31:27 +00:00
|
|
|
};
|
|
|
|
|
SkillRules.FillLineup(Db, Save, h, entry); // 스킬 id·레벨·★ 패시브 배율(PD #1093 B)
|
|
|
|
|
res.Add(entry);
|
2026-11-05 12:11:10 +00:00
|
|
|
}
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 군량 ----------
|
|
|
|
|
|
|
|
|
|
public int StaminaSecondsToNext()
|
|
|
|
|
{
|
|
|
|
|
return EconomyRules.SecondsToNext(Db, Save, Now);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>스테이지 입장 비용 지불(부족 = false · 세이브 그대로).</summary>
|
|
|
|
|
public bool TryPayEntry(BalanceConfig.StageDef s)
|
|
|
|
|
{
|
2026-11-05 15:22:54 +00:00
|
|
|
if (!EconomyRules.SpendStamina(Db, Save, EconomyRules.StageCost(Db, Save, s), Now)) return false;
|
2026-11-05 12:11:10 +00:00
|
|
|
Commit();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string BuyStamina()
|
|
|
|
|
{
|
|
|
|
|
var why = EconomyRules.BuyStaminaWithGem(Db, Save, Now);
|
|
|
|
|
if (why == null) Commit();
|
|
|
|
|
return why;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string AdStamina()
|
|
|
|
|
{
|
|
|
|
|
var why = EconomyRules.AdStamina(Db, Save, Now);
|
|
|
|
|
if (why == null) Commit();
|
|
|
|
|
return why;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 전투 결과 · 소탕 ----------
|
|
|
|
|
|
|
|
|
|
/// <summary>전투 종료(승리·패배) → 보상 계산·반영·저장. 경험 = 출전 전원(Deployed).</summary>
|
|
|
|
|
public StageReward OnBattleEnd(BattleManager bm, bool win)
|
|
|
|
|
{
|
|
|
|
|
var stage = bm.Stage;
|
|
|
|
|
int alive = bm.AliveCount(Faction.Player);
|
|
|
|
|
bool allAlive = bm.Deployed.Count > 0 && alive >= bm.Deployed.Count;
|
|
|
|
|
var r = EconomyRules.Compute(Db, Save, stage, win, allAlive, bm.TurnNumber, false, Rng);
|
2026-11-05 15:22:54 +00:00
|
|
|
r.stamina = EconomyRules.StageCost(Db, Save, stage);
|
2026-11-05 12:11:10 +00:00
|
|
|
var ids = new List<string>();
|
|
|
|
|
foreach (var e in bm.Deployed) ids.Add(e.id);
|
|
|
|
|
EconomyRules.Apply(Save, r, ids);
|
2026-11-05 15:22:54 +00:00
|
|
|
ArenaRules.CountBattle(Save, bm); // 보물 조건 효과(전투 · 보스 처치)
|
2026-11-05 12:11:10 +00:00
|
|
|
LastReward = r;
|
|
|
|
|
Commit();
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>소탕(별 3 · 군량만 · 전투 없음) = 반복 보상 · 경험 = 출전 프리셋. null = 군량 부족·불가.</summary>
|
|
|
|
|
public StageReward Sweep(BalanceConfig.StageDef s)
|
|
|
|
|
{
|
|
|
|
|
if (!EconomyRules.CanSweep(Save, s)) return null;
|
2026-11-05 15:22:54 +00:00
|
|
|
int cost = EconomyRules.StageCost(Db, Save, s);
|
2026-11-05 12:11:10 +00:00
|
|
|
if (!EconomyRules.SpendStamina(Db, Save, cost, Now)) return null;
|
|
|
|
|
var r = EconomyRules.Compute(Db, Save, s, true, true, 0, true, Rng);
|
|
|
|
|
r.stamina = cost;
|
|
|
|
|
var ids = new List<string>();
|
|
|
|
|
foreach (var e in BuildLineup()) ids.Add(e.id);
|
|
|
|
|
EconomyRules.Apply(Save, r, ids);
|
|
|
|
|
LastReward = r;
|
|
|
|
|
Commit();
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>월드맵 진행(챕터별 앞에서부터 연속으로 깬 노드 수) = 세이브 스테이지 기록.</summary>
|
|
|
|
|
public void SyncWorldProgress()
|
|
|
|
|
{
|
|
|
|
|
WorldProgress.Reset();
|
|
|
|
|
for (int ch = 0; ch < Cfg.chapters.Length; ch++)
|
|
|
|
|
{
|
|
|
|
|
var list = Cfg.StagesOfChapter(Cfg.chapters[ch].no);
|
|
|
|
|
int n = 0;
|
|
|
|
|
while (n < list.Count)
|
|
|
|
|
{
|
|
|
|
|
var st = Cfg.stages[list[n]];
|
|
|
|
|
var ss = Save.Stage(st.chapter, st.stage, false);
|
|
|
|
|
if (ss == null || ss.clears == 0) break;
|
|
|
|
|
n++;
|
|
|
|
|
}
|
|
|
|
|
if (n > 0) WorldProgress.MarkCleared(ch, n - 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 거래 ----------
|
|
|
|
|
|
|
|
|
|
public List<GachaPull> Draw(int count)
|
|
|
|
|
{
|
|
|
|
|
var res = GachaRules.Draw(Db, Save, count, Rng);
|
|
|
|
|
if (res.Count > 0) Commit();
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void EnsureShop()
|
|
|
|
|
{
|
|
|
|
|
var day = Save.shop.day;
|
|
|
|
|
int n = Save.shop.items.Count;
|
|
|
|
|
EconomyRules.EnsureShop(Db, Save, Now, Rng);
|
|
|
|
|
if (Save.shop.day != day || Save.shop.items.Count != n) Commit();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string BuyShop(int index)
|
|
|
|
|
{
|
|
|
|
|
var why = EconomyRules.BuyShop(Save, index);
|
|
|
|
|
if (why == null) Commit();
|
|
|
|
|
return why;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string RefreshShop()
|
|
|
|
|
{
|
|
|
|
|
var why = EconomyRules.RefreshShop(Db, Save, Rng);
|
|
|
|
|
if (why == null) Commit();
|
|
|
|
|
return why;
|
|
|
|
|
}
|
2026-11-05 15:22:54 +00:00
|
|
|
|
|
|
|
|
// ---------- 내정 · 보물 · 연무장(PD #1093 C) ----------
|
|
|
|
|
|
|
|
|
|
/// <summary>규칙 호출 → 성공(null)이면 저장 · 반환 = 실패 사유.</summary>
|
|
|
|
|
public string Do(Func<string> action)
|
|
|
|
|
{
|
|
|
|
|
var why = action();
|
|
|
|
|
if (why == null) Commit();
|
|
|
|
|
return why;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>출전 프리셋 id(순찰 경험 대상).</summary>
|
|
|
|
|
public List<string> PresetIds()
|
|
|
|
|
{
|
|
|
|
|
var ids = new List<string>();
|
|
|
|
|
foreach (var e in BuildLineup()) ids.Add(e.id);
|
|
|
|
|
return ids;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public PatrolYield Harvest(bool ad, out string why)
|
|
|
|
|
{
|
|
|
|
|
var y = PatrolRules.Harvest(Db, Save, Now, PresetIds(), ad, Rng, out why);
|
|
|
|
|
if (y != null) Commit();
|
|
|
|
|
return y;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public PatrolYield InstantHarvest(out string why)
|
|
|
|
|
{
|
|
|
|
|
var y = PatrolRules.Instant(Db, Save, PresetIds(), Rng, out why);
|
|
|
|
|
if (y != null) Commit();
|
|
|
|
|
return y;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public ChestResult OpenChest(string id)
|
|
|
|
|
{
|
|
|
|
|
var r = TreasureRules.Open(Db, Save, id, Rng);
|
|
|
|
|
if (r != null) Commit();
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>연무장 전투 문맥(결과 계산용) — kind · 대상(층 · 던전 id · 샘플 번호 · 보스 id).</summary>
|
|
|
|
|
public class ModeCtx
|
|
|
|
|
{
|
|
|
|
|
public string kind;
|
|
|
|
|
public string id;
|
|
|
|
|
public int index;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public ModeCtx Mode;
|
|
|
|
|
|
|
|
|
|
/// <summary>연무장 전투 끝 → 보상·기록·저장(결과 패널 표시용).</summary>
|
|
|
|
|
public ModeResult OnModeEnd(BattleManager bm, bool win)
|
|
|
|
|
{
|
|
|
|
|
var m = Mode;
|
|
|
|
|
if (m == null) return null;
|
|
|
|
|
ModeResult r = null;
|
|
|
|
|
switch (m.kind)
|
|
|
|
|
{
|
|
|
|
|
case ArenaRules.Tower: r = ArenaRules.TowerResult(Db, Save, m.index, win, Rng); break;
|
|
|
|
|
case ArenaRules.Dungeon: r = ArenaRules.DungeonResult(Db, Save, m.id, win, Now, Rng); break;
|
|
|
|
|
case ArenaRules.Arena: r = ArenaRules.ArenaResult(Db, Save, m.index, win, Now); break;
|
|
|
|
|
case ArenaRules.Raid:
|
|
|
|
|
int dmg = 0;
|
|
|
|
|
bool killed = false;
|
|
|
|
|
foreach (var u in bm.Units)
|
|
|
|
|
{
|
|
|
|
|
if (u == null || u.Faction != Faction.Enemy || !u.IsBoss) continue;
|
|
|
|
|
dmg += u.MaxHp - u.Hp;
|
|
|
|
|
killed |= !u.IsAlive;
|
|
|
|
|
}
|
|
|
|
|
r = ArenaRules.RaidResult(Db, Save, m.id, dmg, killed, Now, Rng);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
ArenaRules.CountBattle(Save, bm);
|
|
|
|
|
LastMode = r;
|
|
|
|
|
Mode = null;
|
|
|
|
|
Commit();
|
|
|
|
|
return r;
|
|
|
|
|
}
|
2026-11-05 12:11:10 +00:00
|
|
|
}
|
|
|
|
|
}
|