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:
parent
bc47bc4917
commit
d3c5e06fdd
21 changed files with 982 additions and 145 deletions
|
|
@ -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, []);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue