fix(ui): match retail item appraisal semantics

Preserve PublicWeenieDesc hook identity from CreateObject through the item model so hook appraisals suppress sentinel capacities exactly. Use appraisal-only Value and Burden presence, retain AddItemInfo paragraph and authored font-color selection, and port retail lock, page, enchantment, and spell-block formatting.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 18:04:19 +02:00
parent bc47bc4917
commit d3c5e06fdd
21 changed files with 982 additions and 145 deletions

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
@ -47,6 +48,8 @@ public sealed class AppraisalUiControllerTests
Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId));
var properties = new PropertyBundle();
properties.Ints[19u] = 1_250;
properties.Ints[5u] = 350;
properties.Strings[16u] = "A finely balanced weapon.";
properties.Strings[7u] = "Remember the fallen.";
properties.Strings[8u] = "Tester";
@ -62,6 +65,13 @@ public sealed class AppraisalUiControllerTests
UiText itemText = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.ItemTextId));
Assert.Equal(VJustify.Top, itemText.VerticalJustify);
Assert.Equal(
[
new Vector4(1f, 1f, 1f, 1f),
new Vector4(0f, 1f, 0f, 1f),
new Vector4(1f, 0f, 0f, 1f),
],
itemText.FontColorPalette);
string report = string.Join('\n', itemText.LinesProvider().Select(line => line.Text));
Assert.Contains("Value: 1,250", report);
Assert.Contains("Burden: 350", report);
@ -597,6 +607,8 @@ public sealed class AppraisalUiControllerTests
ObjectId = ObjectId,
Name = "Hooked Decoration",
ContainerId = 0x50000002u,
HookItemTypes = (uint)ItemType.Misc,
HookType = 1u,
});
var messages = new List<string>();
using var interaction = NewInteraction(objects, []);

View file

