Vollständige Bot-Partien sichtbar in Unity, gleich der Partie des Kerns (Vertical Function Test vor dem vollständigen Asset-Import). - VerticalFunctionTests (PlayMode): Seed 2 von Runde 1 bis zum Fall des Blau-Ancient (9 Runden; Türme fallen ab Runde 7, Grottenboss in Runde 8 gewonnen und in Runde 9 auf der Bot-Lane eingesetzt, bis zu 4 Helden) und Seed 9 ab Runde 21 mit der Fixture des Golden-Szenarios high_wave_21 (bis zu 125 Einheiten, 6 Helden, Fall des Rot-Ancient). Beide Fingerabdrücke gleich dem Kernlauf; 14 Bilder der Schlüsselmomente. - macOS-Player-Build (LunePlayerBuild) und LuneBenchmark: 1080p, VSync aus, 116 Einheiten: 161-199 fps, Hauptthread 1,3 ms, Simulation unter 0,1 ms; Ausreißer beim Present (p95 18-26 ms) analysiert und für U5 vorgemerkt. - Dokumentation: U2-WORLD (U2D, Abnahme), PORT-STATUS, PORT-QA, KNOWN-BEHAVIOR-DIFFERENCES (U2 ändert kein Verhalten), README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
16 KiB
C#
278 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/.
|
|
/// </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 = Path.Combine(PlayModeSupport.Root, "docs/port/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.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.Root, "docs/port/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) },
|
|
};
|
|
}
|
|
}
|
|
}
|