ThreeKingdomsSlingshot/Assets/Tests/PlayMode/OutGameTests.cs

658 lines
32 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>
/// 아웃게임 1차(2026-11-05 #1093 · 시스템 기획 §12) — 임포터(행 수·필수 열·id 유일·참조 무결) · 공식(표 값으로 기대치) · 군량 회복·오프라인 ·
/// 승급 6칸 · 진화 조각 · 뽑기 확률·천장·10연 보장 · 보상 공식·별 · 편성 → 스폰 · 세이브 왕복 · 로비 탭·뒤로 · 군량 입장·결과·소탕 · 상점.
/// 기대값은 전부 표(CSV·자산)에서 계산한다(C45 · 테스트에 수치 상수 없음).
/// </summary>
public class OutGameTests
{
static readonly DateTime T0 = new DateTime(2026, 11, 5, 3, 0, 0, DateTimeKind.Utc);
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()
{
return HeroRules.NewSave(Db, Cfg.playerSpawnPoints.Length, T0);
}
// ---------- 임포터 ----------
[Test]
public void Import_Tables_RowCounts_Columns_UniqueIds_References()
{
var r = OutGameTables.Parse(ReadTables());
try
{
Assert.AreEqual(0, r.errors.Count, string.Join("\n", r.errors.ToArray()));
foreach (var f in OutGameTables.Files) Assert.AreEqual(CsvRows(f), r.rowCounts[f], f + " 행 수");
var d = r.data;
Assert.AreEqual(CsvRows("heroes.csv"), d.Heroes.heroes.Count);
Assert.AreEqual(CsvRows("skills.csv"), d.Skills.skills.Count);
Assert.AreEqual(CsvRows("gear_items.csv"), d.Gear.items.Count);
Assert.AreEqual(CsvRows("hero_levels.csv"), d.Levels.rows.Count);
int gearRows = 0;
foreach (var h in d.Heroes.heroes)
{
foreach (var sid in new[] { h.skillActive, h.skillPassive })
if (sid.Length > 0) Assert.AreEqual(h.id, d.Skills.Get(sid).heroId, h.id + " ↔ " + sid);
foreach (var set in h.gear)
foreach (var g in set.slots)
{
Assert.IsNotNull(d.Gear.Get(g), h.id + " " + set.grade + " " + g);
gearRows++;
}
if (h.isSoldier) Assert.AreEqual(0, h.gear.Count, h.id + " 병사 = 승급 없음");
}
Assert.AreEqual(CsvRows("hero_gear.csv"), gearRows, "hero_gear 행 = 영웅 × 등급 × 칸");
foreach (var p in d.Gacha.pool) Assert.IsNotNull(d.Heroes.Get(p.heroId), "뽑기 풀 " + p.heroId);
foreach (var id in d.Economy.StartHeroes) Assert.IsNotNull(d.Heroes.Get(id), "시작 영웅 " + id);
Assert.AreEqual(1, d.Promotion.Get(d.Promotion.FirstGrade).order, "초기 등급 = order 1");
}
finally
{
OutGameTables.Destroy(r);
}
}
[Test]
public void Import_BrokenTable_ReportsErrors()
{
var texts = ReadTables();
int hi = Array.IndexOf(OutGameTables.Files, "heroes.csv");
var lines = new List<string>(texts[hi].Split('\n'));
lines.Insert(2, lines[1]); // 첫 영웅 행 중복
texts[hi] = string.Join("\n", lines.ToArray());
int si = Array.IndexOf(OutGameTables.Files, "skills.csv");
texts[si] = texts[si].Replace("cooldown", "cool_down"); // 필수 열 이름 틀림
int gi = Array.IndexOf(OutGameTables.Files, "hero_gear.csv");
int cut = texts[gi].IndexOf("gear_", texts[gi].IndexOf('\n') + 1, StringComparison.Ordinal); // 머리줄(gear_id) 다음 첫 데이터 행
texts[gi] = texts[gi].Substring(0, cut) + "gear_nope_" + texts[gi].Substring(cut + 5); // 없는 장비 참조
var r = OutGameTables.Parse(texts);
var all = string.Join("\n", r.errors.ToArray());
OutGameTables.Destroy(r);
StringAssert.Contains("중복", all);
StringAssert.Contains("cooldown", all);
StringAssert.Contains("gear_nope_", all);
Assert.AreEqual(CsvRows("heroes.csv"), Db.Heroes.heroes.Count, "실패한 파싱은 자산을 건드리지 않음");
}
[Test]
public void Import_AssetsMatchCsv()
{
Assert.IsTrue(Db.Complete, "Resources/OutGame 9 자산(TKS/Import OutGame Data)");
var r = OutGameTables.Parse(ReadTables());
try
{
var p = r.data;
Assert.AreEqual(p.Heroes.heroes.Count, Db.Heroes.heroes.Count);
for (int i = 0; i < p.Heroes.heroes.Count; i++)
{
var a = p.Heroes.heroes[i];
var b = Db.Heroes.heroes[i];
Assert.AreEqual(a.id, b.id);
Assert.AreEqual(a.type, b.type, a.id);
Assert.AreEqual(a.hpMult, b.hpMult, 1e-6f, a.id);
Assert.AreEqual(a.atkMult, b.atkMult, 1e-6f, a.id);
Assert.AreEqual(a.defMult, b.defMult, 1e-6f, a.id);
Assert.AreEqual(a.art, b.art, a.id);
Assert.AreEqual(a.gear.Count, b.gear.Count, a.id);
}
Assert.AreEqual(p.Gear.items.Count, Db.Gear.items.Count);
Assert.AreEqual(p.Skills.skills.Count, Db.Skills.skills.Count);
Assert.AreEqual(p.Levels.rows.Count, Db.Levels.rows.Count);
Assert.AreEqual(p.Rewards.rows.Count, Db.Rewards.rows.Count);
Assert.AreEqual(p.Economy.values.entries.Count, Db.Economy.values.entries.Count);
foreach (var e in p.Economy.values.entries) Assert.AreEqual(e.value, Db.Economy.values.S(e.key), e.key);
foreach (var e in p.Gacha.values.entries) Assert.AreEqual(e.value, Db.Gacha.values.S(e.key), e.key);
}
finally
{
OutGameTables.Destroy(r);
}
}
// ---------- 공식 · 성장 ----------
[Test]
public void Stats_Formula_FromTables()
{
var cfg = Cfg;
var h = Db.Heroes.Get(Db.Economy.StartHeroes[1]);
string grade = Db.Promotion.Get(Db.Promotion.FirstGrade).nextGrade;
var s = new HeroState { id = h.id, lv = 10, grade = grade, gearTotal = 6, star = 4 };
var st = HeroRules.Compute(Db, cfg, h, s);
var b = cfg.GetStats(h.type);
float g = (1f + Db.Levels.Get(10).statPct / 100f) * (1f + (Db.Promotion.Get(grade).promoPct + 6 * Db.Economy.GearBonusPct) / 100f) * (1f + Db.Evolve.Get(4).starPct / 100f);
Assert.AreEqual(b.maxHp * h.hpMult * g, st.hp, 1e-3f);
Assert.AreEqual(b.atk * h.atkMult * g, st.atk, 1e-3f);
Assert.AreEqual(b.def * h.defMult * g, st.def, 1e-3f);
var e = Db.Economy;
Assert.AreEqual(Mathf.RoundToInt(st.hp / e.PowerHpDiv + st.atk * e.PowerAtkW + st.def * e.PowerDefW), st.power, "전투력 = hp/10 + 공×3 + 방×2");
Assert.AreEqual(0f, st.trainPct, "훈련 = C 단계");
HeroDb.Hero soldier = null;
foreach (var x in Db.Heroes.heroes)
if (x.isSoldier) { soldier = x; break; }
var ss = HeroRules.Compute(Db, cfg, soldier, new UnitState { id = soldier.id, lv = 1 });
Assert.AreEqual(0f, ss.promoPct + ss.gearPct + ss.starPct, "병사 = 승급·장비·진화 없음");
Assert.AreEqual(cfg.GetStats(soldier.type).maxHp * soldier.hpMult * (1f + Db.Levels.Get(1).statPct / 100f), ss.hp, 1e-3f);
}
[Test]
public void Level_GoldExp_ChapterCap()
{
var d = NewSave();
Assert.AreEqual(Db.Economy.StartGold, d.gold);
Assert.AreEqual(Db.Economy.StartGem, d.gem);
Assert.AreEqual(Db.Economy.StartStamina, d.stamina);
var hs = d.Hero(Db.Economy.StartHeroes[0]);
Assert.AreEqual(1, hs.lv);
Assert.AreEqual(Db.Promotion.FirstGrade, hs.grade);
Assert.AreEqual(Db.Heroes.Get(hs.id).rarity, hs.star, "★ = 희귀도");
StringAssert.Contains("경험", HeroRules.CanLevelUp(Db, d, hs));
var row = Db.Levels.Get(1);
HeroRules.GrantExp(hs, row.expToNext);
int gold = d.gold;
Assert.IsTrue(HeroRules.LevelUp(Db, d, hs));
Assert.AreEqual(2, hs.lv);
Assert.AreEqual(gold - row.goldCost, d.gold);
Assert.AreEqual(0, hs.exp);
int cap = Db.Levels.CapFor(1);
Assert.AreEqual(cap, HeroRules.LevelCap(Db, d), "클리어 최고 챕터 1 = 상한");
hs.lv = cap;
hs.exp = 1000000;
d.gold = 1000000;
StringAssert.Contains("상한", HeroRules.CanLevelUp(Db, d, hs));
d.chapterMax = HeroRules.NextCapChapter(Db, cap);
Assert.IsNull(HeroRules.CanLevelUp(Db, d, hs), "다음 챕터 클리어 → 상한 해제");
d.gold = 0;
StringAssert.Contains("골드", HeroRules.CanLevelUp(Db, d, hs));
}
[Test]
public void Stamina_RegenOffline_Cap_GemAndAd()
{
var e = Db.Economy;
var d = NewSave();
d.stamina = 10;
d.staminaAt = SaveStore.Iso(T0);
EconomyRules.TickStamina(Db, d, T0.AddSeconds(3 * e.StaminaRegenSec + 5));
Assert.AreEqual(13, d.stamina, "regen 초마다 1");
Assert.AreEqual(e.StaminaRegenSec - 5, EconomyRules.SecondsToNext(Db, d, T0.AddSeconds(3 * e.StaminaRegenSec + 5)), "남은 초");
var later = T0.AddSeconds(1000.0 * e.StaminaRegenSec); // 오프라인 오래 → 최대에서 멈춤
EconomyRules.TickStamina(Db, d, later);
Assert.AreEqual(e.StaminaMax, d.stamina);
Assert.IsTrue(EconomyRules.SpendStamina(Db, d, e.StageCost, later));
Assert.AreEqual(e.StaminaMax - e.StageCost, d.stamina);
Assert.AreEqual(e.StaminaRegenSec, EconomyRules.SecondsToNext(Db, d, later), "가득 찬 상태에서 쓰면 그때부터 회복");
d.stamina = 0;
Assert.IsFalse(EconomyRules.SpendStamina(Db, d, e.StageCost, later), "부족 = 출전 불가");
d.gem = e.GemStaminaCost * (e.GemStaminaDaily + 1);
for (int i = 0; i < e.GemStaminaDaily; i++) Assert.IsNull(EconomyRules.BuyStaminaWithGem(Db, d, later));
Assert.AreEqual(e.GemStaminaAmount * e.GemStaminaDaily, d.stamina);
Assert.IsNotNull(EconomyRules.BuyStaminaWithGem(Db, d, later), "보옥 구매 일일 상한");
for (int i = 0; i < e.AdStaminaDaily; i++) Assert.IsNull(EconomyRules.AdStamina(Db, d, later));
Assert.IsNotNull(EconomyRules.AdStamina(Db, d, later), "광고 일일 상한");
Assert.IsNull(EconomyRules.AdStamina(Db, d, later.AddDays(1)), "다음 날 초기화");
}
[Test]
public void Promote_SixSlots_GradeUp_ConsumesGear()
{
var d = NewSave();
var h = Db.Heroes.Get(Db.Economy.StartHeroes[0]);
var hs = d.Hero(h.id);
string g0 = hs.grade;
var need = Db.Heroes.GearFor(h, g0);
Assert.IsNotNull(need);
Assert.AreEqual(HeroRules.GearSlotCount(Db), need.Count);
Assert.AreEqual("장비 없음", HeroRules.CanEquip(Db, d, h, hs, 0));
StringAssert.Contains("장비 0", HeroRules.CanPromote(Db, h, hs));
foreach (var id in need) d.inv.AddGear(id, 1);
for (int i = 0; i < need.Count; i++) Assert.IsTrue(HeroRules.Equip(Db, d, h, hs, i), "칸 " + i);
Assert.AreEqual(need.Count, hs.gearTotal, "1칸 = 1강(누적)");
foreach (var id in need) Assert.AreEqual(0, d.inv.GearCount(id), "가방에서 빠짐");
var before = HeroRules.Compute(Db, Cfg, h, hs);
Assert.IsTrue(HeroRules.Promote(Db, h, hs));
Assert.AreEqual(Db.Promotion.Get(g0).nextGrade, hs.grade, "등급 +1");
foreach (var g in hs.gear) Assert.AreEqual("", g, "6칸 비움");
Assert.AreEqual(need.Count, hs.gearTotal, "누적 장비 보너스 유지");
var after = HeroRules.Compute(Db, Cfg, h, hs);
Assert.AreEqual(Db.Promotion.Get(hs.grade).promoPct, after.promoPct);
Assert.Greater(after.hp, before.hp);
Assert.AreEqual(Db.Promotion.Get(hs.grade).order, HeroRules.SkillCap(Db, hs), "스킬 상한 = 등급 순서");
}
[Test]
public void Evolve_ShardsToNextStar()
{
var d = NewSave();
HeroDb.Hero h = null;
foreach (var x in Db.Heroes.heroes)
if (!x.isSoldier && x.rarity == 1) { h = x; break; }
HeroRules.Grant(Db, d, h, HeroRules.GearSlotCount(Db));
var hs = d.Hero(h.id);
Assert.AreEqual(1, hs.star);
int need = Db.Evolve.Get(2).shardsNeeded;
hs.shards = need - 1;
Assert.IsNotNull(HeroRules.CanEvolve(Db, h, hs));
hs.shards = need;
Assert.IsTrue(HeroRules.Evolve(Db, h, hs));
Assert.AreEqual(2, hs.star);
Assert.AreEqual(0, hs.shards);
Assert.AreEqual(Db.Evolve.Get(2).starPct, HeroRules.Compute(Db, Cfg, h, hs).starPct);
hs.star = Db.Evolve.MaxStar;
hs.shards = 100000;
Assert.AreEqual("최고 ★", HeroRules.CanEvolve(Db, h, hs));
}
// ---------- 뽑기 ----------
[Test]
public void Gacha_ProbSumOne_Pity_TenGuarantee_Duplicates()
{
var g = Db.Gacha;
Assert.AreEqual(1f, g.P(1) + g.P(2) + g.P(3), 1e-4f, "확률 합 = 1");
var rng = new System.Random(1093);
var d = NewSave();
int gem = d.gem;
Assert.IsTrue(GachaRules.FirstTenFree(Db, d));
var ten = GachaRules.Draw(Db, d, g.TenCount, rng);
Assert.AreEqual(g.TenCount, ten.Count);
Assert.AreEqual(gem, d.gem, "첫 10연 무료");
Assert.IsTrue(d.gacha.firstTenUsed);
Assert.AreEqual(g.CostTen, GachaRules.Cost(Db, d, g.TenCount));
d.gem = 100000000;
d.gacha.pity = g.Pity - 1;
var p = GachaRules.Draw(Db, d, 1, rng);
Assert.AreEqual(3, p[0].tier, "천장 = ★★★ 확정");
Assert.IsTrue(p[0].pity);
Assert.AreEqual(0, d.gacha.pity, "획득 시 리셋");
Assert.AreEqual(3, Db.Heroes.Get(p[0].heroId).rarity);
for (int k = 0; k < 200; k++)
{
var batch = GachaRules.Draw(Db, d, g.TenCount, rng);
bool ok = false;
foreach (var x in batch) ok |= x.tier >= g.TenGuaranteeRarity;
Assert.IsTrue(ok, "10연 = ★★ 이상 1 보장");
}
var counts = new int[4];
int n = 20000, shards = 0, gold = 0;
for (int k = 0; k < n; k++)
{
d.gacha.pity = 0;
var x = GachaRules.Draw(Db, d, 1, rng)[0];
counts[x.tier]++;
shards += x.shards;
gold += x.gold;
var h = Db.Heroes.Get(x.heroId);
Assert.AreEqual(h.isSoldier ? 1 : h.rarity, x.tier, x.heroId + " 등급 풀");
}
for (int t = 1; t <= 3; t++) Assert.AreEqual(g.P(t), counts[t] / (float)n, 0.01f, "★" + t + " 빈도 ≈ 확률");
Assert.Greater(shards, 0, "중복 영웅 = 조각");
Assert.Greater(gold, 0, "중복 병사 = 골드");
}
// ---------- 보상 · 별 ----------
static BalanceConfig.StageDef FirstStage(StageKind kind)
{
foreach (var s in Cfg.stages)
if (s.chapter == Cfg.stages[0].chapter && s.kind == kind) return s;
return null;
}
[Test]
public void Reward_FirstRepeatBoss_Stars_Sweep()
{
var e = Db.Economy;
var d = NewSave();
var rng = new System.Random(5);
var s = FirstStage(StageKind.Normal);
var row = Db.Rewards.For(s.chapter);
float baseGold = row.goldBase + s.stage * e.GoldPerStage;
var r = EconomyRules.Compute(Db, d, s, true, true, 1, false, rng);
Assert.IsTrue(r.first);
Assert.AreEqual(Mathf.RoundToInt(baseGold * e.FirstClearGoldMult), r.gold);
Assert.AreEqual(row.expBase, r.exp);
Assert.AreEqual(row.gemFirst, r.gem);
Assert.AreEqual(3, r.stars);
int gold = d.gold;
var ids = new List<string>(e.StartHeroes);
EconomyRules.Apply(d, r, ids);
Assert.AreEqual(gold + r.gold, d.gold);
Assert.AreEqual(row.expBase, d.Hero(ids[0]).exp, "출전 전원 경험");
Assert.AreEqual(3, EconomyRules.StarsOf(d, s));
Assert.AreEqual(1, d.Stage(s.chapter, s.stage, false).clears);
Assert.IsTrue(EconomyRules.CanSweep(d, s));
var rep = EconomyRules.Compute(Db, d, s, true, false, row.starTurns + 1, false, rng);
Assert.IsFalse(rep.first);
Assert.AreEqual(Mathf.RoundToInt(baseGold * e.RepeatRewardMult), rep.gold, "반복 ×0.5");
Assert.AreEqual(Mathf.RoundToInt(row.expBase * e.RepeatRewardMult), rep.exp);
Assert.AreEqual(0, rep.gem);
Assert.AreEqual(1, rep.stars, "생존·턴 조건 미달 = 별 1");
EconomyRules.Apply(d, rep, ids);
Assert.AreEqual(3, EconomyRules.StarsOf(d, s), "최고 별 유지");
var sw = EconomyRules.Compute(Db, d, s, true, true, 0, true, rng);
Assert.IsTrue(sw.sweep);
Assert.IsFalse(sw.first);
Assert.AreEqual(rep.gold, sw.gold, "소탕 = 반복 보상");
var boss = FirstStage(StageKind.Boss);
var br = EconomyRules.Compute(Db, d, boss, true, true, 1, false, rng);
Assert.AreEqual(row.gemFirst + row.gemBoss, br.gem, "보스 첫 클리어 보옥");
Assert.IsNotNull(br.gearId, "보스 장비 확정");
Assert.LessOrEqual(Db.Gear.Get(br.gearId).order, Db.Gear.OrderOf(row.gearGradeMax), "등급 ≤ gear_grade_max");
Assert.LessOrEqual(Db.Gear.Get(br.gearId).dropFromChapter, boss.chapter);
var lose = EconomyRules.Compute(Db, d, boss, false, false, 3, false, rng);
Assert.AreEqual(0, lose.gold + lose.exp + lose.gem + lose.stars);
Assert.IsNull(lose.gearId);
}
[Test]
public void Save_RoundTrip_AndSanitize()
{
SaveStore.UseMemory(true);
var d = NewSave();
d.gold = 12345;
d.inv.AddGear(Db.Gear.items[0].id, 3);
d.Stage(1, 2, true).stars = 2;
d.presets[1].slots[0] = "";
d.gacha.pity = 7;
SaveStore.Write(d, T0);
var l = SaveStore.Load();
Assert.IsNotNull(l);
Assert.AreEqual(SaveStore.Version, l.ver);
Assert.AreEqual(JsonUtility.ToJson(d), JsonUtility.ToJson(l), "세이브 왕복 = 같은 JSON");
Assert.AreEqual(3, l.inv.GearCount(Db.Gear.items[0].id));
var broken = new SaveData { presets = null };
broken.heroes.Add(new HeroState { id = Db.Economy.StartHeroes[0], gear = null, grade = "" });
HeroRules.Sanitize(Db, broken, Cfg.playerSpawnPoints.Length);
Assert.AreEqual(Db.Economy.PresetCount, broken.presets.Count);
Assert.AreEqual(Cfg.playerSpawnPoints.Length, broken.presets[0].slots.Count);
Assert.AreEqual(HeroRules.GearSlotCount(Db), broken.heroes[0].gear.Count);
Assert.AreEqual(Db.Promotion.FirstGrade, broken.heroes[0].grade);
}
[Test]
public void Shop_DailySix_Buy_Refresh()
{
var e = Db.Economy;
var d = NewSave();
var rng = new System.Random(3);
EconomyRules.EnsureShop(Db, d, T0, rng);
Assert.AreEqual(e.ShopSlots, d.shop.items.Count);
var grades = new List<string>(e.ShopGrades);
var seen = new HashSet<string>();
foreach (var it in d.shop.items)
{
var g = Db.Gear.Get(it.gearId);
Assert.IsTrue(grades.Contains(g.grade), it.gearId);
Assert.IsTrue(seen.Add(it.gearId), "겹침 없음");
Assert.AreEqual(Mathf.RoundToInt(g.sellGold * e.ShopPriceMult), it.price);
}
int gold = d.gold;
var first = d.shop.items[0];
Assert.IsNull(EconomyRules.BuyShop(d, 0));
Assert.AreEqual(gold - first.price, d.gold);
Assert.AreEqual(1, d.inv.GearCount(first.gearId));
Assert.AreEqual("품절", EconomyRules.BuyShop(d, 0));
int gem = d.gem;
Assert.IsNull(EconomyRules.RefreshShop(Db, d, rng));
Assert.AreEqual(gem - e.ShopRefreshGem, d.gem);
foreach (var it in d.shop.items) Assert.IsFalse(it.sold);
var day = d.shop.day;
EconomyRules.EnsureShop(Db, d, T0.AddDays(1), rng);
Assert.AreNotEqual(day, d.shop.day, "다음 날 새로 깜");
}
// ---------- 씬(로비 · 편성 · 입장 · 결과) ----------
OutGameRoot og;
BattleManager bm;
WorldMapScreen wm;
IEnumerator Boot()
{
WorldProgress.Reset();
SaveStore.UseMemory(true);
SceneManager.LoadScene("Battle");
yield return null;
yield return null;
og = OutGameRoot.Instance;
bm = BattleManager.Instance;
wm = WorldMapScreen.Instance;
Assert.IsNotNull(og);
Assert.IsTrue(og.Session.IsNewUser, "메모리 세이브 = 신규 유저");
}
[UnityTest]
public IEnumerator Lobby_Tabs_Back_ComingSoon()
{
yield return Boot();
Assert.IsTrue(og.LobbyVisible);
og.OnTab(0);
Assert.AreEqual(og.HeroList, og.Current);
yield return null;
// PD 2026-11-10: 하단 탭 글자 = 선택 안 된 탭도 · 선택된 탭 다시 누르면 닫기(로비)
Assert.AreEqual(5, og.VisibleTabLabels, "하단 탭 글자 5개 모두 보임");
og.OnTab(0);
Assert.AreEqual(og.Lobby, og.Current, "선택된 탭 다시 = 닫기(로비)");
Assert.AreEqual(5, og.VisibleTabLabels, "로비에서도 탭 글자 보임");
og.OnTab(0);
Assert.AreEqual(og.HeroList, og.Current, "다시 열림");
yield return null;
Assert.AreEqual(og.Session.Owned(false).Count, og.HeroList.CardCount, "보유 장수 카드");
// 미보유 장수 = 빈 카드가 아니라 그 영웅 그림 카드(PD 2026-11-10 「유닛 목록에 장수들의 모습이 보이도록」)
int heroTotal = 0;
foreach (var h in og.Session.Db.Heroes.heroes)
if (!h.isSoldier) heroTotal++;
Assert.AreEqual(heroTotal - og.Session.Owned(false).Count, og.HeroList.LockedCount, "미보유 장수 카드 = 그림 카드");
Assert.Greater(og.HeroList.LockedCount, 0, "신규 유저 = 미보유 있음");
var lockedTail = og.HeroList.Rt.GetComponentsInChildren<Transform>(true);
int lockedWithArt = 0;
foreach (var tr in lockedTail)
if (tr.name == "Locked" && tr.parent.Find("Bg/Character") != null) lockedWithArt++;
Assert.AreEqual(og.HeroList.LockedCount, lockedWithArt, "미보유 카드마다 일러(Character)");
og.HeroList.SetFilter(true);
yield return null;
Assert.AreEqual(og.Session.Owned(true).Count, og.HeroList.CardCount, "병사 필터");
var id = og.Session.Db.Economy.StartHeroes[0];
og.ShowHero(id);
Assert.AreEqual(og.HeroDetail, og.Current);
Assert.AreEqual(id, og.HeroDetail.HeroId);
og.Back();
Assert.AreEqual(og.HeroList, og.Current, "상세 ◀ = 목록");
og.Back();
Assert.IsTrue(og.LobbyVisible, "목록 ◀ = 로비");
og.OnTab(1);
Assert.AreEqual(og.Treasures, og.Current, "보물 = 수집품 화면(PD #1093 C)");
og.OnTab(2);
Assert.AreEqual(og.Domestic, og.Current, "내정 = 훈련·순찰·개발(PD #1093 C)");
og.Back();
Assert.IsTrue(og.LobbyVisible, "내정 ◀ = 로비");
og.OnTab(3);
Assert.IsTrue(og.ModalVisible, "동맹 = 추후 업데이트");
og.CloseModal();
og.OnTab(4);
Assert.AreEqual(og.Trade, og.Current);
og.Trade.SetPage(2);
yield return null;
Assert.AreEqual(og.Session.Db.Economy.ShopSlots, og.Trade.ShopCardCount, "장비 상점 칸");
og.Back();
Assert.IsTrue(og.LobbyVisible);
og.OpenWorldMap();
yield return null;
Assert.IsTrue(wm.IsVisible);
og.Back();
yield return null;
Assert.IsFalse(wm.IsVisible, "월드맵 ◀ = 로비");
Assert.IsTrue(og.LobbyVisible);
}
[UnityTest]
public IEnumerator Formation_PresetToSpawn_FullAndPartial()
{
yield return Boot();
var s = og.Session;
var cfg = bm.Config;
wm.EnterStage(0, 0);
yield return null;
Assert.AreEqual(s.Save.ActivePreset.slots.Count, bm.Deployed.Count, "프리셋 5 → 출전 5");
Assert.AreEqual(bm.Deployed.Count, bm.AliveCount(Faction.Player));
foreach (var u in bm.AliveUnits(Faction.Player))
{
LineupEntry e = null;
foreach (var x in bm.Deployed)
if (x.name == u.DisplayName) e = x;
Assert.IsNotNull(e, u.DisplayName);
Assert.Less(Vector2.Distance(cfg.playerSpawnPoints[e.slot], u.Position), 0.01f, u.DisplayName + " 자리 = 편성 순서");
var st = s.StatsOf(e.id);
Assert.AreEqual(st.Hp, u.MaxHp, u.DisplayName + " HP = §3-2 공식");
Assert.AreEqual(st.Atk, u.Atk, u.DisplayName + " 공");
Assert.AreEqual(st.Def, u.Def, u.DisplayName + " 방");
}
int front = s.Db.Economy.FormationFront;
for (int i = 0; i < cfg.playerSpawnPoints.Length; i++)
Assert.AreEqual(i < front ? cfg.playerSpawnPoints[0].y : cfg.playerSpawnPoints[cfg.playerSpawnPoints.Length - 1].y, cfg.playerSpawnPoints[i].y, 0.01f, "전열 앞줄 · 후열 뒷줄");
Assert.Greater(cfg.playerSpawnPoints[0].y, cfg.playerSpawnPoints[cfg.playerSpawnPoints.Length - 1].y, "전열이 적 쪽(위)");
var ids = s.Save.ActivePreset.slots;
var partial = new List<string>();
for (int i = 0; i < ids.Count; i++) partial.Add(i == 1 || i == 3 ? ids[i] : "");
Assert.IsNull(s.SavePreset(1, partial));
Assert.AreEqual(1, s.Save.presetIdx, "저장 = 출전 프리셋");
bm.StartStage(0);
yield return null;
Assert.AreEqual(2, bm.AliveCount(Faction.Player), "빈 칸 허용");
foreach (var e in bm.Deployed) Assert.IsTrue(e.slot == 1 || e.slot == 3);
Assert.IsNotNull(s.SavePreset(2, new List<string> { "", "", "", "", "" }), "최소 1");
// 편성 화면 조작: 카드 탭 = 빼기/넣기 · 자리 맞바꾸기 · 저장
og.ShowLobby();
og.Show(og.Formation);
var f = og.Formation;
Assert.AreEqual(1, f.Preset);
f.TapCard(ids[1]);
Assert.AreEqual(1, CountFilled(f.Working), "카드 탭 = 빼기");
f.TapCard(ids[1]);
Assert.AreEqual(2, CountFilled(f.Working), "다시 탭 = 빈 칸에 넣기");
string a0 = f.Working[0], a3 = f.Working[3];
f.Swap(0, 3);
Assert.AreEqual(a3, f.Working[0]);
Assert.AreEqual(a0, f.Working[3]);
f.Place(ids[4], 2);
Assert.AreEqual(ids[4], f.Working[2], "끌어 놓기 = 자리 넣기");
Assert.IsTrue(f.Dirty);
f.SaveNow();
Assert.IsFalse(f.Dirty);
Assert.AreEqual(ids[4], s.Save.presets[1].slots[2]);
}
static int CountFilled(IList<string> l)
{
int n = 0;
foreach (var x in l)
if (!string.IsNullOrEmpty(x)) n++;
return n;
}
[UnityTest]
public IEnumerator WorldMap_StaminaEntry_Result_Stars_Sweep()
{
yield return Boot();
var s = og.Session;
var e = s.Db.Economy;
og.OpenWorldMap();
yield return null;
int si = wm.StageIndexOf(0, 0);
var stage = bm.Config.stages[si];
int cost = EconomyRules.StageCost(s.Db, stage);
s.Save.stamina = cost - 1;
wm.OpenPopup(0);
wm.ConfirmPopup();
yield return null;
Assert.IsTrue(wm.IsVisible, "군량 부족 = 출전 안 됨");
Assert.IsTrue(og.ModalVisible, "군량 충전 팝업");
Assert.AreEqual(BattlePhase.Setup, bm.Phase);
og.CloseModal();
s.Save.stamina = e.StaminaMax;
int gold = s.Save.gold;
int exp0 = s.Save.Hero(e.StartHeroes[0]).exp;
wm.OpenPopup(0);
wm.ConfirmPopup();
yield return null;
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase);
Assert.AreEqual(e.StaminaMax - cost, s.Save.stamina, "스테이지 군량 소모");
foreach (var u in bm.AliveUnits(Faction.Enemy)) u.TakeDamage(u.Hp);
bm.CheckBattleEnd();
yield return null;
Assert.AreEqual(BattlePhase.StageClear, bm.Phase);
var r = s.LastReward;
Assert.IsNotNull(r);
Assert.AreEqual(3, r.stars, "1턴 · 전원 생존 = 별 3");
Assert.IsTrue(og.ResultVisible, "결과 화면 = 보상 패널");
Assert.AreEqual(gold + r.gold, s.Save.gold);
Assert.AreEqual(exp0 + r.exp, s.Save.Hero(e.StartHeroes[0]).exp, "출전 영웅 경험");
Assert.AreEqual(bm.Deployed.Count, r.expTo.Count);
bm.ContinueAfterResult();
yield return null;
Assert.IsTrue(wm.IsVisible);
Assert.AreEqual(3, wm.StarsOf(si));
Assert.AreEqual(NodeState.Clear, wm.StateOf(0, 0));
Assert.IsTrue(wm.CanSweep(si), "별 3 = 소탕");
int st = s.Save.stamina;
wm.Sweep(si);
Assert.AreEqual(st - cost, s.Save.stamina, "소탕 = 군량만");
Assert.AreEqual(2, s.Save.Stage(stage.chapter, stage.stage, false).clears);
Assert.IsTrue(s.LastReward.sweep);
Assert.AreEqual(BattlePhase.Setup, bm.Phase, "소탕 = 전투 없음");
}
}
}