fix #234: port retail appraisal floaty and inscriptions

This commit is contained in:
Erik 2026-07-23 12:12:57 +02:00
parent 643cdfe66e
commit f1a7912160
23 changed files with 1310 additions and 106 deletions

View file

@ -23,18 +23,23 @@ public sealed class AppraisalUiControllerTests
Type = ItemType.MeleeWeapon,
Value = 1250,
Burden = 350,
ContainerId = 0x50000002u,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
});
var sent = new List<uint>();
var inscriptions = new List<(uint ObjectId, string Text)>();
var messages = new List<string>();
using var interaction = NewInteraction(objects, sent);
var combat = new CombatState();
int shown = 0;
int closed = 0;
using AppraisalUiController controller = AppraisalUiController.Bind(
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
combat,
new Spellbook(),
inscriptions,
messages,
() => shown++,
() => closed++)!;
@ -64,10 +69,12 @@ public sealed class AppraisalUiControllerTests
UiField inscription = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
Assert.Contains("Remember the fallen", inscription.Text);
Assert.False(inscription.Enabled);
Assert.True(inscription.Editable);
UiText signature = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.SignatureTextId));
Assert.Equal("Inscribed by Tester", Assert.Single(signature.LinesProvider()).Text);
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
Assert.Empty(inscriptions);
Assert.Empty(messages);
((UiButton)layout.FindElement(AppraisalUiController.CloseId)!).OnClick!.Invoke();
Assert.Equal(1, closed);
@ -88,12 +95,13 @@ public sealed class AppraisalUiControllerTests
using var interaction = NewInteraction(objects, sent);
var combat = new CombatState();
int shown = 0;
using AppraisalUiController controller = AppraisalUiController.Bind(
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
combat,
new Spellbook(),
[],
[],
() => shown++,
() => { })!;
@ -145,12 +153,13 @@ public sealed class AppraisalUiControllerTests
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = ObjectId, Name = "Item" });
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = AppraisalUiController.Bind(
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
new Spellbook(),
[],
[],
() => throw new InvalidOperationException("must not show"),
() => { })!;
@ -158,6 +167,281 @@ public sealed class AppraisalUiControllerTests
Assert.Equal(0u, controller.CurrentObjectId);
}
[Fact]
public void EmptyOwnedInscription_FocusEditAndBlur_SendsRetailTransactionOnce()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Sword",
Type = ItemType.MeleeWeapon,
ContainerId = 0x50000002u,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
});
var inscriptions = new List<(uint ObjectId, string Text)>();
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
inscriptions,
[],
() => { },
() => { })!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
Assert.True(controller.Apply(Parsed(new PropertyBundle())));
UiField field = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
UiText signature = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.SignatureTextId));
Assert.True(field.Editable);
Assert.Equal("<Inscribe here>", field.Text);
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusGained));
Assert.Equal(string.Empty, field.Text);
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
foreach (char c in "For glory")
field.InsertChar(c);
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
Assert.Equal(
new[] { (ObjectId, "For glory") },
inscriptions);
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusGained));
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
Assert.Single(inscriptions);
}
[Fact]
public void ExistingInscription_ClearOnBlur_SendsEmptyAndRestoresPlaceholder()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Sword",
Type = ItemType.MeleeWeapon,
ContainerId = 0x50000002u,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
});
var inscriptions = new List<(uint ObjectId, string Text)>();
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
inscriptions,
[],
() => { },
() => { })!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
properties.Strings[7u] = "Old words";
properties.Strings[8u] = "Tester";
Assert.True(controller.Apply(Parsed(properties)));
UiField field = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
UiText signature = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.SignatureTextId));
field.SetText(string.Empty);
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
Assert.Equal(new[] { (ObjectId, string.Empty) }, inscriptions);
Assert.Equal("<Inscribe here>", field.Text);
Assert.Equal(string.Empty, Assert.Single(signature.LinesProvider()).Text);
}
[Fact]
public void InscriptionPermissionMessages_MatchRetail()
{
ImportedLayout otherScribeLayout = FixtureLoader.LoadExamination();
var otherScribeObjects = new ClientObjectTable();
otherScribeObjects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Sword",
ContainerId = 0x50000002u,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
});
var messages = new List<string>();
using var otherInteraction = NewInteraction(otherScribeObjects, []);
using AppraisalUiController otherController = Bind(
otherScribeLayout,
otherScribeObjects,
otherInteraction,
new CombatState(),
[],
messages,
() => { },
() => { })!;
otherInteraction.ExamineSelectedOrEnterMode(ObjectId);
var otherProperties = new PropertyBundle();
otherProperties.Strings[7u] = "Hands off";
otherProperties.Strings[8u] = "Other";
Assert.True(otherController.Apply(Parsed(otherProperties)));
UiField otherField = Assert.IsType<UiField>(
otherScribeLayout.FindElement(AppraisalUiController.InscriptionTextId));
Assert.False(otherField.Editable);
otherField.OnEvent(new UiEvent(
otherField.EventId,
otherField,
UiEventType.Click));
Assert.Equal("Only Other can change the inscription", Assert.Single(messages));
ImportedLayout unownedLayout = FixtureLoader.LoadExamination();
var unownedObjects = new ClientObjectTable();
unownedObjects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Sword",
ContainerId = 0x60000000u,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
});
messages.Clear();
using var unownedInteraction = NewInteraction(unownedObjects, []);
using AppraisalUiController unownedController = Bind(
unownedLayout,
unownedObjects,
unownedInteraction,
new CombatState(),
[],
messages,
() => { },
() => { })!;
unownedInteraction.ExamineSelectedOrEnterMode(ObjectId);
Assert.True(unownedController.Apply(Parsed(new PropertyBundle())));
UiField unownedField = Assert.IsType<UiField>(
unownedLayout.FindElement(AppraisalUiController.InscriptionTextId));
unownedField.OnEvent(new UiEvent(
unownedField.EventId,
unownedField,
UiEventType.Click));
Assert.Equal(
"Item must be in your inventory to inscribe.",
Assert.Single(messages));
}
[Fact]
public void NonInscribableItem_HidesEditorAndBackgroundReportsRetailMessage()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Component",
ContainerId = 0x50000002u,
});
var messages = new List<string>();
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
messages,
() => { },
() => { })!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
Assert.True(controller.Apply(Parsed(new PropertyBundle())));
UiField field = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
UiText signature = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.SignatureTextId));
Assert.False(field.Visible);
Assert.False(signature.Visible);
UiDatElement background = Assert.IsType<UiDatElement>(
layout.FindElement(AppraisalUiController.InscriptionBackgroundId));
background.OnClick!.Invoke();
Assert.Equal("This item is not inscribable.", Assert.Single(messages));
}
[Fact]
public void HookProfileControlsVisibilityButPublicFlagStillControlsEditing()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Hooked Decoration",
ContainerId = 0x50000002u,
});
var messages = new List<string>();
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
messages,
() => { },
() => { })!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
AppraiseInfoParser.Parsed appraisal = Parsed(new PropertyBundle()) with
{
Flags = AppraiseInfoParser.IdentifyResponseFlags.HookProfile,
HookProfile = new AppraiseInfoParser.HookProfile(
Flags: 1u,
ValidLocations: 0u,
AmmoType: 0u),
};
Assert.True(controller.Apply(appraisal));
UiField field = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
Assert.True(field.Visible);
Assert.False(field.Editable);
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.Click));
Assert.Equal("This item is not inscribable.", Assert.Single(messages));
}
[Fact]
public void Examination_IsAnIndependentFloatyWindow_NotSharedMainPanelContent()
{
Assert.False(
RetailPanelCatalog.TryGetPanelId(WindowNames.Examination, out _));
Assert.DoesNotContain(
RetailPanelCatalog.MountedPanels,
mounted => mounted.WindowName == WindowNames.Examination);
}
private static AppraisalUiController? Bind(
ImportedLayout layout,
ClientObjectTable objects,
ItemInteractionController interaction,
CombatState combat,
List<(uint ObjectId, string Text)> inscriptions,
List<string> messages,
Action show,
Action close)
=> AppraisalUiController.Bind(
layout,
objects,
interaction,
combat,
new Spellbook(),
() => "Tester",
(objectId, text) => inscriptions.Add((objectId, text)),
messages.Add,
show,
close);
private static ItemInteractionController NewInteraction(
ClientObjectTable objects,
List<uint> sent)

