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>
309 lines
16 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|