using System; using System.Collections.Generic; using System.Globalization; using UnityEngine; namespace TKS.OutGame { /// /// C 단계 표 10장(PD #1093 C · 기획서 §7~§9) 해석·검증 — training · training_ranks · patrol · dev_tree · treasures · treasure_sets · chests · tower · dungeons · arena. /// 검증 = 필수 열 · id 유일 · 연속 Lv/랭크/층 · 참조(세트 ↔ 보물 · 상자 · 장비 등급 · 선행 노드) · 확률 합 1 · 효과 키 처리기(DevRules · BattleEvents) · 오류가 있으면 임포터가 자산을 바꾸지 않는다. /// public static partial class OutGameTables { static void ParseC(Dictionary>> rows, OutGameData d, List e) { d.Training = ParseTraining(rows["training.csv"], rows["training_ranks.csv"], e); d.Patrol = ParsePatrol(rows["patrol.csv"], d, e); d.Dev = ParseDev(rows["dev_tree.csv"], e); d.Chests = ParseChests(rows["chests.csv"], e); d.TreasureSets = ParseSets(rows["treasure_sets.csv"], e); d.Treasures = ParseTreasures(rows["treasures.csv"], d, e); d.Tower = ParseTower(rows["tower.csv"], d, e); d.Dungeons = ParseDungeons(rows["dungeons.csv"], e); d.Arena = ScriptableObject.CreateInstance(); KeyValues(rows["arena.csv"], "arena.csv", ArenaConfig.RequiredKeys, d.Arena.values, e); CheckEconomyC(d, e); } static TrainingTable ParseTraining(List> rows, List> rankRows, List e) { var t = ScriptableObject.CreateInstance(); const string f = "training.csv"; if (Cols(rows, f, e, "unit_type", "stat", "lv", "gold_cost", "stat_pct")) { foreach (var r in rows) { UnitType type; if (!UnitTypeInfo.TryParseKey(Get(r, "unit_type"), out type)) { e.Add(f + " 행 " + Get(r, "#") + ": unit_type 「" + Get(r, "unit_type") + "」 모름"); continue; } var stat = Get(r, "stat"); if (Array.IndexOf(TrainingTable.Stats, stat) < 0) { e.Add(f + " 행 " + Get(r, "#") + ": stat 「" + stat + "」 ≠ hp|atk|def"); continue; } t.rows.Add(new TrainingTable.Row { type = type, stat = stat, lv = Int(r, "lv", f, e), goldCost = Int(r, "gold_cost", f, e), statPct = Float(r, "stat_pct", f, e) }); } int max = t.MaxLevel; foreach (UnitType type in Enum.GetValues(typeof(UnitType))) foreach (var stat in TrainingTable.Stats) for (int lv = 1; lv <= max; lv++) if (t.Get(type, stat, lv) == null) e.Add(f + ": " + UnitTypeInfo.Key(type) + " " + stat + " Lv " + lv + " 없음"); } const string g = "training_ranks.csv"; if (Cols(rankRows, g, e, "rank", "name_ko", "rank_pct", "promote_gold", "cost_mult")) { foreach (var r in rankRows) { var pg = Get(r, "promote_gold"); t.ranks.Add(new TrainingTable.Rank { rank = Int(r, "rank", g, e), nameKo = Get(r, "name_ko"), rankPct = Float(r, "rank_pct", g, e), promoteGold = pg.Length == 0 ? 0 : Int(r, "promote_gold", g, e), costMult = Float(r, "cost_mult", g, e), }); } for (int i = 0; i < t.ranks.Count; i++) { if (t.ranks[i].rank != i + 1) e.Add(g + ": rank 가 1부터 연속이 아님(" + (i + 1) + "번째 = " + t.ranks[i].rank + ")"); bool last = i == t.ranks.Count - 1; if (last != (t.ranks[i].promoteGold == 0)) e.Add(g + ": 랭크 " + t.ranks[i].rank + " promote_gold — 최종 랭크만 비어야 함"); } } return t; } static PatrolTable ParsePatrol(List> rows, OutGameData d, List e) { var t = ScriptableObject.CreateInstance(); const string f = "patrol.csv"; if (!Cols(rows, f, e, "chapter", "gold_per_hour", "exp_per_hour", "gear_grade", "chest_bronze_per_hour")) return t; var seen = new HashSet(); foreach (var r in rows) { var row = new PatrolTable.Row { chapter = Int(r, "chapter", f, e), goldPerHour = Int(r, "gold_per_hour", f, e), expPerHour = Int(r, "exp_per_hour", f, e), gearGrade = Get(r, "gear_grade"), chestPerHour = Float(r, "chest_bronze_per_hour", f, e), }; if (!Unique(seen, row.chapter.ToString(CultureInfo.InvariantCulture), f, r, e)) continue; if (d.Gear == null || d.Gear.OrderOf(row.gearGrade) == 0) e.Add(f + ": 챕터 " + row.chapter + " gear_grade 「" + row.gearGrade + "」 가 gear_items.csv 등급에 없음"); t.rows.Add(row); } return t; } static DevTree ParseDev(List> rows, List e) { var t = ScriptableObject.CreateInstance(); const string f = "dev_tree.csv"; if (!Cols(rows, f, e, "branch", "node_id", "name_ko", "kind", "effect_key", "pct_per_lv", "lv_max", "gold_base", "gold_growth", "blueprint_from_lv", "blueprint_per_lv", "requires")) return t; var seen = new HashSet(); foreach (var r in rows) { var n = new DevTree.Node { branch = Get(r, "branch"), id = Get(r, "node_id"), nameKo = Get(r, "name_ko"), kind = Get(r, "kind"), effectKey = Get(r, "effect_key"), pctPerLv = Float(r, "pct_per_lv", f, e), lvMax = Int(r, "lv_max", f, e), goldBase = Int(r, "gold_base", f, e), goldGrowth = Float(r, "gold_growth", f, e), blueprintFromLv = Int(r, "blueprint_from_lv", f, e), blueprintPerLv = Int(r, "blueprint_per_lv", f, e), }; if (!Unique(seen, n.id, f, r, e)) continue; string at = f + " " + n.id; if (n.kind != "small" && n.kind != "big") e.Add(at + ": kind 「" + n.kind + "」 ≠ small|big"); if (n.lvMax < 1) e.Add(at + ": lv_max < 1"); if (n.EffectKeys.Length == 0) e.Add(at + ": effect_key 비었음"); foreach (var k in n.EffectKeys) if (!DevRules.Known(k)) e.Add(at + ": effect_key 「" + k + "」 처리기 없음"); foreach (var part in Get(r, "requires").Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) { var kv = part.Split(':'); int lv; if (kv.Length != 2 || !int.TryParse(kv[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out lv)) { e.Add(at + ": requires 「" + part + "」 ≠ node:lv"); continue; } var pre = t.Get(kv[0]); // 선행 = 앞 행(순환 없음) if (pre == null) e.Add(at + ": requires 「" + kv[0] + "」 가 앞 행에 없음"); else if (pre.branch != n.branch) e.Add(at + ": requires 「" + kv[0] + "」 가 다른 갈래"); else if (lv < 1 || lv > pre.lvMax) e.Add(at + ": requires " + kv[0] + " Lv " + lv + " > lv_max " + pre.lvMax); n.requires.Add(new DevTree.Req { node = kv[0], lv = lv }); } t.nodes.Add(n); } return t; } static ChestDb ParseChests(List> rows, List e) { var t = ScriptableObject.CreateInstance(); const string f = "chests.csv"; if (!Cols(rows, f, e, "id", "name_ko", "points", "p_rare", "p_epic", "p_legend", "p_myth", "gold_min", "gold_max", "gem_min", "gem_max", "source", "iron_min", "iron_max", "buy_gold", "buy_gem", "buy_points")) return t; var seen = new HashSet(); foreach (var r in rows) { var c = new ChestDb.Chest { id = Get(r, "id"), nameKo = Get(r, "name_ko"), points = Int(r, "points", f, e), goldMin = Int(r, "gold_min", f, e), goldMax = Int(r, "gold_max", f, e), gemMin = Int(r, "gem_min", f, e), gemMax = Int(r, "gem_max", f, e), ironMin = Int(r, "iron_min", f, e), ironMax = Int(r, "iron_max", f, e), source = Get(r, "source"), buyGold = Int(r, "buy_gold", f, e), buyGem = Int(r, "buy_gem", f, e), buyPoints = Int(r, "buy_points", f, e), }; for (int i = 0; i < ChestDb.ProbGrades.Length; i++) c.p[i] = Float(r, "p_" + ChestDb.ProbGrades[i], f, e); if (!Unique(seen, c.id, f, r, e)) continue; float sum = 0f; foreach (var p in c.p) sum += p; if (Mathf.Abs(sum - 1f) > 1e-4f) e.Add(f + ": " + c.id + " 등급 확률 합 = " + sum.ToString("0.####", CultureInfo.InvariantCulture) + " ≠ 1"); if (c.goldMin > c.goldMax || c.gemMin > c.gemMax || c.ironMin > c.ironMax) e.Add(f + ": " + c.id + " min > max"); t.chests.Add(c); } return t; } static TreasureSetDb ParseSets(List> rows, List e) { var t = ScriptableObject.CreateInstance(); const string f = "treasure_sets.csv"; if (!Cols(rows, f, e, "set_id", "name_ko", "members", "tier_cond", "effect_key", "pct")) return t; foreach (var r in rows) { var tier = new TreasureSetDb.Tier { setId = Get(r, "set_id"), nameKo = Get(r, "name_ko"), tierCond = Get(r, "tier_cond"), effectKey = Get(r, "effect_key"), pct = Float(r, "pct", f, e) }; tier.members.AddRange(Get(r, "members").Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)); string at = f + " 행 " + Get(r, "#") + " " + tier.setId; if (tier.tierCond == "set") tier.stars = 0; else if (!tier.tierCond.EndsWith("star", StringComparison.Ordinal) || !int.TryParse(tier.tierCond.Substring(0, tier.tierCond.Length - 4), NumberStyles.Integer, CultureInfo.InvariantCulture, out tier.stars) || tier.stars < 1) e.Add(at + ": tier_cond 「" + tier.tierCond + "」 ≠ set|Nstar"); if (Array.IndexOf(TreasureRules.SetEffectKeys, tier.effectKey) < 0) e.Add(at + ": effect_key 「" + tier.effectKey + "」 ≠ hp_pct|atk_pct|def_pct"); if (tier.members.Count == 0) e.Add(at + ": members 비었음"); foreach (var o in t.tiers) { if (o.setId != tier.setId) continue; if (o.tierCond == tier.tierCond) e.Add(at + ": tier_cond 「" + tier.tierCond + "」 중복"); if (string.Join(";", o.members.ToArray()) != string.Join(";", tier.members.ToArray())) e.Add(at + ": 같은 세트의 members 가 행마다 다름"); } t.tiers.Add(tier); } return t; } static TreasureDb ParseTreasures(List> rows, OutGameData d, List e) { var t = ScriptableObject.CreateInstance(); const string f = "treasures.csv"; if (!Cols(rows, f, e, "id", "name_ko", "grade", "set_id", "base_stat", "base_pct", "cond_key", "cond_every", "cond_pct", "cond_max", "star_max", "shards_dup")) return t; var seen = new HashSet(); foreach (var r in rows) { var it = new TreasureDb.Item { id = Get(r, "id"), nameKo = Get(r, "name_ko"), grade = Get(r, "grade"), setId = Get(r, "set_id"), baseStat = Get(r, "base_stat"), basePct = Float(r, "base_pct", f, e), condKey = Get(r, "cond_key"), condEvery = Int(r, "cond_every", f, e), condPct = Float(r, "cond_pct", f, e), condMax = Int(r, "cond_max", f, e), starMax = Int(r, "star_max", f, e), shardsDup = Int(r, "shards_dup", f, e), }; if (!Unique(seen, it.id, f, r, e)) continue; string at = f + " " + it.id; if (Array.IndexOf(TreasureDb.BaseStats, it.baseStat) < 0) e.Add(at + ": base_stat 「" + it.baseStat + "」 ≠ hp|atk|def|crit|range|splash"); if (Array.IndexOf(BattleEvents.Keys, it.condKey) < 0) e.Add(at + ": cond_key 「" + it.condKey + "」 카운터 없음"); if (it.condEvery < 1) e.Add(at + ": cond_every < 1"); if (it.starMax < 1) e.Add(at + ": star_max < 1"); if (it.setId.Length > 0) { var tiers = d.TreasureSets != null ? d.TreasureSets.TiersOf(it.setId) : null; if (tiers == null || tiers.Count == 0) e.Add(at + ": set_id 「" + it.setId + "」 가 treasure_sets.csv 에 없음"); else if (!tiers[0].members.Contains(it.id)) e.Add(at + ": 세트 「" + it.setId + "」 members 에 없음"); } t.items.Add(it); } if (d.TreasureSets != null) foreach (var tier in d.TreasureSets.tiers) foreach (var m in tier.members) { var it = t.Get(m); if (it == null) e.Add("treasure_sets.csv: " + tier.setId + " member 「" + m + "」 가 treasures.csv 에 없음"); else if (it.setId != tier.setId) e.Add("treasure_sets.csv: " + tier.setId + " member 「" + m + "」 의 set_id = " + it.setId); } return t; } static TowerTable ParseTower(List> rows, OutGameData d, List e) { var t = ScriptableObject.CreateInstance(); const string f = "tower.csv"; if (!Cols(rows, f, e, "floor", "hp_mult", "atk_mult", "template_stage", "reward_gem", "reward_blueprint", "chest")) return t; foreach (var r in rows) { var fl = new TowerTable.Floor { floor = Int(r, "floor", f, e), hpMult = Float(r, "hp_mult", f, e), atkMult = Float(r, "atk_mult", f, e), templateStage = Get(r, "template_stage"), rewardGem = Int(r, "reward_gem", f, e), rewardBlueprint = Int(r, "reward_blueprint", f, e), chest = Get(r, "chest"), }; int ch, st; if (!ArenaRules.ParseStageKey(fl.templateStage, out ch, out st)) e.Add(f + ": " + fl.floor + "층 template_stage 「" + fl.templateStage + "」 ≠ 챕터-스테이지"); if (fl.chest.Length > 0 && (d.Chests == null || d.Chests.Get(fl.chest) == null)) e.Add(f + ": " + fl.floor + "층 chest 「" + fl.chest + "」 가 chests.csv 에 없음"); t.floors.Add(fl); } for (int i = 0; i < t.floors.Count; i++) if (t.floors[i].floor != i + 1) e.Add(f + ": floor 가 1부터 연속이 아님(" + (i + 1) + "번째 = " + t.floors[i].floor + ")"); return t; } static DungeonDb ParseDungeons(List> rows, List e) { var t = ScriptableObject.CreateInstance(); const string f = "dungeons.csv"; if (!Cols(rows, f, e, "id", "name_ko", "reward_kind", "weekday", "entries_per_day", "template_stage", "hp_mult", "reward_amount")) return t; var seen = new HashSet(); var sep = new[] { ';' }; foreach (var r in rows) { var dg = new DungeonDb.Dungeon { id = Get(r, "id"), nameKo = Get(r, "name_ko"), entriesPerDay = Int(r, "entries_per_day", f, e), templateStage = Get(r, "template_stage"), hpMult = Float(r, "hp_mult", f, e), }; if (!Unique(seen, dg.id, f, r, e)) continue; string at = f + " " + dg.id; dg.rewardKinds.AddRange(Get(r, "reward_kind").Split(sep, StringSplitOptions.RemoveEmptyEntries)); dg.weekdays.AddRange(Get(r, "weekday").Split(sep, StringSplitOptions.RemoveEmptyEntries)); foreach (var a in Get(r, "reward_amount").Split(sep, StringSplitOptions.RemoveEmptyEntries)) { int v; if (!int.TryParse(a, NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) e.Add(at + ": reward_amount 「" + a + "」 정수 아님"); dg.rewardAmounts.Add(v); } foreach (var k in dg.rewardKinds) if (Array.IndexOf(DungeonDb.RewardKinds, k) < 0) e.Add(at + ": reward_kind 「" + k + "」 모름"); foreach (var w in dg.weekdays) if (Array.IndexOf(DungeonDb.Weekdays, w) < 0) e.Add(at + ": weekday 「" + w + "」 ≠ mon~sun"); if (dg.rewardAmounts.Count != dg.rewardKinds.Count) e.Add(at + ": reward_amount 수 " + dg.rewardAmounts.Count + " ≠ reward_kind 수 " + dg.rewardKinds.Count); int ch, st; if (!ArenaRules.ParseStageKey(dg.templateStage, out ch, out st)) e.Add(at + ": template_stage 「" + dg.templateStage + "」 ≠ 챕터-스테이지"); if (dg.entriesPerDay < 1) e.Add(at + ": entries_per_day < 1"); t.dungeons.Add(dg); } return t; } /// economy.csv C 키 참조(상자 id · 보물 등급 · 적장 영웅). static void CheckEconomyC(OutGameData d, List e) { var ec = d.Economy; if (ec == null || !AllKeys(ec.values, EconomyConfig.RequiredKeys)) return; foreach (var id in new[] { ec.ChestPointReward, ec.StageChest, ec.PatrolChest, ec.RaidChest }) if (d.Chests == null || d.Chests.Get(id) == null) e.Add("economy.csv: 상자 「" + id + "」 가 chests.csv 에 없음"); if (ec.TreasureGrades.Count == 0) e.Add("economy.csv: treasure_iron_mult 「grade:배율;…」 형식 아님"); foreach (var g in ChestDb.ProbGrades) if (ec.TreasureGradeOrder(g) == 0) e.Add("economy.csv: treasure_iron_mult 에 상자 확률 등급 「" + g + "」 없음"); if (d.Treasures != null) foreach (var t in d.Treasures.items) if (ec.TreasureGradeOrder(t.grade) == 0) e.Add("treasures.csv: " + t.id + " grade 「" + t.grade + "」 가 treasure_iron_mult 에 없음"); foreach (var id in ec.RaidBosses) if (d.Heroes == null || d.Heroes.Get(id) == null) e.Add("economy.csv: raid_bosses 「" + id + "」 가 heroes.csv 에 없음"); if (ec.PatrolCapHours <= 0f) e.Add("economy.csv: patrol_cap_hours ≤ 0"); if (ec.ChestPointGoal < 1) e.Add("economy.csv: chest_point_goal < 1"); } } }