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/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 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"); } } }