ThreeKingdomsSlingshot/Assets/Scripts/OutGame/Data/OutGameTables.C.cs

298 lines
19 KiB
C#
Raw Permalink Normal View History

아웃게임 2차(PD #1093 세션 C) — 내정(훈련·순찰·개발) · 보물(수집품·세트·상자) · 연무장(탑·일일 던전·투기장·적장 토벌) · 거래 상자 탭 + 영웅 56명 확장(PD #1096 · 신규 effect_key 4) - 데이터: C 표 10장(training · training_ranks · patrol · dev_tree · treasures · treasure_sets · chests(+iron/buy 열) · tower · dungeons(+reward_amount) · arena(+상점 키)) + economy.csv C 키 24 → 임포트 18 자산(OutGameTables.C.cs 검증: 행·연속 Lv/랭크/층·참조·확률 합 1·선행 노드·효과 키 처리기·카운터 키 · 오류 시 자산 불변) - 영웅 56명: heroes 71 · skills 112 · hero_gear 1344 · gacha_pool 71 재복사 · 신규 키 4(team_dmg_pct_turn · cooldown_add · charm · heal_self_pct) + 기존 키 조건 열(받는 피해 p2 HP 임계/보스/근접 · p3 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) · 대장군 키는 표 미사용(처리기 유지) - 내정: 훈련(병종 5 · 3항목 Lv · cost_mult · 랭크 승급 · train_bonus → §3-2 항목별) · 순찰(챕터 행 × 시간 · 오프라인 · 상한 12h + 농업 · 소수점 이월 · 광고 2배 스텁 · 즉시 수확 보옥) · 개발(갈래 3 · 선행 · 골드·도면 비용 · 효과 키 → 군량·보상·상점·순찰·상자 적용 지점 · 초기화 보옥 + 도면 환급) - 보물: 보유 중(4열 · 등급 색 · 상세·강화) · 세트(단계 효과) · 상자(포인트 → 명장 · 확률 · 중복 조각) · 효과 hp/atk/def → HeroRules · crit/range/splash → BattleManager.SideEffectsProvider → SkillMods · 조건 카운터 8종 = Runtime/BattleEvents(전투 코드 Raise 1줄씩 · 아군만) · 스테이지 청동 상자 10 % - 연무장: BattleManager.StartMode(BattleMode = 적 편성·제한 턴·onExit) · 탑 100층(입장권·층 배율·보상·10층 상자) · 던전 3(요일·2/일·보상) · 투기장(샘플 10 · EnemyAI 방어 · 메달·주간 순위·조각 상점) · 적장 토벌(주간 순환·hp ×20·5턴·누적 피해·명장 상자·천기) · 결과 패널 → 탭 = 연무장 - 거래: 상자 탭(구매 = 골드/보옥/포인트 · 거래 할인) · 패스·월정액·챕터 팩·첫 결제 팩 = 준비 중 스텁 · 스킬서 임시 판매 제거(사당 던전) - 테스트 87/87(기존 73 유지 · 갱신 3 = 로비 탭 · 표 키 목록 · 상점 스킬서 칸 / 신규 14 = OutGameCTests 12 + 확장 키 2) · console error 0 · 캡처 = DataImporter.CaptureC Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:22:54 +00:00
using System;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
namespace TKS.OutGame
{
/// <summary>
/// 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) · 오류가 있으면 임포터가 자산을 바꾸지 않는다.
/// </summary>
public static partial class OutGameTables
{
static void ParseC(Dictionary<string, List<Dictionary<string, string>>> rows, OutGameData d, List<string> 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<ArenaConfig>();
KeyValues(rows["arena.csv"], "arena.csv", ArenaConfig.RequiredKeys, d.Arena.values, e);
CheckEconomyC(d, e);
}
static TrainingTable ParseTraining(List<Dictionary<string, string>> rows, List<Dictionary<string, string>> rankRows, List<string> e)
{
var t = ScriptableObject.CreateInstance<TrainingTable>();
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<Dictionary<string, string>> rows, OutGameData d, List<string> e)
{
var t = ScriptableObject.CreateInstance<PatrolTable>();
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<string>();
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<Dictionary<string, string>> rows, List<string> e)
{
var t = ScriptableObject.CreateInstance<DevTree>();
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<string>();
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<Dictionary<string, string>> rows, List<string> e)
{
var t = ScriptableObject.CreateInstance<ChestDb>();
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<string>();
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<Dictionary<string, string>> rows, List<string> e)
{
var t = ScriptableObject.CreateInstance<TreasureSetDb>();
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<Dictionary<string, string>> rows, OutGameData d, List<string> e)
{
var t = ScriptableObject.CreateInstance<TreasureDb>();
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<string>();
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<Dictionary<string, string>> rows, OutGameData d, List<string> e)
{
var t = ScriptableObject.CreateInstance<TowerTable>();
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<Dictionary<string, string>> rows, List<string> e)
{
var t = ScriptableObject.CreateInstance<DungeonDb>();
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<string>();
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;
}
/// <summary>economy.csv C 키 참조(상자 id · 보물 등급 · 적장 영웅).</summary>
static void CheckEconomyC(OutGameData d, List<string> 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");
}
}
}