View file

@ -94,4 +94,52 @@ public class UiFieldTests
Assert.Equal("5", input.Text);
}
[Fact]
public void ReadOnlyField_RejectsMutationsButReportsClick()
{
int clicks = 0;
var input = new UiField
{
Editable = false,
OnReadOnlyClick = () => clicks++,
};
input.SetText("fixed");
input.InsertChar('!');
input.Backspace();
input.OnEvent(new UiEvent(0u, input, UiEventType.Click));
Assert.Equal("fixed", input.Text);
Assert.Equal(1, clicks);
Assert.False(input.AcceptsFocus);
Assert.False(input.IsEditControl);
}
[Fact]
public void MultiLineField_EnterAddsNewlineInsteadOfSubmitting()
{
int submissions = 0;
var input = new UiField
{
OneLine = false,
OnSubmit = _ => submissions++,
};
input.InsertChar('a');
input.OnEvent(new UiEvent(
0u,
input,
UiEventType.KeyDown,
Data0: (int)Silk.NET.Input.Key.Enter));
input.OnEvent(new UiEvent(
0u,
input,
UiEventType.Char,
Data0: '\r'));
input.InsertChar('b');
Assert.Equal("a\nb", input.Text);
Assert.Equal(0, submissions);
}
}

View file

@ -93,6 +93,39 @@ public sealed class GameEventWiringTests
Assert.False(items.Get(guid)!.Properties.Ints.ContainsKey(25u));
}
[Fact]
public void WireAll_LiteralAceCreatureFlag_ForwardsAndUpdatesHealth()
{
const uint guid = 0x50000001u;
var dispatcher = new GameEventDispatcher();
var combat = new CombatState();
AppraiseInfoParser.Parsed? received = null;
GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
combat,
new Spellbook(),
new ChatLog(),
onAppraisal: appraisal => received = appraisal);
byte[] payload = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(payload, guid);
BinaryPrimitives.WriteUInt32LittleEndian(
payload.AsSpan(4),
0x0100u); // ACE IdentifyResponseFlags.CreatureProfile
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 75u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 100u);
dispatcher.Dispatch(GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.IdentifyObjectResponse, payload))!.Value);
Assert.NotNull(received);
Assert.NotNull(received.Value.CreatureProfile);
Assert.Equal(0.75f, combat.GetHealthPercent(guid), 3);
}
[Fact]
public void WireAll_ChannelBroadcast_RoutesToChatLog()

