Replaces the 473-LOC custom-StbTrueTypeSharp DebugOverlay with an ImGui-rendered DebugPanel using the I.1 widget extensions. Single window with 7 CollapsingHeader sections; checkboxes are the primary toggle surface; F-keys retained where they invoke real gameplay actions, dropped where they only toggled panels. Pieces: - DebugVM (UI.Abstractions): read-through ViewModel with combat-event ring (cap 25), toast ring (cap 25), 4 diagnostic-flag bools (DumpMotion / DumpVitals / DumpOpcodes / DumpSky), 3 Action hooks (CycleTimeOfDay / CycleWeather / ToggleCollisionWires). Self- subscribes to CombatState.DamageTaken/DealtAccepted/Evaded* / Missed*/AttackDone/KillLanded - replaces the old BindCombat path. - DebugPanel (UI.Abstractions): one ImGui window with sections Player Info, Performance, Compass (text-only - draw-list strip deferred to D.6), Help (BeginTable cheat-sheet), Combat events (TextColored by kind: Info=yellow, Warning=red, Error=deep red), Recent toasts, Diagnostics (Checkboxes for the 4 flags + Buttons for the 3 cycle/toggle actions). - All 28 Snapshot data points covered: Fps, FrameMs, PlayerPos, HeadingDeg, CellId, OnGround, InPlayerMode, InFlyMode, VerticalVelocity, EntityCount, AnimatedCount, LandblocksVisible, LandblocksTotal, ShadowObjectCount, NearestObjDist, NearestObjLabel, Colliding, DebugWireframes, StreamingRadius, MouseSensitivity, ChaseDistance, RmbOrbit, HourName, DayFraction, Weather, ActiveLights, RegisteredLights, ParticleCount. - GameWindow surgery (+252/-165): removed _debugOverlay field + snapshot builder block + Update/Draw calls; added _debugVm / _debugPanel construction in the if (DevToolsEnabled) block; added per-frame nearest-object scan cached for VM closures (zero cost when devtools off); helper methods CycleTimeOfDay / CycleWeather / ToggleCollisionWires / GetDebug* / GetActiveSensitivity. F-key disposition: - F1: repurposed - now toggles whole DebugPanel visibility. - F2: kept - ToggleCollisionWires (also a Button in panel). - F4 / F5 / F6: REMOVED - per-section toggles replaced by CollapsingHeader inside one window. - F7: kept - CycleTimeOfDay (also Button). - F8 / F9: kept - mouse-sensitivity adjust; toasts route to _debugVm.AddToast. - F10: kept - CycleWeather (also Button). DebugOverlay.cs DELETED (473 LOC). TextRenderer + BitmapFont kept alive: UiHost references _debugFont and the future HUD-in-world (D.6) will reuse both. 11 new DebugVM tests covering combat-event-ring subscription, toast ring cap, diagnostic-flag toggles. UI.Abstractions.Tests: 96 -> 107. Solution total: 989 green (243 Core.Net + 639 Core + 107 UI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
288 lines
9.6 KiB
C#
288 lines
9.6 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.UI.Abstractions.Panels.Debug;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
|
|
|
|
/// <summary>
|
|
/// Tests for the Phase I.2 <see cref="DebugVM"/> — read-through ViewModel
|
|
/// for the migrated debug panel. Verifies the combat-event subscription
|
|
/// (replacing the old <c>DebugOverlay.BindCombat</c>), the toast ring
|
|
/// cap, and that the diagnostic-flag bools round-trip without affecting
|
|
/// the rest of the VM.
|
|
/// </summary>
|
|
public sealed class DebugVMTests
|
|
{
|
|
/// <summary>
|
|
/// Build a minimal <see cref="DebugVM"/> with safe defaults for every
|
|
/// constructor source. Tests that don't care about a particular source
|
|
/// just leave it stubbed out.
|
|
/// </summary>
|
|
private static DebugVM NewVm(CombatState? combat = null)
|
|
{
|
|
combat ??= new CombatState();
|
|
return new DebugVM(
|
|
getPlayerPosition: () => Vector3.Zero,
|
|
getPlayerHeadingDeg: () => 0f,
|
|
getPlayerCellId: () => 0u,
|
|
getPlayerOnGround: () => true,
|
|
getInPlayerMode: () => false,
|
|
getInFlyMode: () => false,
|
|
getVerticalVelocity: () => 0f,
|
|
getEntityCount: () => 0,
|
|
getAnimatedCount: () => 0,
|
|
getLandblocksVisible: () => 0,
|
|
getLandblocksTotal: () => 0,
|
|
getShadowObjectCount: () => 0,
|
|
getNearestObjDist: () => float.PositiveInfinity,
|
|
getNearestObjLabel: () => "-",
|
|
getColliding: () => false,
|
|
getDebugWireframes: () => false,
|
|
getStreamingRadius: () => 2,
|
|
getMouseSensitivity: () => 1f,
|
|
getChaseDistance: () => 0f,
|
|
getRmbOrbit: () => false,
|
|
getHourName: () => "Dawnsong",
|
|
getDayFraction: () => 0.25f,
|
|
getWeather: () => "Clear",
|
|
getActiveLights: () => 0,
|
|
getRegisteredLights: () => 0,
|
|
getParticleCount: () => 0,
|
|
getFps: () => 60f,
|
|
getFrameMs: () => 16.7f,
|
|
combat: combat);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_ThrowsOnNullCombat()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new DebugVM(
|
|
getPlayerPosition: () => Vector3.Zero,
|
|
getPlayerHeadingDeg: () => 0f,
|
|
getPlayerCellId: () => 0u,
|
|
getPlayerOnGround: () => true,
|
|
getInPlayerMode: () => false,
|
|
getInFlyMode: () => false,
|
|
getVerticalVelocity: () => 0f,
|
|
getEntityCount: () => 0,
|
|
getAnimatedCount: () => 0,
|
|
getLandblocksVisible: () => 0,
|
|
getLandblocksTotal: () => 0,
|
|
getShadowObjectCount: () => 0,
|
|
getNearestObjDist: () => 0f,
|
|
getNearestObjLabel: () => "-",
|
|
getColliding: () => false,
|
|
getDebugWireframes: () => false,
|
|
getStreamingRadius: () => 0,
|
|
getMouseSensitivity: () => 1f,
|
|
getChaseDistance: () => 0f,
|
|
getRmbOrbit: () => false,
|
|
getHourName: () => "",
|
|
getDayFraction: () => 0f,
|
|
getWeather: () => "",
|
|
getActiveLights: () => 0,
|
|
getRegisteredLights: () => 0,
|
|
getParticleCount: () => 0,
|
|
getFps: () => 0f,
|
|
getFrameMs: () => 0f,
|
|
combat: null!));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadThrough_PullsLiveValuesPerAccess_NoCache()
|
|
{
|
|
// The VM does NOT cache; every property read goes through the
|
|
// backing Func<T>. Mutating an external box between two reads
|
|
// must surface immediately.
|
|
int counter = 0;
|
|
var vm = new DebugVM(
|
|
getPlayerPosition: () => Vector3.Zero,
|
|
getPlayerHeadingDeg: () => 0f,
|
|
getPlayerCellId: () => 0u,
|
|
getPlayerOnGround: () => true,
|
|
getInPlayerMode: () => false,
|
|
getInFlyMode: () => false,
|
|
getVerticalVelocity: () => 0f,
|
|
getEntityCount: () => ++counter,
|
|
getAnimatedCount: () => 0,
|
|
getLandblocksVisible: () => 0,
|
|
getLandblocksTotal: () => 0,
|
|
getShadowObjectCount: () => 0,
|
|
getNearestObjDist: () => 0f,
|
|
getNearestObjLabel: () => "-",
|
|
getColliding: () => false,
|
|
getDebugWireframes: () => false,
|
|
getStreamingRadius: () => 0,
|
|
getMouseSensitivity: () => 1f,
|
|
getChaseDistance: () => 0f,
|
|
getRmbOrbit: () => false,
|
|
getHourName: () => "",
|
|
getDayFraction: () => 0f,
|
|
getWeather: () => "",
|
|
getActiveLights: () => 0,
|
|
getRegisteredLights: () => 0,
|
|
getParticleCount: () => 0,
|
|
getFps: () => 0f,
|
|
getFrameMs: () => 0f,
|
|
combat: new CombatState());
|
|
|
|
Assert.Equal(1, vm.EntityCount);
|
|
Assert.Equal(2, vm.EntityCount);
|
|
Assert.Equal(3, vm.EntityCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void DamageTaken_AppendsErrorEvent()
|
|
{
|
|
var combat = new CombatState();
|
|
var vm = NewVm(combat);
|
|
|
|
combat.OnVictimNotification(
|
|
attackerName: "Drudge", attackerGuid: 0x10u,
|
|
damageType: 0u, damage: 12u, hitQuadrant: 0u,
|
|
critical: 0u, attackType: 0u);
|
|
|
|
var events = vm.CombatEvents.ToList();
|
|
Assert.Single(events);
|
|
Assert.Equal(CombatEventKind.Error, events[0].Kind);
|
|
Assert.Contains("Drudge", events[0].Text);
|
|
Assert.Contains("12", events[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void DamageDealt_AppendsInfoEvent()
|
|
{
|
|
var combat = new CombatState();
|
|
var vm = NewVm(combat);
|
|
|
|
combat.OnAttackerNotification(
|
|
defenderName: "Drudge", damageType: 0u,
|
|
damage: 8u, damagePercent: 0.1f);
|
|
|
|
var events = vm.CombatEvents.ToList();
|
|
Assert.Single(events);
|
|
Assert.Equal(CombatEventKind.Info, events[0].Kind);
|
|
Assert.Contains("Drudge", events[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void EvadedIncoming_AppendsWarnEvent()
|
|
{
|
|
var combat = new CombatState();
|
|
var vm = NewVm(combat);
|
|
|
|
combat.OnEvasionDefenderNotification("Mosswart");
|
|
|
|
var events = vm.CombatEvents.ToList();
|
|
Assert.Single(events);
|
|
Assert.Equal(CombatEventKind.Warn, events[0].Kind);
|
|
Assert.Contains("Mosswart", events[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void CombatEventRing_CapsAtMax_DropsOldest()
|
|
{
|
|
var combat = new CombatState();
|
|
var vm = NewVm(combat);
|
|
|
|
// Pump well past the cap (25). The oldest entries must drop out.
|
|
for (int i = 0; i < 40; i++)
|
|
{
|
|
combat.OnAttackerNotification(
|
|
defenderName: $"Foe{i}", damageType: 0u,
|
|
damage: (uint)i, damagePercent: 0.1f);
|
|
}
|
|
|
|
var events = vm.CombatEvents.ToList();
|
|
Assert.Equal(DebugVM.MaxCombatEvents, events.Count);
|
|
// The newest entry must still be present.
|
|
Assert.Contains(events, e => e.Text.Contains("Foe39"));
|
|
// The oldest must have dropped.
|
|
Assert.DoesNotContain(events, e => e.Text.Contains("Foe0,") || e.Text == "Foe0");
|
|
Assert.DoesNotContain(events, e => e.Text.Contains("Foe5"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Toast_RingCapsAtMaxRecent()
|
|
{
|
|
var vm = NewVm();
|
|
|
|
for (int i = 0; i < 30; i++)
|
|
vm.AddToast($"toast-{i}");
|
|
|
|
var toasts = vm.RecentToasts.ToList();
|
|
Assert.Equal(DebugVM.MaxRecentToasts, toasts.Count);
|
|
Assert.Contains(toasts, t => t.Text == "toast-29");
|
|
Assert.DoesNotContain(toasts, t => t.Text == "toast-0");
|
|
}
|
|
|
|
[Fact]
|
|
public void Toast_PreservesKind()
|
|
{
|
|
var vm = NewVm();
|
|
vm.AddToast("warning!", ToastKind.Warn);
|
|
vm.AddToast("error!", ToastKind.Error);
|
|
|
|
var toasts = vm.RecentToasts.ToList();
|
|
Assert.Equal(2, toasts.Count);
|
|
Assert.Contains(toasts, t => t.Text == "warning!" && t.Kind == ToastKind.Warn);
|
|
Assert.Contains(toasts, t => t.Text == "error!" && t.Kind == ToastKind.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void DiagnosticFlags_DefaultFalse_RoundTripIndependently()
|
|
{
|
|
var vm = NewVm();
|
|
Assert.False(vm.DumpMotion);
|
|
Assert.False(vm.DumpVitals);
|
|
Assert.False(vm.DumpOpcodes);
|
|
Assert.False(vm.DumpSky);
|
|
|
|
vm.DumpMotion = true;
|
|
Assert.True(vm.DumpMotion);
|
|
Assert.False(vm.DumpVitals);
|
|
Assert.False(vm.DumpOpcodes);
|
|
Assert.False(vm.DumpSky);
|
|
|
|
vm.DumpSky = true;
|
|
vm.DumpMotion = false;
|
|
Assert.False(vm.DumpMotion);
|
|
Assert.True(vm.DumpSky);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleFlags_DoNotAffectCombatRing()
|
|
{
|
|
var combat = new CombatState();
|
|
var vm = NewVm(combat);
|
|
|
|
combat.OnAttackerNotification("X", 0u, 1u, 0.1f);
|
|
Assert.Single(vm.CombatEvents);
|
|
|
|
vm.DumpMotion = true;
|
|
vm.DumpVitals = true;
|
|
Assert.Single(vm.CombatEvents);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActionHooks_InvokeSuppliedDelegates()
|
|
{
|
|
// The panel needs to invoke "cycle time", "cycle weather", "toggle
|
|
// collision wires" actions when the corresponding Button is
|
|
// clicked. The VM exposes these as Action; the panel calls them.
|
|
int timeHits = 0, weatherHits = 0, wireHits = 0;
|
|
var vm = NewVm();
|
|
vm.CycleTimeOfDay = () => timeHits++;
|
|
vm.CycleWeather = () => weatherHits++;
|
|
vm.ToggleCollisionWires = () => wireHits++;
|
|
|
|
vm.CycleTimeOfDay?.Invoke();
|
|
vm.CycleTimeOfDay?.Invoke();
|
|
vm.CycleWeather?.Invoke();
|
|
vm.ToggleCollisionWires?.Invoke();
|
|
|
|
Assert.Equal(2, timeHits);
|
|
Assert.Equal(1, weatherHits);
|
|
Assert.Equal(1, wireHits);
|
|
}
|
|
}
|