champions-of-luneraria/Assets/Luneraria/Tests/UI/PlayMode/HudVerifyTests.cs
Martin Stickelbroeck 542a9646c8 H2 Brunhild: Figur, Skin Eiswalküre, Oberfläche, Nachweise
Meshy-Figur Brunhild (28.706 Dreiecke mit Lane-Fassung und Speer) mit
Ultimate-Clip Jump Attack, 113 Credits. Skin Eiswalküre über skin_variant.py
(Farbband über Rot, Mindestsättigung schützt Hauttöne). Heldenwahl bei fünf
Helden kompakter; Tests auf fünf Helden angehoben.
Kern 619/619 (h2 22), EditMode 34/34, PlayMode 19/19, Verify 718/0,
Selbsttest 30/30, Golden 0, Fingerabdruck unverändert; A/B mit Sylvara:
Karte gleich, Mid nah -4 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:21:50 +02:00

582 lines
34 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.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
{
/// <summary>
/// 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.
/// </summary>
public sealed class HudVerifyTests
{
readonly List<string> _failures = new List<string>();
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<string, Rect> regions = s.Hud.Regions();
Rect area = s.Hud.Root.Q("lune-hud").layout;
foreach (KeyValuePair<string, Rect> 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<LuneModalPanel>().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/H2: every hero of the catalogue (G3: three; Sylvara and Brunhild follow).
Check(s.Hud.HeroDetail.RecruitButtons.Count == LuneContent.Heroes.Count && LuneContent.Heroes.Count == 5, "all five heroes to recruit (H2)");
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<string, LuneItemSlot> 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<LuneItemSlot>().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<LunePanel>().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<Transform>(FindObjectsInactive.Include, FindObjectsSortMode.None).Select(t => t.gameObject).FirstOrDefault(g => g.name == name);
}
/// <summary>
/// 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.
/// </summary>
[UnityTest]
[Timeout(900000)]
public IEnumerator I_Field()
{
var s = new HudSession();
yield return s.Open();
yield return HudSession.Frames(2);
List<Renderer> pads = Object.FindObjectsByType<Renderer>(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");
}
}
}