@ -1,3 +1,5 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
@ -22,6 +24,8 @@ public sealed class ItemAppraisalTextFormatterTests
};
var properties = new PropertyBundle();
properties.Ints[353u] = 2;
properties.Ints[19u] = 12_500;
properties.Ints[5u] = 450;
properties.Ints[204u] = 4;
properties.Ints[106u] = 300;
properties.Ints[107u] = 250;
@ -83,6 +87,232 @@ public sealed class ItemAppraisalTextFormatterTests
Assert.EndsWith("A finely balanced enchanted blade.", report);
}
[Fact]
public void HookWithHookProfile_UsesRetailUnknownsAndSuppressesBogusCapacity()
{
var obj = new ClientObject
{
ObjectId = 0x50000005u,
Name = "Black Phyntos Hive",
Type = ItemType.Container,
ItemsCapacity = 255,
ContainersCapacity = 255,
HookItemTypes = (uint)ItemType.Container,
HookType = 4u,
};
var properties = new PropertyBundle();
properties.Strings[16u] =
"A hollowed out tree trunk that has a Phyntos Wasp Hive in it.";
ItemAppraisalReport report = ItemAppraisalTextFormatter.BuildReport(
obj,
Parsed(
properties,
hook: new AppraiseInfoParser.HookProfile(
Flags: 0u,
ValidLocations: 0u,
AmmoType: 0u)),
_ => null);
Assert.Equal(
"Value: ???\nBurden: Unknown\n\n"
+ "A hollowed out tree trunk that has a Phyntos Wasp Hive in it.",
report.ToString());
Assert.DoesNotContain("Can hold", report.ToString());
Assert.Collection(
report.Fragments,
value =>
{
Assert.Equal(ItemAppraisalSeparator.None, value.Separator);
Assert.Equal("Value: ???", value.Text);
},
burden =>
{
Assert.Equal(ItemAppraisalSeparator.Line, burden.Separator);
Assert.Equal("Burden: Unknown", burden.Text);
},
description =>
{
Assert.Equal(ItemAppraisalSeparator.Paragraph, description.Separator);
Assert.Equal(properties.Strings[16u], description.Text);
});
}
[Fact]
public void OrdinaryContainer_PresentsCapacityAsRetailParagraph()
{
var obj = new ClientObject
{
ObjectId = 0x50000006u,
Name = "Backpack",
Type = ItemType.Container,
ItemsCapacity = 24,
ContainersCapacity = 1,
};
string report = ItemAppraisalTextFormatter.Build(
obj,
Parsed(new PropertyBundle()),
_ => null);
Assert.Equal(
"Value: ???\nBurden: Unknown\n\n"
+ "Can hold up to 24 items and 1 containers.",
report);
}
[Fact]
public void BookCapacity_UsesRetailUsedThenTotalPropertyOrder()
{
var obj = new ClientObject
{
ObjectId = 0x5000000Bu,
Name = "Book",
Type = ItemType.Writable,
};
var properties = new PropertyBundle();
properties.Ints[174u] = 3;
properties.Ints[175u] = 10;
string report = ItemAppraisalTextFormatter.Build(
obj,
Parsed(properties),
_ => null);
Assert.EndsWith("3 of 10 pages full.", report);
}
[Theory]
[InlineData((ushort)0x0000, ItemAppraisalFontStyle.Detrimental)]
[InlineData((ushort)0x0008, ItemAppraisalFontStyle.Beneficial)]
public void WeaponDamage_UsesRetailEnchantmentFontSelection(
ushort highHalf,
ItemAppraisalFontStyle expected)
{
var obj = new ClientObject
{
ObjectId = 0x50000007u,
Name = "Sword",
Type = ItemType.MeleeWeapon,
ValidLocations = EquipMask.MeleeWeapon,
};
AppraiseInfoParser.Parsed parsed = Parsed(
new PropertyBundle(),
weapon: new AppraiseInfoParser.WeaponProfile(
DamageType: 1u,
WeaponTime: 30u,
WeaponSkill: 44u,
Damage: 40u,
DamageVariance: 0d,
DamageMod: 1d,
WeaponLength: 1d,
MaxVelocity: 0d,
WeaponOffense: 1d,
MaxVelocityEstimated: 0u),
weaponEnchantments: ((ushort)0x0008, highHalf));
ItemAppraisalReport report = ItemAppraisalTextFormatter.BuildReport(
obj,
parsed,
_ => null);
ItemAppraisalFragment damage = Assert.Single(
report.Fragments,
fragment => fragment.Text.StartsWith(
"Damage:",
StringComparison.Ordinal));
Assert.Equal(expected, damage.Style);
}
[Fact]
public void TextLayout_PreservesRetailPaletteAcrossParagraphs()
{
Vector4 normal = Vector4.One;
var beneficial = new Vector4(0f, 1f, 0f, 1f);
var detrimental = new Vector4(1f, 0f, 0f, 1f);
var text = new UiText
{
Width = 1_000f,
FontColorPalette = [normal, beneficial, detrimental],
};
var report = new ItemAppraisalReport(
[
new ItemAppraisalFragment(
"Normal",
ItemAppraisalSeparator.None,
ItemAppraisalFontStyle.Normal),
new ItemAppraisalFragment(
"High",
ItemAppraisalSeparator.Line,
ItemAppraisalFontStyle.Beneficial),
new ItemAppraisalFragment(
"Low",
ItemAppraisalSeparator.Paragraph,
ItemAppraisalFontStyle.Detrimental),
]);
IReadOnlyList<UiText.Line> lines = ItemAppraisalTextLayout.Shape(
text,
report);
Assert.Equal(["Normal", "High", "", "Low"], lines.Select(line => line.Text));
Assert.Equal(normal, lines[0].Color);
Assert.Equal(beneficial, lines[1].Color);
Assert.Equal(detrimental, lines[3].Color);
}
[Fact]
public void LockInformation_UsesRetailPresenceRulesWordingAndParagraphs()
{
var obj = new ClientObject
{
ObjectId = 0x50000009u,
Name = "Locked Chest",
Type = ItemType.Container,
};
var properties = new PropertyBundle();
properties.Bools[3u] = true;
properties.Ints[38u] = 212;
properties.Ints[173u] = 42;
ItemAppraisalReport report = ItemAppraisalTextFormatter.BuildReport(
obj,
Parsed(properties),
_ => null);
Assert.Contains(
report.Fragments,
fragment => fragment.Text == "Locked"
&& fragment.Separator == ItemAppraisalSeparator.Paragraph);
Assert.Contains(
report.Fragments,
fragment => fragment.Text
== "The lock looks difficult to pick (Resistance 212)."
&& fragment.Separator == ItemAppraisalSeparator.Paragraph);
}
[Fact]
public void MissingLockedFlag_ReportsLockpickBonusInsteadOfInventingLockState()
{
var obj = new ClientObject
{
ObjectId = 0x5000000Au,
Name = "Lockpick",
Type = ItemType.Misc,
};
var properties = new PropertyBundle();
properties.Ints[38u] = -7;
string report = ItemAppraisalTextFormatter.Build(
obj,
Parsed(properties),
_ => null);
Assert.Contains("Bonus to Lockpick Skill: -7", report);
Assert.DoesNotContain("Locked", report);
Assert.DoesNotContain("Unlocked", report);
}
[Fact]
public void ArmorRequirementsAndUses_UseRetailLabelsAndResistanceBands()
{
@ -215,7 +445,9 @@ public sealed class ItemAppraisalTextFormatterTests
PropertyBundle properties,
uint[]? spells = null,
AppraiseInfoParser.WeaponProfile? weapon = null,
AppraiseInfoParser.ArmorProfile? armor = null)
AppraiseInfoParser.ArmorProfile? armor = null,
AppraiseInfoParser.HookProfile? hook = null,
(ushort Highlight, ushort Color)? weaponEnchantments = null)
=> new(
Guid: 0x50000001u,
Flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable,
@ -225,10 +457,10 @@ public sealed class ItemAppraisalTextFormatterTests
ArmorProfile: armor,
CreatureProfile: null,
WeaponProfile: weapon,
HookProfile: null,
HookProfile: hook,
ArmorLevels: null,
ArmorEnchantments: null,
WeaponEnchantments: null,
WeaponEnchantments: weaponEnchantments,
ResistEnchantments: null);
private static SpellMetadata Spell(

View file

@ -559,6 +559,43 @@ public sealed class CreateObjectTests
Assert.Equal(7.5f, p.Workmanship);
}
[Fact]
public void TryParse_HookIdentityFields_AreCapturedWithoutMisaligningOverlay()
{
const uint hookItemTypes = (uint)ItemType.Container;
const ushort hookType = 4;
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000027u,
name: "Black Phyntos Hive",
itemType: (uint)ItemType.Container,
weenieFlags: 0x20000000u | 0x10000000u | 0x40000000u,
hookItemTypes: hookItemTypes,
hookType: hookType,
iconOverlayId: 0x4567u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(hookItemTypes, parsed.Value.HookItemTypes);
Assert.Equal((uint)hookType, parsed.Value.HookType);
Assert.Equal(0x06004567u, parsed.Value.IconOverlayId);
}
[Fact]
public void TryParse_AbsentHookIdentityFields_RemainNull()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000028u,
name: "Ordinary Chest",
itemType: (uint)ItemType.Container);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Null(parsed.Value.HookItemTypes);
Assert.Null(parsed.Value.HookType);
}
[Fact]
public void TryParse_MovementSequence_SurfacedFromTimestampBlock()
{
@ -709,7 +746,9 @@ public sealed class CreateObjectTests
uint cooldownId = 0,
double cooldownDuration = 0,
uint petOwnerId = 0,
ushort spellId = 0)
ushort spellId = 0,
uint hookItemTypes = 0,
ushort hookType = 0)
{
var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode);
@ -809,9 +848,9 @@ public sealed class CreateObjectTests
WriteU16(bytes, 0); // count
WriteU16(bytes, 768); // numBuckets (retail constant)
}
if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, 0); // HookItemTypes u32
if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, hookItemTypes); // HookItemTypes u32
if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, 0); // Monarch u32
if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, 0); // HookType u16
if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, hookType); // HookType u16
if ((weenieFlags & 0x40000000u) != 0) WritePackedDword(bytes, iconOverlayId); // IconOverlay
if ((weenieFlags2 & 0x00000001u) != 0) WritePackedDword(bytes, iconUnderlayId); // IconUnderlay
if ((weenieFlags & 0x80000000u) != 0) WriteU32(bytes, materialType); // MaterialType

