feat(ui): port retail item appraisal reports

Follow ItemExamineUI's EoR dispatch and wording for item assessment instead of the generic projection. Resolve material and creature names through installed DAT maps, cover specialized item branches and item-XP curves, and narrow AP-110 to the remaining live/localized preview seams.
This commit is contained in:
Erik 2026-07-23 19:08:05 +02:00
parent 2c00d53db2
commit d78d09cfbc
12 changed files with 1258 additions and 193 deletions

View file

@ -27,6 +27,7 @@ public sealed class ItemAppraisalTextFormatterTests
properties.Ints[19u] = 12_500;
properties.Ints[5u] = 450;
properties.Ints[204u] = 4;
properties.Ints[105u] = 8;
properties.Ints[106u] = 300;
properties.Ints[107u] = 250;
properties.Ints[108u] = 500;
@ -63,21 +64,24 @@ public sealed class ItemAppraisalTextFormatterTests
});
Assert.Contains("Value: 12,500", report);
Assert.Contains("Workmanship: Utterly flawless (8.4)", report);
Assert.Contains("Workmanship: Utterly flawless (8)", report);
Assert.Contains("Skill: Heavy Weapons (Sword)", report);
Assert.Contains("Damage: 30 - 40, Slashing", report);
Assert.Contains("Elemental Damage Bonus: 4, Slashing.", report);
Assert.Contains("Speed: Fast (30)", report);
Assert.Contains("Bonus to Attack Skill: +15%.", report);
Assert.Contains("Bonus to Melee Defense: +5%.", report);
Assert.Contains("Spells: Flame Bolt VI, Impenetrability VI", report);
Assert.Contains("Spells: Flame Bolt VI", report);
Assert.DoesNotContain("Spells: Flame Bolt VI, Impenetrability VI", report);
Assert.Contains("Spellcraft: 300.", report);
Assert.Contains("Mana: 250 / 500.", report);
Assert.Contains("Mana Cost: 20.", report);
Assert.Contains("Spell Descriptions:", report);
Assert.Contains("Flame Bolt VI\nHurls a bolt of flame.", report);
Assert.Contains("~ Flame Bolt VI: Hurls a bolt of flame.", report);
Assert.Contains("Enchantments:", report);
Assert.Contains("Impenetrability VI\nIncreases the target's armor.", report);
Assert.Contains(
"~ Impenetrability VI: Increases the target's armor.",
report);
Assert.True(
report.IndexOf("Skill:", StringComparison.Ordinal)
< report.IndexOf("Spells:", StringComparison.Ordinal));
@ -353,6 +357,7 @@ public sealed class ItemAppraisalTextFormatterTests
properties.Ints[324u] = 4;
properties.Ints[369u] = 50;
properties.Ints[370u] = 3;
properties.Ints[92u] = 8;
properties.Bools[69u] = false;
properties.Strings[25u] = "Tester";
AppraiseInfoParser.Parsed appraisal = Parsed(
@ -372,7 +377,7 @@ public sealed class ItemAppraisalTextFormatterTests
appraisal,
_ => null);
Assert.Contains("Ratings: Dam +3", report);
Assert.Contains("Ratings: Dam 3", report);
Assert.Contains("Armor Level: 200", report);
Assert.Contains("Slashing: Above Average (240)", report);
Assert.Contains("Piercing: Average (160)", report);
@ -461,17 +466,260 @@ public sealed class ItemAppraisalTextFormatterTests
Assert.Contains("This item cannot be further imbued.", report);
}
[Fact]
public void SpecializedItemBranches_FollowRetailNamesWordingAndOrder()
{
var obj = new ClientObject
{
ObjectId = 0x50000020u,
Name = "Ruby Coat",
Type = ItemType.Clothing,
ValidLocations = EquipMask.ChestWear,
Priority = 0x0008u | 0x0010u | 0x0020u | 0x0040u,
};
var properties = new PropertyBundle();
properties.Ints[171u] = 2;
properties.Strings[39u] = "Alyssa";
properties.Strings[40u] = "Borelean";
properties.Ints[105u] = 10;
properties.Ints[170u] = 4;
properties.Ints[265u] = 13;
properties.Ints[370u] = 3;
properties.Ints[371u] = -4;
properties.Ints[379u] = 2;
properties.Ints[86u] = 50;
properties.Ints[87u] = 50;
properties.Int64s[4u] = 300;
properties.Int64s[5u] = 100;
properties.Ints[319u] = 10;
properties.Ints[320u] = 3;
properties.Ints[109u] = 250;
properties.Ints[188u] = 4;
properties.Bools[108u] = true;
properties.Ints[17u] = 42;
properties.Ints[267u] = 1;
properties.Ints[98u] = 1;
properties.Ints[268u] = 90_061;
properties.Strings[16u] = "Ruby sword";
properties.Ints[172u] = 5;
properties.Ints[131u] = 0x26;
properties.Ints[177u] = 2;
properties.Ints[178u] = 0x26;
properties.Ints[111u] = 2 | 0x20 | 0x10;
string report = ItemAppraisalTextFormatter.Build(
obj,
Parsed(properties),
_ => null,
Names(
materials: new Dictionary<uint, string> { [0x26u] = "Ruby" }));
Assert.Contains("This item has been tinkered 2 times.", report);
Assert.Contains("Last tinkered by Alyssa.", report);
Assert.Contains("Imbued by Borelean.", report);
Assert.Contains("Workmanship: Well-crafted (2.50)", report);
Assert.Contains("Salvaged from 4 items.", report);
Assert.Contains("Set: Soldier's", report);
Assert.Contains("Ratings: Dam 3", report);
Assert.DoesNotContain("Dam Resist", report);
Assert.Contains("This item adds 2 Vitality.", report);
Assert.Contains(
"Covers Chest, Abdomen, Upper Arms, Lower Arms",
report);
Assert.Contains("Restricted to characters of Level 50.", report);
Assert.Contains("Item Level: 3 / 10", report);
Assert.Contains("Item XP: 300 / 600", report);
Assert.Contains("Activation requires Arcane Lore: 250, Viamontian", report);
Assert.Contains(
"This rare item has a timer restriction of 3 minutes. "
+ "You will not be able to use another rare item with a timer "
+ "within 3 minutes of using this one.",
report);
Assert.Contains("Rare #42", report);
Assert.Contains(
"This item expires in 1 days, 1 hours, 1 minutes, 1 seconds.",
report);
Assert.Contains("Priceless Ruby sword, set with 2 Rubies", report);
Assert.Contains("Player Killers may not use this portal.", report);
Assert.Contains("This portal cannot be recalled nor linked to.", report);
Assert.Contains("This portal cannot be summoned.", report);
Assert.True(
report.IndexOf("Set: Soldier's", StringComparison.Ordinal)
< report.IndexOf("Covers Chest", StringComparison.Ordinal));
Assert.True(
report.IndexOf("Rare #42", StringComparison.Ordinal)
< report.IndexOf("This item expires", StringComparison.Ordinal));
}
[Fact]
public void SlayerAndWieldCreatureRequirements_UseRetailEnumNames()
{
var properties = new PropertyBundle();
properties.Ints[166u] = 10;
properties.Ints[158u] = 11;
properties.Ints[159u] = 0;
properties.Ints[160u] = 10;
string report = ItemAppraisalTextFormatter.Build(
new ClientObject
{
ObjectId = 0x50000021u,
Name = "Drudge Slayer",
Type = ItemType.MeleeWeapon,
},
Parsed(properties),
_ => null,
Names(creatures: new Dictionary<uint, string> { [10u] = "Drudge" }));
Assert.Contains("Properties: Drudge slayer", report);
Assert.Contains("Wield requires Drudge type", report);
Assert.DoesNotContain("Creature type 10", report);
Assert.DoesNotContain("creature 10", report);
}
[Fact]
public void HealerProfile_UsesHealingRowsInsteadOfConsumableBoostProse()
{
var properties = new PropertyBundle();
properties.Ints[89u] = 2;
properties.Ints[90u] = 12;
properties.Floats[100u] = 1.25d;
var obj = new ClientObject
{
ObjectId = 0x50000022u,
Name = "Healing Kit",
Type = ItemType.Misc,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Healer,
};
string report = ItemAppraisalTextFormatter.Build(
obj,
Parsed(properties),
_ => null);
Assert.Contains("Bonus to Healing Skill: 12", report);
Assert.Contains("Restoration Bonus: 125%", report);
Assert.DoesNotContain("Restores 12 Health when used.", report);
}
[Fact]
public void FailedMagicAndLauncher_PreserveRetailUnknownRows()
{
var obj = new ClientObject
{
ObjectId = 0x50000023u,
Name = "Unknown Bow",
Type = ItemType.MissileWeapon,
ValidLocations = EquipMask.MissileWeapon,
AmmoType = 1,
};
AppraiseInfoParser.Parsed appraisal = Parsed(
new PropertyBundle(),
spells: [101u],
weapon: new AppraiseInfoParser.WeaponProfile(
DamageType: 2u,
WeaponTime: uint.MaxValue,
WeaponSkill: 47u,
Damage: uint.MaxValue,
DamageVariance: 0d,
DamageMod: 1d,
WeaponLength: 1d,
MaxVelocity: 0d,
WeaponOffense: 1d,
MaxVelocityEstimated: 0u),
success: false);
string report = ItemAppraisalTextFormatter.Build(
obj,
appraisal,
_ => null);
Assert.Equal(2, Count(report, "Spells: unknown."));
Assert.Contains("Damage Bonus: Unknown", report);
Assert.Contains("Damage Modifier: Unknown", report);
Assert.Contains("Speed: Unknown", report);
Assert.Contains("Range: Unknown", report);
}
[Fact]
public void OrdinaryStaminaConsumable_UsesRetailConsumedWording()
{
var properties = new PropertyBundle();
properties.Ints[89u] = 4;
properties.Ints[90u] = -20;
string report = ItemAppraisalTextFormatter.Build(
new ClientObject
{
ObjectId = 0x50000024u,
Name = "Stamina Food",
Type = ItemType.Food,
},
Parsed(properties),
_ => null);
Assert.Contains("Depletes 20 Stamina when consumed.", report);
}
[Theory]
[InlineData(1, 250L, "Item Level: 3 / 10", "Item XP: 250 / 300")]
[InlineData(2, 300L, "Item Level: 3 / 10", "Item XP: 300 / 700")]
public void ItemExperienceStyles_UseRetailCumulativeCurves(
int style,
long experience,
string expectedLevel,
string expectedExperience)
{
var properties = new PropertyBundle();
properties.Int64s[4u] = experience;
properties.Int64s[5u] = 100L;
properties.Ints[319u] = 10;
properties.Ints[320u] = style;
string report = ItemAppraisalTextFormatter.Build(
new ClientObject
{
ObjectId = 0x50000025u,
Name = "Leveling Item",
Type = ItemType.Misc,
},
Parsed(properties),
_ => null);
Assert.Contains(expectedLevel, report);
Assert.Contains(expectedExperience, report);
}
[Fact]
public void EndOfRetailDatNameResolver_LoadsMaterialAndCreatureNames()
{
string? datDirectory = ResolveDatDirectory();
if (datDirectory is null)
return;
using var dats =
new AcDream.App.Tests.BoundedTestDatCollection(datDirectory);
CreatureDisplayNameResolver creatures =
CreatureDisplayNameResolver.Load(dats);
RetailAppraisalNameResolver names =
RetailAppraisalNameResolver.Load(dats, creatures);
Assert.Equal("Ruby", names.ResolveMaterial(0x26));
Assert.Equal("Ghost", names.ResolveCreature(77));
}
private static AppraiseInfoParser.Parsed Parsed(
PropertyBundle properties,
uint[]? spells = null,
AppraiseInfoParser.WeaponProfile? weapon = null,
AppraiseInfoParser.ArmorProfile? armor = null,
AppraiseInfoParser.HookProfile? hook = null,
(ushort Highlight, ushort Color)? weaponEnchantments = null)
(ushort Highlight, ushort Color)? weaponEnchantments = null,
bool success = true)
=> new(
Guid: 0x50000001u,
Flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable,
Success: true,
Success: success,
Properties: properties,
SpellBook: spells ?? [],
ArmorProfile: armor,
@ -511,4 +759,44 @@ public sealed class ItemAppraisalTextFormatterTests
TargetEffect: 0u,
TargetMask: 0u,
SpellType: 0);
private static RetailAppraisalNameResolver Names(
IReadOnlyDictionary<uint, string>? materials = null,
IReadOnlyDictionary<uint, string>? creatures = null)
=> new(
materials ?? new Dictionary<uint, string>(),
new CreatureDisplayNameResolver(
creatures ?? new Dictionary<uint, string>()));
private static int Count(string source, string value)
{
int count = 0;
int start = 0;
while ((start = source.IndexOf(
value,
start,
StringComparison.Ordinal)) >= 0)
{
count++;
start += value.Length;
}
return count;
}
private static string? ResolveDatDirectory()
{
string? configured =
Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(configured)
&& Directory.Exists(configured))
{
return configured;
}
string fallback = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
return Directory.Exists(fallback) ? fallback : null;
}
}