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(