feat(ui): port retail radar and compass
This commit is contained in:
parent
c4af181b92
commit
3cbe4b00a1
43 changed files with 2882 additions and 262 deletions
|
|
@ -16,6 +16,17 @@ public class DatWidgetFactoryTests
|
|||
Assert.IsType<UiMeter>(e);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailRadarClass_MakesUiRadar()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(
|
||||
new ElementInfo { Type = UiRadar.RetailClassId, Width = 120, Height = 140 },
|
||||
NoTex,
|
||||
null);
|
||||
|
||||
Assert.IsType<UiRadar>(e);
|
||||
}
|
||||
|
||||
// ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -57,6 +57,14 @@ public static class FixtureLoader
|
|||
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
|
||||
=> LoadInfos("chat_21000006.json");
|
||||
|
||||
/// <summary>Builds the committed retail radar LayoutDesc 0x21000074 fixture.</summary>
|
||||
public static ImportedLayout LoadRadar()
|
||||
=> LayoutImporter.Build(LoadRadarInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
/// <summary>Returns the resolved ElementInfo tree for retail radar LayoutDesc 0x21000074.</summary>
|
||||
public static AcDream.App.UI.Layout.ElementInfo LoadRadarInfos()
|
||||
=> LoadInfos("radar_21000074.json");
|
||||
|
||||
// ── Shared loader ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
159
tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs
Normal file
159
tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RadarControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Bind_RealFixture_ReusesImportedCoordinateTextAndDatBackground()
|
||||
{
|
||||
var layout = FixtureLoader.LoadRadar();
|
||||
_ = RadarController.Bind(
|
||||
layout,
|
||||
() => new UiRadarSnapshot(0f, Array.Empty<UiRadarBlip>(), "42.1N,33.6E"));
|
||||
|
||||
var coordinates = Assert.IsType<UiText>(
|
||||
layout.FindElement(RadarController.CoordinateContainerId));
|
||||
Assert.Equal(0x06004CC0u, coordinates.BackgroundSprite);
|
||||
Assert.Empty(coordinates.Children);
|
||||
Assert.True(coordinates.ClickThrough);
|
||||
Assert.False(coordinates.AcceptsFocus);
|
||||
Assert.Equal("42.1N,33.6E", Assert.Single(coordinates.LinesProvider()).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_UsesImportedChildrenForCompassCoordinatesAndLockChrome()
|
||||
{
|
||||
var layout = BuildRadarLayout();
|
||||
var state = new UiRadarSnapshot(
|
||||
0f,
|
||||
Array.Empty<UiRadarBlip>(),
|
||||
"42.1N,33.6E",
|
||||
UiLocked: false);
|
||||
bool? requestedLock = null;
|
||||
|
||||
_ = RadarController.Bind(
|
||||
layout,
|
||||
() => state,
|
||||
setUiLocked: value => requestedLock = value);
|
||||
|
||||
var radar = Assert.IsType<UiRadar>(layout.Root);
|
||||
Assert.Equal(new Vector2(60f, 60f), radar.Center);
|
||||
Assert.True(radar.Draggable);
|
||||
|
||||
var coordinateContainer = Assert.IsType<UiDatElement>(
|
||||
layout.FindElement(RadarController.CoordinateContainerId));
|
||||
Assert.True(coordinateContainer.Visible);
|
||||
var coordinateText = Assert.IsType<UiText>(Assert.Single(coordinateContainer.Children));
|
||||
Assert.Equal("42.1N,33.6E", Assert.Single(coordinateText.LinesProvider()).Text);
|
||||
|
||||
var north = layout.FindElement(RadarController.NorthTokenId)!;
|
||||
Assert.Equal(55f, north.Left);
|
||||
Assert.Equal(1f, north.Top);
|
||||
|
||||
var lockButton = Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
|
||||
Assert.Equal("UnlockedUI", lockButton.ActiveState);
|
||||
var click = new UiEvent(lockButton.EventId, lockButton, UiEventType.Click);
|
||||
Assert.True(lockButton.OnEvent(in click));
|
||||
Assert.True(requestedLock);
|
||||
|
||||
state = state with
|
||||
{
|
||||
PlayerHeadingDegrees = 90f,
|
||||
CoordinatesText = null,
|
||||
UiLocked = true,
|
||||
};
|
||||
radar.Refresh();
|
||||
|
||||
Assert.False(radar.Draggable);
|
||||
Assert.False(coordinateContainer.Visible);
|
||||
Assert.False(coordinateText.Visible);
|
||||
Assert.Equal("LockedUI", lockButton.ActiveState);
|
||||
|
||||
var drag = layout.FindElement(RadarController.DragButtonId)!;
|
||||
Assert.False(drag.Visible);
|
||||
Assert.False(drag.ClickThrough);
|
||||
|
||||
float northMagnitude = Vector2.Distance(new Vector2(60f, 5.5f), radar.Center);
|
||||
var expectedNorth = RetailRadar.GetCompassTokenTopLeft(
|
||||
90f,
|
||||
RadarCompassPoint.North,
|
||||
radar.Center,
|
||||
northMagnitude,
|
||||
new Vector2(north.Width, north.Height));
|
||||
Assert.Equal(expectedNorth.X, north.Left);
|
||||
Assert.Equal(expectedNorth.Y, north.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_LeavesStaticDatChildrenInRetainedTree()
|
||||
{
|
||||
var layout = BuildRadarLayout();
|
||||
_ = RadarController.Bind(layout, () => UiRadarSnapshot.Empty);
|
||||
|
||||
Assert.Equal(8, layout.Root.Children.Count);
|
||||
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.RadarDiscId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
|
||||
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.DragButtonId));
|
||||
Assert.False(layout.FindElement(RadarController.CoordinateContainerId)!.Visible);
|
||||
}
|
||||
|
||||
private static ImportedLayout BuildRadarLayout()
|
||||
{
|
||||
var root = new ElementInfo
|
||||
{
|
||||
Id = RadarController.RootId,
|
||||
Type = UiRadar.RetailClassId,
|
||||
Width = 120,
|
||||
Height = 140,
|
||||
};
|
||||
|
||||
root.Children.Add(Sprite(RadarController.CoordinateContainerId, 0, 120, 120, 18, 0x06004CC0u));
|
||||
root.Children.Add(Sprite(RadarController.RadarDiscId, 0, 0, 120, 120, 0x06004CC1u));
|
||||
root.Children.Add(Sprite(RadarController.NorthTokenId, 55, 1, 10, 9, 0x060011FBu));
|
||||
root.Children.Add(Sprite(RadarController.EastTokenId, 110, 55, 10, 9, 0x06001938u));
|
||||
root.Children.Add(Sprite(RadarController.SouthTokenId, 55, 110, 10, 9, 0x0600193Au));
|
||||
root.Children.Add(Sprite(RadarController.WestTokenId, 0, 55, 10, 9, 0x0600193Cu));
|
||||
|
||||
var lockButton = new ElementInfo
|
||||
{
|
||||
Id = RadarController.LockButtonId,
|
||||
Type = 1,
|
||||
X = 6,
|
||||
Y = 6,
|
||||
Width = 27,
|
||||
Height = 27,
|
||||
};
|
||||
lockButton.StateMedia["LockedUI"] = (0x060074B7u, 3);
|
||||
lockButton.StateMedia["UnlockedUI"] = (0x060074B8u, 3);
|
||||
root.Children.Add(lockButton);
|
||||
|
||||
root.Children.Add(Sprite(RadarController.DragButtonId, 87, 6, 27, 27, 0x060074C9u, type: 2));
|
||||
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
|
||||
}
|
||||
|
||||
private static ElementInfo Sprite(
|
||||
uint id,
|
||||
float x,
|
||||
float y,
|
||||
float width,
|
||||
float height,
|
||||
uint sprite,
|
||||
uint type = 3)
|
||||
{
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Id = id,
|
||||
Type = type,
|
||||
X = x,
|
||||
Y = y,
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
info.StateMedia[""] = (sprite, 1);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RadarLayoutConformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void RadarFixture_HasRetailRootAndAllStaticAssets()
|
||||
{
|
||||
var root = FixtureLoader.LoadRadarInfos();
|
||||
|
||||
Assert.Equal(RadarController.RootId, root.Id);
|
||||
Assert.Equal(UiRadar.RetailClassId, root.Type);
|
||||
Assert.Equal(120f, root.Width);
|
||||
Assert.Equal(140f, root.Height);
|
||||
Assert.Equal(8, root.Children.Count);
|
||||
|
||||
AssertElement(root, RadarController.CoordinateContainerId, 0, 120, 120, 18, 0x06004CC0u);
|
||||
AssertElement(root, RadarController.RadarDiscId, 0, 0, 120, 120, 0x06004CC1u);
|
||||
AssertElement(root, RadarController.NorthTokenId, 55, 1, 10, 9, 0x060011FBu);
|
||||
AssertElement(root, RadarController.EastTokenId, 110, 55, 10, 9, 0x06001938u);
|
||||
AssertElement(root, RadarController.SouthTokenId, 55, 110, 10, 9, 0x0600193Au);
|
||||
AssertElement(root, RadarController.WestTokenId, 0, 55, 10, 9, 0x0600193Cu);
|
||||
AssertElement(root, RadarController.DragButtonId, 87, 6, 27, 27, 0x060074C9u);
|
||||
|
||||
var lockButton = Find(root, RadarController.LockButtonId)!;
|
||||
Assert.Equal(1u, lockButton.Type);
|
||||
Assert.Equal(0x060074B7u, lockButton.StateMedia["LockedUI"].File);
|
||||
Assert.Equal(0x060074B8u, lockButton.StateMedia["UnlockedUI"].File);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadarFixture_BuildsDedicatedWidgetAndPreservesStaticChildren()
|
||||
{
|
||||
var layout = FixtureLoader.LoadRadar();
|
||||
|
||||
Assert.IsType<UiRadar>(layout.Root);
|
||||
Assert.Equal(8, layout.Root.Children.Count);
|
||||
var coordinates = Assert.IsType<UiText>(
|
||||
layout.FindElement(RadarController.CoordinateContainerId));
|
||||
Assert.Equal(0x06004CC0u, coordinates.BackgroundSprite);
|
||||
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.RadarDiscId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
|
||||
}
|
||||
|
||||
private static void AssertElement(
|
||||
ElementInfo root,
|
||||
uint id,
|
||||
float x,
|
||||
float y,
|
||||
float width,
|
||||
float height,
|
||||
uint sprite)
|
||||
{
|
||||
var element = Find(root, id)!;
|
||||
Assert.Equal(x, element.X);
|
||||
Assert.Equal(y, element.Y);
|
||||
Assert.Equal(width, element.Width);
|
||||
Assert.Equal(height, element.Height);
|
||||
Assert.Equal(sprite, element.StateMedia[""].File);
|
||||
}
|
||||
|
||||
private static ElementInfo? Find(ElementInfo node, uint id)
|
||||
{
|
||||
if (node.Id == id)
|
||||
return node;
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
var found = Find(child, id);
|
||||
if (found is not null)
|
||||
return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>One-off generator for the committed retail radar LayoutDesc fixture.</summary>
|
||||
public sealed class RadarLayoutFixtureGenerator
|
||||
{
|
||||
[Fact(Skip = "manual: regenerates the committed radar fixture; needs the real dats (ACDREAM_DAT_DIR)")]
|
||||
public void GenerateRadarFixture()
|
||||
{
|
||||
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var info = LayoutImporter.ImportInfos(dats, RadarController.LayoutId);
|
||||
Assert.NotNull(info);
|
||||
|
||||
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
||||
{
|
||||
IncludeFields = true,
|
||||
WriteIndented = true,
|
||||
});
|
||||
File.WriteAllText(FixturePath(), json);
|
||||
}
|
||||
|
||||
private static string FixturePath([CallerFilePath] string thisFile = "")
|
||||
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "radar_21000074.json");
|
||||
}
|
||||
146
tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs
Normal file
146
tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RadarSnapshotProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildSnapshot_JoinsWorldAndWeenieTables_UsingCoreRetailMath()
|
||||
{
|
||||
const uint player = 1u;
|
||||
const uint monster = 2u;
|
||||
var objects = new ClientObjectTable();
|
||||
objects.Ingest(Weenie(player, "Player", ItemType.Creature));
|
||||
objects.Ingest(Weenie(monster, "Drudge", ItemType.Creature) with
|
||||
{
|
||||
RadarBehavior = (byte)RadarBehavior.ShowAlways,
|
||||
});
|
||||
|
||||
var entities = new Dictionary<uint, WorldEntity>
|
||||
{
|
||||
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
|
||||
[monster] = Entity(monster, new Vector3(0f, 15f, 6f), Quaternion.Identity),
|
||||
};
|
||||
var spawns = new Dictionary<uint, WorldSession.EntitySpawn>
|
||||
{
|
||||
[player] = Spawn(player) with { ObjectDescriptionFlags = 0x00000008u }, // BF_PLAYER
|
||||
[monster] = Spawn(monster) with { ObjectDescriptionFlags = 0x00000010u }, // BF_ATTACKABLE
|
||||
};
|
||||
|
||||
uint? selected = monster;
|
||||
var provider = new RadarSnapshotProvider(
|
||||
objects, entities, spawns,
|
||||
playerGuid: () => player,
|
||||
playerYawRadians: () => MathF.PI / 2f, // retail heading 0 degrees
|
||||
playerCellId: () => 0xA9B40001u,
|
||||
selectedGuid: () => selected,
|
||||
coordinatesOnRadar: () => true,
|
||||
uiLocked: () => false);
|
||||
|
||||
var snapshot = provider.BuildSnapshot();
|
||||
|
||||
Assert.Equal(0f, snapshot.PlayerHeadingDegrees, 3);
|
||||
Assert.Equal(RadarCoordinates.TryFromCell(0xA9B40001u, out var coords)
|
||||
? coords.CombinedText : null, snapshot.CoordinatesText);
|
||||
var blip = Assert.Single(snapshot.Blips);
|
||||
Assert.Equal(monster, blip.ObjectId);
|
||||
Assert.Equal("Drudge", blip.Name);
|
||||
Assert.Equal(60f, blip.PixelX);
|
||||
Assert.Equal(50f, blip.PixelY); // 15 m * 50 px / 75 m
|
||||
Assert.Equal(RadarBlipShape.Plus, blip.Shape);
|
||||
Assert.True(blip.Selected);
|
||||
Assert.Equal(RadarBlipColors.Gold.Red * 0.65f, blip.Color.X, 5);
|
||||
Assert.Equal(RadarBlipColors.Gold.Green * 0.65f, blip.Color.Y, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSnapshot_RejectsShowNever_AndHidesIndoorCoordinates()
|
||||
{
|
||||
const uint player = 10u;
|
||||
const uint hidden = 11u;
|
||||
var objects = new ClientObjectTable();
|
||||
objects.Ingest(Weenie(player, "Player", ItemType.Creature));
|
||||
objects.Ingest(Weenie(hidden, "Hidden", ItemType.Portal) with
|
||||
{
|
||||
RadarBehavior = (byte)RadarBehavior.ShowNever,
|
||||
});
|
||||
var entities = new Dictionary<uint, WorldEntity>
|
||||
{
|
||||
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
|
||||
[hidden] = Entity(hidden, new Vector3(2f, 2f, 0f), Quaternion.Identity),
|
||||
};
|
||||
var spawns = new Dictionary<uint, WorldSession.EntitySpawn>
|
||||
{
|
||||
[player] = Spawn(player),
|
||||
[hidden] = Spawn(hidden),
|
||||
};
|
||||
var provider = new RadarSnapshotProvider(
|
||||
objects, entities, spawns,
|
||||
playerGuid: () => player,
|
||||
playerYawRadians: () => 0f,
|
||||
playerCellId: () => 0xA9B40100u, // EnvCell: no landscape coordinate
|
||||
selectedGuid: () => null,
|
||||
coordinatesOnRadar: () => true,
|
||||
uiLocked: () => true);
|
||||
|
||||
var snapshot = provider.BuildSnapshot();
|
||||
|
||||
Assert.Empty(snapshot.Blips);
|
||||
Assert.Null(snapshot.CoordinatesText);
|
||||
Assert.True(snapshot.UiLocked);
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new()
|
||||
{
|
||||
Id = guid,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0u,
|
||||
Position = position,
|
||||
Rotation = rotation,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid) => new(
|
||||
Guid: guid,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
|
||||
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
||||
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: null,
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
|
||||
private static WeenieData Weenie(uint guid, string name, ItemType type) => new(
|
||||
Guid: guid,
|
||||
Name: name,
|
||||
Type: type,
|
||||
WeenieClassId: 1u,
|
||||
IconId: 0u,
|
||||
IconOverlayId: 0u,
|
||||
IconUnderlayId: 0u,
|
||||
Effects: 0u,
|
||||
Value: null,
|
||||
StackSize: null,
|
||||
StackSizeMax: null,
|
||||
Burden: null,
|
||||
ContainerId: null,
|
||||
WielderId: null,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null);
|
||||
}
|
||||
250
tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json
Normal file
250
tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
{
|
||||
"Id": 268437203,
|
||||
"Type": 268435472,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 120,
|
||||
"Height": 140,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 0,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": [
|
||||
{
|
||||
"Id": 268435518,
|
||||
"Type": 12,
|
||||
"X": 0,
|
||||
"Y": 120,
|
||||
"Width": 120,
|
||||
"Height": 18,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 1,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 1073741825,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100682944,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435519,
|
||||
"Type": 3,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 120,
|
||||
"Height": 120,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 2,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100682945,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435520,
|
||||
"Type": 3,
|
||||
"X": 55,
|
||||
"Y": 1,
|
||||
"Width": 10,
|
||||
"Height": 9,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 5,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100667899,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435521,
|
||||
"Type": 3,
|
||||
"X": 110,
|
||||
"Y": 55,
|
||||
"Width": 10,
|
||||
"Height": 9,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 6,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100669752,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435522,
|
||||
"Type": 3,
|
||||
"X": 55,
|
||||
"Y": 110,
|
||||
"Width": 10,
|
||||
"Height": 9,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 7,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100669754,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268437155,
|
||||
"Type": 2,
|
||||
"X": 87,
|
||||
"Y": 6,
|
||||
"Width": 27,
|
||||
"Height": 27,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 4,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100693193,
|
||||
"Item2": 3
|
||||
}
|
||||
},
|
||||
"StateCursors": {
|
||||
"": {
|
||||
"File": 100688153,
|
||||
"HotspotX": 16,
|
||||
"HotspotY": 16,
|
||||
"IsValid": true
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268437017,
|
||||
"Type": 1,
|
||||
"X": 6,
|
||||
"Y": 6,
|
||||
"Width": 27,
|
||||
"Height": 27,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 3,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"LockedUI": {
|
||||
"Item1": 100693175,
|
||||
"Item2": 3
|
||||
},
|
||||
"UnlockedUI": {
|
||||
"Item1": 100693176,
|
||||
"Item2": 3
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "Normal",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435523,
|
||||
"Type": 3,
|
||||
"X": 0,
|
||||
"Y": 55,
|
||||
"Width": 10,
|
||||
"Height": 9,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 8,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100669756,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
91
tests/AcDream.App.Tests/UI/UiRadarTests.cs
Normal file
91
tests/AcDream.App.Tests/UI/UiRadarTests.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class UiRadarTests
|
||||
{
|
||||
[Fact]
|
||||
public void HoverAndClick_SelectNearestProjectedBlip()
|
||||
{
|
||||
uint? selected = null;
|
||||
uint? hovered = null;
|
||||
var radar = new UiRadar
|
||||
{
|
||||
Width = 120,
|
||||
Height = 140,
|
||||
SelectObject = guid => selected = guid,
|
||||
HoveredObjectChanged = guid => hovered = guid,
|
||||
};
|
||||
radar.ApplySnapshot(new UiRadarSnapshot(
|
||||
0f,
|
||||
[
|
||||
new UiRadarBlip(0x10u, "Far", 31f, 30f, Vector4.One, RadarBlipShape.Plus),
|
||||
new UiRadarBlip(0x20u, "Near", 28f, 30f, Vector4.One, RadarBlipShape.Circle),
|
||||
],
|
||||
"1.0N,1.0E"));
|
||||
|
||||
var root = new UiRoot { Width = 200, Height = 200 };
|
||||
root.AddChild(radar);
|
||||
|
||||
root.OnMouseMove(27, 30);
|
||||
Assert.Equal(0x20u, radar.HoveredObjectId);
|
||||
Assert.Equal(0x20u, hovered);
|
||||
Assert.Equal("Near", radar.GetTooltipText());
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 27, 30);
|
||||
root.OnMouseUp(UiMouseButton.Left, 27, 30);
|
||||
Assert.Equal(0x20u, selected);
|
||||
|
||||
root.OnMouseMove(100, 100);
|
||||
Assert.Null(radar.HoveredObjectId);
|
||||
Assert.Null(hovered);
|
||||
Assert.Null(radar.GetTooltipText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankBlips_DisablesHoverButKeepsSnapshot()
|
||||
{
|
||||
var radar = new UiRadar { Width = 120, Height = 140 };
|
||||
radar.ApplySnapshot(new UiRadarSnapshot(
|
||||
0f,
|
||||
[new UiRadarBlip(0x10u, "Hidden", 30f, 30f, Vector4.One, RadarBlipShape.Plus)],
|
||||
null,
|
||||
BlankBlips: true));
|
||||
|
||||
var root = new UiRoot { Width = 200, Height = 200 };
|
||||
root.AddChild(radar);
|
||||
root.OnMouseMove(30, 30);
|
||||
|
||||
Assert.Single(radar.Snapshot.Blips);
|
||||
Assert.Null(radar.HoveredObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_PollsProviderAtRetailTwentyFiveMillisecondCadence()
|
||||
{
|
||||
int calls = 0;
|
||||
var radar = new UiRadar
|
||||
{
|
||||
Width = 120,
|
||||
Height = 140,
|
||||
SnapshotProvider = () =>
|
||||
{
|
||||
calls++;
|
||||
return UiRadarSnapshot.Empty;
|
||||
},
|
||||
};
|
||||
var root = new UiRoot { Width = 200, Height = 200 };
|
||||
root.AddChild(radar);
|
||||
|
||||
root.Tick(0.024, 24);
|
||||
Assert.Equal(0, calls);
|
||||
|
||||
root.Tick(0.0011, 25);
|
||||
Assert.Equal(1, calls);
|
||||
|
||||
root.Tick(0.100, 125);
|
||||
Assert.Equal(2, calls); // retail schedules one fresh update; it does not replay four stale ticks
|
||||
}
|
||||
}
|
||||
|
|
@ -187,6 +187,59 @@ public class UiRootInputTests
|
|||
Assert.Equal(80f, panel.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowDrag_ConstrainedPanel_StaysFullyInsideParent()
|
||||
{
|
||||
var root = new UiRoot { Width = 200, Height = 150 };
|
||||
var panel = new UiPanel
|
||||
{
|
||||
Left = 10,
|
||||
Top = 10,
|
||||
Width = 120,
|
||||
Height = 100,
|
||||
Draggable = true,
|
||||
ConstrainDragToParent = true,
|
||||
};
|
||||
root.AddChild(panel);
|
||||
root.RegisterWindow("radar", panel);
|
||||
(string name, UiElement window)? moved = null;
|
||||
root.WindowMoved += (name, window) => moved = (name, window);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 20);
|
||||
root.OnMouseMove(500, 500);
|
||||
Assert.Equal(80f, panel.Left);
|
||||
Assert.Equal(50f, panel.Top);
|
||||
|
||||
root.OnMouseMove(-500, -500);
|
||||
Assert.Equal(0f, panel.Left);
|
||||
Assert.Equal(0f, panel.Top);
|
||||
root.OnMouseUp(UiMouseButton.Left, -500, -500);
|
||||
Assert.Equal("radar", moved?.name);
|
||||
Assert.Same(panel, moved?.window);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiLocked_BlocksWindowMoveWithoutDisablingWindow()
|
||||
{
|
||||
var root = new UiRoot { Width = 200, Height = 150, UiLocked = true };
|
||||
var panel = new UiPanel
|
||||
{
|
||||
Left = 10,
|
||||
Top = 10,
|
||||
Width = 100,
|
||||
Height = 60,
|
||||
Draggable = true,
|
||||
};
|
||||
root.AddChild(panel);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 20);
|
||||
root.OnMouseMove(100, 100);
|
||||
|
||||
Assert.Equal(10f, panel.Left);
|
||||
Assert.Equal(10f, panel.Top);
|
||||
Assert.True(panel.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonDraggablePanel_DoesNotMoveOnDrag()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -172,6 +172,70 @@ public sealed class CreateObjectTests
|
|||
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Retail radar: PublicWeenieDesc carries two independently gated bytes.
|
||||
// Absence is distinct from an explicitly transmitted zero because zero is
|
||||
// the enum's undefined/default value, not proof the field was omitted.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryParse_NoRadarFlags_LeavesRadarFieldsNull()
|
||||
{
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x5000000Au,
|
||||
name: "Unspecified Radar Object",
|
||||
itemType: (uint)ItemType.Misc);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Null(parsed.Value.RadarBlipColor);
|
||||
Assert.Null(parsed.Value.RadarBehavior);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_RadarFlags_CapturesBothBytesAndContinuesTailWalk()
|
||||
{
|
||||
const uint radarBlipColorFlag = 0x00100000u;
|
||||
const uint radarBehaviorFlag = 0x00800000u;
|
||||
const uint workmanshipFlag = 0x01000000u;
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x5000000Bu,
|
||||
name: "Radar Portal",
|
||||
itemType: (uint)ItemType.Portal,
|
||||
weenieFlags: radarBlipColorFlag | radarBehaviorFlag | workmanshipFlag,
|
||||
radarBlipColor: 4,
|
||||
radarBehavior: 4,
|
||||
workmanship: 6.25f);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal((byte)4, parsed.Value.RadarBlipColor);
|
||||
Assert.Equal((byte)4, parsed.Value.RadarBehavior);
|
||||
Assert.Equal(6.25f, parsed.Value.Workmanship);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_RadarFlagsWithZero_PreservesExplicitDefaultValues()
|
||||
{
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x5000000Cu,
|
||||
name: "Explicit Radar Defaults",
|
||||
itemType: (uint)ItemType.Misc,
|
||||
weenieFlags: 0x00100000u | 0x00800000u,
|
||||
radarBlipColor: 0,
|
||||
radarBehavior: 0);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.True(parsed.Value.RadarBlipColor.HasValue);
|
||||
Assert.Equal((byte)0, parsed.Value.RadarBlipColor.Value);
|
||||
Assert.True(parsed.Value.RadarBehavior.HasValue);
|
||||
Assert.Equal((byte)0, parsed.Value.RadarBehavior.Value);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the
|
||||
// action bar / equipment UI can read icon dat ids from spawn messages.
|
||||
|
|
@ -500,6 +564,8 @@ public sealed class CreateObjectTests
|
|||
uint? currentWieldedLocation = null,
|
||||
uint? priority = null,
|
||||
float? workmanship = null,
|
||||
byte? radarBlipColor = null,
|
||||
byte? radarBehavior = null,
|
||||
ushort movementSeq = 0)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
|
|
@ -562,8 +628,8 @@ public sealed class CreateObjectTests
|
|||
if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations u32
|
||||
if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation u32
|
||||
if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority u32
|
||||
if ((weenieFlags & 0x00100000u) != 0) bytes.Add(0); // RadarBlipColor u8
|
||||
if ((weenieFlags & 0x00800000u) != 0) bytes.Add(0); // RadarBehavior u8
|
||||
if ((weenieFlags & 0x00100000u) != 0) bytes.Add(radarBlipColor ?? 0); // RadarBlipColor u8
|
||||
if ((weenieFlags & 0x00800000u) != 0) bytes.Add(radarBehavior ?? 0); // RadarBehavior u8
|
||||
if ((weenieFlags & 0x08000000u) != 0) WriteU16(bytes, 0); // PScript u16
|
||||
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ public sealed class ObjectTableWiringTests
|
|||
Workmanship = 4.5f,
|
||||
Useability = 0x000A0008u,
|
||||
TargetType = (uint)ItemType.Creature,
|
||||
RadarBlipColor = 4,
|
||||
RadarBehavior = 3,
|
||||
};
|
||||
|
||||
var d = ObjectTableWiring.ToWeenieData(spawn);
|
||||
|
|
@ -100,6 +102,8 @@ public sealed class ObjectTableWiringTests
|
|||
Assert.Equal(4.5f, d.Workmanship);
|
||||
Assert.Equal(0x000A0008u, d.Useability);
|
||||
Assert.Equal((uint)ItemType.Creature, d.TargetType);
|
||||
Assert.Equal((byte)4, d.RadarBlipColor);
|
||||
Assert.Equal((byte)3, d.RadarBehavior);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
44
tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs
Normal file
44
tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
||||
public sealed class WorldSessionRadarTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToEntitySpawn_PreservesRadarBytes()
|
||||
{
|
||||
var parsed = MinimalParsed() with
|
||||
{
|
||||
RadarBlipColor = 7,
|
||||
RadarBehavior = 3,
|
||||
};
|
||||
|
||||
var spawn = WorldSession.ToEntitySpawn(parsed);
|
||||
|
||||
Assert.Equal((byte)7, spawn.RadarBlipColor);
|
||||
Assert.Equal((byte)3, spawn.RadarBehavior);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEntitySpawn_PreservesMissingRadarFields()
|
||||
{
|
||||
var spawn = WorldSession.ToEntitySpawn(MinimalParsed());
|
||||
|
||||
Assert.Null(spawn.RadarBlipColor);
|
||||
Assert.Null(spawn.RadarBehavior);
|
||||
}
|
||||
|
||||
private static CreateObject.Parsed MinimalParsed() => new(
|
||||
Guid: 0x50000001u,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
|
||||
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
||||
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "Radar Test",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
}
|
||||
|
|
@ -277,6 +277,28 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal((uint)ItemType.Creature, item.TargetType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_RadarMetadata_PatchesOnlyPresentWireFields()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x500000B8u) with
|
||||
{
|
||||
RadarBlipColor = 4,
|
||||
RadarBehavior = 3,
|
||||
});
|
||||
|
||||
table.Ingest(FullWeenie(0x500000B8u) with
|
||||
{
|
||||
RadarBlipColor = null,
|
||||
RadarBehavior = 4,
|
||||
});
|
||||
|
||||
var item = table.Get(0x500000B8u);
|
||||
Assert.NotNull(item);
|
||||
Assert.Equal((byte)4, item!.RadarBlipColor);
|
||||
Assert.Equal((byte)4, item.RadarBehavior);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordMembership_CreatesEntry_AndSetsEquip()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,83 +1,132 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Ui;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Ui;
|
||||
|
||||
public sealed class RadarBlipColorsTests
|
||||
{
|
||||
// PWD bit constants per docs/research/named-retail/acclient.h:6431-6463
|
||||
private const uint BF_PLAYER = 0x8u;
|
||||
private const uint BF_PLAYER_KILLER = 0x20u;
|
||||
private const uint BF_VENDOR = 0x200u;
|
||||
private const uint BF_PORTAL = 0x40000u;
|
||||
private const uint BF_PKLITE_PKSTATUS = 0x2000000u;
|
||||
// PublicWeenieDesc::BitfieldIndex, acclient.h:6431.
|
||||
private const uint BfPlayer = 0x00000008u;
|
||||
private const uint BfAttackable = 0x00000010u;
|
||||
private const uint BfPlayerKiller = 0x00000020u;
|
||||
private const uint BfHiddenAdmin = 0x00000040u;
|
||||
private const uint BfVendor = 0x00000200u;
|
||||
private const uint BfPortal = 0x00040000u;
|
||||
private const uint BfAdmin = 0x00100000u;
|
||||
private const uint BfFreePk = 0x00200000u;
|
||||
private const uint BfPkLite = 0x02000000u;
|
||||
|
||||
[Fact]
|
||||
public void Item_NoFlags_ReturnsItemColor()
|
||||
public void Palette_UsesExactRetailFloatConstants()
|
||||
{
|
||||
// SpellComponents is itemType=0x1000 (e.g. a Taper) — not a creature.
|
||||
var result = RadarBlipColors.For(itemType: (uint)ItemType.SpellComponents, pwdBitfield: 0);
|
||||
Assert.Equal(RadarBlipColors.Item, result);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(0.25f, 0.660000026f, 1f, 1f), RadarBlipColors.Blue);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.670000017f, 0f, 1f), RadarBlipColors.Gold);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(1f, 1f, 0.5f, 1f), RadarBlipColors.Yellow);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.25f, 0.389999986f, 1f), RadarBlipColors.Red);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(0.75f, 0.389999986f, 1f, 1f), RadarBlipColors.Purple);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.660000026f, 0.75f, 1f), RadarBlipColors.Pink);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(0f, 0.5f, 0.25f, 1f), RadarBlipColors.Green);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Misc_NoFlags_ReturnsItemColor()
|
||||
public void Override_MapsValuesOneThroughTenInRetailOrder()
|
||||
{
|
||||
var result = RadarBlipColors.For((uint)ItemType.Misc, pwdBitfield: 0);
|
||||
Assert.Equal(RadarBlipColors.Item, result);
|
||||
RadarBlipColors.Rgba[] expected =
|
||||
[
|
||||
RadarBlipColors.Blue,
|
||||
RadarBlipColors.Gold,
|
||||
RadarBlipColors.White,
|
||||
RadarBlipColors.Purple,
|
||||
RadarBlipColors.Red,
|
||||
RadarBlipColors.Pink,
|
||||
RadarBlipColors.Green,
|
||||
RadarBlipColors.Yellow,
|
||||
RadarBlipColors.Cyan,
|
||||
RadarBlipColors.BrightGreen,
|
||||
];
|
||||
|
||||
for (byte value = 1; value <= expected.Length; value++)
|
||||
Assert.Equal(expected[value - 1], RadarBlipColors.ForOverride(value));
|
||||
|
||||
Assert.Equal(RadarBlipColors.Default, RadarBlipColors.ForOverride(11));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Creature_NotPlayer_ReturnsCreatureColor()
|
||||
public void WireOverride_WinsOverObjectClassification()
|
||||
{
|
||||
// NPC: itemType has Creature bit, no Player flag.
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: 0);
|
||||
Assert.Equal(RadarBlipColors.Creature, result);
|
||||
var traits = new RadarObjectTraits(
|
||||
BlipColorOverride: 1,
|
||||
IsPortal: true,
|
||||
IsVendor: true,
|
||||
IsPlayer: true,
|
||||
IsPlayerKiller: true);
|
||||
|
||||
Assert.Equal(RadarBlipColors.Blue, RadarBlipColors.For(traits));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FriendlyPlayer_ReturnsDefaultColor()
|
||||
public void PortalAndVendor_UsePurpleAndYellow()
|
||||
{
|
||||
// Friendly player: Creature itemType + Player flag, no PK bits.
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: BF_PLAYER);
|
||||
Assert.Equal(RadarBlipColors.Default, result);
|
||||
Assert.Equal(
|
||||
RadarBlipColors.Purple,
|
||||
RadarBlipColors.For((uint)ItemType.Creature, BfPortal | BfVendor));
|
||||
Assert.Equal(
|
||||
RadarBlipColors.Yellow,
|
||||
RadarBlipColors.For((uint)ItemType.Creature, BfVendor));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PK_Player_ReturnsPlayerKillerColor()
|
||||
public void Creature_MustBeAttackableAndNotAPlayer()
|
||||
{
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature,
|
||||
pwdBitfield: BF_PLAYER | BF_PLAYER_KILLER);
|
||||
Assert.Equal(RadarBlipColors.PlayerKiller, result);
|
||||
Assert.Equal(
|
||||
RadarBlipColors.Gold,
|
||||
RadarBlipColors.For((uint)ItemType.Creature, BfAttackable));
|
||||
Assert.Equal(
|
||||
RadarBlipColors.White,
|
||||
RadarBlipColors.For((uint)ItemType.Creature, 0));
|
||||
Assert.Equal(
|
||||
RadarBlipColors.White,
|
||||
RadarBlipColors.For((uint)ItemType.Creature, BfAttackable | BfPlayer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PKLite_Player_ReturnsPKLiteColor()
|
||||
public void Player_StatusDispatch_MatchesRetailPriority()
|
||||
{
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature,
|
||||
pwdBitfield: BF_PLAYER | BF_PKLITE_PKSTATUS);
|
||||
Assert.Equal(RadarBlipColors.PKLite, result);
|
||||
uint creature = (uint)ItemType.Creature;
|
||||
|
||||
Assert.Equal(RadarBlipColors.Cyan, RadarBlipColors.For(creature, BfPlayer | BfAdmin));
|
||||
Assert.Equal(RadarBlipColors.Red, RadarBlipColors.For(creature, BfPlayer | BfPlayerKiller));
|
||||
Assert.Equal(RadarBlipColors.Pink, RadarBlipColors.For(creature, BfPlayer | BfPkLite));
|
||||
Assert.Equal(RadarBlipColors.Gold, RadarBlipColors.For(creature, BfPlayer | BfFreePk));
|
||||
Assert.Equal(
|
||||
RadarBlipColors.Red,
|
||||
RadarBlipColors.For(creature, BfPlayer | BfAdmin | BfHiddenAdmin | BfPlayerKiller));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vendor_BeatsCreatureFlag()
|
||||
public void Fellowship_OverridesPlayerStatusColor()
|
||||
{
|
||||
// A vendor NPC: Creature itemType + Vendor flag. Vendor wins per
|
||||
// retail's dispatch order (vendor check happens before creature
|
||||
// check at 0x004d7946-004d7973).
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature,
|
||||
pwdBitfield: BF_VENDOR);
|
||||
Assert.Equal(RadarBlipColors.Vendor, result);
|
||||
var traits = new RadarObjectTraits(IsPlayer: true, IsPlayerKiller: true);
|
||||
var relationship = new RadarRelationshipTraits(
|
||||
IsFellowshipMember: true,
|
||||
IsFellowshipLeader: true);
|
||||
|
||||
Assert.Equal(RadarBlipColors.BrightGreen, RadarBlipColors.For(traits, relationship));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portal_TopPriority()
|
||||
public void InvalidDescription_ReturnsDefault()
|
||||
{
|
||||
// Portal flag wins over everything else (retail dispatch order
|
||||
// checks BF_PORTAL first).
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature,
|
||||
pwdBitfield: BF_PORTAL | BF_PLAYER | BF_PLAYER_KILLER);
|
||||
Assert.Equal(RadarBlipColors.Portal, result);
|
||||
var traits = new RadarObjectTraits(IsValid: false, BlipColorOverride: 1, IsPortal: true);
|
||||
|
||||
Assert.Equal(RadarBlipColors.Default, RadarBlipColors.For(traits));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rgba8Projection_RemainsAvailableToExistingUiConsumers()
|
||||
{
|
||||
Assert.Equal((byte)64, RadarBlipColors.Blue.R);
|
||||
Assert.Equal((byte)168, RadarBlipColors.Blue.G);
|
||||
Assert.Equal(0xFFFFA840u, RadarBlipColors.Blue.ToAbgr32());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
209
tests/AcDream.Core.Tests/Ui/RetailRadarTests.cs
Normal file
209
tests/AcDream.Core.Tests/Ui/RetailRadarTests.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.Core.Tests.Ui;
|
||||
|
||||
public sealed class RetailRadarTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(RadarBehavior.Undef, true, false)]
|
||||
[InlineData(RadarBehavior.ShowNever, true, false)]
|
||||
[InlineData(RadarBehavior.ShowMovement, true, true)]
|
||||
[InlineData(RadarBehavior.ShowAttacking, true, true)]
|
||||
[InlineData(RadarBehavior.ShowAlways, true, true)]
|
||||
[InlineData(RadarBehavior.ShowAlways, false, false)]
|
||||
public void Showability_AcceptsRetailEnumValuesWithoutLiveStateChecks(
|
||||
RadarBehavior behavior,
|
||||
bool hasPhysics,
|
||||
bool expected)
|
||||
=> Assert.Equal(expected, RetailRadar.IsShowable(behavior, hasPhysics));
|
||||
|
||||
[Fact]
|
||||
public void ShapeClassification_UsesFellowshipAllegianceAndMutualPkPriority()
|
||||
{
|
||||
var pk = new RadarObjectTraits(IsPlayerKiller: true);
|
||||
var pkLite = new RadarObjectTraits(IsPkLite: true);
|
||||
|
||||
Assert.Equal(RadarBlipShape.Default, RetailRadar.GetBlipShape(pk));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.Threat,
|
||||
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(PlayerIsPlayerKiller: true)));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.Threat,
|
||||
RetailRadar.GetBlipShape(pkLite, new RadarRelationshipTraits(PlayerIsPkLite: true)));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.AllegianceMember,
|
||||
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
|
||||
IsAllegianceMember: true,
|
||||
PlayerIsPlayerKiller: true)));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.Fellowship,
|
||||
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
|
||||
IsFellowshipMember: true,
|
||||
IsAllegianceMember: true,
|
||||
PlayerIsPlayerKiller: true)));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.FellowshipLeader,
|
||||
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
|
||||
IsFellowshipMember: true,
|
||||
IsFellowshipLeader: true)));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.Undef,
|
||||
RetailRadar.GetBlipShape(new RadarObjectTraits(IsValid: false)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlipPixelShapes_MatchRetailDrawRoutines()
|
||||
{
|
||||
AssertOffsets(RadarBlipShape.Circle, [(0, 0)]);
|
||||
AssertOffsets(RadarBlipShape.Plus, [(0, 0), (0, -1), (0, 1), (-1, 0), (1, 0)]);
|
||||
AssertOffsets(RadarBlipShape.X, [(0, 0), (1, 1), (-1, -1), (-1, 1), (1, -1)]);
|
||||
AssertOffsets(
|
||||
RadarBlipShape.Box,
|
||||
[(0, -1), (0, 1), (-1, 0), (1, 0), (1, 1), (-1, -1), (-1, 1), (1, -1)]);
|
||||
AssertOffsets(RadarBlipShape.Triangle, [(0, 0), (-1, 1), (0, 1), (1, 1)]);
|
||||
AssertOffsets(RadarBlipShape.InvertedTriangle, [(0, 0), (-1, -1), (0, -1), (1, -1)]);
|
||||
AssertOffsets(
|
||||
RadarBlipShape.XBox,
|
||||
[
|
||||
(0, -1), (0, 1), (-1, 0), (1, 0),
|
||||
(1, 1), (-1, -1), (-1, 1), (1, -1),
|
||||
(-2, -2), (2, -2), (-2, 2), (2, 2),
|
||||
]);
|
||||
Assert.Empty(RetailRadar.GetBlipPixels(RadarBlipShape.Undef).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CenterMarkerAndSelectionOutline_MatchRetailPixelCountsAndExtents()
|
||||
{
|
||||
var player = RetailRadar.PlayerMarkerPixels.ToArray();
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
new RadarPixelOffset(0, 0),
|
||||
new RadarPixelOffset(0, -1),
|
||||
new RadarPixelOffset(0, 1),
|
||||
new RadarPixelOffset(-1, 0),
|
||||
new RadarPixelOffset(1, 0),
|
||||
new RadarPixelOffset(-2, 0),
|
||||
new RadarPixelOffset(2, 0),
|
||||
new RadarPixelOffset(0, -2),
|
||||
new RadarPixelOffset(0, 2),
|
||||
},
|
||||
player);
|
||||
|
||||
var selected = RetailRadar.SelectionPixels.ToArray();
|
||||
Assert.Equal(20, selected.Length);
|
||||
Assert.All(selected, p => Assert.True(Math.Abs(p.X) == 3 || Math.Abs(p.Y) == 3));
|
||||
for (int coordinate = -2; coordinate <= 2; coordinate++)
|
||||
{
|
||||
Assert.Contains(new RadarPixelOffset(coordinate, -3), selected);
|
||||
Assert.Contains(new RadarPixelOffset(coordinate, 3), selected);
|
||||
Assert.Contains(new RadarPixelOffset(-3, coordinate), selected);
|
||||
Assert.Contains(new RadarPixelOffset(3, coordinate), selected);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_UsesRetailRangeScaleAndScreenYAxis()
|
||||
{
|
||||
bool visible = RetailRadar.TryProject(
|
||||
new Vector3(10f, 20f, 4.999f),
|
||||
new Vector2(60f, 60f),
|
||||
radarRadiusPixels: 55,
|
||||
radarRangeMeters: RetailRadar.OutdoorRangeMeters,
|
||||
out var projection);
|
||||
|
||||
Assert.True(visible);
|
||||
Assert.Equal(new Vector2(67f, 45f), projection.Pixel);
|
||||
Assert.Equal(1f, projection.RgbMultiplier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_ExcludesExactRangeMinusOneBoundary()
|
||||
{
|
||||
Assert.True(RetailRadar.TryProject(
|
||||
new Vector3(73.999f, 0f, 0f),
|
||||
Vector2.Zero,
|
||||
55,
|
||||
RetailRadar.OutdoorRangeMeters,
|
||||
out _));
|
||||
Assert.False(RetailRadar.TryProject(
|
||||
new Vector3(74f, 0f, 0f),
|
||||
Vector2.Zero,
|
||||
55,
|
||||
RetailRadar.OutdoorRangeMeters,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(4.999f, 1f)]
|
||||
[InlineData(-4.999f, 1f)]
|
||||
[InlineData(5f, 0.65f)]
|
||||
[InlineData(-5f, 0.65f)]
|
||||
public void AltitudeDim_UsesStrictFiveMeterThreshold(float z, float expected)
|
||||
=> Assert.Equal(expected, RetailRadar.GetAltitudeRgbMultiplier(z));
|
||||
|
||||
[Fact]
|
||||
public void CompassTokens_OrbitUsingPhysicalHeading()
|
||||
{
|
||||
Vector2 center = new(60f, 60f);
|
||||
Vector2 size = new(10f, 10f);
|
||||
|
||||
Assert.Equal(
|
||||
new Vector2(55f, 0f),
|
||||
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.North, center, 55f, size));
|
||||
Assert.Equal(
|
||||
// Retail's east offset is the float 1.57079637; after x87 cosine
|
||||
// and C++ float-to-int truncation its center is one subpixel above 60.
|
||||
new Vector2(110f, 54f),
|
||||
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.East, center, 55f, size));
|
||||
Assert.Equal(
|
||||
new Vector2(55f, 110f),
|
||||
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.South, center, 55f, size));
|
||||
Assert.Equal(
|
||||
new Vector2(0f, 55f),
|
||||
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.West, center, 55f, size));
|
||||
|
||||
Assert.Equal(
|
||||
new Vector2(0f, 55f),
|
||||
RetailRadar.GetCompassTokenTopLeft(90f, RadarCompassPoint.North, center, 55f, size));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coordinates_UseOutdoorLandcellAndRetailAxisOrder()
|
||||
{
|
||||
uint cellId = LandDefs.LcoordToGid(1358, 1440);
|
||||
|
||||
Assert.True(RadarCoordinates.TryFromCell(cellId, out var coordinates));
|
||||
Assert.Equal(33.9, coordinates.X, precision: 10);
|
||||
Assert.Equal(42.1, coordinates.Y, precision: 10);
|
||||
Assert.Equal("33.9E", coordinates.XText);
|
||||
Assert.Equal("42.1N", coordinates.YText);
|
||||
Assert.Equal("42.1N,33.9E", coordinates.CombinedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coordinates_FormatWestSouthAndZeroWithoutDirection()
|
||||
{
|
||||
uint cellId = LandDefs.LcoordToGid(900, 800);
|
||||
Assert.True(RadarCoordinates.TryFromCell(cellId, out var coordinates));
|
||||
Assert.Equal("11.9W", coordinates.XText);
|
||||
Assert.Equal("21.9S", coordinates.YText);
|
||||
|
||||
cellId = LandDefs.LcoordToGid(1019, 1019);
|
||||
Assert.True(RadarCoordinates.TryFromCell(cellId, out coordinates));
|
||||
Assert.Equal("0.0", coordinates.XText);
|
||||
Assert.Equal("0.0,0.0", coordinates.CombinedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coordinates_AreUnavailableIndoors()
|
||||
=> Assert.False(RadarCoordinates.TryFromCell(0xA9B40164u, out _));
|
||||
|
||||
private static void AssertOffsets(RadarBlipShape shape, (int X, int Y)[] expected)
|
||||
=> Assert.Equal(
|
||||
expected.Select(p => new RadarPixelOffset(p.X, p.Y)),
|
||||
RetailRadar.GetBlipPixels(shape).ToArray());
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ public sealed class GameplaySettingsTests
|
|||
Assert.True(d.ShowTooltips);
|
||||
Assert.False(d.VividTargetingIndicator);
|
||||
Assert.False(d.SideBySideVitals);
|
||||
Assert.False(d.CoordinatesOnRadar);
|
||||
Assert.True(d.CoordinatesOnRadar); // retail default mask includes 0x00400000
|
||||
Assert.True(d.SpellDuration);
|
||||
Assert.True(d.AllowGive);
|
||||
Assert.True(d.ShowHelm);
|
||||
|
|
|
|||
|
|
@ -368,4 +368,20 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
Assert.False(store.LoadChat().HearTradeChat);
|
||||
Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowPositions_RoundTripPerCharacterAndPreserveSettings()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
|
||||
store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f));
|
||||
store.SaveWindowPosition("Bob", "radar", new UiWindowPosition(44f, 55f));
|
||||
|
||||
Assert.Equal(new UiWindowPosition(321.5f, 18f),
|
||||
store.LoadWindowPosition("Alice", "radar"));
|
||||
Assert.Equal(new UiWindowPosition(44f, 55f),
|
||||
store.LoadWindowPosition("Bob", "radar"));
|
||||
Assert.Null(store.LoadWindowPosition("Alice", "inventory"));
|
||||
Assert.True(store.LoadGameplay().LockUI);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue