using System;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
namespace TKS.OutGame
{
/// 연무장 전투 1판의 결과(결과 패널 · 화면 표시).
public class ModeResult
{
public string kind;
public string title;
public bool win;
public List lines = new List();
}
///
/// 연무장 규칙(기획서 §9 · S13 · PD #1093 C) — ① 천하제일 탑(tower.csv · 입장권 tower_tickets_daily/일 + 광고 tower_ad_tickets · 입장 = 1장 · 승리 = 다음 층 · 보옥·도면·상자)
/// ② 일일 던전(dungeons.csv · 요일 · entries_per_day/일 · 템플릿 × hp_mult · 보상 = reward_amount) ③ 투기장(arena.csv · 샘플 상대 sample_lineups(heroes.csv 에서 생성 · 세이브) ·
/// 방어 = 기존 EnemyAI · entries_per_day/일 · 메달 승/패 · 주간 순위(로컬) · 메달 → 영웅 조각 상점) ④ 적장 토벌(raid_bosses 주간 순환 · 보스 hp × raid_hp_mult · raid_turns 턴 · 누적 피해 · raid_daily/일 · 명장 상자 + 천기 raid_arcana_pct).
/// 적 편성 = 챕터 스테이지 템플릿(BalanceConfig.stages)을 복사해 배율만 곱한다.
///
public static class ArenaRules
{
public const string Tower = "tower";
public const string Dungeon = "dungeon";
public const string Arena = "arena";
public const string Raid = "raid";
// ---------- 공통 ----------
public static bool ParseStageKey(string key, out int chapter, out int stage)
{
chapter = stage = 0;
if (string.IsNullOrEmpty(key)) return false;
var p = key.Split('-');
return p.Length == 2 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out chapter) && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out stage);
}
public static BalanceConfig.StageDef FindStage(BalanceConfig cfg, string key)
{
int ch, st;
if (!ParseStageKey(key, out ch, out st)) return null;
foreach (var s in cfg.stages)
if (s.chapter == ch && s.stage == st) return s;
return null;
}
/// 템플릿 스테이지 복사(적 hp/atk 배율 곱 · 제목 교체).
public static BalanceConfig.StageDef CopyStage(BalanceConfig.StageDef src, string title, float hpMult, float atkMult)
{
var s = new BalanceConfig.StageDef
{
title = title, subtitle = src.title, aiAccuracy = src.aiAccuracy, aiAggroRange = src.aiAggroRange,
chapter = src.chapter, stage = src.stage, kind = src.kind, place = src.place, faction = src.faction, bgTheme = src.bgTheme, note = src.note,
};
foreach (var e in src.enemies)
s.enemies.Add(new BalanceConfig.EnemySpawn
{
name = e.name, type = e.type, position = e.position, hpMult = e.hpMult * hpMult, atkMult = e.atkMult * atkMult, isBoss = e.isBoss, isGeneral = e.isGeneral, art = e.art,
});
return s;
}
/// 날짜가 바뀌면 일일 횟수 초기화 · 탑 입장권을 하루치까지 채움.
public static void RollDaily(OutGameData db, SaveData d, DateTime nowUtc)
{
var day = EconomyRules.DayKey(nowUtc);
if (d.dailyC != null && d.dailyC.day == day) return;
d.dailyC = new DailyC { day = day };
d.arena.towerTickets = Mathf.Max(d.arena.towerTickets, db.Economy.TowerTicketsDaily);
}
/// 전투 결과 공통: 전투 카운터 · 쓰러진 적 보스 수(보물 조건 효과).
public static void CountBattle(SaveData d, BattleManager bm)
{
TreasureRules.AddCount(d, BattleEvents.Battles, 1);
int bosses = 0;
foreach (var u in bm.Units)
if (u != null && u.Faction == Faction.Enemy && u.IsBoss && !u.IsAlive) bosses++;
TreasureRules.AddCount(d, BattleEvents.BossKills, bosses);
}
// ---------- 천하제일 탑 ----------
public static int TowerNext(OutGameData db, SaveData d)
{
return Mathf.Min(d.arena.towerFloor + 1, db.Tower.Top);
}
public static string CanTower(OutGameData db, SaveData d, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
if (d.arena.towerFloor >= db.Tower.Top) return "100층 정복 완료";
if (d.arena.towerTickets <= 0) return "입장권 없음(매일 " + db.Economy.TowerTicketsDaily + "장 · 광고 +" + db.Economy.TowerAdTickets + ")";
return null;
}
public static BalanceConfig.StageDef TowerStage(OutGameData db, BalanceConfig cfg, int floor)
{
var f = db.Tower.Get(floor);
var src = f != null ? FindStage(cfg, f.templateStage) : null;
return src == null ? null : CopyStage(src, "천하제일 탑 " + floor + "층", f.hpMult, f.atkMult);
}
/// 입장 = 입장권 1장(승패 무관).
public static string EnterTower(OutGameData db, SaveData d, DateTime nowUtc)
{
var why = CanTower(db, d, nowUtc);
if (why != null) return why;
d.arena.towerTickets--;
return null;
}
public static string TowerAd(OutGameData db, SaveData d, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
if (d.dailyC.towerAd >= 1) return "오늘 광고 입장권을 받았습니다";
d.dailyC.towerAd++;
d.arena.towerTickets += db.Economy.TowerAdTickets;
return null;
}
public static ModeResult TowerResult(OutGameData db, SaveData d, int floor, bool win, System.Random rng)
{
var r = new ModeResult { kind = Tower, win = win, title = "천하제일 탑 " + floor + "층" };
if (!win)
{
r.lines.Add("도전 실패 — 입장권 1장 소모 · 층 유지");
return r;
}
var f = db.Tower.Get(floor);
d.arena.towerFloor = Mathf.Max(d.arena.towerFloor, floor);
int gem = Mathf.RoundToInt(f.rewardGem * DevRules.Mult(db, d, DevRules.GemBonus));
int bp = Mathf.RoundToInt(f.rewardBlueprint * DevRules.Mult(db, d, DevRules.BlueprintGain));
d.gem += gem;
d.inv.blueprint += bp;
r.lines.Add("보옥 +" + gem + " · 도면 +" + bp);
if (f.chest.Length > 0)
{
var got = TreasureRules.AddChest(db, d, f.chest, rng);
if (got != null) r.lines.Add(db.Chests.Get(got).nameKo + " 획득");
}
r.lines.Add("다음 = " + TowerNext(db, d) + "층");
return r;
}
// ---------- 일일 던전 ----------
public static bool OpenToday(DungeonDb.Dungeon dg, DateTime nowUtc)
{
return dg.OpenOn(nowUtc.ToLocalTime().DayOfWeek);
}
public static int DungeonLeft(OutGameData db, SaveData d, DungeonDb.Dungeon dg, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
return Mathf.Max(0, dg.entriesPerDay - d.dailyC.Dungeon(dg.id));
}
public static string CanDungeon(OutGameData db, SaveData d, string id, DateTime nowUtc)
{
var dg = db.Dungeons.Get(id);
if (dg == null) return "없는 던전";
if (!OpenToday(dg, nowUtc)) return "오늘은 닫힘";
if (DungeonLeft(db, d, dg, nowUtc) <= 0) return "오늘 입장 " + dg.entriesPerDay + "회 완료";
return null;
}
public static BalanceConfig.StageDef DungeonStage(OutGameData db, BalanceConfig cfg, string id)
{
var dg = db.Dungeons.Get(id);
var src = dg != null ? FindStage(cfg, dg.templateStage) : null;
return src == null ? null : CopyStage(src, dg.nameKo + " 던전", dg.hpMult, 1f);
}
/// 입장 횟수는 승리했을 때만 소모(패배 = 다시 도전 가능 · PD 확인 대상).
public static ModeResult DungeonResult(OutGameData db, SaveData d, string id, bool win, DateTime nowUtc, System.Random rng)
{
var dg = db.Dungeons.Get(id);
var r = new ModeResult { kind = Dungeon, win = win, title = dg.nameKo + " 던전" };
if (!win)
{
r.lines.Add("도전 실패 — 입장 횟수 유지");
return r;
}
RollDaily(db, d, nowUtc);
d.dailyC.AddDungeon(id);
foreach (var k in dg.rewardKinds)
{
int n = dg.Amount(k);
switch (k)
{
case "gold":
n = Mathf.RoundToInt(n * DevRules.Mult(db, d, DevRules.GoldGain));
d.gold += n;
r.lines.Add("골드 +" + n.ToString("N0"));
break;
case "iron":
n = Mathf.RoundToInt(n * DevRules.Mult(db, d, DevRules.IronGain));
d.inv.iron += n;
r.lines.Add("운철 +" + n);
break;
case "skill_book":
d.inv.books.skill += n;
r.lines.Add("스킬서 +" + n);
break;
case "gear":
float gf = n * DevRules.Mult(db, d, DevRules.DungeonGear);
int cnt = Mathf.FloorToInt(gf + 1e-4f) + (rng.NextDouble() < gf - Mathf.Floor(gf + 1e-4f) ? 1 : 0);
var row = db.Rewards.For(Mathf.Max(1, d.chapterMax));
int maxOrder = row != null ? db.Gear.OrderOf(row.gearGradeMax) : 1;
var pool = new List();
foreach (var it in db.Gear.items)
if (it.order <= maxOrder) pool.Add(it);
for (int i = 0; i < cnt && pool.Count > 0; i++)
{
var g = pool[rng.Next(pool.Count)];
d.inv.AddGear(g.id, 1);
r.lines.Add("장비 " + g.nameKo + " (" + UiKit.GradeKo(g.grade) + ")");
}
break;
case "shards":
var heroes = new List();
foreach (var h in d.heroes) heroes.Add(h);
if (heroes.Count == 0) break;
var pick = heroes[rng.Next(heroes.Count)];
pick.shards += n;
var def = db.Heroes.Get(pick.id);
r.lines.Add((def != null ? def.nameKo : pick.id) + " 조각 +" + n);
break;
}
}
r.lines.Add("오늘 남은 입장 " + DungeonLeft(db, d, dg, nowUtc) + " / " + dg.entriesPerDay);
return r;
}
// ---------- 투기장 ----------
public static int ArenaLeft(OutGameData db, SaveData d, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
return Mathf.Max(0, db.Arena.EntriesPerDay - d.dailyC.arena);
}
static int MaxOwnedLevel(SaveData d)
{
int m = 1;
foreach (var h in d.heroes) m = Mathf.Max(m, h.lv);
foreach (var u in d.units) m = Mathf.Max(m, u.lv);
return m;
}
///
/// 시즌 유지: 시작 시각이 없으면 지금 시작 · season_days 지나면 순위 보상(1~10위 top10 · 그 밖 top100 보옥) 후 새 시즌 ·
/// 샘플 상대가 없으면 생성(영웅 5인 무작위 · Lv = 보유 최고 Lv ± · 점수 = sample_score_step × 순번 · 임시 규칙 = PD 확인 대상).
///
public static void EnsureSeason(OutGameData db, SaveData d, DateTime nowUtc, System.Random rng)
{
var a = d.arena;
var start = SaveStore.ParseIso(a.seasonStart, DateTime.MinValue);
if (start == DateTime.MinValue)
{
a.seasonStart = SaveStore.Iso(nowUtc);
a.samples.Clear();
}
else if ((nowUtc - start).TotalDays >= Mathf.Max(1, db.Arena.SeasonDays))
{
int rank = Rank(d);
int gem = rank <= 10 ? db.Arena.RewardTop10Gem : db.Arena.RewardTop100Gem;
d.gem += gem;
a.lastSeason = "지난 시즌 " + rank + "위 · 보옥 +" + gem;
a.seasonStart = SaveStore.Iso(nowUtc);
a.seasonScore = 0;
a.samples.Clear();
}
if (a.samples.Count == 0) a.samples.AddRange(MakeSamples(db, d, rng));
}
public static List MakeSamples(OutGameData db, SaveData d, System.Random rng)
{
var pool = new List();
foreach (var h in db.Heroes.heroes)
if (!h.isSoldier) pool.Add(h);
int n = Mathf.Max(1, db.Arena.SampleLineups);
int top = MaxOwnedLevel(d);
var res = new List();
for (int k = 0; k < n; k++)
{
var s = new ArenaSample { lv = Mathf.Clamp(top + k - n / 2, 1, db.Levels.MaxLevel), score = db.Arena.SampleScoreStep * (k + 1) };
var bag = new List(pool);
for (int i = 0; i < 5 && bag.Count > 0; i++)
{
int j = rng.Next(bag.Count);
s.heroes.Add(bag[j].id);
bag.RemoveAt(j);
}
var lead = db.Heroes.Get(s.heroes[0]);
s.name = (lead != null ? lead.nameKo : "?") + " 군 · Lv " + s.lv;
res.Add(s);
}
return res;
}
/// 내 주간 순위 = 1 + 나보다 점수 높은 샘플 수.
public static int Rank(SaveData d)
{
int r = 1;
foreach (var s in d.arena.samples)
if (s.score > d.arena.seasonScore) r++;
return r;
}
public static string CanArena(OutGameData db, SaveData d, int sample, DateTime nowUtc)
{
if (sample < 0 || sample >= d.arena.samples.Count) return "없는 상대";
if (ArenaLeft(db, d, nowUtc) <= 0) return "오늘 " + db.Arena.EntriesPerDay + "회 완료";
return null;
}
/// 상대 편성 = 샘플 영웅 5인(Lv · 초기 등급 · ★ = 희귀도 · §3-2 공식) · 자리 = 아군 출발 자리의 위아래 반전 · 무장(패시브 Lv1 · EnemyAI 방어).
public static BalanceConfig.StageDef ArenaStage(OutGameData db, BalanceConfig cfg, ArenaSample s)
{
var st = new BalanceConfig.StageDef { title = "투기장 — " + s.name, subtitle = "비동기 5v5", chapter = 0, stage = 0, kind = StageKind.Normal };
for (int i = 0; i < s.heroes.Count && i < cfg.playerSpawnPoints.Length; i++)
{
var h = db.Heroes.Get(s.heroes[i]);
if (h == null) continue;
var hs = new HeroState { id = h.id, lv = s.lv, grade = db.Promotion.FirstGrade, star = Mathf.Clamp(h.rarity, 1, db.Evolve.MaxStar) };
var stats = HeroRules.Compute(db, cfg, h, hs);
var p = cfg.playerSpawnPoints[i];
st.enemies.Add(new BalanceConfig.EnemySpawn
{
name = h.nameKo, type = h.type, position = new Vector2(p.x, -p.y), art = h.art, isGeneral = true,
hpMult = h.hpMult * stats.GrowthOf("hp"), atkMult = h.atkMult * stats.GrowthOf("atk"),
});
}
return st;
}
public static ModeResult ArenaResult(OutGameData db, SaveData d, int sample, bool win, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
d.dailyC.arena++;
int medals = win ? db.Arena.MedalWin : db.Arena.MedalLose;
d.arena.medals += medals;
d.arena.seasonScore += medals;
if (win) d.arena.wins++;
else d.arena.losses++;
var s = sample >= 0 && sample < d.arena.samples.Count ? d.arena.samples[sample] : null;
var r = new ModeResult { kind = Arena, win = win, title = "투기장 " + (win ? "승리" : "패배") };
r.lines.Add((s != null ? s.name : "상대") + " 상대");
r.lines.Add("메달 +" + medals + " (보유 " + d.arena.medals + ")");
r.lines.Add("주간 점수 " + d.arena.seasonScore + " · 순위 " + Rank(d) + "위");
r.lines.Add("오늘 남은 도전 " + ArenaLeft(db, d, nowUtc) + " / " + db.Arena.EntriesPerDay);
return r;
}
/// 메달 상점: shop_price 메달 → 그 영웅 조각 shop_shards(하루 shop_daily 회 · 보유 영웅만).
public static string BuyShards(OutGameData db, SaveData d, string heroId, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
var hs = d.Hero(heroId);
if (hs == null) return "보유 영웅만";
if (d.dailyC.arenaShop >= db.Arena.ShopDaily) return "오늘 " + db.Arena.ShopDaily + "회 완료";
if (d.arena.medals < db.Arena.ShopPrice) return "메달 부족 " + d.arena.medals + " / " + db.Arena.ShopPrice;
d.arena.medals -= db.Arena.ShopPrice;
d.dailyC.arenaShop++;
hs.shards += db.Arena.ShopShards;
return null;
}
// ---------- 적장 토벌 ----------
/// 주 키(현지 · 월요일 시작 · yyyy-ww).
public static string WeekKey(DateTime nowUtc)
{
var local = nowUtc.ToLocalTime().Date;
int week = CultureInfo.InvariantCulture.Calendar.GetWeekOfYear(local, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday);
return local.Year.ToString(CultureInfo.InvariantCulture) + "-" + week.ToString("00", CultureInfo.InvariantCulture);
}
/// 이번 주 보스 = raid_bosses 를 주 번호로 순환 · 주가 바뀌면 누적 피해 초기화.
public static string RaidBoss(OutGameData db, SaveData d, DateTime nowUtc)
{
var bosses = db.Economy.RaidBosses;
if (bosses.Length == 0) return "";
var wk = WeekKey(nowUtc);
int week;
int.TryParse(wk.Substring(wk.Length - 2), NumberStyles.Integer, CultureInfo.InvariantCulture, out week);
var boss = bosses[week % bosses.Length];
if (d.arena.raidWeek != wk || d.arena.raidBoss != boss)
{
d.arena.raidWeek = wk;
d.arena.raidBoss = boss;
d.arena.raidDamage = 0;
}
return boss;
}
public static int RaidLeft(OutGameData db, SaveData d, DateTime nowUtc)
{
RollDaily(db, d, nowUtc);
return Mathf.Max(0, db.Economy.RaidDaily - d.dailyC.raid);
}
public static string CanRaid(OutGameData db, SaveData d, DateTime nowUtc)
{
if (RaidLeft(db, d, nowUtc) <= 0) return "오늘 " + db.Economy.RaidDaily + "회 완료";
return null;
}
/// 보스 편성 = 그 영웅 아트가 보스인 첫 스테이지(없으면 보스 1기) · 보스 hp × raid_hp_mult.
public static BalanceConfig.StageDef RaidStage(OutGameData db, BalanceConfig cfg, string bossId)
{
var h = db.Heroes.Get(bossId);
if (h == null) return null;
string title = "적장 토벌 — " + h.nameKo;
foreach (var s in cfg.stages)
foreach (var e in s.enemies)
if (e.isBoss && e.art == h.art)
{
var c = CopyStage(s, title, 1f, 1f);
foreach (var ce in c.enemies)
if (ce.isBoss && ce.art == h.art) ce.hpMult *= db.Economy.RaidHpMult;
return c;
}
var st = new BalanceConfig.StageDef { title = title, subtitle = "주간 보스", kind = StageKind.Boss };
st.enemies.Add(new BalanceConfig.EnemySpawn { name = h.nameKo, type = h.type, position = new Vector2(0f, 8f), hpMult = h.hpMult * db.Economy.RaidHpMult, atkMult = h.atkMult, isBoss = true, isGeneral = true, art = h.art });
return st;
}
/// 토벌 1회: 보스에게 준 피해 누적 · 명장 상자(raid_chest) · 천기 확률(raid_arcana_pct · 미보유 = 획득 · 보유 = 조각).
public static ModeResult RaidResult(OutGameData db, SaveData d, string bossId, int damage, bool killed, DateTime nowUtc, System.Random rng)
{
RollDaily(db, d, nowUtc);
d.dailyC.raid++;
RaidBoss(db, d, nowUtc);
d.arena.raidDamage += Mathf.Max(0, damage);
var h = db.Heroes.Get(bossId);
var r = new ModeResult { kind = Raid, win = true, title = "적장 토벌 — " + (h != null ? h.nameKo : bossId) };
r.lines.Add("이번 피해 " + damage.ToString("N0") + (killed ? " (격파!)" : ""));
r.lines.Add("이번 주 누적 " + d.arena.raidDamage.ToString("N0"));
var got = TreasureRules.AddChest(db, d, db.Economy.RaidChest, rng);
if (got != null) r.lines.Add(db.Chests.Get(got).nameKo + " 획득");
if (rng.NextDouble() < db.Economy.RaidArcanaPct)
{
bool isNew = false;
int shards = 0;
var top = db.Economy.TreasureGrades;
var id = top.Count > 0 ? TreasureRules.GrantGrade(db, d, top[top.Count - 1].Key, rng, out isNew, out shards) : null;
if (id != null) r.lines.Add("천기 보물 " + db.Treasures.Get(id).nameKo + (isNew ? " 획득!" : " 조각 +" + shards));
}
return r;
}
}
}