champions-of-luneraria/Assets/Luneraria/Tests/UI/EditMode/HudModelTests.cs
Martin Stickelbroeck 4dee9b18d2 U3A HUD-Grundlagen: UI Toolkit, Sichtmodell, menschlicher Sitz
Beginn des Ports des G3-HUD (Referenz luneraria/g3, 1b540f2).

- Entscheidung UI Toolkit statt uGUI nach kurzer Bewertung und einer Probe:
  Panel rendert im Batchmodus in eine 1920x1080-Textur (Skalierung wie Godot
  canvas_items/expand, 1600x960), gefaste Verlaufsplatten, alle 50 Symbole,
  Schriften aus den variablen Fonts der Referenz.
- Sichtmodell portiert (LuneHudModel, LuneLaneThreat, LuneStats) mit den
  Privatsphäre-Regeln der Referenz: Gold, Entwurf, Rückgängig und neue
  Buchungen nur für den eigenen Sitz; Lanes, Gebäude, Boss und Wellen aus
  öffentlichem Zustand.
- Menschlicher Sitz in LuneMatchRunner/LuneMatchDriver: Bots planen die
  übrigen fünf Sitze, die Planung wartet auf Bereit; Tempo 1x/2x/4x und Pause
  nur im Kampf. Bot-Partien unverändert.
- Theme-Grundlagen: Farben und Zahlenformate, Platten (forged_style.gd),
  Vektorsymbole (lune_glyphs.gd) mit Painter2D, statische SDF-Atlanten aus
  den benannten Instanzen von Cantarell und Cinzel (OFL).
- Tests: EditMode 23/23 (12 neue: gleiches öffentliches Bild für alle Sitze,
  fremde Entwürfe privat, private Boss-Lane-Wahl, Lane-Bedrohung, Bosszustände,
  nächste Welle, Bereit-Sperre, Formate, menschlicher Sitz); PlayMode 3/3,
  Fingerabdrücke der Bot-Partien unverändert (DCE95C96556F3A1A, 7B8E43AEFC0D60C5).
