using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Spells; namespace AcDream.App.Tests.UI.Layout; public sealed class ItemAppraisalTextFormatterTests { [Fact] public void WeaponAndMagic_AreProjectedInRetailOrderWithDatDescriptions() { var obj = new ClientObject { ObjectId = 0x50000001u, Name = "Flaming Sword", Type = ItemType.MeleeWeapon, ValidLocations = EquipMask.MeleeWeapon, Value = 12_500, Burden = 450, Workmanship = 8.4f, }; var properties = new PropertyBundle(); properties.Ints[353u] = 2; 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; properties.Ints[117u] = 20; properties.Floats[29u] = 1.05d; properties.Strings[14u] = "Use this item to attack."; properties.Strings[16u] = "A finely balanced enchanted blade."; SpellMetadata flame = Spell(101u, "Flame Bolt VI", "Hurls a bolt of flame."); SpellMetadata armor = Spell(102u, "Impenetrability VI", "Increases the target's armor."); AppraiseInfoParser.Parsed appraisal = Parsed( properties, spells: [101u, 0x8000_0066u], weapon: new AppraiseInfoParser.WeaponProfile( DamageType: 1u, WeaponTime: 30u, WeaponSkill: 44u, Damage: 40u, DamageVariance: 0.25d, DamageMod: 1d, WeaponLength: 1d, MaxVelocity: 0d, WeaponOffense: 1.15d, MaxVelocityEstimated: 0u)); string report = ItemAppraisalTextFormatter.Build( obj, appraisal, id => id switch { 101u => flame, 102u => armor, _ => null, }); Assert.Contains("Value: 12,500", 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", 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: Hurls a bolt of flame.", report); Assert.Contains("Enchantments:", report); Assert.Contains( "~ Impenetrability VI: Increases the target's armor.", report); Assert.True( report.IndexOf("Skill:", StringComparison.Ordinal) < report.IndexOf("Spells:", StringComparison.Ordinal)); Assert.True( report.IndexOf("Spells:", StringComparison.Ordinal) < report.IndexOf("Spell Descriptions:", StringComparison.Ordinal)); Assert.EndsWith("A finely balanced enchanted blade.", report); } [Fact] public void NpcLooksLikeObjectCreature_UsesRetailUnknownsAndSuppressesNegativeCapacity() { var obj = new ClientObject { ObjectId = 0x50000005u, Name = "Black Phyntos Hive", Type = ItemType.Creature, ItemsCapacity = -1, ContainersCapacity = -1, }; 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), _ => 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 HookWithHookProfile_SuppressesHooksOwnCapacity() { var obj = new ClientObject { ObjectId = 0x50000015u, Name = "Weapon Hook", Type = ItemType.Container, ItemsCapacity = 24, ContainersCapacity = 1, HookItemTypes = (uint)ItemType.Weapon, HookType = 4u, }; ItemAppraisalReport report = ItemAppraisalTextFormatter.BuildReport( obj, Parsed( new PropertyBundle(), hook: new AppraiseInfoParser.HookProfile( Flags: 0u, ValidLocations: 0u, AmmoType: 0u)), _ => null); Assert.DoesNotContain("Can hold", report.ToString()); } [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 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() { var obj = new ClientObject { ObjectId = 0x50000002u, Name = "Platemail Hauberk", Type = ItemType.Armor, ValidLocations = EquipMask.ChestArmor, Structure = 8, MaxStructure = 10, }; var properties = new PropertyBundle(); properties.Ints[28u] = 200; properties.Ints[158u] = 1; properties.Ints[159u] = 6; properties.Ints[160u] = 250; 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( properties, armor: new AppraiseInfoParser.ArmorProfile( SlashingProtection: 1.2f, PiercingProtection: 0.8f, BludgeoningProtection: 0.4f, ColdProtection: 0f, FireProtection: 1.6f, AcidProtection: 2f, NetherProtection: 1f, LightningProtection: 0.2f)); string report = ItemAppraisalTextFormatter.Build( obj, appraisal, _ => null); 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); Assert.Contains("Bludgeoning: Below Average (80)", report); Assert.Contains("Cold: None (0)", report); Assert.Contains("Fire: Excellent (320)", report); Assert.Contains("Acid: Unparalleled (400)", report); Assert.Contains("Electric: Poor (40)", report); Assert.Contains("Wield requires Viamontian", report); Assert.Contains("Wield requires Melee Defense 250", report); Assert.Contains("Use requires level 50.", report); Assert.Contains("Number of uses remaining: 8", report); Assert.Contains("Created by Tester.", report); Assert.Contains("This item cannot be sold.", report); } [Fact] public void Launcher_UsesDamageBonusModifierAndAmmunitionWording() { var obj = new ClientObject { ObjectId = 0x50000003u, Name = "Longbow", Type = ItemType.MissileWeapon, ValidLocations = EquipMask.MissileWeapon, AmmoType = 1, }; AppraiseInfoParser.Parsed appraisal = Parsed( new PropertyBundle(), weapon: new AppraiseInfoParser.WeaponProfile( DamageType: 2u, WeaponTime: 45u, WeaponSkill: 47u, Damage: 12u, DamageVariance: 0d, DamageMod: 1.13d, WeaponLength: 1d, MaxVelocity: 50d, WeaponOffense: 1d, MaxVelocityEstimated: 1u)); string report = ItemAppraisalTextFormatter.Build( obj, appraisal, _ => null); Assert.Contains("Skill: Missile Weapons", report); Assert.Contains("Damage Bonus: 12", report); Assert.DoesNotContain("Damage Bonus: 12, Piercing", report); Assert.Contains("Damage Modifier: +13%.", report); Assert.Contains("Speed: Average (45)", report); Assert.Contains("Range: 85 yds. (based on STRENGTH 100)", report); Assert.Contains("Uses arrows as ammunition.", report); } [Fact] public void SpecialProperties_AreCombinedUsingRetailNames() { var obj = new ClientObject { ObjectId = 0x50000004u, Name = "Imbued Katar", Type = ItemType.MeleeWeapon, }; var properties = new PropertyBundle(); properties.Ints[279u] = 3; properties.Ints[292u] = 4; properties.Ints[179u] = 0x0000_0481; properties.Ints[114u] = 1; properties.Ints[33u] = 1; properties.Floats[167u] = 90d; properties.Bools[99u] = true; properties.Bools[100u] = true; string report = ItemAppraisalTextFormatter.Build( obj, Parsed(properties), _ => null); Assert.Contains("You can only carry 3 of these items.", report); Assert.Contains("Cooldown When Used: 1m 30s ", report); Assert.Contains("Cleave: 4 enemies in front arc.", report); Assert.Contains( "Properties: Critical Strike, Cold Rending, +1 Melee Defense, Attuned, Bonded, Ivoryable, Dyeable", report); 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 { [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 { [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, bool success = true) => new( Guid: 0x50000001u, Flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable, Success: success, Properties: properties, SpellBook: spells ?? [], ArmorProfile: armor, CreatureProfile: null, WeaponProfile: weapon, HookProfile: hook, ArmorLevels: null, ArmorEnchantments: null, WeaponEnchantments: weaponEnchantments, ResistEnchantments: null); private static SpellMetadata Spell( uint id, string name, string description) => new( SpellId: id, Name: name, School: "War Magic", Family: id, IconId: 0u, SpellWords: string.Empty, Duration: 0f, ManaCost: 10, IsDebuff: false, IsFellowship: false, Description: description, SortKey: 0, Difficulty: 0, Flags: 0u, Generation: 6, IsFastWindup: false, IsOffensive: true, IsUntargeted: false, Speed: 0f, CasterEffect: 0u, TargetEffect: 0u, TargetMask: 0u, SpellType: 0); private static RetailAppraisalNameResolver Names( IReadOnlyDictionary? materials = null, IReadOnlyDictionary? creatures = null) => new( materials ?? new Dictionary(), new CreatureDisplayNameResolver( creatures ?? new Dictionary())); 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; } }