champions-of-luneraria/Assets/Luneraria/Tests/View/PlayMode/VerticalFunctionTests.cs
Martin Stickelbroeck 2b86c0d3b5 U3B/U3C Zwischenstand: HUD-Bereiche, Eingabe und Befehle
Das G3-HUD steht im Spiel; die Abnahme (U3D) folgt.

- U3B: alle Bereiche und Komponenten aus Godot übertragen (LuneHud,
  Lanes, Bossgrotte, nächste Welle, Kaufkarten, Armee, Held mit Items und
  Talenten, Angriffschampions, Team, Ereignisse, Minimap, Tooltip,
  Optionen, Partieende). Porträts aus den echten Modellen, einmal gerendert.
- Offene G3-Punkte umgesetzt: „1 WELLE“ als Band am Porträtrand,
  Schadens- und Rüstungssymbole auf den Karten, Armeewert, Heldenporträts
  auf der Minimap. Befehlsplatz für die Ultimates der geplanten Helden
  reserviert (verborgen).
- U3C: LuneGame verdrahtet Befehle (gleiche Schnittstelle wie die Bots),
  Bereit mit 1-s-Sperre nach dem Kampf, Tempo und Pause, Tasten wie in G3,
  Kamera-Ziele, Auswahl im Feld, Planungsvorschau und Aufstellungsflächen.
  Ersetzt die U2-Debug-Anzeige. Bot-Partien und Benchmark unverändert.
- Szenen- und Bibliotheksbauer idempotent: Animator-Controller und
  Post-Processing-Profil werden an Ort und Stelle aktualisiert. Die Szene
  bekommt bei jedem Bau neue Objekt-IDs, der Inhalt ist gleich.
- Prüfung (Port von verify_g3, Teile A–H): 699 Prüfungen ohne Fehler.
  EditMode 23/23, PlayMode 12/12, Fingerabdrücke der Bot-Partien
  unverändert (DCE95C96556F3A1A, 7B8E43AEFC0D60C5).

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

