using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Luneraria.Core;
using Luneraria.View;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.UIElements;
namespace Luneraria.UI.Tests
{
///
/// U3 acceptance of the HUD in the real game (port of tools/luneraria/verify_g3.gd, parts A–H):
/// A layout at 1152×720 … 2560×1440 and ultrawide, at several UI scales;
/// B match status (Ancients, towers, lanes, waves with two and three digits, own gold only);
/// C hero, items, talents and normal units (no item slots), lane orders with the grotto warning;
/// D attack champions (temporary), the boss grotto in every state, the chooser's lane, banners;
/// E Ready lock, bots, privacy, a dropped human seat;
/// F input: minimap, lane clicks, hotkeys, field clicks, no click-through, tooltips;
/// G events, critical alerts and reduced motion;
/// H update rates and the portrait cache;
/// I (U3 addition) the field: formation areas, grotto entrance, resting boss, name tags, owner ring, Ancient bar.
/// Every check is counted; the totals go to tools/out/hud-verify-*.txt.
///
public sealed class HudVerifyTests
{
readonly List _failures = new List();
int _checks;
[SetUp]
public void Reset()
{
_failures.Clear();
_checks = 0;
}
void Check(bool ok, string label)
{
_checks++;
if (!ok)
_failures.Add(label);
}
void Done(string part)
{
Debug.Log(string.Format("LUNE_VERIFY {0}: {1} checks, {2} failed{3}", part, _checks, _failures.Count, string.Concat(_failures.Select(f => "\n FAIL " + f))));
System.IO.Directory.CreateDirectory(System.IO.Path.Combine(HudSession.Root, "tools/out"));
System.IO.File.WriteAllText(System.IO.Path.Combine(HudSession.Root, "tools/out/hud-verify-" + part + ".txt"), part + " " + _checks + " " + _failures.Count + "\n" + string.Join("\n", _failures) + "\n");
Assert.IsEmpty(_failures, string.Join("\n", _failures));
}
static bool Overlap(Rect a, Rect b)
{
return a.xMin < b.xMax - .5f && b.xMin < a.xMax - .5f && a.yMin < b.yMax - .5f && b.yMin < a.yMax - .5f;
}
// ---------------------------------------------------------------- A layout
static readonly (int w, int h, float[] scales)[] Sizes =
{
(1152, 720, new[] { 1f }), (1280, 720, new[] { .9f, 1f, 1.3f }), (1920, 1080, new[] { .9f, 1f, 1.15f, 1.3f }),
(2560, 1440, new[] { .9f, 1f, 1.3f }), (2560, 1080, new[] { 1f, 1.3f }),
};
void Layout(HudSession s, string label)
{
Dictionary regions = s.Hud.Regions();
Rect area = s.Hud.Root.Q("lune-hud").layout;
foreach (KeyValuePair region in regions)
Check(region.Value.xMin >= -.5f && region.Value.yMin >= -.5f && region.Value.xMax <= area.width + .5f && region.Value.yMax <= area.height + .5f,
label + ": " + region.Key + " inside the window " + region.Value + " in " + area.size);
string[][] pairs =
{
new[] { "top", "system" }, new[] { "top", "left" }, new[] { "top", "right" }, new[] { "left", "bottom" }, new[] { "left", "right" },
new[] { "right", "ready" }, new[] { "right", "minimap" }, new[] { "bottom", "ready" }, new[] { "bottom", "minimap" }, new[] { "ready", "minimap" },
new[] { "system", "right" }, new[] { "left", "minimap" },
};
foreach (string[] pair in pairs)
{
if (regions.TryGetValue(pair[0], out Rect a) && regions.TryGetValue(pair[1], out Rect b))
Check(!Overlap(a, b), label + ": " + pair[0] + " and " + pair[1] + " overlap " + a + " " + b);
}
Check(s.Hud.LogicalSize.x >= LuneHud.MinArea.x - 1f || s.Hud.UiScale <= .6f + 1e-4f, label + ": logical width " + s.Hud.LogicalSize);
Check(s.Hud.LogicalSize.y >= LuneHud.MinArea.y - 1f || s.Hud.UiScale <= .6f + 1e-4f, label + ": logical height " + s.Hud.LogicalSize);
}
[UnityTest]
[Timeout(1800000)]
public IEnumerator A_Layout()
{
foreach (var size in Sizes)
{
foreach (float scale in size.scales)
{
var s = new HudSession();
yield return s.Open(size.w, size.h, uiScale: scale);
string label = size.w + "x" + size.h + "@" + Mathf.RoundToInt(scale * 100f) + "%";
float expected = LuneHud.ScaleFor(new Vector2(size.w, size.h), scale, out _);
Check(Mathf.Abs(s.Hud.UiScale - expected) < 1e-4f, label + ": UI scale " + s.Hud.UiScale + " expected " + expected);
Check(s.Hud.UiScale <= scale + 1e-4f, label + ": scale never above the preference");
yield return s.Starter();
yield return HudSession.Frames(3);
Layout(s, label + " planning");
s.Hud.OpenHero();
yield return HudSession.Frames(2);
VisualElement modal = s.Hud.Root.Query().First().Panel;
Rect area = s.Hud.Root.Q("lune-hud").layout;
Rect box = s.Hud.Root.Q("lune-hud").WorldToLocal(modal.worldBound);
Check(box.xMin >= 0f && box.yMin >= 0f && box.xMax <= area.width + .5f && box.yMax <= area.height + .5f, label + ": hero window inside " + box);
s.Hud.CloseModal();
s.Runner.Ready();
yield return s.Fight(8.0);
yield return HudSession.Frames(2);
Layout(s, label + " combat");
if (Mathf.Approximately(scale, 1f) && (size.w != 1920 || size.h != 1080))
yield return s.Shot("docs/port/screenshots/u3/aufloesungen/kampf-" + size.w + "x" + size.h + ".png");
s.Close();
}
}
Done("A");
}
// ---------------------------------------------------------------- B status
[UnityTest]
[Timeout(900000)]
public IEnumerator B_Status()
{
var s = new HudSession();
yield return s.Open();
string text = s.Texts();
Check(text.Contains("DEIN ANCIENT · BLAU"), "own Ancient caption");
Check(text.Contains("GEGNER · ROT"), "enemy Ancient caption");
Check(text.Contains("12.000 / 12.000"), "Ancient HP with thousands separator");
Check(text.Contains("WELLE 1"), "wave 1");
Check(text.Contains("PLANUNG"), "phase badge");
foreach (int round in new[] { 21, 99, 120 })
{
s.Match.RoundNumber = round;
s.Sync();
yield return HudSession.Frames(2);
Check(s.Texts().Contains("WELLE " + round), "unlimited waves: WELLE " + round);
Rect top = s.Hud.Regions()["top"];
Check(top.width < 1100f, "top panel stays compact at wave " + round + ": " + top.width);
}
s.Match.RoundNumber = 1;
double core = s.Match.Map.BuildingDef("blau_ancient").MaxHp;
s.Match.BuildingHp["blau_ancient"] = core * .45;
s.Sync();
Check(s.Texts().Contains("BESCHÄDIGT"), "own Ancient below 50 %: BESCHÄDIGT");
s.Match.BuildingHp["blau_ancient"] = core * .25;
s.Sync();
s.Tick(.3f);
text = s.Texts();
Check(text.Contains("KRITISCH"), "own Ancient below 30 %: KRITISCH");
Check(text.Contains("Dein Ancient ist kritisch: 25 % Leben"), "banner for the own critical Ancient");
Check(s.Hud.Feed.Texts.Any(t => t.StartsWith("Dein Ancient kritisch")), "event for the own critical Ancient");
s.Match.BuildingHp["rot_ancient"] = core * .2;
s.Sync();
s.Tick(.3f);
Check(s.Hud.Feed.Texts.Any(t => t.StartsWith("Gegnerischer Ancient kritisch")), "event for the enemy's critical Ancient");
// A lane tower falls: indicator, threat and event.
s.Match.BuildingHp["blau_mid_outer"] = 0.0;
s.Sync();
s.Tick(.3f);
LuneLaneIndicator mid = s.Hud.Lanes["mid"];
Check(!mid.Towers[1].Alive, "fallen Grenzwacht shown as ruin");
Check(mid.Level >= LuneLaneThreat.Pressure, "Mid threat after the Grenzwacht fell: " + mid.Level);
Check(s.Hud.Feed.Texts.Any(t => t.Contains("MID – Grenzwacht (Blau) zerstört")), "event for the fallen tower");
// Own gold only: another seat's gold never reaches the HUD.
s.Match.Players[4].Draft.Gold = 98765;
s.Match.Players[0].Draft.Gold = 87654;
s.Sync();
s.Tick(.3f);
text = s.Texts();
Check(!text.Contains("98.765") && !text.Contains("87.654"), "other players' gold is never shown");
Check(text.Contains(LuneUiTheme.Number(s.Match.GoldOf(HudSession.Human))), "own gold shown");
yield return s.Shot("docs/port/screenshots/u3/pruefung/b-status.png");
s.Close();
Done("B");
}
// ---------------------------------------------------------------- C hero, items, units
[UnityTest]
[Timeout(900000)]
public IEnumerator C_HeroItemsUnits()
{
var s = new HudSession();
yield return s.Open();
yield return s.Tap(() => s.Hud.HeroCard);
Check(s.Hud.ModalOpen, "hero card opens the hero window");
// H1: every hero of the catalogue (G3: three; Sylvara is the fourth).
Check(s.Hud.HeroDetail.RecruitButtons.Count == LuneContent.Heroes.Count && LuneContent.Heroes.Count == 4, "all four heroes to recruit (H1)");
int heroCost = LuneContent.Heroes.Values[0].Cost;
bool unaffordable = s.Match.GoldOf(HudSession.Human) < heroCost;
Check(!unaffordable || s.Hud.HeroDetail.RecruitButtons.Values.All(b => b.Disabled), "recruiting blocked without gold");
Check(!unaffordable || s.Texts().Contains("Es fehlen " + (heroCost - s.Match.GoldOf(HudSession.Human)) + " Gold."), "missing gold explained");
s.Me.Draft.Gold += 2000;
s.Sync();
string heroId = LuneContent.Heroes.Keys[0];
yield return s.Tap(() => s.Hud.HeroDetail.RecruitButtons[heroId]);
Check(s.Model.OwnHero != null && s.Model.OwnHero.HeroId == heroId, "hero recruited through the window");
Check(s.Hud.HeroDetail.Tabs != null, "hero window shows the hero tabs");
Check(s.Hud.ToastText.Length > 0, "feedback after recruiting");
yield return s.Shot("docs/port/screenshots/u3/pruefung/c-held.png");
// Items: buy one, the card turns unique; slots fill up; higher items need levels.
yield return s.Tap(() => s.Hud.HeroDetail.Tabs.Tabs[LuneHeroDetail.TabItems]);
Dictionary cards = s.Hud.HeroDetail.ItemCards;
// S2: recipes only under the extended rules; the verify plays the reference rules.
Check(cards.Count == LuneContent.Items.Values.Count(item => !item.IsRecipe || s.Match.Extended), "item shop lists every item of the rules in play");
string first = cards.First(c => c.Value.Current == LuneItemSlot.State.Available).Key;
yield return s.Tap(() => cards[first]);
Check(s.Model.OwnHero.Items.Contains(first), "item bought by click");
Check(s.Hud.HeroDetail.ItemCards[first].Current == LuneItemSlot.State.Unique, "carried item is marked GETRAGEN");
ItemSpec gated = LuneContent.Items.Values.FirstOrDefault(i => i.MinLevel > 1);
if (gated != null)
Check(s.Hud.HeroDetail.ItemCards[gated.Id].Current == LuneItemSlot.State.Incompatible, "item above the hero level: STUFE FEHLT");
foreach (string id in s.Hud.HeroDetail.ItemCards.Where(c => c.Value.Current == LuneItemSlot.State.Available).Select(c => c.Key).ToList())
{
if (s.Model.OwnHero.Items.Count >= LuneContent.ItemSlots)
break;
yield return s.Tap(() => s.Hud.HeroDetail.ItemCards[id]);
}
if (s.Model.OwnHero.Items.Count >= LuneContent.ItemSlots)
Check(s.Hud.HeroDetail.ItemCards.Values.Where(c => !s.Model.OwnHero.Items.Contains(c.ItemId)).All(c => c.Current == LuneItemSlot.State.Full || c.Current == LuneItemSlot.State.Incompatible),
"all slots used: other items PLÄTZE VOLL");
yield return s.Shot("docs/port/screenshots/u3/pruefung/c-items.png");
// Talents: locked at level 1, choosable at level 3.
yield return s.Tap(() => s.Hud.HeroDetail.Tabs.Tabs[LuneHeroDetail.TabTalents]);
Check(s.Hud.HeroDetail.TalentCards.All(c => c.Disabled), "no talent at level 1");
s.Me.Draft.Hero.Xp = LuneContent.LevelXp[LuneContent.TalentLevels[0] - 1];
s.Sync();
LuneButton option = s.Hud.HeroDetail.TalentCards.First(c => !c.Disabled);
yield return s.Tap(() => option);
Check(s.Model.OwnHero.Talents.Count == 1, "talent chosen by click");
s.Hud.CloseModal();
Check(!s.Hud.ModalOpen, "window closes");
// Normal units: details, lane order with the grotto warning, no item slots, return.
yield return s.Starter();
s.Sync();
Check(s.Model.OwnRoster.Count == 3, "starter lineup bought");
LuneArmyChip chip = s.Hud.Chips.Values.First();
yield return s.Tap(() => chip);
Check(s.Hud.SelectedUid == chip.Uid, "chip selects the unit");
Check(HudSession.Visible(s.Hud.UnitDetail), "unit details shown");
Check(s.Hud.UnitDetail.Query().ToList().Count == 0, "normal units have no item slots");
yield return s.Tap(() => s.Hud.UnitDetail.LaneButtons[LuneMatch.Grotto]);
Check(s.Model.OwnRoster.First(e => e.Uid == chip.Uid).Lane == LuneMatch.Grotto, "lane order GROTTE by click");
Check(s.Texts().Contains(LuneUnitDetail.GrottoWarning), "grotto warning shown");
yield return s.Shot("docs/port/screenshots/u3/pruefung/c-einheit.png");
int before = s.Model.OwnRoster.Count;
yield return s.Tap(() => s.Hud.UnitDetail.SellButton);
Check(s.Model.OwnRoster.Count == before - 1, "return by click");
Check(!HudSession.Visible(s.Hud.UnitDetail), "details close when the unit is gone");
s.Close();
Done("C");
}
// ---------------------------------------------------------------- D champions and boss
[UnityTest]
[Timeout(900000)]
public IEnumerator D_ChampionsAndBoss()
{
var s = new HudSession();
yield return s.Open();
s.Me.Draft.Gold += 3000;
s.Hud.OpenChampions();
s.Sync();
LuneChampionPanel panel = s.Hud.ChampionPanel;
Check(HudSession.Visible(panel), "champion tab");
yield return s.Tap(() => panel.LaneButtons["top"]);
Check(panel.TargetLane == "top", "target lane TOP");
string championId = panel.Cards.Keys.First();
yield return s.Tap(() => panel.Cards[championId]);
Check(s.Me.Draft.NewBookings.Count == 1 && s.Me.Draft.NewBookings[0].Lane == "top", "champion booked on TOP by click");
Check(s.Texts().Contains("Einsatz in Welle 2 – TOP"), "booking listed with wave and lane");
yield return s.Tap(() => panel.Cards[championId]);
Check(panel.Cards.Values.All(c => c.Disabled), "at most " + LuneContent.MaxBookings + " bookings per round");
Check(panel.Cards.Values.First().Reason.Contains("Höchstens"), "the limit is explained");
yield return s.Shot("docs/port/screenshots/u3/pruefung/d-champions.png");
LuneButton cancel = s.ButtonWithText("×", panel);
yield return s.Tap(() => cancel);
Check(s.Me.Draft.NewBookings.Count == 1, "booking cancelled by click");
s.Hud.OpenTroops();
s.Sync();
// Boss states from public state.
LuneBossIndicator boss = s.Hud.BossCard;
Check(boss.BadgeText == "BOSS VERFÜGBAR", "available: " + boss.BadgeText);
s.Match.RoundNumber = 3;
s.Match.Boss.NextAvailableRound = 6;
s.Sync();
Check(boss.BadgeText == "COOLDOWN – 3 RUNDEN", "cooldown: " + boss.BadgeText);
// The grotto was won in round 2; the boss attacks in round 3, its chooser picks the lane now.
s.Match.Boss.Claim = new BossClaim { Team = 0, WonRound = 2, DeployRound = 3, ChooserSlot = HudSession.Human, Lane = "mid" };
s.Sync();
Check(boss.BadgeText == "EINSATZ NÄCHSTE WELLE", "own boss next wave: " + boss.BadgeText);
Check(HudSession.Visible(boss.LaneButtons["bot"]), "the chooser picks the lane in the card");
yield return s.Tap(() => boss.LaneButtons["bot"]);
Check(s.Me.Draft.BossLane == "bot", "boss lane chosen by click (private until combat)");
Check(s.Texts().Contains("Deine SCHLUNDKRONE greift in Welle 3 auf BOTTOM an"), "banner for the own boss");
yield return s.Shot("docs/port/screenshots/u3/pruefung/d-boss-eigen.png");
s.Match.Boss.Claim = new BossClaim { Team = 1, WonRound = 2, DeployRound = 3, ChooserSlot = 4, Lane = "top" };
s.Sync();
string text = s.Texts();
Check(text.Contains("SCHLUNDKRONE (Rot) greift in Welle 3 auf TOP an"), "banner for the enemy boss");
Check(text.Contains("ANGEKÜNDIGT") && text.Contains("GEGEN EUCH"), "announced in the wave preview");
Check(s.Hud.Lanes["top"].Level >= LuneLaneThreat.Pressure, "enemy boss raises the lane threat");
s.Close();
Done("D");
}
// ---------------------------------------------------------------- E ready, bots, privacy
[UnityTest]
[Timeout(900000)]
public IEnumerator E_ReadyBotsPrivacy()
{
var s = new HudSession();
yield return s.Open();
string team = s.Texts();
Check(team.Contains("BOT") && team.Contains("PLANT"), "team panel: bots and the planning human");
yield return s.Starter();
s.Game.Key(KeyCode.Space);
s.Sync();
yield return HudSession.Frames(1);
Check(s.Runner.Current == LuneMatchRunner.Stage.Combat, "Space reports ready; with every bot ready the combat starts");
yield return s.FinishCombat();
Check(s.Runner.Current == LuneMatchRunner.Stage.Planning, "back in planning after the wave");
s.Game.Key(KeyCode.Space);
Check(!s.Me.Ready, "Bereit is blocked for a second after a combat (stale clicks)");
yield return new WaitForSecondsRealtime(1.05f);
s.Sync();
int roster = s.Model.OwnRoster.Count;
s.Match.SetReady(HudSession.Human, true);
s.Sync();
Check(s.Model.Locked, "plan locked");
Check(s.Hud.ShopCards.Values.All(c => c.Disabled), "cards locked after Ready");
s.Game.Key(KeyCode.Alpha1);
Check(s.Model.OwnRoster.Count == roster, "hotkey buys nothing after Ready");
Check(s.Hud.ToastText == "Bereit gemeldet – die Planung ist verbindlich.", "hotkey explains the lock");
Check(s.Texts().Contains("PLANUNG · BEREIT"), "phase badge PLANUNG · BEREIT");
// A human seat of the own team drops: a bot plays it on, marked as such.
s.Match.Players[0].IsHuman = true;
s.Sync();
s.Match.Players[0].IsHuman = false;
s.Sync();
Check(s.Texts().Contains("BOT ÜBERNIMMT"), "dropped human seat: BOT ÜBERNIMMT");
// Privacy: the same public picture for every seat, other drafts invisible.
string digest = s.Hud.PublicDigest();
for (int seat = 0; seat < 6; seat++)
Check(new LuneHudModel(s.Match, seat).PublicDigest() == digest, "seat " + seat + " sees the same public state");
s.Match.Players[4].Draft.Gold = 77777;
s.Sync();
Check(!s.Texts().Contains("77.777"), "an enemy draft's gold never appears");
s.Close();
Done("E");
}
// ---------------------------------------------------------------- F input
[UnityTest]
[Timeout(900000)]
public IEnumerator F_Input()
{
var s = new HudSession();
yield return s.Open();
// Minimap: a click moves the camera to that map point.
LuneMinimap map = s.Hud.Minimap;
Vector2 local = new Vector2(map.layout.width * .75f, map.layout.height * .25f);
Vec2 expected = map.ToWorld(local);
s.ClickAt(map.LocalToWorld(local));
yield return HudSession.Frames(1);
yield return new WaitForSecondsRealtime(.6f);
Check(s.Rig.Focus.DistanceTo(expected) < 3.0, "minimap click moves the camera to " + expected + ", focus " + s.Rig.Focus);
// Lane click and hotkeys.
yield return s.Tap(() => s.Hud.Lanes["bot"]);
yield return new WaitForSecondsRealtime(.6f);
LanePath path = s.Match.Map.LanePaths["bot"];
Vec2 lanePoint = s.Match.Map.LanePoint(0, "bot", path.Length * .2);
Check(s.Rig.Focus.DistanceTo(lanePoint) < 3.0, "lane click focuses the own BOTTOM deployment " + s.Rig.Focus + " vs " + lanePoint);
s.Game.Key(KeyCode.F3);
yield return new WaitForSecondsRealtime(.6f);
Check(s.Rig.Focus.DistanceTo(s.Match.Map.LanePoint(0, "mid", s.Match.Map.LanePaths["mid"].Length * .2)) < 3.0, "F3 focuses Mid");
s.Game.Key(KeyCode.G);
yield return new WaitForSecondsRealtime(.6f);
Check(s.Rig.Focus.DistanceTo(s.Match.Map.GrottoCenter()) < 3.0, "G focuses the grotto");
s.Game.Key(KeyCode.B);
yield return new WaitForSecondsRealtime(.6f);
Check(s.Rig.Focus.DistanceTo(s.Match.Map.CorePosition(0)) < 3.0, "B focuses the own Ancient");
s.Game.Key(KeyCode.H);
Check(s.Hud.ToastText.StartsWith("Kein Held im Feld"), "H without a hero explains why");
// Hotkeys 1–5 buy.
int roster = s.Model.OwnRoster.Count;
s.Game.Key(KeyCode.Alpha2);
Check(s.Model.OwnRoster.Count == roster + 1, "hotkey 2 buys the second card's troop");
// No click-through: HUD panels take the pointer, the open field does not.
Vector2 topPixel = PanelToPixel(s, HudSession.Center(s.Hud.Root.Q("lune-hud").Query().First()));
Check(s.Hud.IsPointerOverHud(topPixel), "the HUD takes clicks on its panels");
Check(!s.Hud.IsPointerOverHud(new Vector2(s.Width * .5f, s.Height * .5f)), "the open field is free");
// Field click on the own preview unit selects it.
s.Game.FocusLane("mid", true);
yield return HudSession.Frames(4);
LuneUnitView figure = s.View.Units.FirstOrDefault(v => v.Unit != null && v.Unit.OwnerSlot == HudSession.Human && v.Unit.Kind == "troop");
Check(figure != null, "planning shows the own army in formation");
if (figure != null)
{
Vector3 screen = s.Camera.WorldToScreenPoint(figure.AimPoint);
s.Game.ClickField(new Vector2(screen.x, screen.y));
Check(s.Hud.SelectedUid == figure.Unit.PersistentUid, "field click selects the own unit");
s.Game.Key(KeyCode.Escape);
Check(s.Hud.SelectedUid.Length == 0, "Esc clears the selection");
}
// Tooltips: delay, content, gone on leave.
LuneUnitCard card = s.Hud.ShopCards.Values.First();
LuneTooltip tip = s.Hover(card);
Check(tip.Open && tip.Shown.Contains(DataCatalog.GetUnit(card.TypeId).DisplayName), "card tooltip with the unit: " + tip.Shown);
s.Leave();
Check(!tip.Open, "tooltip hides when the pointer leaves");
tip = s.Hover(s.Hud.Lanes["mid"]);
Check(tip.Open && tip.Shown.Contains("MID-LANE"), "lane tooltip");
yield return s.Shot("docs/port/screenshots/u3/pruefung/f-tooltip.png");
s.Leave();
// Options: F1 opens, a running combat pauses, Esc closes.
s.Runner.Ready();
yield return s.Fight(2.0);
s.Game.Key(KeyCode.F1);
Check(s.Hud.Settings.Open, "F1 opens the options");
Check(s.Driver.Paused, "options pause the combat offline");
s.Game.Key(KeyCode.Escape);
Check(!s.Hud.Settings.Open && !s.Driver.Paused, "Esc closes the options and resumes");
s.Close();
Done("F");
}
static Vector2 PanelToPixel(HudSession s, Vector2 panel)
{
float scale = Mathf.Min(s.Width / (float)LuneHud.Reference.x, s.Height / (float)LuneHud.Reference.y) * s.Hud.UiScale;
Vector2 pixel = panel * scale;
return new Vector2(pixel.x, s.Height - pixel.y);
}
// ---------------------------------------------------------------- G events and reduced motion
[UnityTest]
[Timeout(900000)]
public IEnumerator G_EventsAndMotion()
{
foreach (bool reduced in new[] { false, true })
{
var s = new HudSession();
yield return s.Open(reducedMotion: reduced);
for (int index = 0; index < 7; index++)
s.Hud.Feed.Push("Ereignis " + index);
Check(s.Hud.Feed.Texts.Count == LuneEventFeed.Limit, "event feed holds at most five entries");
Check(s.Hud.Feed.Texts[0] == "Ereignis 6", "newest entry on top");
s.Tick(LuneEventFeed.Lifetime + .5f, .1f);
Check(s.Hud.Feed.Texts.Count == 0, "entries fade after their lifetime");
double max = s.Match.Map.BuildingDef("rot_ancient").MaxHp;
s.Match.BuildingHp["rot_ancient"] = max * .2;
s.Sync();
s.Tick(.3f);
var lanes = s.Hud.Lanes["mid"];
s.Match.BuildingHp["blau_mid_outer"] = 0.0;
s.Match.BuildingHp["blau_mid_inner"] = 0.0;
s.Sync();
s.Tick(.3f);
Check(lanes.Level == LuneLaneThreat.Critical, "both Mid towers fell: KRITISCH");
Check(s.Hud.Feed.Texts.Count >= 2, "tower falls reported");
Check(lanes.Pulsing == !reduced, (reduced ? "reduced motion: no pulse" : "critical lane pulses") + " on the threat badge");
Check(s.Hud.AncientPulsing(1) == !reduced, (reduced ? "reduced motion: no pulse" : "critical Ancient pulses") + " on the Ancient bar");
s.Close();
}
Done("G");
}
// ---------------------------------------------------------------- H rates and portraits
[UnityTest]
[Timeout(900000)]
public IEnumerator H_RatesAndPortraits()
{
var s = new HudSession();
yield return s.Open();
int before = s.Hud.Syncs;
s.Tick(2f, .01f);
int planning = s.Hud.Syncs - before;
Check(planning >= 18 && planning <= 22, "planning: 10 syncs per second (" + planning + " in 2 s)");
s.Runner.Ready();
s.Hud.Tick(.01f);
before = s.Hud.Syncs;
s.Tick(2f, .01f);
int combat = s.Hud.Syncs - before;
Check(combat >= 9 && combat <= 11, "combat: 5 syncs per second (" + combat + " in 2 s)");
Check(s.Hud.SyncMs < 4.0, "one HUD sync costs " + s.Hud.SyncMs.ToString("0.000") + " ms");
LunePortraits cache = LunePortraits.Shared;
cache.Portrait("g01");
cache.Flush();
int rendered = cache.Rendered;
Texture first = cache.Portrait("g01");
cache.Flush();
Texture again = cache.Portrait("g01");
Check(first != null && first == again, "portrait cached");
Check(cache.Rendered == rendered, "each portrait is rendered once");
Debug.Log("LUNE_VERIFY rates planning " + planning + "/2s combat " + combat + "/2s sync " + s.Hud.SyncMs.ToString("0.000") + " ms portraits " + cache.Rendered);
s.Close();
Done("H");
}
// ---------------------------------------------------------------- I field
static GameObject SceneObject(string name)
{
return Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).Select(t => t.gameObject).FirstOrDefault(g => g.name == name);
}
///
/// U3 addition (from the G3 motif comparison): the field around the HUD. Formation areas and
/// the own grotto entrance in planning, the resting Schlundkrone while the grotto is open,
/// name tags of heroes and of a deployed boss, its owner ring, the Ancients' health bar.
///
[UnityTest]
[Timeout(900000)]
public IEnumerator I_Field()
{
var s = new HudSession();
yield return s.Open();
yield return HudSession.Frames(2);
List pads = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).Where(r => r.gameObject.name.StartsWith("Deployment_")).ToList();
Check(pads.Count == 6, "six formation areas in the scene: " + pads.Count);
Check(pads.Where(p => p.gameObject.name.StartsWith("Deployment_0_")).All(p => p.gameObject.activeInHierarchy), "own formation areas shown in planning");
Check(pads.Where(p => p.gameObject.name.StartsWith("Deployment_1_")).All(p => !p.gameObject.activeInHierarchy), "enemy formation areas hidden");
GameObject ownEntry = SceneObject("GrottoEntry_0");
GameObject enemyEntry = SceneObject("GrottoEntry_1");
Check(ownEntry != null && ownEntry.activeInHierarchy, "own grotto entrance shown while the grotto is open");
Check(enemyEntry != null && !enemyEntry.activeInHierarchy, "enemy grotto entrance hidden");
Check(s.View.RestingBoss != null && s.View.RestingBoss.activeInHierarchy, "the Schlundkrone rests in the open grotto");
s.Match.Boss.NextAvailableRound = 5;
yield return HudSession.Frames(2);
Check(ownEntry != null && !ownEntry.activeInHierarchy, "grotto entrance hidden while the grotto is locked");
Check(s.View.RestingBoss != null && !s.View.RestingBoss.activeInHierarchy, "no resting boss while the grotto is locked");
s.Match.Boss.NextAvailableRound = 1;
// The starter army stands on Mid: that area lights up.
yield return s.Starter();
s.Leave();
yield return HudSession.Frames(2);
var block = new MaterialPropertyBlock();
Renderer mid = pads.First(p => p.gameObject.name == "Deployment_0_mid");
mid.GetPropertyBlock(block);
Check(block.GetFloat("_Highlight") > .5f, "the area of a lane with own units lights up");
Renderer top = pads.First(p => p.gameObject.name == "Deployment_0_top");
top.GetPropertyBlock(block);
Check(block.GetFloat("_Highlight") < .5f, "an empty lane's area stays plain");
// A recruited hero carries its name and level in the planning preview.
s.Me.Draft.Gold += 2000;
s.Hud.OpenHero();
string heroId = LuneContent.Heroes.Keys[0];
yield return s.Tap(() => s.Hud.HeroDetail.RecruitButtons[heroId]);
s.Hud.CloseModal();
s.Leave();
yield return HudSession.Frames(3);
string heroTag = LuneContent.Heroes[heroId].Name + " · Stufe 1";
Check(s.Hud.FieldTags.Texts.Contains(heroTag), "hero tag in the planning preview: " + string.Join(", ", s.Hud.FieldTags.Texts));
// An Ancient below full health shows its bar.
double core = s.Match.Map.BuildingDef("blau_ancient").MaxHp;
s.Match.BuildingHp["blau_ancient"] = core * .6;
yield return HudSession.Frames(2);
Transform bar = s.View.Building("blau_ancient").transform.Find("AncientBar");
Check(bar != null && bar.gameObject.activeInHierarchy, "damaged Ancient shows its health bar");
Transform enemyBar = s.View.Building("rot_ancient").transform.Find("AncientBar");
Check(enemyBar == null || !enemyBar.gameObject.activeInHierarchy, "an Ancient at full health shows none");
// Combat with the own team's boss deployed on Mid: areas hidden, boss tag and owner ring.
s.Match.Boss.Claim = new BossClaim { Team = 0, WonRound = 0, DeployRound = s.Match.RoundNumber, ChooserSlot = HudSession.Human, Lane = "mid" };
s.Game.Key(KeyCode.Space);
yield return s.Fight(1.0);
yield return HudSession.Frames(2);
Check(pads.All(p => !p.gameObject.activeInHierarchy), "formation areas hidden in combat");
Check(ownEntry != null && !ownEntry.activeInHierarchy, "grotto entrance hidden in combat");
Check(s.Hud.FieldTags.Texts.Contains("SCHLUNDKRONE · für Blau"), "deployed boss says whom it fights for: " + string.Join(", ", s.Hud.FieldTags.Texts));
Check(s.Hud.FieldTags.Texts.Contains(heroTag), "hero tag in combat");
LuneUnitView boss = s.View.Units.FirstOrDefault(v => v.Unit.Kind == "boss" && v.Unit.Team == 0);
Transform ring = boss != null ? boss.transform.Find("OwnerRing") : null;
Check(ring != null && ring.gameObject.activeInHierarchy, "deployed boss wears the owner ring of its team");
Check(s.View.RestingBoss == null || !s.View.RestingBoss.activeInHierarchy, "no resting boss while it is deployed");
s.Close();
Done("I");
}
}
}