acdream/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs
Erik 2c00d53db2 fix(net): sign-extend retail capacity bytes
Mirror PublicWeenieDesc::UnPack MOVSX behavior so ACE's FF capacity sentinels remain -1 instead of becoming 255. This suppresses non-container capacity prose through the normal retail appraisal checks, with raw-wire and formatter regression coverage.

Co-authored-by: Codex <codex@openai.com>
2026-07-23 18:25:17 +02:00

514 lines
18 KiB
C#

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[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.4)", 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("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("Enchantments:", report);
Assert.Contains("Impenetrability VI\nIncreases 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<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()
{
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.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);
}
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)
=> new(
Guid: 0x50000001u,
Flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable,
Success: true,
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);
}