280 lines
16 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Luneraria.Core;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace Luneraria.View.Tests
{
/// <summary>
/// U2D vertical function test: complete bot matches visible in the Unity scene, before the full
/// asset import. Match 1 (seed 2) runs from round 1 to the fall of an Ancient: lanes, towers
/// fall, heroes fight, the grotto boss is won and deployed. Match 2 (seed 9) starts at round 21
/// with the fixture of the golden scenario high_wave_21 (waves above 20). Both must end with the
/// fingerprint of the plain core run. A camera director photographs the key moments
/// (docs/port/screenshots/u2d/); results and frame times go to docs/port/logs/u2d/ (or the
/// LUNE_EVIDENCE folder).
/// </summary>
public sealed class VerticalFunctionTests
{
const float Speed = 6f;
sealed class Shot
{
public string Label;
public Func<LuneMatchRunner, LuneMatchView, bool> Trigger;
public Action<LuneMatchRunner, LuneMatchView, LuneCameraRig> Aim;
public float Delay = .35f;
public bool Done;
}
sealed class Stats
{
public int Frames;
public double Seconds;
public readonly List<double> FrameMs = new List<double>();
public double AdvanceMs;
public double SyncMs;
public int Units;
}
[UnityTest]
[Timeout(3600000)]
public IEnumerator BotMatchesRunVisiblyAndMatchTheCore()
{
var report = new StringBuilder("{\n \"runs\": [\n");
var failures = new List<string>();
yield return Play(2, 1, 0, "match-seed2", SeedTwoShots(), report, failures, false);
report.Append(",\n");
yield return Play(9, 21, 4000, "match-seed9-round21", WaveShots(), report, failures, true);
report.Append("\n ]\n}\n");
string logs = PlayModeSupport.Evidence("logs", "u2d");
Directory.CreateDirectory(logs);
File.WriteAllText(Path.Combine(logs, "vertical-function-test.json"), report.ToString());
Assert.IsEmpty(failures, string.Join("\n", failures));
}
static IEnumerator Play(int seed, int startRound, int extraGold, string label, List<Shot> shots, StringBuilder report, List<string> failures, bool measure)
{
var options = new MatchOptions { Seed = seed, HumanSlot = -1 };
LuneTrace core = LuneTrace.RunCore(options, startRound, extraGold, "");
QualitySettings.vSyncCount = 0;
Application.targetFrameRate = 60;
yield return PlayModeSupport.Load();
var driver = UnityEngine.Object.FindAnyObjectByType<LuneMatchDriver>();
var view = UnityEngine.Object.FindAnyObjectByType<LuneMatchView>();
var rig = UnityEngine.Object.FindAnyObjectByType<LuneCameraRig>();
Camera camera = Camera.main;
rig.Interactive = false;
driver.HumanSlot = -1;
driver.Seed = seed;
driver.StartRound = startRound;
driver.ExtraGold = extraGold;
driver.Speed = Speed;
driver.Restart();
LuneMatchRunner runner = driver.Runner;
rig.Overview(true);
var rounds = new List<string>();
var events = new List<string>();
int maxUnits = 0;
int maxHeroes = 0;
runner.Settled += settlement =>
{
rounds.Add(string.Format("{{\"round\": {0}, \"combat_seconds\": {1}, \"lost\": [{2}], \"boss\": \"{3}\", \"end\": \"{4}\"}}", settlement.Round,
PlayModeSupport.Number(settlement.CombatTime), string.Join(", ", settlement.BuildingsLost.Select(b => "\"" + b + "\"")), settlement.Boss, settlement.EndReason));
foreach (string building in settlement.BuildingsLost)
events.Add(string.Format("\"round {0}: {1} destroyed\"", settlement.Round, building));
if (settlement.Boss != "none")
events.Add(string.Format("\"round {0}: grotto boss {1}\"", settlement.Round, settlement.Boss));
};
var captures = new List<string>();
Shot active = null;
float activeSince = 0f;
var stats = new Stats();
bool measuring = false;
float realStart = Time.realtimeSinceStartup;
float overSince = -1f;
while (Time.realtimeSinceStartup - realStart < 900f)
{
yield return null;
LuneMatch match = runner.Match;
if (match.Combat != null && runner.Current == LuneMatchRunner.Stage.Combat)
{
maxUnits = Math.Max(maxUnits, match.Combat.Units.Count(u => u.IsMobile() && u.Alive));
maxHeroes = Math.Max(maxHeroes, match.Combat.Units.Count(u => u.Kind == "hero" && u.Alive));
}
// Frame times at normal game speed with the most units on the map (match 2, round 21).
if (measure && runner.Current == LuneMatchRunner.Stage.Combat && match.RoundNumber == startRound)
{
double elapsed = match.Combat.Elapsed;
if (!measuring && elapsed >= 10.0 && stats.Frames == 0)
{
measuring = true;
driver.Speed = 1f;
Application.targetFrameRate = -1;
rig.LookAt(new Vec2(0, 0), 64f, true);
}
if (measuring)
{
stats.Frames++;
stats.Seconds += Time.unscaledDeltaTime;
stats.FrameMs.Add(Time.unscaledDeltaTime * 1000.0);
stats.AdvanceMs += driver.LastAdvanceMs;
stats.SyncMs += view.LastSyncMs;
stats.Units = Math.Max(stats.Units, match.Combat.Units.Count(u => u.IsMobile() && u.Alive));
if (stats.Seconds >= 20.0)
{
measuring = false;
driver.Speed = Speed;
Application.targetFrameRate = 60;
}
}
}
if (measuring)
continue;
if (active == null)
{
active = shots.FirstOrDefault(s => !s.Done && s.Trigger(runner, view));
if (active != null)
{
rig.SetYaw(0f, true);
active.Aim(runner, view, rig);
activeSince = Time.realtimeSinceStartup;
}
}
else if (Time.realtimeSinceStartup - activeSince >= active.Delay)
{
// Render directly: WaitForEndOfFrame never resumes in batch mode.
string file = Path.Combine(PlayModeSupport.Evidence("screenshots", "u2d"), active.Label + ".png");
LuneCapture.Render(camera, file);
captures.Add("\"" + active.Label + "\"");
Debug.Log("LUNE_U2D shot " + active.Label + " round " + runner.Match.RoundNumber + " " + runner.Current);
active.Done = true;
active = null;
}
if (runner.Current == LuneMatchRunner.Stage.Over)
{
if (overSince < 0f)
overSince = Time.realtimeSinceStartup;
bool pending = shots.Any(s => !s.Done && s.Trigger(runner, view)) || active != null;
if (!pending && Time.realtimeSinceStartup - overSince > 3.5f)
break;
if (Time.realtimeSinceStartup - overSince > 12f)
break;
}
}
LuneTrace trace = runner.Trace;
bool same = runner.Current == LuneMatchRunner.Stage.Over && trace.Hash == core.Hash && trace.Steps == core.Steps;
if (!same)
failures.Add(label + ": fingerprint " + trace.Hash.ToString("X16") + " / core " + core.Hash.ToString("X16"));
foreach (Shot shot in shots.Where(s => !s.Done))
failures.Add(label + ": no shot " + shot.Label);
double[] frameMs = stats.FrameMs.OrderBy(v => v).ToArray();
string performance = stats.Frames == 0 ? "null" : string.Format("{{\"window\": \"20 s real time at 1x speed, unlimited frame rate, round {0}\", \"frames\": {1}, \"fps\": {2}, \"frame_ms_avg\": {3}, \"frame_ms_p95\": {4}, \"simulation_ms_per_frame\": {5}, \"views_ms_per_frame\": {6}, \"units_alive_max\": {7}}}",
startRound, stats.Frames, PlayModeSupport.Number(stats.Frames / stats.Seconds), PlayModeSupport.Number(stats.Seconds * 1000.0 / stats.Frames),
PlayModeSupport.Number(frameMs[(int)(frameMs.Length * .95)]), PlayModeSupport.Number(stats.AdvanceMs / stats.Frames), PlayModeSupport.Number(stats.SyncMs / stats.Frames), stats.Units);
report.AppendFormat(" {{\"label\": \"{0}\", \"seed\": {1}, \"start_round\": {2}, \"extra_gold\": {3}, \"speed\": {4}, \"winner\": {5}, \"end_note\": \"{6}\", \"steps\": {7}, \"hash\": \"{8:X16}\", \"core_hash\": \"{9:X16}\", \"same_as_core\": {10},\n \"units_alive_max\": {11}, \"heroes_alive_max\": {12}, \"boss_log\": [{13}],\n \"rounds\": [{14}],\n \"events\": [{15}],\n \"shots\": [{16}],\n \"performance\": {17}}}",
label, seed, startRound, extraGold, Speed, runner.Match.Winner, runner.Match.EndNote, trace.Steps, trace.Hash, core.Hash, same ? "true" : "false",
maxUnits, maxHeroes, string.Join(", ", runner.Match.BossLog.Select(e => string.Format("\"round {0}: {1} team {2} {3}\"", e.Round, e.Event, e.Team, e.Lane))),
string.Join(", ", rounds), string.Join(", ", events), string.Join(", ", captures), performance);
Application.targetFrameRate = -1;
}
static bool InCombat(LuneMatchRunner runner, int round, double from)
{
return runner.Current == LuneMatchRunner.Stage.Combat && runner.Match.RoundNumber == round && runner.Match.Combat.Elapsed >= from;
}
static LuneUnit Find(LuneMatchRunner runner, Func<LuneUnit, bool> predicate)
{
return runner.Match.Combat == null ? null : runner.Match.Combat.Units.FirstOrDefault(predicate);
}
static Shot Building(string label, Func<LuneMatchView, LuneBuildingView> pick, LuneBuildingView.Look look, float zoom, float delay)
{
LuneBuildingView chosen = null;
return new Shot
{
Label = label,
Delay = delay,
Trigger = (runner, view) =>
{
chosen = pick(view);
return chosen != null && chosen.Current == look;
},
Aim = (runner, view, rig) =>
{
// Towers are watched from their lane (open ground), not through the forest.
Vec2 at = LuneSpace.Map(chosen.transform.position);
BuildingDef definition = runner.Match.Map.BuildingDef(chosen.BuildingId);
float yaw = 0f;
if (!chosen.IsAncient && definition.Tier != "base")
yaw = LuneCameraRig.YawTowards(at, runner.Match.Map.LanePoint(definition.Team, definition.Lane, definition.Progress));
rig.SetYaw(yaw, true);
rig.LookAt(at, zoom, true);
},
};
}
static List<Shot> SeedTwoShots()
{
string fallen = null;
return new List<Shot>
{
new Shot { Label = "01-runde1-aufmarsch", Trigger = (r, v) => InCombat(r, 1, 9.0), Aim = (r, v, rig) => rig.LookAt(new Vec2(0, 0), 64f, true) },
new Shot { Label = "02-runde1-mid-kampf", Trigger = (r, v) => InCombat(r, 1, 24.0), Aim = (r, v, rig) =>
{
LuneUnit fighter = Find(r, u => u.IsMobile() && u.Alive && u.Lane == "mid" && u.Target != null && u.InRange);
rig.LookAt(fighter != null ? fighter.Position : new Vec2(0, 0), 20f, true);
} },
Building("03-turm-faellt", v => AnyBuilding(v, b => !b.IsAncient && b.Current == LuneBuildingView.Look.Destroying, ref fallen), LuneBuildingView.Look.Destroying, 22f, .7f),
Building("04-turm-ruine", v => fallen != null ? v.Building(fallen) : null, LuneBuildingView.Look.Ruin, 22f, .4f),
new Shot { Label = "05-grotte-bosskampf", Delay = .5f, Trigger = (r, v) => Find(r, u => u.Kind == "boss" && u.Zone == "grotto" && u.Alive && !u.Dormant) != null,
Aim = (r, v, rig) => rig.LookAt(LuneMap.Grotto, 20f, true) },
new Shot { Label = "06-held-kaempft", Delay = .3f, Trigger = (r, v) => Find(r, u => u.Kind == "hero" && u.Alive && u.Zone == "lane" && u.Target != null && u.InRange) != null,
Aim = (r, v, rig) => rig.LookAt(Find(r, u => u.Kind == "hero" && u.Alive && u.Zone == "lane" && u.Target != null && u.InRange).Position, 16f, true) },
new Shot { Label = "07-boss-auf-der-lane", Delay = .3f, Trigger = (r, v) => r.Current == LuneMatchRunner.Stage.Combat && r.Match.Combat.Elapsed > 12.0 && Find(r, u => u.Kind == "boss" && u.Zone == "lane" && u.Alive) != null,
Aim = (r, v, rig) => rig.LookAt(Find(r, u => u.Kind == "boss" && u.Zone == "lane" && u.Alive).Position, 22f, true) },
Building("08-ancient-faellt", v => v.Building("blau_ancient"), LuneBuildingView.Look.Destroying, 30f, .8f),
Building("09-ancient-ruine", v => v.Building("blau_ancient"), LuneBuildingView.Look.Ruin, 30f, .5f),
new Shot { Label = "10-ende-uebersicht", Delay = .5f, Trigger = (r, v) => r.Current == LuneMatchRunner.Stage.Over && v.Building("blau_ancient").Current == LuneBuildingView.Look.Ruin,
Aim = (r, v, rig) => rig.Overview(true) },
};
}
/// <summary>The first building that satisfies the condition; remembers its id.</summary>
static LuneBuildingView AnyBuilding(LuneMatchView view, Func<LuneBuildingView, bool> condition, ref string remembered)
{
foreach (LuneBuildingView building in UnityEngine.Object.FindObjectsByType<LuneBuildingView>(FindObjectsSortMode.None).OrderBy(b => b.BuildingId, StringComparer.Ordinal))
{
if (condition(building))
{
remembered = building.BuildingId;
return building;
}
}
return null;
}
static List<Shot> WaveShots()
{
return new List<Shot>
{
new Shot { Label = "11-welle21-uebersicht", Trigger = (r, v) => InCombat(r, 21, 6.0), Aim = (r, v, rig) => rig.LookAt(new Vec2(0, 0), 70f, true) },
new Shot { Label = "12-welle21-kampf", Trigger = (r, v) => InCombat(r, 21, 36.0), Aim = (r, v, rig) =>
{
LuneUnit fighter = Find(r, u => u.IsMobile() && u.Alive && u.Target != null && u.InRange && u.Zone == "lane");
rig.LookAt(fighter != null ? fighter.Position : new Vec2(0, 0), 24f, true);
} },
Building("13-ancient-rot-faellt", v => v.Building("rot_ancient"), LuneBuildingView.Look.Destroying, 30f, .8f),
new Shot { Label = "14-welle-ende", Delay = .6f, Trigger = (r, v) => r.Current == LuneMatchRunner.Stage.Over && v.Building("rot_ancient").Current == LuneBuildingView.Look.Ruin,
Aim = (r, v, rig) => rig.LookAt(r.Match.Map.CorePosition(1), 34f, true) },
};
}
}
}