using System; using System.Collections; using System.Collections.Generic; using System.IO; using NUnit.Framework; using TKS.OutGame; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace TKS.Tests { /// /// 아웃게임 2차(PD #1093 C · 시스템 기획 §7~§9 · §12) — 임포터(C 표 10장 행 수·참조·확률 합·트리 선행 · 깨진 표) · 훈련 공식·랭크 · 순찰 누적·오프라인·광고·즉시 · /// 개발 선행·비용·도면·효과 적용·초기화 · 보물 상자 확률·중복 조각·강화·세트 단계·조건 카운터·스탯·전투 효과 · 탑 층·입장권 · 던전 요일·입장 · 투기장 메달·순위·시즌·상점 · /// 적장 주간 순환·누적 · 화면(내정·보물·연무장·거래 상자) · 연무장 모드 전투 → 결과 → 복귀 · 전투 이벤트 카운터. /// 기대값은 전부 표(CSV·자산)에서 계산한다(C45 · 테스트에 수치 상수 없음). /// public class OutGameCTests { static readonly DateTime T0 = new DateTime(2026, 11, 5, 3, 0, 0, DateTimeKind.Utc); static readonly string[] CFiles = { "training.csv", "training_ranks.csv", "patrol.csv", "dev_tree.csv", "treasures.csv", "treasure_sets.csv", "chests.csv", "tower.csv", "dungeons.csv", "arena.csv", }; static string[] ReadTables() { var dir = Path.Combine(Application.dataPath, OutGameTables.Folder); var texts = new string[OutGameTables.Files.Length]; for (int i = 0; i < texts.Length; i++) texts[i] = File.ReadAllText(Path.Combine(dir, OutGameTables.Files[i]), System.Text.Encoding.UTF8); return texts; } static int CsvRows(string file) { int n = 0; foreach (var l in File.ReadAllLines(Path.Combine(Application.dataPath, OutGameTables.Folder, file), System.Text.Encoding.UTF8)) if (l.Trim().Length > 0) n++; return n - 1; } static OutGameData Db { get { return OutGameData.Default; } } static BalanceConfig Cfg { get { return Resources.Load("BalanceConfig"); } } static SaveData NewSave() { var d = HeroRules.NewSave(Db, Cfg.playerSpawnPoints.Length, T0); ArenaRules.RollDaily(Db, d, T0); return d; } static DevTree.Node NodeWith(string key) { foreach (var n in Db.Dev.nodes) if (Array.IndexOf(n.EffectKeys, key) >= 0) return n; Assert.Fail("개발 노드에 " + key + " 없음"); return null; } // ---------- 임포터 ---------- [Test] public void Import_CTables_RowsRefsProbs_AssetsMatch() { var r = OutGameTables.Parse(ReadTables()); try { Assert.AreEqual(0, r.errors.Count, string.Join("\n", r.errors.ToArray())); foreach (var f in CFiles) Assert.AreEqual(CsvRows(f), r.rowCounts[f], f + " 행 수"); var d = r.data; Assert.AreEqual(CsvRows("training.csv"), UnitTypeInfo.Count * TrainingTable.Stats.Length * d.Training.MaxLevel, "훈련 = 병종 × 3항목 × Lv"); Assert.AreEqual(CsvRows("training_ranks.csv"), d.Training.MaxRank); Assert.AreEqual(CsvRows("dev_tree.csv"), d.Dev.nodes.Count); Assert.AreEqual(CsvRows("treasures.csv"), d.Treasures.items.Count); Assert.AreEqual(CsvRows("treasure_sets.csv"), d.TreasureSets.tiers.Count); Assert.AreEqual(CsvRows("chests.csv"), d.Chests.chests.Count); Assert.AreEqual(CsvRows("tower.csv"), d.Tower.Top); Assert.AreEqual(CsvRows("dungeons.csv"), d.Dungeons.dungeons.Count); foreach (var c in d.Chests.chests) { float sum = 0f; foreach (var p in c.p) sum += p; Assert.AreEqual(1f, sum, 1e-4f, c.id + " 확률 합 = 1"); } var seen = new HashSet(); foreach (var n in d.Dev.nodes) { foreach (var q in n.requires) Assert.IsTrue(seen.Contains(q.node), n.id + " 선행 = 앞 행 " + q.node); seen.Add(n.id); } foreach (var f in d.Tower.floors) Assert.IsNotNull(ArenaRules.FindStage(Cfg, f.templateStage), f.floor + "층 템플릿 " + f.templateStage); foreach (var g in d.Dungeons.dungeons) Assert.IsNotNull(ArenaRules.FindStage(Cfg, g.templateStage), g.id + " 템플릿 " + g.templateStage); foreach (var t in d.TreasureSets.tiers) foreach (var m in t.members) Assert.AreEqual(t.setId, d.Treasures.Get(m).setId, t.setId + " ↔ " + m); Assert.IsTrue(Db.Complete, "Resources/OutGame 18 자산(TKS/Import OutGame Data)"); Assert.AreEqual(d.Training.rows.Count, Db.Training.rows.Count); Assert.AreEqual(d.Dev.nodes.Count, Db.Dev.nodes.Count); Assert.AreEqual(d.Treasures.items.Count, Db.Treasures.items.Count); Assert.AreEqual(d.Tower.floors.Count, Db.Tower.floors.Count); foreach (var e in d.Arena.values.entries) Assert.AreEqual(e.value, Db.Arena.values.S(e.key), e.key); for (int i = 0; i < d.Chests.chests.Count; i++) Assert.AreEqual(d.Chests.chests[i].buyGem, Db.Chests.chests[i].buyGem, d.Chests.chests[i].id); } finally { OutGameTables.Destroy(r); } } [Test] public void Import_CBroken_ReportsErrors_AssetsUnchanged() { var texts = ReadTables(); int ci = Array.IndexOf(OutGameTables.Files, "chests.csv"); texts[ci] = texts[ci].Replace("bronze,청동 상자,1,0.8,0.2", "bronze,청동 상자,1,0.9,0.2"); // 확률 합 ≠ 1 int di = Array.IndexOf(OutGameTables.Files, "dev_tree.csv"); texts[di] = texts[di].Replace("com_gold_gain:3", "com_nope:3"); // 없는 선행 int ti = Array.IndexOf(OutGameTables.Files, "treasures.csv"); int cut = texts[ti].IndexOf(",boss_kills,", StringComparison.Ordinal); texts[ti] = texts[ti].Substring(0, cut) + ",nope_key," + texts[ti].Substring(cut + ",boss_kills,".Length); // 없는 카운터 int wi = Array.IndexOf(OutGameTables.Files, "tower.csv"); texts[wi] = texts[wi].Replace(",1-1,", ",1-x,"); // 템플릿 형식 var r = OutGameTables.Parse(texts); var all = string.Join("\n", r.errors.ToArray()); OutGameTables.Destroy(r); StringAssert.Contains("bronze", all); StringAssert.Contains("com_nope", all); StringAssert.Contains("nope_key", all); StringAssert.Contains("1-x", all); Assert.AreEqual(CsvRows("treasures.csv"), Db.Treasures.items.Count, "실패한 파싱은 자산을 건드리지 않음"); } // ---------- 훈련 ---------- [Test] public void Training_CostLevelRankPromote_StatFormula() { var d = NewSave(); var T = Db.Training; var t = Db.Heroes.Get(Db.Economy.StartHeroes[0]).type; d.gold = 100000000; Assert.AreEqual(Mathf.RoundToInt(T.Get(t, "hp", 1).goldCost * T.RankOf(1).costMult), TrainingRules.Cost(Db, d, t, 0), "비용 = gold_cost × cost_mult"); int gold = d.gold, spent = 0; for (int i = 0; i < T.MaxLevel; i++) { spent += TrainingRules.Cost(Db, d, t, 0); Assert.IsNull(TrainingRules.Level(Db, d, t, 0), "HP Lv " + (i + 1)); } Assert.AreEqual(gold - spent, d.gold); Assert.AreEqual("Max", TrainingRules.CanLevel(Db, d, t, 0)); Assert.AreEqual(T.Get(t, "hp", T.MaxLevel).statPct + T.RankOf(1).rankPct, TrainingRules.Bonus(Db, d, t, "hp"), 1e-4f); StringAssert.Contains("남은 레벨", TrainingRules.CanPromote(Db, d, t)); for (int s = 1; s < 3; s++) for (int i = 0; i < T.MaxLevel; i++) TrainingRules.Level(Db, d, t, s); Assert.AreEqual(0, TrainingRules.Remaining(Db, d, t)); int pc = TrainingRules.PromoteCost(Db, d, t); Assert.AreEqual(T.RankOf(1).promoteGold, pc); d.gold = pc - 1; StringAssert.Contains("골드", TrainingRules.CanPromote(Db, d, t)); d.gold = pc; Assert.IsNull(TrainingRules.Promote(Db, d, t)); Assert.AreEqual(0, d.gold); Assert.AreEqual(2, TrainingRules.Rank(d, t)); Assert.AreEqual(T.MaxLevel * 3, TrainingRules.Remaining(Db, d, t), "레벨 리셋"); Assert.AreEqual(T.RankOf(2).rankPct, TrainingRules.Bonus(Db, d, t, "hp"), 1e-4f, "랭크 보너스 유지"); Assert.AreEqual(Mathf.RoundToInt(T.Get(t, "hp", 1).goldCost * T.RankOf(2).costMult), TrainingRules.Cost(Db, d, t, 0), "비용 × 1.6^(랭크−1)"); d.gold = 100000000; for (int i = 0; i < 3; i++) TrainingRules.Level(Db, d, t, 1); // §3-2: 그 병종 영웅·병사 전원 × (1 + train(항목)) · 다른 병종 = 0 HeroDb.Hero same = null, other = null; foreach (var h in Db.Heroes.heroes) { if (h.isSoldier) continue; if (h.type == t && same == null) same = h; if (h.type != t && other == null) other = h; } var hs = new HeroState { id = same.id, lv = 1, grade = Db.Promotion.FirstGrade, star = 1 }; var st = HeroRules.Compute(Db, Cfg, same, hs, d); var b = Cfg.GetStats(same.type); Assert.AreEqual(b.maxHp * same.hpMult * st.Core * (1f + TrainingRules.Bonus(Db, d, t, "hp") / 100f), st.hp, 1e-3f); Assert.AreEqual(b.atk * same.atkMult * st.Core * (1f + (T.RankOf(2).rankPct + T.Get(t, "atk", 3).statPct) / 100f), st.atk, 1e-3f); var so = HeroRules.Compute(Db, Cfg, other, new HeroState { id = other.id, lv = 1, grade = Db.Promotion.FirstGrade, star = 1 }, d); Assert.AreEqual(0f, so.trainHpPct + so.trainPct + so.trainDefPct, "다른 병종 = 훈련 없음"); d.Train(t).rank = T.MaxRank; Assert.AreEqual("최종 랭크", TrainingRules.CanPromote(Db, d, t)); } // ---------- 순찰 ---------- [Test] public void Patrol_AccumulateOfflineCap_Harvest_Ad_Instant_DevCap() { var e = Db.Economy; var d = NewSave(); d.chapterMax = 3; var row = Db.Patrol.For(3); Assert.AreEqual(3f, PatrolRules.Hours(Db, d, T0.AddHours(3)), 1e-3f); Assert.AreEqual(e.PatrolCapHours, PatrolRules.Hours(Db, d, T0.AddHours(1000)), 1e-3f, "오프라인 = 상한에서 멈춤"); Assert.AreEqual(Mathf.RoundToInt(row.goldPerHour * 3f), PatrolRules.Preview(Db, d, 3f, 1f).gold); var rng = new System.Random(4); var ids = new List(e.StartHeroes); int gold = d.gold, exp = d.Hero(ids[0]).exp; string why; var y = PatrolRules.Harvest(Db, d, T0.AddHours(5), ids, false, rng, out why); Assert.IsNull(why); Assert.AreEqual(gold + Mathf.RoundToInt(row.goldPerHour * 5f), d.gold, "골드 = 시간당 × 시간"); Assert.AreEqual(exp + Mathf.RoundToInt(row.expPerHour * 5f), d.Hero(ids[0]).exp, "경험 = 출전 편성"); int chests = Mathf.FloorToInt(row.chestPerHour * 5f + 1e-4f); Assert.AreEqual(chests, y.chests); Assert.AreEqual(chests, TreasureRules.ChestCount(d, e.PatrolChest)); Assert.AreEqual(row.chestPerHour * 5f - chests, d.patrol.carryChest, 1e-3f, "소수점 이월"); Assert.AreEqual(Mathf.FloorToInt(e.PatrolGearPerHour * 5f + 1e-4f), y.gear); Assert.AreEqual(1, TreasureRules.Counter(d, BattleEvents.Patrols), "순찰 카운터"); Assert.AreEqual(0f, PatrolRules.Hours(Db, d, T0.AddHours(5)), 1e-4f, "수확 = 타이머 리셋"); Assert.IsNull(PatrolRules.Harvest(Db, d, T0.AddHours(5), ids, false, rng, out why)); Assert.IsNotNull(why); gold = d.gold; var ad = PatrolRules.Harvest(Db, d, T0.AddHours(7), ids, true, rng, out why); Assert.AreEqual(e.PatrolAdMult, ad.mult); Assert.AreEqual(gold + Mathf.RoundToInt(row.goldPerHour * 2f * e.PatrolAdMult), d.gold, "광고 2배"); for (int i = 1; i < e.PatrolAdDaily; i++) PatrolRules.Harvest(Db, d, T0.AddHours(7 + i), ids, true, rng, out why); Assert.IsNull(PatrolRules.Harvest(Db, d, T0.AddHours(9), ids, true, rng, out why), "광고 일일 상한"); var start = d.patrol.startAt; d.gem = e.PatrolInstantGem; var inst = PatrolRules.Instant(Db, d, ids, rng, out why); Assert.IsNotNull(inst); Assert.AreEqual(e.PatrolInstantHours, inst.hours, 1e-4f); Assert.AreEqual(0, d.gem, "즉시 수확 = 보옥"); Assert.AreEqual(start, d.patrol.startAt, "즉시 수확은 타이머 그대로"); Assert.IsNull(PatrolRules.Instant(Db, d, ids, rng, out why), "보옥 부족"); var cap = NodeWith(DevRules.PatrolCap); Counts.Set(d.dev.nodes, cap.id, 2); Assert.AreEqual(e.PatrolCapHours + cap.pctPerLv * 2f / 60f, PatrolRules.CapHours(Db, d), 1e-4f, "농업 순찰 상한 + 분"); } // ---------- 개발 ---------- [Test] public void Dev_RequiresCostBlueprint_EffectsApplied_Reset() { var e = Db.Economy; var d = NewSave(); d.gold = 100000000; d.gem = e.DevResetGem; d.inv.blueprint = 1000; DevTree.Node root = null, child = null; foreach (var n in Db.Dev.nodes) { if (root == null && n.requires.Count == 0) root = n; else if (root != null && child == null && n.requires.Count == 1 && n.requires[0].node == root.id) child = n; } StringAssert.Contains("선행", DevRules.CanUpgrade(Db, d, child.id)); int g0, b0; DevRules.NextCost(root, 0, out g0, out b0); Assert.AreEqual(root.goldBase, g0, "Lv1 = gold_base"); int gold = d.gold, bp = d.inv.blueprint, bpSpent = 0; for (int lv = 0; lv < root.lvMax; lv++) { int g, b; DevRules.NextCost(root, lv, out g, out b); Assert.AreEqual(Mathf.RoundToInt(root.goldBase * Mathf.Pow(root.goldGrowth, lv)), g); Assert.AreEqual(lv + 1 >= root.blueprintFromLv ? root.blueprintPerLv : 0, b, "도면 = blueprint_from_lv 이상"); gold -= g; bpSpent += b; Assert.IsNull(DevRules.Upgrade(Db, d, root.id)); } Assert.AreEqual(gold, d.gold); Assert.AreEqual(bp - bpSpent, d.inv.blueprint); Assert.AreEqual("최고 레벨", DevRules.CanUpgrade(Db, d, root.id)); Assert.IsNull(DevRules.CanUpgrade(Db, d, child.id), "선행 충족"); Assert.AreEqual(root.pctPerLv * root.lvMax, DevRules.Mod(Db, d, root.EffectKeys[0]), 1e-4f, "효과 = pct × Lv"); // 적용 지점: 군량 상한 · 군량 회복 · 스테이지 골드 · 상점 가격 · 무료 새로고침 var cap = NodeWith(DevRules.FoodCap); Counts.Set(d.dev.nodes, cap.id, 3); Assert.AreEqual(e.StaminaMax + Mathf.RoundToInt(cap.pctPerLv * 3), EconomyRules.StaminaMax(Db, d)); var regen = NodeWith(DevRules.FoodRegen); Counts.Set(d.dev.nodes, regen.id, 2); Assert.AreEqual(e.StaminaRegenSec - Mathf.RoundToInt(regen.pctPerLv * 2), EconomyRules.RegenSec(Db, d)); var gg = NodeWith(DevRules.GoldGain); int ggLv = DevRules.Level(d, gg.id); var stage = Cfg.stages[0]; var rw = Db.Rewards.For(stage.chapter); var r = EconomyRules.Compute(Db, d, stage, true, true, 1, false, new System.Random(1)); Assert.AreEqual(Mathf.RoundToInt((rw.goldBase + stage.stage * e.GoldPerStage) * e.FirstClearGoldMult * (1f + gg.pctPerLv * ggLv / 100f)), r.gold, "골드 획득 %"); var disc = NodeWith(DevRules.TradeDiscount); Counts.Set(d.dev.nodes, disc.id, 5); EconomyRules.EnsureShop(Db, d, T0, new System.Random(2)); var it = Db.Gear.Get(d.shop.items[0].gearId); Assert.AreEqual(Mathf.RoundToInt(it.sellGold * e.ShopPriceMult * (1f - disc.pctPerLv * 5 / 100f)), d.shop.items[0].price, "거래 할인"); var market = NodeWith(DevRules.TradeFreeRefresh); Counts.Set(d.dev.nodes, market.id, 1); int gem = d.gem; Assert.AreEqual(Mathf.RoundToInt(market.pctPerLv), EconomyRules.FreeRefreshesLeft(Db, d)); Assert.IsNull(EconomyRules.RefreshShop(Db, d, new System.Random(3))); Assert.AreEqual(gem, d.gem, "시장 개설 = 무료 새로고침"); d.dev.blueprintSpent = bpSpent; bp = d.inv.blueprint; Assert.IsNull(DevRules.Reset(Db, d)); Assert.AreEqual(gem - e.DevResetGem, d.gem, "초기화 = 보옥"); Assert.AreEqual(bp + bpSpent, d.inv.blueprint, "도면 환급"); Assert.AreEqual(0, DevRules.TotalLevels(d)); Assert.AreEqual(e.StaminaMax, EconomyRules.StaminaMax(Db, d)); } // ---------- 보물 ---------- [Test] public void Treasure_ChestProb_Duplicates_Enhance_Sets_Conditions_Effects() { var e = Db.Economy; var d = NewSave(); var rng = new System.Random(1093); var bronze = Db.Chests.Get(e.StageChest); int n = 4000; Counts.Set(d.inv.chests, bronze.id, n); var freq = new int[ChestDb.ProbGrades.Length]; int shards = 0; for (int i = 0; i < n; i++) { var r = TreasureRules.Open(Db, d, bronze.id, rng); Assert.IsNotNull(r); freq[Array.IndexOf(ChestDb.ProbGrades, r.grade)]++; shards += r.shards; Assert.That(r.gold, Is.InRange(bronze.goldMin, bronze.goldMax)); } for (int g = 0; g < freq.Length; g++) Assert.AreEqual(bronze.p[g], freq[g] / (float)n, 0.03f, ChestDb.ProbGrades[g] + " 빈도 ≈ 확률"); Assert.Greater(shards, 0, "중복 = 조각"); Assert.AreEqual(0, TreasureRules.ChestCount(d, bronze.id)); Assert.AreEqual(n * bronze.points / e.ChestPointGoal, TreasureRules.ChestCount(d, e.ChestPointReward), "상자 포인트 → 명장 상자"); Assert.AreEqual(n * bronze.points % e.ChestPointGoal, d.inv.chestPoints); var top = e.TreasureGrades[e.TreasureGrades.Count - 1].Key; foreach (var t in d.inv.treasures) Assert.AreNotEqual(top, Db.Treasures.Get(t.id).grade, "천기 = 상자에서 안 나옴"); // 강화: 조각 shard_per_star × ★ + 운철 iron_per_star × ★ × 등급 배율 · 효과 = 기본 × (1 + star_pct × ★) var st = d.inv.treasures[0]; var item = Db.Treasures.Get(st.id); int ns, ni; TreasureRules.EnhanceCost(Db, item, 1, out ns, out ni); Assert.AreEqual(e.TreasureShardPerStar, ns); Assert.AreEqual(Mathf.RoundToInt(e.TreasureIronPerStar * e.TreasureIronMult(item.grade)), ni); st.star = 0; st.shards = ns; d.inv.iron = ni - 1; StringAssert.Contains("운철", TreasureRules.CanEnhance(Db, d, st.id)); d.inv.iron = ni; Assert.IsNull(TreasureRules.Enhance(Db, d, st.id)); Assert.AreEqual(1, st.star); Assert.AreEqual(0, st.shards); Assert.AreEqual(0, d.inv.iron); Assert.AreEqual(item.basePct * (1f + e.TreasureStarPct), TreasureRules.Value(Db, d, item), 1e-4f); // 조건 효과 = 카운터 cond_every 마다 cond_pct · 최대 cond_max TreasureRules.AddCount(d, item.condKey, item.condEvery * 2); Assert.AreEqual(item.basePct * (1f + e.TreasureStarPct) + 2 * item.condPct, TreasureRules.Value(Db, d, item), 1e-4f); TreasureRules.AddCount(d, item.condKey, item.condEvery * (item.condMax + 5)); Assert.AreEqual(item.condMax, TreasureRules.CondStacks(d, item), "최대 cond_max"); // 세트: 전원 보유 = [세트] · 전원 ★N = [N성] · 가장 높은 단계 1개 var sd = NewSave(); var setId = Db.TreasureSets.SetIds[0]; var tiers = Db.TreasureSets.TiersOf(setId); Assert.IsNull(TreasureRules.SetTier(Db, sd, setId)); int dummy; foreach (var m in tiers[0].members) TreasureRules.Grant(sd, Db.Treasures.Get(m), out dummy); Assert.AreEqual(tiers[0], TreasureRules.SetTier(Db, sd, setId)); foreach (var m in tiers[0].members) sd.inv.Treasure(m).star = tiers[1].stars; Assert.AreEqual(tiers[1], TreasureRules.SetTier(Db, sd, setId)); string stat = tiers[1].effectKey.Replace("_pct", ""); float expect = tiers[1].pct; foreach (var it in Db.Treasures.items) if (it.baseStat == stat && sd.inv.Treasure(it.id) != null) expect += TreasureRules.Value(Db, sd, it); Assert.AreEqual(expect, TreasureRules.StatPct(Db, sd, stat), 1e-4f, "스탯 % = 보물 + 세트"); // §3-2 공식 = × (1 + 보물(항목)) · 전투 진영 효과(crit·range·splash) var h = Db.Heroes.Get(e.StartHeroes[0]); var hs = sd.Hero(h.id); var s1 = HeroRules.Compute(Db, Cfg, h, hs, sd); var b = Cfg.GetStats(h.type); float hpPct = TreasureRules.StatPct(Db, sd, "hp"), atkPct = TreasureRules.StatPct(Db, sd, "atk"); Assert.AreEqual(b.maxHp * h.hpMult * s1.Core * (1f + hpPct / 100f), s1.hp, 1e-3f); Assert.AreEqual(b.atk * h.atkMult * s1.Core * (1f + atkPct / 100f), s1.atk, 1e-3f); foreach (var it in Db.Treasures.items) if (sd.inv.Treasure(it.id) == null) TreasureRules.Grant(sd, it, out dummy); var fx = TreasureRules.BattleEffects(Db, sd); var keys = new Dictionary(); foreach (var x in fx) keys[x.Key] = x.P; Assert.AreEqual(TreasureRules.StatPct(Db, sd, "crit") / 100f, keys[SkillMods.SideCritPct], 1e-5f); Assert.AreEqual(1f + TreasureRules.StatPct(Db, sd, "range") / 100f, keys[SkillKeys.RangeMult], 1e-5f); Assert.AreEqual(1f + TreasureRules.StatPct(Db, sd, "splash") / 100f, keys[SkillKeys.SplashRadiusMult], 1e-5f); // 스테이지 청동 상자 stage_chest_pct · 거래 구매(보옥 · 포인트) int got = 0, m2 = 2000; var rs = new System.Random(9); for (int i = 0; i < m2; i++) if (EconomyRules.Compute(Db, d, Cfg.stages[0], true, true, 1, false, rs).chest == e.StageChest) got++; Assert.AreEqual(e.StageChestPct, got / (float)m2, 0.03f, "스테이지 상자 확률"); ChestDb.Chest gemBuy = null; foreach (var c in Db.Chests.chests) if (c.buyGem > 0 && c.buyPoints > 0) gemBuy = c; int before = TreasureRules.ChestCount(d, gemBuy.id); d.gem = gemBuy.buyGem; Assert.IsNull(TreasureRules.Buy(Db, d, gemBuy.id, "gem")); Assert.AreEqual(0, d.gem); d.inv.chestPoints = gemBuy.buyPoints; Assert.IsNull(TreasureRules.Buy(Db, d, gemBuy.id, "points")); Assert.AreEqual(0, d.inv.chestPoints); Assert.AreEqual(before + 2, TreasureRules.ChestCount(d, gemBuy.id)); Assert.IsNotNull(TreasureRules.Buy(Db, d, gemBuy.id, "gem"), "보옥 부족"); } // ---------- 연무장 ---------- [Test] public void Tower_TicketsFloorRewards_Ad_Daily() { var e = Db.Economy; var d = NewSave(); Assert.AreEqual(e.TowerTicketsDaily, d.arena.towerTickets, "하루 입장권"); Assert.AreEqual(1, ArenaRules.TowerNext(Db, d)); var f1 = Db.Tower.Get(1); var src = ArenaRules.FindStage(Cfg, f1.templateStage); var st = ArenaRules.TowerStage(Db, Cfg, 1); Assert.AreEqual(src.enemies.Count, st.enemies.Count); Assert.AreEqual(src.enemies[0].hpMult * f1.hpMult, st.enemies[0].hpMult, 1e-4f, "층 hp 배율"); Assert.AreEqual(src.enemies[0].atkMult * f1.atkMult, st.enemies[0].atkMult, 1e-4f); Assert.IsNull(ArenaRules.EnterTower(Db, d, T0)); Assert.AreEqual(e.TowerTicketsDaily - 1, d.arena.towerTickets, "입장 = 1장"); ArenaRules.TowerResult(Db, d, 1, false, new System.Random(1)); Assert.AreEqual(0, d.arena.towerFloor, "패배 = 층 유지"); int gem = d.gem, bp = d.inv.blueprint; ArenaRules.TowerResult(Db, d, 1, true, new System.Random(1)); Assert.AreEqual(1, d.arena.towerFloor); Assert.AreEqual(gem + f1.rewardGem, d.gem); Assert.AreEqual(bp + f1.rewardBlueprint, d.inv.blueprint); TowerTable.Floor chestFloor = null; foreach (var f in Db.Tower.floors) if (chestFloor == null && f.chest.Length > 0) chestFloor = f; d.arena.towerFloor = chestFloor.floor - 1; ArenaRules.TowerResult(Db, d, chestFloor.floor, true, new System.Random(1)); Assert.AreEqual(1, TreasureRules.ChestCount(d, chestFloor.chest), chestFloor.floor + "층 상자"); d.arena.towerTickets = 0; Assert.IsNotNull(ArenaRules.CanTower(Db, d, T0), "입장권 없음"); Assert.IsNull(ArenaRules.TowerAd(Db, d, T0)); Assert.AreEqual(e.TowerAdTickets, d.arena.towerTickets); Assert.IsNotNull(ArenaRules.TowerAd(Db, d, T0), "광고 하루 1"); ArenaRules.RollDaily(Db, d, T0.AddDays(1)); Assert.AreEqual(Mathf.Max(e.TowerAdTickets, e.TowerTicketsDaily), d.arena.towerTickets, "다음 날 채움"); d.arena.towerFloor = Db.Tower.Top; Assert.IsNotNull(ArenaRules.CanTower(Db, d, T0.AddDays(1)), "정복 완료"); } [Test] public void Dungeon_WeekdayEntries_Rewards() { foreach (var dg in Db.Dungeons.dungeons) { var d = NewSave(); DateTime open = DateTime.MinValue, closed = DateTime.MinValue; for (int k = 0; k < 7; k++) { var t = T0.AddDays(k); if (ArenaRules.OpenToday(dg, t)) { if (open == DateTime.MinValue) open = t; } else if (closed == DateTime.MinValue) closed = t; } Assert.AreNotEqual(DateTime.MinValue, open, dg.id + " 열리는 요일"); if (closed != DateTime.MinValue) StringAssert.Contains("닫힘", ArenaRules.CanDungeon(Db, d, dg.id, closed)); Assert.AreEqual(dg.entriesPerDay, ArenaRules.DungeonLeft(Db, d, dg, open)); Assert.AreEqual(ArenaRules.FindStage(Cfg, dg.templateStage).enemies[0].hpMult * dg.hpMult, ArenaRules.DungeonStage(Db, Cfg, dg.id).enemies[0].hpMult, 1e-4f, "hp_mult"); ArenaRules.DungeonResult(Db, d, dg.id, false, open, new System.Random(1)); Assert.AreEqual(dg.entriesPerDay, ArenaRules.DungeonLeft(Db, d, dg, open), "패배 = 횟수 유지"); int gold = d.gold, iron = d.inv.iron, books = d.inv.books.skill, shards = 0, gear = 0; foreach (var h in d.heroes) shards += h.shards; foreach (var c in d.inv.gear) gear += c.n; ArenaRules.DungeonResult(Db, d, dg.id, true, open, new System.Random(1)); Assert.AreEqual(dg.entriesPerDay - 1, ArenaRules.DungeonLeft(Db, d, dg, open)); int shards2 = 0, gear2 = 0; foreach (var h in d.heroes) shards2 += h.shards; foreach (var c in d.inv.gear) gear2 += c.n; Assert.AreEqual(gold + dg.Amount("gold"), d.gold, dg.id + " 골드"); Assert.AreEqual(iron + dg.Amount("iron"), d.inv.iron, dg.id + " 운철"); Assert.AreEqual(books + dg.Amount("skill_book"), d.inv.books.skill, dg.id + " 스킬서"); Assert.AreEqual(shards + dg.Amount("shards"), shards2, dg.id + " 조각"); Assert.AreEqual(gear + dg.Amount("gear"), gear2, dg.id + " 장비"); for (int i = 1; i < dg.entriesPerDay; i++) ArenaRules.DungeonResult(Db, d, dg.id, true, open, new System.Random(1)); StringAssert.Contains("완료", ArenaRules.CanDungeon(Db, d, dg.id, open)); Assert.IsNull(ArenaRules.CanDungeon(Db, d, dg.id, open.AddDays(7)), "다음 주 같은 요일 = 다시"); } } [Test] public void Arena_SamplesMedalsRankSeason_Shop() { var A = Db.Arena; var d = NewSave(); var rng = new System.Random(5); ArenaRules.EnsureSeason(Db, d, T0, rng); Assert.AreEqual(A.SampleLineups, d.arena.samples.Count, "샘플 상대"); foreach (var s in d.arena.samples) { Assert.AreEqual(Cfg.playerSpawnPoints.Length, s.heroes.Count); foreach (var id in s.heroes) Assert.IsFalse(Db.Heroes.Get(id).isSoldier); } var st = ArenaRules.ArenaStage(Db, Cfg, d.arena.samples[0]); Assert.AreEqual(Cfg.playerSpawnPoints.Length, st.enemies.Count); for (int i = 0; i < st.enemies.Count; i++) { Assert.AreEqual(-Cfg.playerSpawnPoints[i].y, st.enemies[i].position.y, 1e-4f, "자리 = 위아래 반전"); Assert.IsTrue(st.enemies[i].isGeneral); } Assert.AreEqual(d.arena.samples.Count + 1, ArenaRules.Rank(d), "점수 0 = 꼴찌"); ArenaRules.ArenaResult(Db, d, 0, true, T0); ArenaRules.ArenaResult(Db, d, 1, false, T0); Assert.AreEqual(A.MedalWin + A.MedalLose, d.arena.medals); Assert.AreEqual(1, d.arena.wins); Assert.AreEqual(1, d.arena.losses); for (int i = 2; i < A.EntriesPerDay; i++) ArenaRules.ArenaResult(Db, d, 0, true, T0); Assert.IsNotNull(ArenaRules.CanArena(Db, d, 0, T0), "하루 상한"); Assert.IsNull(ArenaRules.CanArena(Db, d, 0, T0.AddDays(1)), "다음 날"); int topScore = 0; foreach (var s in d.arena.samples) topScore = Mathf.Max(topScore, s.score); d.arena.seasonScore = topScore + 1; Assert.AreEqual(1, ArenaRules.Rank(d)); int gem = d.gem; ArenaRules.EnsureSeason(Db, d, T0.AddDays(A.SeasonDays), rng); Assert.AreEqual(gem + A.RewardTop10Gem, d.gem, "시즌 1위 보상"); Assert.AreEqual(0, d.arena.seasonScore, "새 시즌"); Assert.AreEqual(A.SampleLineups, d.arena.samples.Count); var hid = d.heroes[0].id; int sh = d.heroes[0].shards; d.arena.medals = A.ShopPrice; Assert.IsNull(ArenaRules.BuyShards(Db, d, hid, T0.AddDays(A.SeasonDays))); Assert.AreEqual(sh + A.ShopShards, d.Hero(hid).shards, "메달 → 조각"); Assert.AreEqual(0, d.arena.medals); StringAssert.Contains("메달", ArenaRules.BuyShards(Db, d, hid, T0.AddDays(A.SeasonDays))); } [Test] public void Raid_WeeklyBoss_Accumulate_Daily() { var e = Db.Economy; var d = NewSave(); var boss = ArenaRules.RaidBoss(Db, d, T0); Assert.Contains(boss, e.RaidBosses); if (e.RaidBosses.Length > 1) Assert.AreNotEqual(boss, ArenaRules.RaidBoss(Db, NewSave(), T0.AddDays(7)), "주간 순환"); var h = Db.Heroes.Get(boss); var st = ArenaRules.RaidStage(Db, Cfg, boss); BalanceConfig.EnemySpawn b = null, srcB = null; foreach (var x in st.enemies) if (x.isBoss && x.art == h.art) b = x; Assert.IsNotNull(b, "보스 = 그 영웅 아트"); foreach (var s in Cfg.stages) foreach (var x in s.enemies) if (srcB == null && x.isBoss && x.art == h.art) srcB = x; if (srcB != null) Assert.AreEqual(srcB.hpMult * e.RaidHpMult, b.hpMult, 1e-4f, "보스 hp × raid_hp_mult"); ArenaRules.RaidResult(Db, d, boss, 500, false, T0, new System.Random(1)); Assert.AreEqual(500, d.arena.raidDamage); Assert.AreEqual(1, TreasureRules.ChestCount(d, e.RaidChest), "명장 상자"); Assert.IsNotNull(ArenaRules.CanRaid(Db, d, T0), "하루 상한"); var sameWeek = T0; for (int k = 1; k < 7; k++) if (ArenaRules.WeekKey(T0.AddDays(k)) == ArenaRules.WeekKey(T0)) sameWeek = T0.AddDays(k); if (sameWeek != T0) { Assert.IsNull(ArenaRules.CanRaid(Db, d, sameWeek)); ArenaRules.RaidResult(Db, d, boss, 300, false, sameWeek, new System.Random(1)); Assert.AreEqual(800, d.arena.raidDamage, "같은 주 누적"); } ArenaRules.RaidBoss(Db, d, T0.AddDays(7)); Assert.AreEqual(0, d.arena.raidDamage, "주가 바뀌면 초기화"); } // ---------- 씬(화면 · 모드 전투) ---------- OutGameRoot og; BattleManager bm; IEnumerator Boot() { WorldProgress.Reset(); SaveStore.UseMemory(true); SceneManager.LoadScene("Battle"); yield return null; yield return null; og = OutGameRoot.Instance; bm = BattleManager.Instance; Assert.IsNotNull(og); og.ShowLobby(); } [UnityTest] public IEnumerator Screens_Domestic_Treasure_Arena_TradeChest() { yield return Boot(); var s = og.Session; og.OnTab(2); Assert.AreEqual(og.Domestic, og.Current); for (int p = 0; p < DomesticScreen.PageNames.Length; p++) { og.Domestic.SetPage(p); yield return null; } Assert.AreEqual(s.Db.Dev.InBranch(og.Domestic.Branch).Count, og.Domestic.NodeCount, "개발 트리 노드"); og.Domestic.SetBranch(1); yield return null; Assert.AreEqual(s.Db.Dev.InBranch(s.Db.Dev.Branches[1]).Count, og.Domestic.NodeCount, "갈래 ▶"); og.Domestic.NodePopup(s.Db.Dev.InBranch(og.Domestic.Branch)[0].id); Assert.IsTrue(og.ModalVisible, "노드 탭 = 강화 팝업"); og.CloseModal(); og.OnTab(1); Assert.AreEqual(og.Treasures, og.Current); og.Treasures.SetPage(0); yield return null; Assert.AreEqual(s.Db.Treasures.items.Count, og.Treasures.GridCount, "보물 칸 = 전체(미보유 회색)"); og.Treasures.SetPage(1); yield return null; Assert.AreEqual(s.Db.TreasureSets.SetIds.Count, og.Treasures.SetCount, "세트 묶음"); Counts.Set(s.Save.inv.chests, s.Db.Economy.StageChest, 1); og.Treasures.SetPage(2); og.Treasures.SelectChest(s.Db.Economy.StageChest); yield return null; og.Treasures.DoOpen(s.Db.Economy.StageChest); Assert.IsNotNull(og.Treasures.LastOpen, "열기"); Assert.AreEqual(1, s.Save.inv.treasures.Count, "첫 보물"); og.CloseModal(); og.OnTab(4); og.Trade.SetPage(1); yield return null; Assert.AreEqual(s.Db.Chests.chests.Count, og.Trade.ChestRowCount, "거래 > 상자"); og.ShowLobby(); og.Arena.SetPage(0); og.Show(og.Arena); yield return null; Assert.AreEqual(og.Arena, og.Current); Assert.AreEqual(s.Db.Arena.SampleLineups, s.Save.arena.samples.Count, "연무장 진입 = 시즌·샘플"); for (int p = 1; p <= 4; p++) { og.Arena.SetPage(p); yield return null; } og.Back(); Assert.AreEqual(0, og.Arena.Page, "모드 ◀ = 카드"); og.Back(); Assert.IsTrue(og.LobbyVisible); } [UnityTest] public IEnumerator ModeBattle_Tower_Raid_Result_Return_Counters_SideEffects() { yield return Boot(); var s = og.Session; Time.timeScale = 20f; try { // 탑 1층: 입장권 1장 → 모드 전투(층 배율 적) → 적 전멸 = 승리 → 결과 패널 → 탭 = 연무장 탑 페이지 Assert.IsNull(s.Do(() => ArenaRules.EnterTower(s.Db, s.Save, s.Now))); var stage = ArenaRules.TowerStage(s.Db, bm.Config, 1); og.StartMode(ArenaRules.Tower, "", 1, stage, 0, 1); yield return null; Assert.IsNotNull(bm.Mode); Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase); Assert.AreEqual(stage.enemies.Count, bm.AliveCount(Faction.Enemy)); Assert.Greater(bm.AliveCount(Faction.Player), 0, "아군 = 출전 편성"); // 전투 이벤트 카운터: 아군만 센다 var p0 = bm.AliveUnits(Faction.Player)[0]; int au = TreasureRules.Counter(s.Save, BattleEvents.ActiveUses); BattleEvents.Raise(BattleEvents.ActiveUses, p0); BattleEvents.Raise(BattleEvents.ActiveUses, bm.AliveUnits(Faction.Enemy)[0]); Assert.AreEqual(au + 1, TreasureRules.Counter(s.Save, BattleEvents.ActiveUses), "적 행동은 세지 않음"); int battles = TreasureRules.Counter(s.Save, BattleEvents.Battles); foreach (var u in bm.AliveUnits(Faction.Enemy)) u.TakeDamage(u.Hp); bm.CheckBattleEnd(); yield return null; Assert.AreEqual(BattlePhase.StageClear, bm.Phase); Assert.IsTrue(og.ResultVisible, "결과 패널"); Assert.AreEqual(1, s.Save.arena.towerFloor, "탑 1층 클리어"); Assert.IsTrue(s.LastMode.win); Assert.AreEqual(battles + 1, TreasureRules.Counter(s.Save, BattleEvents.Battles), "전투 카운터"); bm.ContinueAfterResult(); yield return null; Assert.IsNull(bm.Mode); Assert.AreEqual(BattlePhase.Setup, bm.Phase); Assert.AreEqual(og.Arena, og.Current, "탭 = 연무장"); Assert.AreEqual(1, og.Arena.Page, "탑 페이지로"); // 적장 토벌: 제한 1턴 → 적 턴을 마치면 끝 · 보스에게 준 피해 누적 var boss = ArenaRules.RaidBoss(s.Db, s.Save, s.Now); og.StartMode(ArenaRules.Raid, boss, 0, ArenaRules.RaidStage(s.Db, bm.Config, boss), 1, 4); yield return null; Unit bu = null; foreach (var u in bm.AliveUnits(Faction.Enemy)) if (u.IsBoss) bu = u; Assert.IsNotNull(bu); bu.TakeDamage(100); yield return bm.RunEnemyTurn(); Assert.AreEqual(BattlePhase.Defeat, bm.Phase, "제한 턴 = 끝"); yield return null; int dealt = 0; foreach (var u in bm.Units) if (u != null && u.Faction == Faction.Enemy && u.IsBoss) dealt += u.MaxHp - u.Hp; Assert.GreaterOrEqual(dealt, 100); Assert.AreEqual(dealt, s.Save.arena.raidDamage, "보스 피해 누적"); Assert.GreaterOrEqual(TreasureRules.ChestCount(s.Save, s.Db.Economy.RaidChest), 1, "명장 상자"); bm.ContinueAfterResult(); yield return null; Assert.AreEqual(4, og.Arena.Page); // 보물 crit = 진영 상시 효과 → 크리 배율 TreasureDb.Item crit = null; foreach (var it in s.Db.Treasures.items) if (crit == null && it.baseStat == "crit") crit = it; int dummy; TreasureRules.Grant(s.Save, crit, out dummy); bm.StartStage(0); yield return null; var pu = bm.AliveUnits(Faction.Player)[0]; Assert.AreEqual(bm.Config.critMultiplier * (1f + TreasureRules.StatPct(s.Db, s.Save, "crit") / 100f), SkillMods.CritMult(pu, bm.Config), 1e-4f, "보물 크리"); Assert.AreEqual(bm.Config.critMultiplier, SkillMods.CritMult(bm.AliveUnits(Faction.Enemy)[0], bm.Config), 1e-4f, "적 = 미적용"); } finally { Time.timeScale = 1f; } } } }