386 lines
18 KiB
C#
386 lines
18 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using NUnit.Framework;
|
|
using TKS.OutGame;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
using UnityEngine.TestTools;
|
|
using UnityEngine.UI;
|
|
|
|
namespace TKS.Tests
|
|
{
|
|
/// <summary>
|
|
/// 명장 등용(뽑기 메뉴 개편 · PD 2026-11-13) — ① 배너 표(gacha_banners.csv): 픽업 + 진영마다 상시 배너 · 주요 영웅이 그 배너 풀에서 나옴 · 배치 표(recruit_banner.csv)에 자리·깃발이 있음
|
|
/// ② 상시 배너 = 그 진영 영웅(+ 병사)만 · 픽업 영웅은 상시 배너에서 안 나옴(pickup_exclusive) ③ 픽업 배너 = ★★★ 의 pickup_share 가 주요 영웅
|
|
/// ④ 천장 = pity_group 끼리 같이 셈 · 다른 묶음은 따로 ⑤ 기간(start + days) 밖 = 등용 불가(보옥 그대로) ⑥ 구 세이브 천장 → 상시 묶음으로 옮김
|
|
/// ⑦ 풀 성장 = 최고 레벨·등급·★·장비 전부(표 값) ⑧ 화면: 탭 이름 「명장 등용」 · 「뽑기」 글자 없음 · 좌우 쪽 넘김 · 깃발 = 배너 고르기 · 주요 영웅 5 · 상세 정보 팝업 · 고른 배너 풀로 등용.
|
|
/// 기대 값은 전부 표에서 읽는다(C45).
|
|
/// </summary>
|
|
public class RecruitTests
|
|
{
|
|
static readonly DateTime T0 = new DateTime(2026, 11, 5, 3, 0, 0, DateTimeKind.Utc);
|
|
OutGameRoot og;
|
|
|
|
static OutGameData Db
|
|
{
|
|
get { return OutGameData.Default; }
|
|
}
|
|
|
|
static BalanceConfig Cfg
|
|
{
|
|
get { return Resources.Load<BalanceConfig>("BalanceConfig"); }
|
|
}
|
|
|
|
static SaveData RichSave()
|
|
{
|
|
var d = HeroRules.NewSave(Db, Cfg.playerSpawnPoints.Length, T0);
|
|
d.gem = 100000000;
|
|
d.gacha.firstTenUsed = true;
|
|
return d;
|
|
}
|
|
|
|
/// <summary>그 배너가 열려 있는 시각(기간 있음 = 시작 + 1시간 · 없음 = T0).</summary>
|
|
static DateTime Inside(GachaConfig.Banner b)
|
|
{
|
|
var s = GachaRules.StartOf(b);
|
|
return s != null ? s.Value.AddHours(1) : T0;
|
|
}
|
|
|
|
static List<GachaConfig.Banner> Banners(bool pickup)
|
|
{
|
|
var l = new List<GachaConfig.Banner>();
|
|
foreach (var b in Db.Gacha.banners)
|
|
if (b.IsPickup == pickup) l.Add(b);
|
|
return l;
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
GachaCutscene.Manual = false;
|
|
SaveStore.UseMemory(false);
|
|
}
|
|
|
|
// ---------- 표 ----------
|
|
|
|
[Test]
|
|
public void Banners_Table_PickupAndFactionStanding_FeaturedInPool_LayoutRows()
|
|
{
|
|
var g = Db.Gacha;
|
|
Assert.Greater(Banners(true).Count, 0, "픽업 배너");
|
|
// 진영마다 상시 배너 1(영웅이 있는 진영 전부)
|
|
var factions = new HashSet<string>();
|
|
foreach (var h in Db.Heroes.heroes)
|
|
if (!h.isSoldier) factions.Add(h.faction);
|
|
foreach (var f in factions)
|
|
{
|
|
int n = 0;
|
|
foreach (var b in Banners(false))
|
|
if (b.faction == f && b.pool.Contains(f)) n++;
|
|
Assert.AreEqual(1, n, "진영 " + f + " 상시 배너");
|
|
Assert.IsNotNull(RecruitBanner.First("flag_" + f), "배치 표 깃발 flag_" + f);
|
|
Assert.IsNotNull(UISkin.Icon(RecruitBanner.First("flag_" + f).sprite), "깃발 그림 " + f);
|
|
}
|
|
Assert.IsNotNull(RecruitBanner.First("map"), "배치 표 지도");
|
|
Assert.IsNotNull(UISkin.Icon(RecruitBanner.First("map").sprite), "지도 그림");
|
|
foreach (var b in g.banners)
|
|
{
|
|
Assert.IsNotNull(UISkin.Icon(b.art), b.id + " 배너 그림 " + b.art);
|
|
Assert.Greater(b.featured.Count, 0, b.id);
|
|
foreach (var id in b.featured)
|
|
{
|
|
var h = Db.Heroes.Get(id);
|
|
Assert.IsNotNull(h, b.id + " featured " + id);
|
|
Assert.IsTrue(GachaRules.InBanner(g, Db.Heroes, b, h), b.id + " 주요 영웅 " + id + " 가 풀에서 나옴");
|
|
Assert.IsNotNull(UiKit.ArtSprite(h.art), id + " 인게임 그림");
|
|
}
|
|
if (b.IsPickup) continue;
|
|
Assert.AreEqual(b.featured.Count, RecruitBanner.Slots("hero", b.featured.Count).Count, b.id + " 영웅 자리 수");
|
|
Assert.AreEqual(b.featured.Count, RecruitBanner.Slots("tag", b.featured.Count).Count, b.id + " 이름표 수");
|
|
}
|
|
}
|
|
|
|
// ---------- 규칙 ----------
|
|
|
|
[Test]
|
|
public void Standing_OnlyThatFaction_PickupHeroExcluded()
|
|
{
|
|
var g = Db.Gacha;
|
|
var rng = new System.Random(1113);
|
|
foreach (var b in Banners(false))
|
|
{
|
|
var d = RichSave();
|
|
var seen = new HashSet<string>();
|
|
for (int k = 0; k < 300; k++)
|
|
foreach (var p in GachaRules.Draw(Db, d, b, g.TenCount, rng, Inside(b)))
|
|
{
|
|
var h = Db.Heroes.Get(p.heroId);
|
|
seen.Add(h.id);
|
|
if (h.isSoldier) Assert.IsTrue(b.soldiers, b.id + " 병사 " + h.id);
|
|
else Assert.IsTrue(b.pool.Contains(GachaConfig.PoolAll) || b.pool.Contains(h.faction), b.id + " 에서 다른 진영 " + h.id + "(" + h.faction + ")");
|
|
if (g.PickupExclusive) Assert.IsFalse(GachaRules.IsPickupHero(g, h.id), b.id + " 에서 픽업 영웅 " + h.id);
|
|
Assert.IsFalse(p.pickup, "상시 배너 = 픽업 표시 없음");
|
|
}
|
|
foreach (var id in b.featured) Assert.IsTrue(seen.Contains(id), b.id + " 주요 영웅 " + id + " 가 3000회 안에 나옴");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Pickup_FeaturedShare_OfTopTier()
|
|
{
|
|
var g = Db.Gacha;
|
|
var rng = new System.Random(1114);
|
|
foreach (var b in Banners(true))
|
|
{
|
|
var d = RichSave();
|
|
int n = 4000, hit = 0;
|
|
for (int k = 0; k < n; k++)
|
|
{
|
|
d.gacha.SetPity(b.pityGroup, g.Pity - 1); // 천장 = ★★★ 확정
|
|
var p = GachaRules.Draw(Db, d, b, 1, rng, Inside(b))[0];
|
|
Assert.AreEqual(3, p.tier);
|
|
Assert.AreEqual(b.featured.Contains(p.heroId), p.pickup, p.heroId + " 픽업 표시");
|
|
if (p.pickup) hit++;
|
|
}
|
|
Assert.AreEqual(g.PickupShare, hit / (float)n, 0.03f, b.id + " ★★★ 중 주요 영웅 비율 ≈ pickup_share");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Pity_SharedInsideGroup_SeparateAcrossGroups()
|
|
{
|
|
var g = Db.Gacha;
|
|
var rng = new System.Random(1115);
|
|
foreach (var a in g.banners)
|
|
{
|
|
var d = RichSave();
|
|
var before = new Dictionary<string, int>();
|
|
foreach (var o in g.banners) before[o.id] = GachaRules.Pity(d, o);
|
|
var pulls = GachaRules.Draw(Db, d, a, 1, rng, Inside(a));
|
|
Assert.AreEqual(1, pulls.Count);
|
|
int expect = pulls[0].tier == 3 ? 0 : 1;
|
|
foreach (var o in g.banners)
|
|
{
|
|
if (o.pityGroup == a.pityGroup) Assert.AreEqual(expect, GachaRules.Pity(d, o), a.id + " → " + o.id + " 같은 묶음 = 같이 셈");
|
|
else Assert.AreEqual(before[o.id], GachaRules.Pity(d, o), a.id + " → " + o.id + " 다른 묶음 = 그대로");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Period_OutsideIsClosed_NoGemSpent()
|
|
{
|
|
var g = Db.Gacha;
|
|
var rng = new System.Random(1116);
|
|
foreach (var b in g.banners)
|
|
{
|
|
var start = GachaRules.StartOf(b);
|
|
var end = GachaRules.EndOf(b);
|
|
if (start == null)
|
|
{
|
|
Assert.IsTrue(GachaRules.IsOpen(b, T0) && GachaRules.IsOpen(b, T0.AddYears(5)), b.id + " 기간 없음 = 항상 열림");
|
|
continue;
|
|
}
|
|
Assert.IsTrue(GachaRules.IsOpen(b, start.Value), b.id + " 시작 시각 = 열림");
|
|
Assert.IsFalse(GachaRules.IsOpen(b, start.Value.AddSeconds(-1)), b.id + " 시작 전");
|
|
if (end == null) continue;
|
|
Assert.AreEqual(b.days, (end.Value - start.Value).TotalDays, 1e-6, b.id + " 기간 = days");
|
|
Assert.IsTrue(GachaRules.IsOpen(b, end.Value.AddSeconds(-1)), b.id + " 끝 직전");
|
|
Assert.IsFalse(GachaRules.IsOpen(b, end.Value), b.id + " 끝 시각 = 닫힘");
|
|
Assert.AreEqual(TimeSpan.Zero, GachaRules.Remaining(b, end.Value));
|
|
var d = RichSave();
|
|
int gem = d.gem;
|
|
Assert.IsNotNull(GachaRules.CanDraw(Db, d, b, 1, end.Value), b.id + " 기간 뒤 = 불가 사유");
|
|
Assert.AreEqual(0, GachaRules.Draw(Db, d, b, 1, rng, end.Value).Count);
|
|
Assert.AreEqual(gem, d.gem, "닫힌 배너 = 보옥 그대로");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Sanitize_LegacyPity_MovesToStandingGroup()
|
|
{
|
|
var standing = Banners(false);
|
|
if (standing.Count == 0) Assert.Ignore("상시 배너 없음");
|
|
var d = HeroRules.NewSave(Db, Cfg.playerSpawnPoints.Length, T0);
|
|
d.gacha.pity = 5; // 구 세이브(배너 하나 시절)
|
|
HeroRules.Sanitize(Db, d, Cfg.playerSpawnPoints.Length);
|
|
Assert.AreEqual(0, d.gacha.pity);
|
|
Assert.AreEqual(5, d.gacha.PityOf(standing[0].pityGroup), "구 천장 → 상시 묶음");
|
|
}
|
|
|
|
[Test]
|
|
public void FullGrowth_IsTableMaximum()
|
|
{
|
|
foreach (var b in Db.Gacha.banners)
|
|
foreach (var id in b.featured)
|
|
{
|
|
var h = Db.Heroes.Get(id);
|
|
var full = HeroRules.FullGrowth(Db, h);
|
|
Assert.AreEqual(Db.Levels.MaxLevel, full.lv, id);
|
|
Assert.AreEqual(Db.Evolve.MaxStar, full.star, id);
|
|
Assert.AreEqual("", Db.Promotion.Get(full.grade).nextGrade, id + " 최고 등급 = 다음 등급 없음");
|
|
int gear = 0;
|
|
foreach (var set in h.gear) gear += set.slots.Count;
|
|
Assert.AreEqual(gear, full.gearTotal, id + " 승급 장비 전부");
|
|
for (int i = 0; i < 2; i++) Assert.AreEqual(SkillRules.Cap(Db, h, full, i), SkillRules.Level(full, i), id + " 스킬 " + i + " = 상한");
|
|
var top = HeroRules.Compute(Db, Cfg, h, full);
|
|
var first = HeroRules.Compute(Db, Cfg, h, HeroRules.NewHero(Db, h, 0));
|
|
Assert.Greater(top.Hp, first.Hp, id);
|
|
Assert.Greater(top.Atk, first.Atk, id);
|
|
Assert.Greater(top.power, first.power, id);
|
|
}
|
|
}
|
|
|
|
// ---------- 화면 ----------
|
|
|
|
IEnumerator Boot()
|
|
{
|
|
WorldProgress.Reset();
|
|
SaveStore.UseMemory(true);
|
|
SceneManager.LoadScene("Battle");
|
|
yield return null;
|
|
yield return null;
|
|
og = OutGameRoot.Instance;
|
|
Assert.IsNotNull(og);
|
|
}
|
|
|
|
static List<string> Texts(Transform root)
|
|
{
|
|
var l = new List<string>();
|
|
foreach (var t in root.GetComponentsInChildren<Text>(true)) l.Add(t.text ?? "");
|
|
return l;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator Screen_Names_Pages_Flags_Featured_Info_Draw()
|
|
{
|
|
yield return Boot();
|
|
var s = og.Session;
|
|
var g = s.Db.Gacha;
|
|
var picks = Banners(true);
|
|
var standing = Banners(false);
|
|
if (picks.Count > 0)
|
|
{
|
|
var inside = Inside(picks[0]);
|
|
s.Clock = () => inside; // 픽업 기간 안(날짜가 지나도 같은 화면)
|
|
}
|
|
og.OnTab(4);
|
|
og.Trade.SetPage(0);
|
|
og.Trade.SelectBanner(GachaRules.Default(s.Db, s.Now).id);
|
|
yield return null;
|
|
|
|
// 이름 = 「명장 등용」 · 「등용」 버튼 · 「뽑기」 글자 없음
|
|
var texts = Texts(og.Trade.Rt);
|
|
Assert.IsTrue(texts.Contains("명장 등용"), "거래 탭 이름");
|
|
Assert.IsTrue(texts.Contains("1회 등용"), "1회 등용 버튼");
|
|
Assert.IsTrue(texts.Contains(g.TenCount + "회 등용"), "10회 등용 버튼");
|
|
foreach (var t in texts) Assert.IsFalse(t.Contains("뽑기"), "「뽑기」 글자 남음: " + t);
|
|
StringAssert.Contains("명장 등용", CollectionCards.InfoText(s.Db.Heroes.Get(g.banners[0].featured[0]), false));
|
|
|
|
// 쪽 = 픽업 배너 수 + 상시 묶음 1 · 화살표 = 넘김(끝에서 돈다)
|
|
Assert.AreEqual(picks.Count + (standing.Count > 0 ? 1 : 0), og.Trade.BannerPageCount);
|
|
Assert.AreEqual(GachaRules.Default(s.Db, s.Now).id, og.Trade.Banner.id, "처음 = 표 순서의 첫 열린 배너");
|
|
int page0 = og.Trade.BannerPage;
|
|
for (int i = 0; i < og.Trade.BannerPageCount; i++) og.Trade.StepBanner(1);
|
|
Assert.AreEqual(page0, og.Trade.BannerPage, "한 바퀴 = 제자리");
|
|
|
|
// 상시 = 깃발마다 그 진영 배너 · 주요 영웅 전부 섬 · 깃발 수 = 상시 배너 수
|
|
foreach (var b in standing)
|
|
{
|
|
og.Trade.SelectBanner(b.id);
|
|
yield return null;
|
|
Assert.AreEqual(b.id, og.Trade.Banner.id);
|
|
Assert.AreEqual(b.featured.Count, og.Trade.FeaturedShown, b.id + " 주요 영웅");
|
|
Assert.AreEqual(standing.Count, og.Trade.FlagCount, b.id + " 깃발");
|
|
var names = Texts(og.Trade.Rt);
|
|
Assert.IsTrue(names.Contains(b.nameKo), b.id + " 제목");
|
|
}
|
|
if (standing.Count > 1 && picks.Count > 0)
|
|
{
|
|
// 쪽을 넘겼다 돌아오면 고른 진영 그대로
|
|
string last = standing[standing.Count - 1].id;
|
|
og.Trade.SelectBanner(last);
|
|
og.Trade.SelectBanner(picks[0].id);
|
|
Assert.IsTrue(og.Trade.Banner.IsPickup);
|
|
for (int i = 0; i < og.Trade.BannerPageCount && og.Trade.Banner.IsPickup; i++) og.Trade.StepBanner(1);
|
|
Assert.AreEqual(last, og.Trade.Banner.id, "상시 쪽 = 마지막에 고른 진영");
|
|
}
|
|
|
|
// 깃발 버튼을 누르면 그 배너(실제 Button)
|
|
if (standing.Count > 1)
|
|
{
|
|
og.Trade.SelectBanner(standing[0].id);
|
|
yield return null;
|
|
Button other = null;
|
|
foreach (var btn in og.Trade.Rt.GetComponentsInChildren<Button>(false))
|
|
if (btn.name == "Flag_" + standing[1].id) other = btn;
|
|
Assert.IsNotNull(other, "깃발 버튼 Flag_" + standing[1].id);
|
|
other.onClick.Invoke();
|
|
Assert.AreEqual(standing[1].id, og.Trade.Banner.id);
|
|
}
|
|
|
|
// 픽업 = 상세 정보 팝업(풀 성장 수치 = 표 계산값)
|
|
if (picks.Count > 0)
|
|
{
|
|
var b = picks[0];
|
|
og.Trade.SelectBanner(b.id);
|
|
yield return null;
|
|
Button info = null;
|
|
foreach (var btn in og.Trade.Rt.GetComponentsInChildren<Button>(false))
|
|
if (btn.name == "BannerInfo") info = btn;
|
|
Assert.IsNotNull(info, "상세 정보 버튼");
|
|
info.onClick.Invoke();
|
|
Assert.IsTrue(og.ModalVisible, "상세 정보 팝업");
|
|
Assert.AreEqual(b.featured[0], HeroInfoPopup.LastHeroId);
|
|
var h = s.Def(b.featured[0]);
|
|
var want = HeroRules.Compute(s.Db, s.Cfg, h, HeroRules.FullGrowth(s.Db, h));
|
|
Assert.AreEqual(want.power, HeroInfoPopup.LastStats.power);
|
|
Assert.AreEqual(want.Hp, HeroInfoPopup.LastStats.Hp);
|
|
og.CloseModal();
|
|
}
|
|
|
|
// 등용 = 고른 배너의 풀(상시 = 그 진영) · 연출로 이어짐
|
|
if (standing.Count > 0)
|
|
{
|
|
var b = standing[standing.Count - 1];
|
|
og.Trade.SelectBanner(b.id);
|
|
s.Save.gem = GachaRules.Cost(s.Db, s.Save, 1) + 1;
|
|
GachaCutscene.Manual = true;
|
|
og.Trade.DoDraw(1);
|
|
Assert.IsNotNull(og.Trade.LastPulls);
|
|
Assert.AreEqual(1, og.Trade.LastPulls.Count);
|
|
var got = s.Def(og.Trade.LastPulls[0].heroId);
|
|
Assert.IsTrue(got.isSoldier || b.pool.Contains(got.faction), b.id + " 에서 " + got.id);
|
|
Assert.IsTrue(og.Trade.ResultVisible, "등용 결과(연출)");
|
|
og.Trade.RevealAll();
|
|
og.Trade.CloseResult();
|
|
yield return null;
|
|
}
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator Screen_ClosedPickup_ButtonsOff_Toast()
|
|
{
|
|
yield return Boot();
|
|
var s = og.Session;
|
|
GachaConfig.Banner b = null;
|
|
foreach (var x in Banners(true))
|
|
if (b == null && GachaRules.EndOf(x) != null) b = x;
|
|
if (b == null) Assert.Ignore("기간 있는 픽업 배너 없음");
|
|
var after = GachaRules.EndOf(b).Value.AddHours(1);
|
|
s.Clock = () => after;
|
|
og.OnTab(4);
|
|
og.Trade.SetPage(0);
|
|
og.Trade.SelectBanner(b.id);
|
|
yield return null;
|
|
Assert.IsTrue(Texts(og.Trade.Rt).Contains("등용 기간 종료"));
|
|
int gem = s.Save.gem;
|
|
og.Trade.DoDraw(1);
|
|
Assert.IsFalse(og.Trade.ResultVisible);
|
|
Assert.AreEqual(gem, s.Save.gem);
|
|
Assert.IsNotEmpty(og.LastToast);
|
|
}
|
|
}
|
|
}
|