View file

@ -54,6 +54,8 @@ public sealed class ObjectTableWiringTests
Priority = 0x00000005u,
ItemsCapacity = 0,
ContainersCapacity = 0,
HookItemTypes = (uint)ItemType.Container,
HookType = 4u,
Structure = 80,
MaxStructure = 100,
Workmanship = 4.5f,
@ -102,6 +104,8 @@ public sealed class ObjectTableWiringTests
// --- capacity ---
Assert.Equal(0, d.ItemsCapacity);
Assert.Equal(0, d.ContainersCapacity);
Assert.Equal((uint)ItemType.Container, d.HookItemTypes);
Assert.Equal(4u, d.HookType);
// --- durability ---
Assert.Equal(80, d.Structure);

View file

@ -300,12 +300,16 @@ public sealed class ClientObjectTableTests
var o = new ClientObject
{
ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7,
HookItemTypes = (uint)ItemType.Container, HookType = 4u,
ContainerTypeHint = 1u, Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f,
};
o.WeenieClassId = 0xABCDu; // now settable
Assert.Equal(0x42u, o.WielderId);
Assert.Equal(24, o.ItemsCapacity);
Assert.Equal(7, o.ContainersCapacity);
Assert.Equal((uint)ItemType.Container, o.HookItemTypes);
Assert.Equal(4u, o.HookType);
Assert.True(o.IsHook);
Assert.Equal(1u, o.ContainerTypeHint);
Assert.Equal(8u, o.Priority);
Assert.Equal(5, o.Structure);
@ -314,6 +318,40 @@ public sealed class ClientObjectTableTests
Assert.Equal(0xABCDu, o.WeenieClassId);
}
[Fact]
public void IngestAndLiveProperties_PreserveRetailHookIdentity()
{
const uint guid = 0x500000AEu;
var table = new ClientObjectTable();
ClientObject obj = table.Ingest(
FullWeenie(guid) with
{
HookItemTypes = (uint)ItemType.Container,
HookType = 4u,
});
Assert.True(obj.IsHook);
Assert.Equal((uint)ItemType.Container, obj.HookItemTypes);
Assert.Equal(4u, obj.HookType);
Assert.True(table.UpdateIntProperty(
guid,
ClientObjectTable.HookTypePropertyId,
value: 0));
Assert.False(obj.IsHook);
Assert.True(table.UpdateIntProperty(
guid,
ClientObjectTable.HookTypePropertyId,
value: 2));
Assert.True(table.UpdateIntProperty(
guid,
ClientObjectTable.HookItemTypesPropertyId,
value: (int)ItemType.Misc));
Assert.True(obj.IsHook);
Assert.Equal(2u, obj.HookType);
Assert.Equal((uint)ItemType.Misc, obj.HookItemTypes);
}
[Fact]
public void WeenieData_Construct()
{