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