View file

@ -8,6 +8,27 @@ namespace AcDream.Core.Net.Tests.Messages;
public sealed class AppraiseInfoParserTests
{
[Theory]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable, 0x0001u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable, 0x0002u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.FloatStatsTable, 0x0004u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.StringStatsTable, 0x0008u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.SpellBook, 0x0010u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.WeaponProfile, 0x0020u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.HookProfile, 0x0040u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ArmorProfile, 0x0080u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile, 0x0100u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ArmorEnchantmentBitfield, 0x0200u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ResistEnchantmentBitfield, 0x0400u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.WeaponEnchantmentBitfield, 0x0800u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.DidStatsTable, 0x1000u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.Int64StatsTable, 0x2000u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ArmorLevels, 0x4000u)]
public void IdentifyResponseFlags_MatchRetailWireValues(
AppraiseInfoParser.IdentifyResponseFlags flag,
uint wireValue)
=> Assert.Equal(wireValue, (uint)flag);
/// <summary>
/// Build an AppraiseInfo payload matching ACE's wire format. Starts
/// with (guid, flags, success) then per-flag tables.
@ -319,6 +340,27 @@ public sealed class AppraiseInfoParserTests
Assert.Equal(300u, c.Health);
}
[Fact]
public void TryParse_LiteralAceCreatureFlag_DoesNotDecodeAsWeapon()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0x50000001u);
bw.Write(0x0100u); // ACE IdentifyResponseFlags.CreatureProfile
bw.Write(1u);
bw.Write(0u);
bw.Write(300u);
bw.Write(400u);
AppraiseInfoParser.Parsed? parsed =
AppraiseInfoParser.TryParse(ms.ToArray());
Assert.NotNull(parsed);
Assert.NotNull(parsed.Value.CreatureProfile);
Assert.Null(parsed.Value.WeaponProfile);
Assert.Equal(300u, parsed.Value.CreatureProfile.Value.Health);
}
[Fact]
public void TryParse_ArmorEnchantmentBitfield_HighlightAndColor()
{

View file

@ -148,4 +148,42 @@ public sealed class InventoryActionsTests
Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
}
[Fact]
public void BuildSetInscription_UsesRetailOpcodeGuidAndCp1252String16L()
{
byte[] body = InventoryActions.BuildSetInscription(
seq: 7,
itemGuid: 0x50000A01u,
inscription: "Café");
Assert.Equal(24, body.Length);
Assert.Equal(0xF7B1u,
BinaryPrimitives.ReadUInt32LittleEndian(body));
Assert.Equal(7u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(0x00BFu,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x50000A01u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(4,
BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(16)));
Assert.Equal(new byte[] { 0x43, 0x61, 0x66, 0xE9 },
body.AsSpan(18, 4).ToArray());
Assert.Equal(new byte[] { 0, 0 }, body.AsSpan(22, 2).ToArray());
}
[Fact]
public void BuildSetInscription_EmptyTextPacksAlignedClearRequest()
{
byte[] body = InventoryActions.BuildSetInscription(
seq: 1,
itemGuid: 0x50000A01u,
inscription: string.Empty);
Assert.Equal(20, body.Length);
Assert.Equal(0,
BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(16)));
Assert.Equal(new byte[] { 0, 0 }, body.AsSpan(18, 2).ToArray());
}
}

View file

@ -60,4 +60,19 @@ public sealed class WorldSessionInventoryActionTests
InventoryActions.BuildGiveObjectRequest(1, 0xAAu, 0xBBu, 3u),
captured);
}
[Fact]
public void SendSetInscription_UsesNextSequenceAndExactBuilderBytes()
{
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 65000));
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendSetInscription(0xAAu, "Remember.");
Assert.Equal(
InventoryActions.BuildSetInscription(1, 0xAAu, "Remember."),
captured);
}
}