ThreeKingdomsSlingshot/Assets/Tests/PlayMode/OutGameCTests.cs

748 lines
41 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
{
/// <summary>
/// 아웃게임 2차(PD #1093 C · 시스템 기획 §7~§9 · §12) — 임포터(C 표 10장 행 수·참조·확률 합·트리 선행 · 깨진 표) · 훈련 공식·랭크 · 순찰 누적·오프라인·광고·즉시 ·
/// 개발 선행·비용·도면·효과 적용·초기화 · 보물 상자 확률·중복 조각·강화·세트 단계·조건 카운터·스탯·전투 효과 · 탑 층·입장권 · 던전 요일·입장 · 투기장 메달·순위·시즌·상점 ·
/// 적장 주간 순환·누적 · 화면(내정·보물·연무장·거래 상자) · 연무장 모드 전투 → 결과 → 복귀 · 전투 이벤트 카운터.
/// 기대값은 전부 표(CSV·자산)에서 계산한다(C45 · 테스트에 수치 상수 없음).
/// </summary>
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>("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<string>();
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<string>(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<string, float>();
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;
}
}
}
}