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