- Dokumentation: U3-UI (Bewertung, U3A), U5-VISUAL (Vorgaben des
  Projektinhabers: stilisierter Realismus für die ganze Welt, erst in U5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:57:45 +02:00

309 lines
16 KiB
C#

using System.Collections.Generic;
using System.Linq;
using Luneraria.Core;
using Luneraria.View;
using NUnit.Framework;
namespace Luneraria.UI.Tests
{
/// <summary>
/// U3: the HUD's view model (LuneHudModel, LuneLaneThreat, LuneStats) against the rules of the
/// G3 reference (lune_hud_model.gd, lune_lane_threat.gd, verify_g3 parts B, D and E at model
/// level): privacy of gold, drafts and bookings, the same public picture for every seat, lane
/// threat levels, boss states, the coming wave, number formats, and the human seat of the host
/// loop (planning waits for Ready; bot matches unchanged).
/// </summary>
public sealed class HudModelTests
{
const int Human = 1;
static LuneMatch Fresh(int seed = 4, int human = Human)
{
var match = new LuneMatch();
match.Setup(new MatchOptions { Seed = seed, HumanSlot = human });
for (int slot = 0; slot < 6; slot++)
{
if (slot != human)
LuneBot.Plan(match, slot, "");
}
return match;
}
static string Digest(LuneMatch match, int viewer)
{
return new LuneHudModel(match, viewer).PublicDigest();
}
static void AssertSameForAll(LuneMatch match, string moment)
{
string reference = Digest(match, 0);
for (int viewer = 1; viewer < 6; viewer++)
Assert.AreEqual(reference, Digest(match, viewer), moment + ": public picture of seat " + viewer);
}
// ---------------------------------------------------------------- privacy (verify_g3 part E)
[Test]
public void EverySeatSeesTheSamePublicPicture()
{
LuneMatch match = Fresh();
AssertSameForAll(match, "planning round 1");
match.SetReady(Human);
match.BeginCombat();
for (int step = 0; step < 400 && match.Phase == Phase.Combat; step++)
{
match.StepCombat();
if (step % 100 == 0)
AssertSameForAll(match, "combat step " + step);
}
}
[Test]
public void OtherSeatsPlansStayPrivateUntilCombat()
{
LuneMatch match = Fresh();
match.SetReady(Human);
match.BeginCombat();
while (match.Phase == Phase.Combat)
match.StepCombat();
Assert.AreEqual(Phase.Planning, match.Phase, "the first wave must not end the match");
for (int slot = 0; slot < 6; slot++)
{
if (slot != Human)
LuneBot.Plan(match, slot, "");
}
var viewer = new LuneHudModel(match, Human);
string before = viewer.PublicDigest();
string membersBefore = string.Join("|", viewer.Members(1));
// An enemy recruits a hero and books an attack champion on Mid: both stay private.
int enemy = Enumerable.Range(3, 3).First(slot => match.Players[slot].Draft.NewBookings.Count < LuneContent.MaxBookings);
match.Players[enemy].Draft.Gold += 2000;
if (match.Players[enemy].Draft.Hero == null)
Assert.IsTrue(match.Submit(enemy, Command.HeroBuy(LuneContent.Heroes.Keys[0])).Ok, "hero recruit");
string championId = LuneContent.Champions.Keys[0];
CommandResult booking = match.Submit(enemy, Command.ChampionBook(championId, "mid"));
Assert.IsTrue(booking.Ok, "champion booking: " + booking.Message);
Assert.AreEqual(before, viewer.PublicDigest(), "an enemy draft changed the public picture (buildings, lanes, seats, boss, wave)");
Assert.AreEqual(membersBefore, string.Join("|", viewer.Members(1)), "an enemy draft hero is visible before combat");
Assert.AreEqual(match.GoldOf(Human), viewer.OwnGold, "own gold");
// The enemy sees its own draft: gold, booking and hero.
var enemyView = new LuneHudModel(match, enemy);
Assert.AreEqual(match.Players[enemy].Draft.Gold, enemyView.OwnGold, "enemy's own gold");
Assert.IsTrue(enemyView.OwnBookings().Any(b => b.Fresh && b.ChampionId == championId), "own fresh booking");
Assert.IsNotNull(enemyView.OwnHero, "own draft hero");
}
[Test]
public void BossLaneChoiceIsPrivateToTheChooser()
{
LuneMatch match = Fresh();
match.RoundNumber = 4;
match.Boss.Claim = new BossClaim { Team = 1, WonRound = 3, DeployRound = 5, ChooserSlot = 4, Lane = "top" };
match.Players[4].Draft.BossLane = "bot";
BossInfoCheck(new LuneHudModel(match, 4).Boss(), "deploy_next", "bot", true, true);
BossInfoCheck(new LuneHudModel(match, Human).Boss(), "deploy_next", "top", false, false);
AssertSameForAll(match, "boss claim with a private lane choice");
}
static void BossInfoCheck(LuneHudModel.BossInfo boss, string state, string lane, bool own, bool chooser)
{
Assert.AreEqual(state, boss.State);
Assert.AreEqual(lane, boss.Lane);
Assert.AreEqual(own, boss.Own);
Assert.AreEqual(chooser, boss.Chooser);
}
// ---------------------------------------------------------------- lane threat
static LuneLaneThreat.Result Threat(LuneMatch match, int team, string lane)
{
return LuneLaneThreat.Evaluate(match, team, lane);
}
[Test]
public void LaneThreatFollowsTowerHealth()
{
LuneMatch match = Fresh();
foreach (string lane in LuneMap.Lanes)
{
for (int team = 0; team < 2; team++)
Assert.AreEqual(LuneLaneThreat.Calm, Threat(match, team, lane).Level, lane + "/" + team + " at the start");
}
double outer = match.Map.BuildingDef("blau_mid_outer").MaxHp;
double inner = match.Map.BuildingDef("blau_mid_inner").MaxHp;
match.BuildingHp["blau_mid_outer"] = outer * .8;
LuneLaneThreat.Result result = Threat(match, 0, "mid");
Assert.AreEqual(LuneLaneThreat.Contested, result.Level);
CollectionAssert.AreEqual(new[] { "Grenzwacht bei 80 %" }, result.Reasons);
Assert.AreEqual(LuneLaneThreat.Calm, Threat(match, 1, "mid").Level, "the enemy's view of its own half");
match.BuildingHp["blau_mid_outer"] = outer * .4;
Assert.AreEqual(LuneLaneThreat.Pressure, Threat(match, 0, "mid").Level);
match.BuildingHp["blau_mid_outer"] = 0.0;
result = Threat(match, 0, "mid");
Assert.AreEqual(LuneLaneThreat.Pressure, result.Level);
CollectionAssert.AreEqual(new[] { "Grenzwacht gefallen" }, result.Reasons);
match.BuildingHp["blau_mid_inner"] = inner * .7;
CollectionAssert.AreEqual(new[] { "Grenzwacht gefallen", "Bastion bei 70 %" }, Threat(match, 0, "mid").Reasons);
match.BuildingHp["blau_mid_inner"] = inner * .4;
Assert.AreEqual(LuneLaneThreat.Critical, Threat(match, 0, "mid").Level);
match.BuildingHp["blau_mid_inner"] = 0.0;
result = Threat(match, 0, "mid");
Assert.AreEqual(LuneLaneThreat.Critical, result.Level);
Assert.AreEqual("KRITISCH", result.Name);
}
[Test]
public void AnnouncedDangersRaiseTheThreat()
{
LuneMatch match = Fresh();
string championId = LuneContent.Champions.Keys[0];
match.Players[4].Bookings.Add(new Booking { Uid = "test-b1", ChampionId = championId, Lane = "mid", DeployRound = match.RoundNumber, Cost = 0 });
LuneLaneThreat.Result result = Threat(match, 0, "mid");
Assert.AreEqual(LuneLaneThreat.Contested, result.Level);
CollectionAssert.AreEqual(new[] { "Angriffschampion " + LuneContent.Champions[championId].Name + " angekündigt" }, result.Reasons);
Assert.AreEqual(LuneLaneThreat.Calm, Threat(match, 1, "mid").Level, "an own champion is no threat");
match.Boss.Claim = new BossClaim { Team = 1, WonRound = 1, DeployRound = match.RoundNumber, ChooserSlot = 4, Lane = "top" };
Assert.AreEqual(LuneLaneThreat.Pressure, Threat(match, 0, "top").Level, "enemy boss with the Grenzwacht standing");
match.BuildingHp["blau_top_outer"] = 0.0;
Assert.AreEqual(LuneLaneThreat.Critical, Threat(match, 0, "top").Level, "enemy boss after the Grenzwacht fell");
var model = new LuneHudModel(match, Human);
Assert.AreEqual(1, model.Lane("mid").Dangers.Count, "danger listed in the lane");
LuneHudModel.WaveInfo wave = model.NextWave();
Assert.AreEqual(1, wave.Champions.Count);
Assert.IsFalse(wave.Champions[0].Own);
Assert.IsNotNull(wave.Boss);
Assert.AreEqual("top", wave.Boss.Lane);
}
[Test]
public void BattlefieldCountsTheLaneUnits()
{
LuneMatch match = Fresh();
match.SetReady(Human);
match.BeginCombat();
for (int step = 0; step < 300; step++)
match.StepCombat();
var model = new LuneHudModel(match, Human);
foreach (string lane in LuneMap.Lanes)
{
int own = match.Combat.Units.Count(u => u.Alive && u.IsMobile() && u.Zone == "lane" && u.Lane == lane && u.Team == 0);
int enemy = match.Combat.Units.Count(u => u.Alive && u.IsMobile() && u.Zone == "lane" && u.Lane == lane && u.Team == 1);
LuneHudModel.LaneInfo info = model.Lane(lane);
Assert.IsTrue(info.HasCounts);
Assert.AreEqual(own, info.OwnCount, lane + " own units");
Assert.AreEqual(enemy, info.EnemyCount, lane + " enemy units");
Assert.AreEqual(4, info.Towers.Length);
}
}
// ---------------------------------------------------------------- boss and waves
[Test]
public void BossStatesFromPublicState()
{
LuneMatch match = Fresh();
Assert.AreEqual("available", new LuneHudModel(match, Human).Boss().State, "round 1");
match.RoundNumber = 2;
match.Boss.NextAvailableRound = 5;
LuneHudModel.BossInfo boss = new LuneHudModel(match, Human).Boss();
Assert.AreEqual("cooldown", boss.State);
Assert.AreEqual(3, boss.RoundsLeft);
match.Boss.Claim = new BossClaim { Team = 0, WonRound = 2, DeployRound = 3, ChooserSlot = 0, Lane = "bot" };
boss = new LuneHudModel(match, Human).Boss();
Assert.AreEqual("deploy_next", boss.State);
Assert.IsTrue(boss.Own);
Assert.AreEqual(LuneContent.BossBountyFor(3), boss.Bounty);
Assert.IsFalse(new LuneHudModel(match, 4).Boss().Own, "the enemy's view");
}
[Test]
public void NextWaveIsTheRulesWave()
{
LuneMatch match = Fresh();
for (int round = 1; round <= 24; round++)
{
match.RoundNumber = round;
LuneHudModel.WaveInfo wave = new LuneHudModel(match, Human).NextWave();
List<UnitDef> rules = LuneWaves.Build(match.Balance, round);
Assert.AreEqual(round, wave.Round);
Assert.AreEqual(rules.Count, wave.Count);
Assert.AreEqual(rules.Count, wave.Monsters.Sum(m => m.Count), "round " + round);
List<string> before = round > 1 ? LuneWaves.UnlockedTypes(match.Balance, round - 1) : new List<string>();
foreach (LuneHudModel.MonsterGroup group in wave.Monsters)
Assert.AreEqual(round > 1 && !before.Contains(group.Id), group.New, "round " + round + " " + group.Id);
}
}
[Test]
public void ReadyLocksThePlan()
{
LuneMatch match = Fresh();
var model = new LuneHudModel(match, Human);
Assert.IsFalse(model.Locked);
Assert.IsFalse(model.CanUndo, "nothing to undo yet");
Assert.IsTrue(match.Submit(Human, Command.Buy(match.BaseIds(match.Players[Human].Bund)[0])).Ok);
Assert.IsTrue(model.CanUndo);
match.SetReady(Human);
Assert.IsTrue(model.Locked);
Assert.IsFalse(model.CanUndo, "undo after Ready");
model.ReadyCount(out int ready, out int humans);
Assert.AreEqual(1, humans);
Assert.AreEqual(1, ready);
Assert.AreEqual("self", model.StatusOf(Human));
Assert.AreEqual("bot", model.StatusOf(0));
}
// ---------------------------------------------------------------- formats
[Test]
public void NumbersAreFormattedAsInGodot()
{
Assert.AreEqual("12.000", LuneUiTheme.Number(12000));
Assert.AreEqual("999", LuneUiTheme.Number(999));
Assert.AreEqual("-1.500", LuneUiTheme.Number(-1500));
Assert.AreEqual("1.234.568", LuneUiTheme.Number(1234567.5));
Assert.AreEqual("1,25", LuneUiTheme.Decimal(1.25, 2));
Assert.AreEqual("2,0", LuneUiTheme.Decimal(2.0));
Assert.AreEqual("Leicht", LuneStats.StrongAgainst(0));
Assert.AreEqual("Schwer", LuneStats.WeakAgainst(0));
Assert.AreEqual(1, LuneStats.StageOf("g01"));
Assert.AreEqual(2, LuneStats.StageOf("w04"));
Assert.AreEqual("Grenzwacht", LuneHudModel.BuildingTitle("blau_mid_outer"));
Assert.AreEqual("Kernwacht", LuneHudModel.BuildingTitle("rot_base_2"));
Assert.AreEqual("MID · Bastion Rot", LuneHudModel.BuildingPlace("rot_mid_inner"));
Assert.AreEqual("Basis Blau · Kernwacht 1", LuneHudModel.BuildingPlace("blau_base_1"));
}
// ---------------------------------------------------------------- the human seat of the host loop
[Test]
public void HumanSeatPlanningWaitsForReady()
{
var runner = new LuneMatchRunner(new MatchOptions { Seed = 4, HumanSlot = Human });
Assert.AreEqual(Human, runner.HumanSlot);
for (int frame = 0; frame < 600; frame++)
runner.Advance(1.0 / 60.0);
Assert.AreEqual(LuneMatchRunner.Stage.Planning, runner.Current, "planning waits for the human");
Assert.IsFalse(runner.Match.Players[Human].Ready);
Assert.IsEmpty(runner.Match.Players[Human].Draft.Roster, "the bots never plan the human seat");
for (int slot = 0; slot < 6; slot++)
{
if (slot != Human)
Assert.IsTrue(runner.Match.Players[slot].Ready, "bot seat " + slot + " ready");
}
Assert.IsTrue(runner.Ready());
Assert.AreEqual(LuneMatchRunner.Stage.Combat, runner.Current, "Ready starts the combat when everyone is ready");
int frames = 0;
while (runner.Current == LuneMatchRunner.Stage.Combat && frames++ < 100000)
runner.Advance(1.0 / 60.0);
runner.Advance(1.0 / 60.0);
Assert.AreEqual(LuneMatchRunner.Stage.Planning, runner.Current, "planning follows the settlement at once");
Assert.IsFalse(runner.Match.Players[Human].Ready, "the human plans the next round again");
}
[Test]
public void BotMatchStillStartsByItself()
{
var runner = new LuneMatchRunner(new MatchOptions { Seed = 4, HumanSlot = -1 });
Assert.AreEqual(-1, runner.HumanSlot);
Assert.IsFalse(runner.Ready(), "no human seat");
runner.Advance(runner.PlanningSeconds + .01);
Assert.AreEqual(LuneMatchRunner.Stage.Combat, runner.Current);
}
}
}