From bc47bc4917436578264921bbffc412c2972c22ff Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Jul 2026 17:03:26 +0200 Subject: [PATCH] feat(ui): complete retail item appraisal reports Restore the authored examination geometry and top-origin item list, then port retail's ordered weapon, armor, magic, requirement, capacity, cooldown, use, and description branches into a dedicated formatter with DAT spell prose. Keep the remaining specialized display-name and preview gaps explicit in AP-110. Co-authored-by: Codex --- docs/ISSUES.md | 13 +- .../retail-divergence-register.md | 2 +- ...2026-07-23-world-interaction-completion.md | 22 +- ...26-07-23-retail-appraisal-ui-pseudocode.md | 145 +++ .../UI/Layout/AppraisalUiController.cs | 300 +---- .../UI/Layout/CreatureAppraisalRows.cs | 7 +- .../UI/Layout/ItemAppraisalTextFormatter.cs | 1131 +++++++++++++++++ .../UI/Layout/AppraisalUiControllerTests.cs | 2 + .../Layout/ItemAppraisalTextFormatterTests.cs | 262 ++++ 9 files changed, 1587 insertions(+), 297 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 45787297..b246bceb 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -101,11 +101,18 @@ animated clone, bounding-box camera, and private-viewport light. Its correction ports the second authored damage/critical/resistance rating list, composites row chrome behind the animated preview and row text in front, adds balanced text inset, and automatically reassesses each new selection -while the floaty remains visible. +while the floaty remains visible. The next correction restores the authored +310 x 400 profile size, insets only foreground row text, lays generated item +text out from the top, and replaces the sparse property dump with retail's +ordered item-report branches for common weapons, armor, magic and DAT spell +descriptions, requirements, capacities, cooldown/special properties, uses, +crafting, ratings, rare state, and prose. AP-110 records the remaining +specialized/player-dependent/DAT-name/font/object-preview branches explicitly. **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; +`src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/UiField.cs`; `src/AcDream.Core.Net/Messages/InventoryActions.cs`. @@ -119,7 +126,9 @@ noninscribable cases show retail's exact permission messages. Monster pages show the animated target, mapped creature type, level, and nine ordered retail stat rows; rated creatures show retail's paired rating rows; the animated target remains above row chrome but below text; changing selection -updates the open window without another magnifier click. +updates the open window without another magnifier click. Item text begins at +the top and assessed melee/missile/armor/magic items show their retail-ordered +stats and full DAT spell descriptions. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 38c2f369..d515e194 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -205,7 +205,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | -| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, item/creature/character reports, inscription transaction, retail creature stat/rating/animated-preview presentation, and default selection-follow, but still lacks item-object preview, exhaustive specialized item/character detail regions, and exact appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview loop cover the active loops; the remaining examination mechanisms require additional retail field/font/object-preview projections rather than fabricated content | Specialized item/character assessments omit some retail fields and item-object presentation; enchanted/incomplete appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | +| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, and retail-ordered item reports covering weapon/armor/caster modifiers, spell names plus DAT descriptions, requirements, capacities, cooldown/special properties, uses, crafting, rare, and description data. It still lacks item-object preview, player-dependent effective shield projection, DAT display-name resolution for equipment sets and slayer creature types, some expiry/material/portal/PK-specialized description branches, exhaustive character detail regions, and exact appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the remaining examination mechanisms require player-state or DAT display-name resolvers and object-preview ownership rather than fabricated content | Uncommon item/character assessments may omit the listed specialized branches and item-object presentation; unresolved equipment-set/slayer values use their numeric identity; enchanted/incomplete appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 6de8c5db..92035df0 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -6,7 +6,9 @@ shared item-cooldown follow-up passed their connected gates. Slice 3's first connected gate exposed an incorrect IdentifyResponse flag table, a non-retail shared-panel mount, and its missing inscription transaction. All three are corrected and live-confirmed. The creature-page follow-up now has retail's -ordered stat rows and animated private preview; its visual gate is pending. +ordered stat rows and animated private preview. The follow-up item report and +authored 310 x 400 layout correction are implemented; their connected visual +gate is pending. **Milestone:** M4 prerequisite/preamble. **Architecture:** retained gameplay UI over shared selection, object, and interaction state. `GameWindow` remains a composition/callback shell. @@ -160,6 +162,24 @@ passes the Release solution build with the 17 warnings already tracked by #228, 3,510 App tests / 3 skips, and 7,927 complete-solution tests / 5 skips. Its corrected connected visual result remains the closeout gate. +The item-report/layout correction restores the authored 310 x 400 examination +size for the connected profile, leaves retail's ordinary resize range intact, +keeps row chrome at its LayoutDesc origin while insetting only foreground +creature text, and starts generated item prose at the top of its authored +scroll surface. `ItemAppraisalTextFormatter` now owns the decomp-ordered item +projection outside `GameWindow` and outside the examination controller. It +ports common weapon damage ranges/speed/range/ammunition, armor protection +bands, defense/caster modifiers, workmanship, ratings, wield/use/activation +requirements, item XP/capacity/lock/mana/uses/creator/rare data, cooldown and +imbued special properties, and both short spell lists and full DAT spell +descriptions. AP-110 now names only the remaining specialized, +player-dependent, DAT-display-name, font-state, and object-preview gaps. +Focused conformance fixtures cover the geometry layering plus melee, launcher, +armor, spell, and special-property reports. The Release solution build passes +with the 17 warnings already tracked by #228, 3,514 App tests / 3 skips, and +7,931 complete-solution tests / 5 skips. The connected visual result remains +the closeout gate. + ## Slice 1 — spell-bar overflow arrows ### Retail oracle diff --git a/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md b/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md index 343309ad..4438d502 100644 --- a/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md +++ b/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md @@ -329,6 +329,143 @@ this order: 9. caster/boost/healer/capacity/lock/mana/uses/craftsman details; 10. sale restriction, rare status, full magic, and description. +The individual branches are not a generic property dump. They project the +wire profile into retail prose: + +```text +ShowWeaponAndArmor(validLocations, profile): + ammoType = live PublicWeenieDesc ammo type + if object is a hook: + ammoType = HookProfile ammo type + + if validLocations contains Shield: + append "Base Shield Level: " + append the player-dependent effective Shield level when available + + if validLocations contains a weapon location: + weapon = profile.WeaponProfile + append "Skill: " + if WeaponType exists: + append the parenthesized weapon subtype + + launcher = validLocations contains MissileWeapon and ammoType != None + append launcher ? "Damage Bonus: " : "Damage: " + minimum = (1 - DamageVariance) * Damage + if Damage - minimum > 0.0002: + append " - " + else: + append "" + append damage type for non-launchers + + append Elemental Damage Bonus when present + append launcher Damage Modifier when present + + if validLocations contains MeleeWeapon, MissileWeapon, or TwoHanded: + append "Speed: ()" + categories: + raw < 11 -> Very Fast + raw < 31 -> Fast + raw < 50 -> Average + raw < 80 -> Slow + otherwise -> Very Slow + + if launcher: + rangeYards = min( + 85, + 2 * MaxVelocity^2 * (1 / 9.8) * 1.094) + if rangeYards < 10: + displayedRange = ceil(rangeYards) + else: + displayedRange = truncate down to a multiple of 5 + append "Range: yds." + if MaxVelocityEstimated: + append " (based on STRENGTH 100)" + + if WeaponOffense != 1 and this is not launcher damage: + append "Bonus to Attack Skill: ." + + append the retail ammunition sentence: + ammunition -> "Used as ammunition by bows/crossbows/atlatls." + launcher -> "Uses arrows/quarrels/atlatl darts as ammunition." + +ShowArmor(profile): + append "Armor Level: " + for Slashing, Piercing, Bludgeoning, Fire, Cold, Acid, Electric, Nether: + adjective from DamageResistanceToString: + 0.0 -> None + (0.0, 0.4) -> Poor + [0.4, 0.8) -> Below Average + [0.8, 1.2) -> Average + [1.2, 1.6) -> Above Average + [1.6, 2.0) -> Excellent + >= 2.0 -> Unparalleled + append ": ()" + +ShowMagic(profile): + if the spellbook exists and appraisal failed: + append "Spells: unknown." + return + + split spell ids by the high enchantment bit + resolve every id with that bit masked through ClientMagicSystem + + for ordinary spells: + append Spellcraft, Mana, and Mana Cost when present + append "Spell Descriptions:" + append each spell name and its DAT description + + for high-bit enchantments: + append "Enchantments:" + append each spell name and its DAT description + +ShowSpecialProperties(profile): + append carry limit and authored cooldown duration when present + append cleave count + build one "Properties: " line from: + creature slayer, Multi-Strike, imbued-effect flags, + Magic Absorbing, Unenchantable, Attuned/Bonded/death behavior, + Retained, Crushing Blow, Biting Strike, Armor/Resistance Cleaving, + Cast on Strike, Ivoryable, and Dyeable + if an imbued-effect flag exists: + append "This item cannot be further imbued." + if AutowieldLeft: + append the left-hand tether sentence + +ShowRemainingItemFields(profile): + append, in SetAppraiseInfo order: + usage text + level, direct heritage restriction, and wield requirements + (up to four requirement triplets) + use restrictions + item level and item XP + activation requirements + caster, boost, healing, capacity, lock, and mana-stone data + remaining key/charge counts + creator, last tinkerer, and imbuer + cannot-be-sold and rare notices + short and long descriptions +``` + +Sources: + +- `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0` +- `ItemExamineUI::Appraisal_ShowWieldRequirements @ 0x004AF9A0` +- `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140` +- `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0` +- `ItemExamineUI::Appraisal_ShowArmorModsData @ 0x004B1F60` +- `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10` +- `ItemExamineUI::Appraisal_ShowActivationRequirements @ 0x004B3270` +- `AppraisalSystem::WeaponTimeToString @ 0x005B4970` +- `AppraisalSystem::DamageResistanceToString @ 0x005B5490` +- `AppraisalSystem::InqWorkmanshipAdjective @ 0x005B48E0` +- `ClientUISystem::DeltaTimeToString @ 0x00565E10` + +ACE's `AppraiseInfo` serializer and property enums were used to pin the +typed-table IDs carried by the network profile. ACViewer's portal.dat +SpellTable path was used as the second reference check that item spell names +and descriptions come from DAT spell metadata rather than a hand-maintained +client list. Retail's named functions remain the behavioral oracle. + The main text and inscription areas use their authored scrollbars. A newly selected object resets those scroll positions to the top; a refresh of the same object preserves the user's position. @@ -344,6 +481,14 @@ Inventory/Skills/Spellbook panel. `gmFloatyExaminationUI::ResizeTo` delegates to ordinary `UIElement::ResizeTo` and persists width/height; it adds no special resize algorithm. +The authored root is exactly 310 by 400 pixels and has 310-by-400 minimum +constraints. Its maximum constraints are 2000 by 2000, so 310 by 400 is the +retail opening/default size, not a fixed-size panel. The main item report +control is authored with bottom vertical justification, but +`ItemExamineUI::AddItemInfo` appends rows from the start of the cleared text +surface. The retained projection must therefore lay out that generated report +from the top while leaving the separate authored inscription behavior intact. + ## Inscription presentation and eligibility ```text diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index a4402d15..3b0cd166 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -1,6 +1,4 @@ using System.Globalization; -using System.Numerics; -using System.Text; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; @@ -128,6 +126,11 @@ public sealed class AppraisalUiController : IRetainedPanelController _title.LinesProvider = () => [new UiText.Line(_titleValue, _title.DefaultColor)]; + // ItemExamineUI::AddItemInfo @ 0x004AC050 appends the freshly + // rebuilt report from the beginning of the authored text surface. + // LayoutDesc 0x2100006B carries Bottom here, which is appropriate for + // static text but not for the generated retail item list. + _itemText.VerticalJustify = VJustify.Top; ConfigureScrollableText(_itemText, ItemScrollbarId, () => _itemReport); if (_inscriptionText is not null) ConfigureScrollableText( @@ -337,10 +340,10 @@ public sealed class AppraisalUiController : IRetainedPanelController AppraiseInfoParser.Parsed appraisal, bool newlySelected) { - _itemReport = AppraisalTextFormatter.BuildItem( + _itemReport = ItemAppraisalTextFormatter.Build( obj, appraisal, - ResolveSpellName); + ResolveSpell); SetInscription(obj, appraisal); if (newlySelected) { @@ -611,9 +614,9 @@ public sealed class AppraisalUiController : IRetainedPanelController characterInfo.Visible = view == AppraisalView.Character; } - private string? ResolveSpellName(uint spellId) + private SpellMetadata? ResolveSpell(uint spellId) => _spellbook.TryGetMetadata(spellId & 0x7FFF_FFFFu, out SpellMetadata metadata) - ? metadata.Name + ? metadata : null; private static AppraisalView SelectView(AppraiseInfoParser.Parsed appraisal) @@ -713,288 +716,3 @@ public enum AppraisalView Creature, Character, } - -/// Pure retail-ordered text projection for the authored examination fields. -public static class AppraisalTextFormatter -{ - private const uint Level = 25u; - private const uint ArmorLevel = 28u; - private const uint Damage = 44u; - private const uint DamageType = 45u; - private const uint WeaponTime = 49u; - private const uint Structure = 92u; - private const uint MaxStructure = 91u; - private const uint Workmanship = 105u; - private const uint CurrentMana = 107u; - private const uint MaximumMana = 108u; - private const uint Difficulty = 109u; - private const uint WieldRequirement = 158u; - private const uint WieldSkill = 159u; - private const uint WieldDifficulty = 160u; - private const uint RareId = 17u; - - public static string BuildItem( - ClientObject obj, - AppraiseInfoParser.Parsed appraisal, - Func resolveSpellName) - { - ArgumentNullException.ThrowIfNull(obj); - ArgumentNullException.ThrowIfNull(resolveSpellName); - PropertyBundle p = appraisal.Properties; - var text = new StringBuilder(); - - Append(text, "Value", obj.Value != 0 ? obj.Value : GetInt(p, 19u), "N0"); - Append(text, "Burden", obj.Burden != 0 ? obj.Burden : GetInt(p, 5u), "N0"); - - int workmanship = GetInt(p, Workmanship); - if (workmanship != 0 || obj.Workmanship != 0) - AppendLine(text, "Workmanship", - (workmanship != 0 ? workmanship : obj.Workmanship) - .ToString("0.##", CultureInfo.CurrentCulture)); - - if (appraisal.WeaponProfile is { } weapon) - { - AppendLine(text, "Damage", - $"{weapon.Damage.ToString(CultureInfo.CurrentCulture)} {DamageTypeName(weapon.DamageType)}"); - AppendLine(text, "Speed", weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)); - AppendPercent(text, "Damage Variance", weapon.DamageVariance); - AppendPercent(text, "Attack Bonus", weapon.WeaponOffense - 1d); - } - else - { - Append(text, "Damage", GetInt(p, Damage)); - int damageType = GetInt(p, DamageType); - if (damageType != 0) - AppendLine(text, "Damage Type", DamageTypeName((uint)damageType)); - Append(text, "Speed", GetInt(p, WeaponTime)); - } - - if (appraisal.ArmorProfile is { } armor) - { - Append(text, "Armor Level", GetInt(p, ArmorLevel)); - AppendProtection(text, "Slashing", armor.SlashingProtection); - AppendProtection(text, "Piercing", armor.PiercingProtection); - AppendProtection(text, "Bludgeoning", armor.BludgeoningProtection); - AppendProtection(text, "Fire", armor.FireProtection); - AppendProtection(text, "Cold", armor.ColdProtection); - AppendProtection(text, "Acid", armor.AcidProtection); - AppendProtection(text, "Electric", armor.LightningProtection); - AppendProtection(text, "Nether", armor.NetherProtection); - } - - Append(text, "Level", GetInt(p, Level)); - Append(text, "Difficulty", GetInt(p, Difficulty)); - int currentMana = GetInt(p, CurrentMana); - int maxMana = GetInt(p, MaximumMana); - if (currentMana != 0 || maxMana != 0) - AppendLine(text, "Mana", - $"{currentMana.ToString("N0", CultureInfo.CurrentCulture)} / {maxMana.ToString("N0", CultureInfo.CurrentCulture)}"); - - int currentUses = obj.Structure != 0 ? obj.Structure : GetInt(p, Structure); - int maxUses = obj.MaxStructure != 0 ? obj.MaxStructure : GetInt(p, MaxStructure); - if (currentUses != 0 || maxUses != 0) - AppendLine(text, "Uses", - maxUses > 0 - ? $"{currentUses.ToString(CultureInfo.CurrentCulture)} / {maxUses.ToString(CultureInfo.CurrentCulture)}" - : currentUses.ToString(CultureInfo.CurrentCulture)); - - if (GetInt(p, WieldRequirement) != 0) - AppendLine( - text, - "Wield Requirement", - $"{SkillName(GetInt(p, WieldSkill))} {GetInt(p, WieldDifficulty).ToString(CultureInfo.CurrentCulture)}"); - - if (appraisal.HookProfile is { } hook) - { - if ((hook.Flags & 0x1u) != 0) - AppendFlag(text, "Inscribable"); - if ((hook.Flags & 0x2u) != 0) - AppendFlag(text, "Healer"); - if ((hook.Flags & 0x4u) != 0) - AppendFlag(text, "Food"); - if ((hook.Flags & 0x8u) != 0) - AppendFlag(text, "Lockpick"); - } - - foreach (uint rawSpellId in appraisal.SpellBook) - { - uint spellId = rawSpellId & 0x7FFF_FFFFu; - AppendFlag(text, resolveSpellName(spellId) ?? $"Spell {spellId}"); - } - - if (GetInt(p, RareId) != 0) - AppendFlag(text, "Rare Item"); - - string use = GetString(p, 14u); - if (!string.IsNullOrWhiteSpace(use)) - AppendParagraph(text, use); - string shortDescription = GetString(p, 15u); - if (!string.IsNullOrWhiteSpace(shortDescription)) - AppendParagraph(text, shortDescription); - string longDescription = GetString(p, 16u); - if (!string.IsNullOrWhiteSpace(longDescription) - && !string.Equals(longDescription, shortDescription, StringComparison.Ordinal)) - AppendParagraph(text, longDescription); - - if (!appraisal.Success && text.Length == 0) - text.Append("Assessment incomplete"); - return text.ToString().TrimEnd(); - } - - public static string BuildCreature( - ClientObject obj, - AppraiseInfoParser.Parsed appraisal, - bool character) - { - ArgumentNullException.ThrowIfNull(obj); - PropertyBundle p = appraisal.Properties; - var text = new StringBuilder(); - Append(text, "Level", GetInt(p, Level)); - if (appraisal.CreatureProfile is { } creature) - { - AppendLine(text, "Health", - $"{creature.Health.ToString("N0", CultureInfo.CurrentCulture)} / {creature.HealthMax.ToString("N0", CultureInfo.CurrentCulture)}"); - AppendOptional(text, "Stamina", creature.Stamina, creature.StaminaMax); - AppendOptional(text, "Mana", creature.Mana, creature.ManaMax); - AppendOptional(text, "Strength", creature.Strength); - AppendOptional(text, "Endurance", creature.Endurance); - AppendOptional(text, "Coordination", creature.Coordination); - AppendOptional(text, "Quickness", creature.Quickness); - AppendOptional(text, "Focus", creature.Focus); - AppendOptional(text, "Self", creature.Self); - } - - string title = GetString(p, 2u); - if (character && !string.IsNullOrWhiteSpace(title)) - AppendLine(text, "Title", title); - string description = GetString(p, 16u); - if (!string.IsNullOrWhiteSpace(description)) - AppendParagraph(text, description); - if (!appraisal.Success && text.Length == 0) - text.Append("Assessment incomplete"); - return text.ToString().TrimEnd(); - } - - public static string CreatureTypeName(int creatureType) - => creatureType == 0 - ? string.Empty - : $"Creature type {creatureType.ToString(CultureInfo.CurrentCulture)}"; - - private static void AppendOptional( - StringBuilder text, - string label, - uint? current, - uint? maximum = null) - { - if (current is null) - return; - AppendLine( - text, - label, - maximum is { } max - ? $"{current.Value.ToString("N0", CultureInfo.CurrentCulture)} / {max.ToString("N0", CultureInfo.CurrentCulture)}" - : current.Value.ToString("N0", CultureInfo.CurrentCulture)); - } - - private static void Append( - StringBuilder text, - string label, - int value, - string? format = null) - { - if (value == 0) - return; - AppendLine( - text, - label, - value.ToString(format ?? "0", CultureInfo.CurrentCulture)); - } - - private static void AppendPercent(StringBuilder text, string label, double value) - { - if (Math.Abs(value) < 0.000001) - return; - AppendLine(text, label, value.ToString("+0%;-0%;0%", CultureInfo.CurrentCulture)); - } - - private static void AppendProtection(StringBuilder text, string label, float value) - { - if (Math.Abs(value - 1f) < 0.0001f) - return; - AppendLine(text, label, value.ToString("0.00x", CultureInfo.CurrentCulture)); - } - - private static void AppendFlag(StringBuilder text, string value) - => AppendParagraph(text, value); - - private static void AppendLine(StringBuilder text, string label, string value) - { - if (string.IsNullOrWhiteSpace(value)) - return; - if (text.Length != 0) - text.Append('\n'); - text.Append(label).Append(": ").Append(value); - } - - private static void AppendParagraph(StringBuilder text, string value) - { - if (text.Length != 0) - text.Append("\n\n"); - text.Append(value); - } - - private static int GetInt(PropertyBundle p, uint id) - => p.Ints.TryGetValue(id, out int value) ? value : 0; - - private static string GetString(PropertyBundle p, uint id) - => p.Strings.TryGetValue(id, out string? value) ? value : string.Empty; - - private static string DamageTypeName(uint type) => type switch - { - 1u => "Slashing", - 2u => "Piercing", - 4u => "Bludgeoning", - 8u => "Cold", - 16u => "Fire", - 32u => "Acid", - 64u => "Electric", - 1024u => "Nether", - _ => $"Type {type.ToString(CultureInfo.CurrentCulture)}", - }; - - private static string SkillName(int skill) => skill switch - { - 1 => "Axe", - 3 => "Dagger", - 6 => "Mace", - 9 => "Spear", - 10 => "Staff", - 12 => "Sword", - 13 => "Thrown Weapons", - 14 => "Unarmed Combat", - 15 => "Arcane Lore", - 18 => "Magic Defense", - 19 => "Mana Conversion", - 21 => "Item Tinkering", - 22 => "Assess Person", - 23 => "Deception", - 24 => "Healing", - 27 => "Lockpick", - 30 => "Creature Enchantment", - 31 => "Item Enchantment", - 32 => "Life Magic", - 34 => "War Magic", - 40 => "Light Weapons", - 41 => "Heavy Weapons", - 42 => "Finesse Weapons", - 43 => "Missile Weapons", - 44 => "Shield", - 45 => "Dual Wield", - 46 => "Recklessness", - 47 => "Sneak Attack", - 48 => "Dirty Fighting", - 54 => "Void Magic", - 55 => "Summoning", - _ => $"Skill {skill.ToString(CultureInfo.CurrentCulture)}", - }; -} diff --git a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs index f2dd57cd..a9eec101 100644 --- a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs +++ b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs @@ -370,7 +370,7 @@ public sealed class CreatureAppraisalRowTemplateFactory /// public sealed class CreatureAppraisalLayeredList { - public const float TextInset = 4f; + public const float TextInset = 8f; private readonly CreatureAppraisalRowTemplateFactory _templates; @@ -403,7 +403,10 @@ public sealed class CreatureAppraisalLayeredList backgroundHost.ZOrder = backgroundZOrder; var scroll = new UiScrollable(); var background = NewList( - left: TextInset, + // The authored row chrome keeps its LayoutDesc origin. Only the + // foreground text is inset; moving both layers clips the left + // edge of the retail separator/background media. + left: 0f, top: 0f, width: templates.Width, height: backgroundHost.Height, diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs new file mode 100644 index 00000000..292b07a3 --- /dev/null +++ b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs @@ -0,0 +1,1131 @@ +using System.Globalization; +using System.Text; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using AcDream.Core.Spells; + +namespace AcDream.App.UI.Layout; + +/// +/// Pure text projection for retail's authored item-examination list. +/// Ordering and wording follow ItemExamineUI::SetAppraiseInfo @ 0x004B72B0 +/// and its Appraisal_Show* helpers. Static spell prose comes from the +/// installed portal.dat spell table, like retail's ClientMagicSystem. +/// +public static class ItemAppraisalTextFormatter +{ + private static readonly (uint Requirement, uint Stat, uint Difficulty)[] + WieldRequirements = + [ + (158u, 159u, 160u), + (270u, 271u, 272u), + (273u, 274u, 275u), + (276u, 277u, 278u), + ]; + + public static string Build( + ClientObject obj, + AppraiseInfoParser.Parsed appraisal, + Func resolveSpell) + { + ArgumentNullException.ThrowIfNull(obj); + ArgumentNullException.ThrowIfNull(resolveSpell); + + PropertyBundle properties = appraisal.Properties; + var report = new RetailReportBuilder(); + + // ItemExamineUI::SetAppraiseInfo @ 0x004B72B0. + ShowValueAndBurden(report, obj, properties); + ShowTinkering(report, obj, properties); + ShowSetAndRatings(report, properties); + ShowWeaponAndArmor(report, obj, appraisal); + ShowDefenseModifiers(report, properties); + ShowShortMagicInfo(report, appraisal, resolveSpell); + ShowSpecialProperties(report, obj, properties); + ShowUsage(report, properties); + ShowLevelLimits(report, properties); + ShowWieldRequirements(report, properties); + ShowUsageLimits(report, properties); + ShowItemLevel(report, properties); + ShowActivationRequirements(report, properties); + ShowCasterData(report, properties); + ShowBoostAndHealing(report, properties); + ShowCapacityAndLock(report, obj, properties); + ShowManaStone(report, appraisal); + ShowRemainingUses(report, obj, appraisal); + ShowCraftsman(report, properties); + ShowSaleAndRareInfo(report, properties); + ShowMagicInfo(report, appraisal, resolveSpell); + ShowDescription(report, properties); + + if (!appraisal.Success && report.IsEmpty) + report.Paragraph("Assessment incomplete"); + return report.ToString(); + } + + private static void ShowValueAndBurden( + RetailReportBuilder report, + ClientObject obj, + PropertyBundle properties) + { + int value = obj.Value != 0 ? obj.Value : properties.GetInt(19u); + int burden = obj.Burden != 0 ? obj.Burden : properties.GetInt(5u); + if (value != 0) + report.Line($"Value: {value.ToString("N0", CultureInfo.CurrentCulture)}"); + if (burden != 0) + report.Line($"Burden: {burden.ToString("N0", CultureInfo.CurrentCulture)}"); + } + + /// + /// Appraisal_ShowTinkeringInfo @ 0x004B0E70. + /// + private static void ShowTinkering( + RetailReportBuilder report, + ClientObject obj, + PropertyBundle properties) + { + int tinkers = properties.GetInt(171u); + if (tinkers != 0) + report.Line( + $"This item has been tinkered {tinkers.ToString(CultureInfo.CurrentCulture)} " + + (tinkers == 1 ? "time." : "times.")); + + int integerWorkmanship = properties.GetInt(105u); + float workmanship = integerWorkmanship != 0 + ? integerWorkmanship + : obj.Workmanship; + if (workmanship != 0f) + { + int workmanshipBand = Math.Clamp( + (int)MathF.Round(workmanship), + 0, + 10); + string adjective = WorkmanshipAdjective(workmanshipBand); + report.Line( + $"Workmanship: {adjective} " + + $"({workmanship.ToString("0.##", CultureInfo.CurrentCulture)})"); + } + + string lastTinkeredBy = properties.GetString(39u); + if (!string.IsNullOrWhiteSpace(lastTinkeredBy)) + report.Line($"Last tinkered by {lastTinkeredBy}."); + string imbuedBy = properties.GetString(40u); + if (!string.IsNullOrWhiteSpace(imbuedBy)) + report.Line($"Imbued by {imbuedBy}."); + } + + /// + /// Appraisal_ShowSet @ 0x004AE880 and + /// Appraisal_ShowRatings @ 0x004AF080. + /// + private static void ShowSetAndRatings( + RetailReportBuilder report, + PropertyBundle properties) + { + if (properties.Ints.TryGetValue(265u, out int setId) && setId != 0) + report.Line($"Set: {setId.ToString(CultureInfo.CurrentCulture)}"); + + (uint Property, string Label)[] ratingProperties = + [ + (370u, "Dam"), + (371u, "Dam Resist"), + (372u, "Crit"), + (374u, "Crit Dam"), + (373u, "Crit Resist"), + (375u, "Crit Dam Resist"), + (376u, "Heal Boost"), + (377u, "Nether Resist"), + (378u, "Life Resist"), + ]; + string[] ratings = ratingProperties + .Where(pair => properties.Ints.TryGetValue(pair.Property, out int value) + && value != 0) + .Select(pair => + $"{pair.Label} {properties.GetInt(pair.Property).ToString("+0;-0;0", CultureInfo.CurrentCulture)}") + .ToArray(); + if (ratings.Length != 0) + report.Line($"Ratings: {string.Join(", ", ratings)}"); + if (properties.GetInt(379u) is int vitality && vitality != 0) + report.Line( + $"This item adds {vitality.ToString(CultureInfo.CurrentCulture)} Vitality."); + } + + /// + /// Appraisal_ShowWeaponAndArmorData @ 0x004B10E0 and + /// Appraisal_ShowArmorModsData @ 0x004B1F60. + /// + private static void ShowWeaponAndArmor( + RetailReportBuilder report, + ClientObject obj, + AppraiseInfoParser.Parsed appraisal) + { + PropertyBundle properties = appraisal.Properties; + uint validLocations = (uint)obj.ValidLocations; + uint ammoType = appraisal.HookProfile?.AmmoType + ?? obj.AmmoType + ?? 0u; + + if ((validLocations & (uint)EquipMask.Shield) != 0) + { + if (properties.Ints.TryGetValue(28u, out int shieldLevel)) + report.Line( + $"Base Shield Level: {shieldLevel.ToString(CultureInfo.CurrentCulture)}"); + else if (!appraisal.Success) + report.Line("Shield Level: Unknown"); + } + + if (appraisal.WeaponProfile is { } weapon) + { + string skill = SkillName((int)weapon.WeaponSkill); + int weaponType = properties.GetInt(353u); + report.Line( + $"Skill: {skill}{WeaponSubtype(weaponType)}"); + + bool launcher = (validLocations & (uint)EquipMask.MissileWeapon) != 0 + && ammoType != 0u; + string damageLabel = launcher ? "Damage Bonus" : "Damage"; + double minimumDamage = (1d - weapon.DamageVariance) * weapon.Damage; + string damage = weapon.Damage - minimumDamage > 0.0002d + ? $"{minimumDamage.ToString("0.###", CultureInfo.CurrentCulture)}" + + $" - {weapon.Damage.ToString(CultureInfo.CurrentCulture)}" + : weapon.Damage.ToString(CultureInfo.CurrentCulture); + if (!launcher) + damage += $", {DamageTypeName(weapon.DamageType)}"; + report.Line($"{damageLabel}: {damage}"); + + int elementalBonus = properties.GetInt(204u); + if (elementalBonus > 0) + report.Line( + $"Elemental Damage Bonus: " + + $"{elementalBonus.ToString(CultureInfo.CurrentCulture)}, " + + $"{DamageTypeName(weapon.DamageType)}."); + + if (launcher) + report.Line( + $"Damage Modifier: {FormatModifier(weapon.DamageMod)}."); + + const uint timedWeaponLocations = + (uint)(EquipMask.MeleeWeapon + | EquipMask.MissileWeapon + | EquipMask.TwoHanded); + if ((validLocations & timedWeaponLocations) != 0) + report.Line( + $"Speed: {WeaponTimeName((int)weapon.WeaponTime)} " + + $"({weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)})"); + + if (launcher && weapon.MaxVelocity > 0d) + { + // Appraisal_ShowWeaponAndArmorData @ 0x004B1845: + // 2*v^2/g, converted from meters to yards and capped at 85. + double rawRange = Math.Min( + 85d, + 2d * Math.Pow(weapon.MaxVelocity, 2d) + * (1d / 9.8d) + * 1.094d); + int range = rawRange < 10d + ? (int)Math.Ceiling(rawRange) + : (int)rawRange - (int)rawRange % 5; + report.Line( + $"Range: {range.ToString(CultureInfo.CurrentCulture)} yds." + + (weapon.MaxVelocityEstimated != 0u + ? " (based on STRENGTH 100)" + : string.Empty)); + } + + if (!launcher && Math.Abs(weapon.WeaponOffense - 1d) > 0.000001d) + report.Line( + $"Bonus to Attack Skill: " + + $"{FormatSignedPercent(weapon.WeaponOffense - 1d)}."); + } + + ShowAmmunitionDescription(report, validLocations, ammoType); + + if (appraisal.ArmorProfile is not { } armor) + return; + + int armorLevel = properties.GetInt(28u); + report.Line( + appraisal.Success + ? $"Armor Level: {armorLevel.ToString(CultureInfo.CurrentCulture)}" + : "Armor Level: Unknown"); + ShowProtection(report, "Slashing", armorLevel, armor.SlashingProtection); + ShowProtection(report, "Piercing", armorLevel, armor.PiercingProtection); + ShowProtection(report, "Bludgeoning", armorLevel, armor.BludgeoningProtection); + ShowProtection(report, "Fire", armorLevel, armor.FireProtection); + ShowProtection(report, "Cold", armorLevel, armor.ColdProtection); + ShowProtection(report, "Acid", armorLevel, armor.AcidProtection); + ShowProtection(report, "Electric", armorLevel, armor.LightningProtection); + ShowProtection(report, "Nether", armorLevel, armor.NetherProtection); + } + + private static void ShowAmmunitionDescription( + RetailReportBuilder report, + uint validLocations, + uint ammoType) + { + if (ammoType == 0u) + return; + + uint baseAmmoType = ammoType switch + { + 0x08u or 0x40u => 1u, + 0x10u or 0x80u => 2u, + 0x20u or 0x100u => 4u, + _ => ammoType, + }; + bool launcher = (validLocations & (uint)EquipMask.MissileWeapon) != 0; + string? description = (launcher, baseAmmoType) switch + { + (true, 1u) => "Uses arrows as ammunition.", + (true, 2u) => "Uses quarrels as ammunition.", + (true, 4u) => "Uses atlatl darts as ammunition.", + (false, 1u) => "Used as ammunition by bows.", + (false, 2u) => "Used as ammunition by crossbows.", + (false, 4u) => "Used as ammunition by atlatls.", + _ => null, + }; + if (description is not null) + report.Line(description); + } + + private static void ShowProtection( + RetailReportBuilder report, + string damageType, + int armorLevel, + float modifier) + { + // AppraisalSystem::DamageResistanceToString @ 0x005B5490. + string quality = modifier switch + { + <= 0.0002f => "None", + < 0.4f => "Poor", + < 0.8f => "Below Average", + < 1.2f => "Average", + < 1.6f => "Above Average", + < 2.0f => "Excellent", + _ => "Unparalleled", + }; + double effective = armorLevel * modifier; + report.Line( + $"{damageType}: {quality} " + + $"({effective.ToString("0", CultureInfo.CurrentCulture)})"); + } + + /// Appraisal_ShowDefenseModData @ 0x004B1DF0. + private static void ShowDefenseModifiers( + RetailReportBuilder report, + PropertyBundle properties) + { + ShowModifier(report, properties, 29u, "Bonus to Melee Defense"); + ShowModifier(report, properties, 149u, "Bonus to Missile Defense"); + ShowModifier(report, properties, 150u, "Bonus to Magic Defense"); + } + + private static void ShowModifier( + RetailReportBuilder report, + PropertyBundle properties, + uint property, + string label) + { + if (!properties.Floats.TryGetValue(property, out double modifier) + || Math.Abs(modifier - 1d) <= 0.000001d) + return; + report.Line($"{label}: {FormatSignedPercent(modifier - 1d)}."); + } + + /// Appraisal_ShowShortMagicInfo @ 0x004B2C90. + private static void ShowShortMagicInfo( + RetailReportBuilder report, + AppraiseInfoParser.Parsed appraisal, + Func resolveSpell) + { + if (appraisal.SpellBook.Length == 0) + return; + if (!appraisal.Success) + { + report.Line("Spells: unknown."); + return; + } + + string names = string.Join( + ", ", + appraisal.SpellBook.Select(raw => + { + uint id = raw & 0x7FFF_FFFFu; + return resolveSpell(id)?.Name + ?? $"Spell {id.ToString(CultureInfo.CurrentCulture)}"; + })); + report.Line($"Spells: {names}"); + } + + /// Appraisal_ShowSpecialProperties @ 0x004B0140. + private static void ShowSpecialProperties( + RetailReportBuilder report, + ClientObject obj, + PropertyBundle properties) + { + int carryLimit = properties.GetInt(279u); + if (carryLimit > 0) + report.Line( + $"You can only carry " + + $"{carryLimit.ToString("N0", CultureInfo.CurrentCulture)} " + + "of these items."); + + double cooldown = properties.Floats.TryGetValue(167u, out double assessedCooldown) + ? assessedCooldown + : obj.CooldownDuration ?? 0d; + if (cooldown > 0d) + report.Line($"Cooldown When Used: {FormatDeltaTime(cooldown)}"); + + int cleave = properties.GetInt(292u); + if (cleave > 1) + report.Line( + $"Cleave: {cleave.ToString(CultureInfo.CurrentCulture)} enemies in front arc."); + + var special = new List(); + int slayer = properties.GetInt(166u); + if (slayer != 0) + special.Add(slayer == 31 + ? "Bael'Zharon's Hate" + : $"Creature type {slayer.ToString(CultureInfo.CurrentCulture)} slayer"); + if ((properties.GetInt(47u) & 0x79E0) != 0) + special.Add("Multi-Strike"); + + uint imbuedEffects = unchecked((uint)( + properties.GetInt(179u) + | properties.GetInt(303u) + | properties.GetInt(304u) + | properties.GetInt(305u) + | properties.GetInt(306u))); + AppendImbuedEffects(special, imbuedEffects); + + if (properties.Floats.ContainsKey(159u)) + special.Add("Magic Absorbing"); + if (properties.GetInt(36u) >= 9_999) + special.Add("Unenchantable"); + + int attuned = properties.GetInt(114u); + if (attuned is 1 or 2) + special.Add("Attuned"); + int bonded = properties.GetInt(33u); + switch (bonded) + { + case -2: + special.Add("Destroyed on Death"); + break; + case -1: + special.Add("Dropped on Death"); + break; + case 1: + special.Add("Bonded"); + break; + } + + if (properties.GetBool(91u)) + special.Add("Retained"); + if (properties.Floats.ContainsKey(136u)) + special.Add("Crushing Blow"); + if (properties.Floats.ContainsKey(147u)) + special.Add("Biting Strike"); + if (properties.Floats.ContainsKey(155u)) + special.Add("Armor Cleaving"); + if (properties.Floats.ContainsKey(157u) + && properties.Ints.TryGetValue(263u, out int resistanceType)) + special.Add( + $"Resistance Cleaving: {DamageTypeName((uint)resistanceType)}"); + if (properties.DataIds.ContainsKey(55u)) + special.Add("Cast on Strike"); + if (properties.GetBool(99u)) + special.Add("Ivoryable"); + if (properties.GetBool(100u)) + special.Add("Dyeable"); + + if (special.Count != 0) + report.Line($"Properties: {string.Join(", ", special)}"); + if (imbuedEffects != 0u) + report.Line("This item cannot be further imbued."); + if (properties.GetBool(130u)) + report.Line("This item is tethered to the left hand."); + } + + private static void AppendImbuedEffects( + List properties, + uint imbuedEffects) + { + (uint Flag, string Text)[] names = + [ + (0x0000_0001u, "Critical Strike"), + (0x0000_0002u, "Crippling Blow"), + (0x0000_0004u, "Armor Rending"), + (0x0000_0008u, "Slash Rending"), + (0x0000_0010u, "Pierce Rending"), + (0x0000_0020u, "Bludgeon Rending"), + (0x0000_0040u, "Acid Rending"), + (0x0000_0080u, "Cold Rending"), + (0x0000_0100u, "Lightning Rending"), + (0x0000_0200u, "Fire Rending"), + (0x0000_0400u, "+1 Melee Defense"), + (0x0000_0800u, "+1 Missile Defense"), + (0x0000_1000u, "+1 Magic Defense"), + (0x0000_4000u, "Nether Rending"), + (0x8000_0000u, "Phantasmal"), + ]; + foreach ((uint flag, string text) in names) + if ((imbuedEffects & flag) != 0) + properties.Add(text); + } + + private static void ShowUsage( + RetailReportBuilder report, + PropertyBundle properties) + { + string use = properties.GetString(14u); + if (!string.IsNullOrWhiteSpace(use)) + report.Paragraph(use); + } + + private static void ShowLevelLimits( + RetailReportBuilder report, + PropertyBundle properties) + { + int minimum = properties.GetInt(86u); + int maximum = properties.GetInt(87u); + if (minimum > 0 && maximum > 0) + report.Line( + $"Restricted to characters of level " + + $"{minimum.ToString(CultureInfo.CurrentCulture)} to " + + $"{maximum.ToString(CultureInfo.CurrentCulture)}."); + else if (minimum > 0) + report.Line( + $"Restricted to characters of level " + + $"{minimum.ToString(CultureInfo.CurrentCulture)} or greater."); + else if (maximum > 0) + report.Line( + $"Restricted to characters of level " + + $"{maximum.ToString(CultureInfo.CurrentCulture)} or lower."); + + string destination = properties.GetString(38u); + if (!string.IsNullOrWhiteSpace(destination)) + report.Line($"Destination: {destination}"); + } + + /// Appraisal_ShowWieldRequirements @ 0x004AF9A0. + private static void ShowWieldRequirements( + RetailReportBuilder report, + PropertyBundle properties) + { + if (properties.GetBool(85u)) + { + string owner = properties.GetString(25u); + report.Line( + $"Wield requires " + + $"{(string.IsNullOrWhiteSpace(owner) ? "the original owner" : owner)}"); + } + if (properties.GetInt(26u) == 1) + report.Line("Use requires Throne of Destiny."); + + // ItemExamineUI::Appraisal_ShowWieldRequirements @ 0x004AF9A0 + // queries the direct HeritageGroup property before the four generic + // requirement triplets. + if (properties.Ints.TryGetValue(324u, out int heritage)) + { + string? heritageName = + CharacterIdentityText.HeritageGroupDisplayName(heritage); + if (heritageName is not null) + report.Line($"Wield requires {heritageName}"); + } + + foreach ((uint requirementId, uint statId, uint difficultyId) + in WieldRequirements) + { + if (!properties.Ints.TryGetValue(requirementId, out int requirement) + || !properties.Ints.TryGetValue(statId, out int stat) + || !properties.Ints.TryGetValue(difficultyId, out int difficulty)) + continue; + + string quality = RequirementQuality(requirement, stat, difficulty); + if (requirement == 8) + { + string training = difficulty == 3 ? "specialized" : "trained"; + report.Line($"Wield requires {training} {quality}"); + } + else if (requirement == 11) + { + report.Line($"Wield requires {quality} type"); + } + else if (requirement == 12) + { + report.Line($"Wield requires {quality} race"); + } + else if (!string.IsNullOrEmpty(quality)) + { + report.Line( + $"Wield requires {quality} " + + $"{difficulty.ToString(CultureInfo.CurrentCulture)}"); + } + } + } + + private static string RequirementQuality( + int requirement, + int stat, + int difficulty) + { + string basePrefix = requirement is 2 or 4 or 6 ? "base " : string.Empty; + return requirement switch + { + 1 or 2 or 8 => basePrefix + SkillName(stat), + 3 or 4 => basePrefix + PrimaryAttributeName(stat), + 5 or 6 => basePrefix + SecondaryAttributeName(stat), + 7 => "level", + 9 or 10 => stat switch + { + 0x11F => "Standing with the Celestial Hand", + 0x120 => "Standing with the Eldrytch Web", + 0x121 => "Standing with the Radiant Blood", + _ => "unknown quality", + }, + 11 => $"creature {difficulty.ToString(CultureInfo.CurrentCulture)}", + 12 => CharacterIdentityText.HeritageGroupDisplayName(difficulty) + ?? string.Empty, + _ => string.Empty, + }; + } + + /// Appraisal_ShowUsageLimits @ 0x004B2A70. + private static void ShowUsageLimits( + RetailReportBuilder report, + PropertyBundle properties) + { + int level = properties.GetInt(369u); + if (level > 0) + report.Line( + $"Use requires level {level.ToString(CultureInfo.CurrentCulture)}."); + int skill = properties.GetInt(366u); + int difficulty = properties.GetInt(367u); + if (skill > 0 && difficulty > 0) + report.Line( + $"Use requires {SkillName(skill)} of at least " + + $"{difficulty.ToString(CultureInfo.CurrentCulture)}."); + int specializedSkill = properties.GetInt(368u); + if (specializedSkill > 0) + report.Line( + $"Use requires specialized {SkillName(specializedSkill)}."); + } + + /// Appraisal_ShowItemLevel @ 0x004B3C60. + private static void ShowItemLevel( + RetailReportBuilder report, + PropertyBundle properties) + { + int level = properties.GetInt(25u); + int maximum = properties.GetInt(319u); + if (maximum > 0) + report.Line( + $"Item Level: {level.ToString(CultureInfo.CurrentCulture)} / " + + $"{maximum.ToString(CultureInfo.CurrentCulture)}"); + long experience = properties.GetInt64(4u); + long baseExperience = properties.GetInt64(5u); + if (experience != 0 || baseExperience != 0) + report.Line( + $"Item XP: {experience.ToString("N0", CultureInfo.CurrentCulture)} / " + + $"{baseExperience.ToString("N0", CultureInfo.CurrentCulture)}"); + } + + /// + /// Appraisal_ShowActivationRequirements @ 0x004B3270. + /// + private static void ShowActivationRequirements( + RetailReportBuilder report, + PropertyBundle properties) + { + var requirements = new List(); + AddRequirement(requirements, "Arcane Lore", properties.GetInt(109u)); + AddRequirement(requirements, "Allegiance Rank", properties.GetInt(110u)); + + int skillLevel = properties.GetInt(115u); + int skill = properties.GetInt(176u); + if (skillLevel > 0 && skill > 0) + requirements.Add( + $"{SkillName(skill)}: {skillLevel.ToString(CultureInfo.CurrentCulture)}"); + int attributeLevel = properties.GetInt(258u); + int attribute = properties.GetInt(257u); + if (attributeLevel > 0 && attribute > 0) + requirements.Add( + $"{PrimaryAttributeName(attribute)}: " + + $"{attributeLevel.ToString(CultureInfo.CurrentCulture)}"); + int secondaryLevel = properties.GetInt(260u); + int secondary = properties.GetInt(259u); + if (secondaryLevel > 0 && secondary > 0) + requirements.Add( + $"{SecondaryAttributeName(secondary)}: " + + $"{secondaryLevel.ToString(CultureInfo.CurrentCulture)}"); + + if (requirements.Count != 0) + report.Line($"Activation requires {string.Join(", ", requirements)}"); + if (properties.GetBool(94u)) + { + string owner = properties.GetString(25u); + report.Line( + $"This item can only be activated by " + + $"{(string.IsNullOrWhiteSpace(owner) ? "the original owner" : owner)}."); + } + } + + private static void AddRequirement( + List requirements, + string name, + int value) + { + if (value > 0) + requirements.Add( + $"{name}: {value.ToString(CultureInfo.CurrentCulture)}"); + } + + /// Appraisal_ShowCasterData @ 0x004B1B10. + private static void ShowCasterData( + RetailReportBuilder report, + PropertyBundle properties) + { + if (properties.Floats.TryGetValue(144u, out double manaConversion)) + report.Line( + $"Bonus to Mana Conversion: " + + $"{FormatSignedPercent(manaConversion)}."); + + if (properties.Floats.TryGetValue(152u, out double elemental) + && properties.Ints.TryGetValue(45u, out int damageType)) + { + report.Line( + $"Damage bonus for {DamageTypeName((uint)damageType)} spells:"); + report.Line($" vs. Monsters: {FormatSignedPercent(elemental - 1d)}."); + double playerModifier = 1d + (elemental - 1d) * 0.25d; + report.Line( + $" vs. Players: {FormatSignedPercent(playerModifier - 1d)}."); + } + } + + /// + /// Appraisal_ShowBoostValue @ 0x004B2480 and + /// Appraisal_ShowHealKitData @ 0x004B25A0. + /// + private static void ShowBoostAndHealing( + RetailReportBuilder report, + PropertyBundle properties) + { + int boost = properties.GetInt(90u); + string? vital = properties.GetInt(89u) switch + { + 2 => "Health", + 4 => "Stamina", + 6 => "Mana", + _ => null, + }; + if (boost != 0 && vital is not null) + report.Line( + $"{(boost > 0 ? "Restores" : "Depletes")} " + + $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} " + + $"{vital} when used."); + + if (boost != 0 && properties.Floats.ContainsKey(100u)) + report.Line( + $"Bonus to Healing Skill: {boost.ToString(CultureInfo.CurrentCulture)}"); + if (properties.Floats.TryGetValue(100u, out double healKitModifier)) + report.Line( + $"Restoration Bonus: " + + $"{(healKitModifier * 100d).ToString("0", CultureInfo.CurrentCulture)}%"); + } + + /// + /// Appraisal_ShowCapacity @ 0x004B2680 and + /// Appraisal_ShowLockData @ 0x004B2790. + /// + private static void ShowCapacityAndLock( + RetailReportBuilder report, + ClientObject obj, + PropertyBundle properties) + { + if (obj.ItemsCapacity > 0 && obj.ContainersCapacity > 0) + report.Line( + $"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} " + + $"items and {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers."); + else if (obj.ItemsCapacity > 0) + report.Line( + $"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} items."); + else if (obj.ContainersCapacity > 0) + report.Line( + $"Can hold up to {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers."); + + int pages = properties.GetInt(174u); + int pagesUsed = properties.GetInt(175u); + if (pages > 0) + report.Line( + $"Pages: {pagesUsed.ToString(CultureInfo.CurrentCulture)} / " + + $"{pages.ToString(CultureInfo.CurrentCulture)}"); + + if (properties.GetBool(3u)) + report.Line("This item is locked."); + int resistance = properties.GetInt(38u); + if (resistance > 0) + report.Line( + $"Resistance to Lockpick: {resistance.ToString(CultureInfo.CurrentCulture)}"); + int chance = properties.GetInt(173u); + if (chance > 0) + report.Line( + $"Chance of success: {chance.ToString(CultureInfo.CurrentCulture)}%"); + } + + /// Appraisal_ShowManaStoneInfo @ 0x004B3900. + private static void ShowManaStone( + RetailReportBuilder report, + AppraiseInfoParser.Parsed appraisal) + { + if (appraisal.SpellBook.Length != 0) + return; + PropertyBundle properties = appraisal.Properties; + if (properties.Ints.TryGetValue(107u, out int storedMana)) + report.Line( + $"Stored Mana: {storedMana.ToString(CultureInfo.CurrentCulture)}"); + if (properties.Floats.TryGetValue(87u, out double efficiency)) + report.Line( + $"Efficiency: {(efficiency * 100d).ToString("0", CultureInfo.CurrentCulture)}%"); + if (properties.Floats.TryGetValue(137u, out double destruction)) + report.Line( + $"Chance of Destruction: " + + $"{(destruction * 100d).ToString("0", CultureInfo.CurrentCulture)}%"); + } + + /// Appraisal_ShowRemainingUses @ 0x004B3A20. + private static void ShowRemainingUses( + RetailReportBuilder report, + ClientObject obj, + AppraiseInfoParser.Parsed appraisal) + { + PropertyBundle properties = appraisal.Properties; + if (properties.Ints.TryGetValue(193u, out int keys)) + report.Line( + $"Contains {keys.ToString(CultureInfo.CurrentCulture)} " + + (keys == 1 ? "key." : "keys.")); + + if (properties.GetBool(63u)) + { + report.Line("Number of uses remaining: Unlimited"); + return; + } + + int uses = obj.Structure != 0 ? obj.Structure : properties.GetInt(92u); + if (uses != 0) + report.Line( + $"Number of uses remaining: {uses.ToString(CultureInfo.CurrentCulture)}"); + else if (!appraisal.Success + && (obj.MaxStructure != 0 + || appraisal.HookProfile is { Flags: var flags } + && (flags & 0xAu) != 0)) + report.Line("Number of uses remaining: Unknown"); + } + + private static void ShowCraftsman( + RetailReportBuilder report, + PropertyBundle properties) + { + string craftsman = properties.GetString(25u); + if (!string.IsNullOrWhiteSpace(craftsman) + && !properties.GetBool(85u) + && !properties.GetBool(94u)) + report.Line($"Created by {craftsman}."); + } + + private static void ShowSaleAndRareInfo( + RetailReportBuilder report, + PropertyBundle properties) + { + if (properties.Bools.TryGetValue(69u, out bool sellable) && !sellable) + report.Line("This item cannot be sold."); + int rare = properties.GetInt(17u); + if (rare > 0) + report.Line($"Rare #{rare.ToString(CultureInfo.CurrentCulture)}"); + } + + /// Appraisal_ShowMagicInfo @ 0x004B2E10. + private static void ShowMagicInfo( + RetailReportBuilder report, + AppraiseInfoParser.Parsed appraisal, + Func resolveSpell) + { + if (appraisal.SpellBook.Length == 0 || !appraisal.Success) + return; + + var ordinary = new List<(uint Id, SpellMetadata? Metadata)>(); + var enchantments = new List<(uint Id, SpellMetadata? Metadata)>(); + foreach (uint rawSpellId in appraisal.SpellBook) + { + uint spellId = rawSpellId & 0x7FFF_FFFFu; + var spell = (spellId, resolveSpell(spellId)); + if ((rawSpellId & 0x8000_0000u) == 0) + ordinary.Add(spell); + else + enchantments.Add(spell); + } + + PropertyBundle properties = appraisal.Properties; + if (ordinary.Count != 0) + { + if (properties.Ints.TryGetValue(106u, out int spellcraft)) + report.Line( + $"Spellcraft: {spellcraft.ToString(CultureInfo.CurrentCulture)}."); + if (properties.Ints.TryGetValue(107u, out int currentMana) + && properties.Ints.TryGetValue(108u, out int maximumMana)) + report.Line( + $"Mana: {currentMana.ToString(CultureInfo.CurrentCulture)} / " + + $"{maximumMana.ToString(CultureInfo.CurrentCulture)}."); + + if (properties.Floats.TryGetValue(5u, out double manaRate) + && Math.Abs(manaRate) > 0.000001d) + { + int seconds = (int)Math.Round(1d / manaRate); + report.Line( + $"Mana Cost: 1 point per {seconds.ToString(CultureInfo.CurrentCulture)} " + + (seconds == 1 ? "second." : "seconds.")); + } + else if (properties.Ints.TryGetValue(117u, out int manaCost)) + { + report.Line($"Mana Cost: {manaCost.ToString(CultureInfo.CurrentCulture)}."); + if (manaCost > 0) + report.Line("(Can be reduced by the Mana Conversion skill)."); + } + + report.Paragraph("Spell Descriptions:"); + foreach ((uint id, SpellMetadata? metadata) in ordinary) + report.Spell(metadata?.Name, metadata?.Description, id); + } + + if (enchantments.Count != 0) + { + report.Paragraph("Enchantments:"); + foreach ((uint id, SpellMetadata? metadata) in enchantments) + report.Spell(metadata?.Name, metadata?.Description, id); + } + } + + private static void ShowDescription( + RetailReportBuilder report, + PropertyBundle properties) + { + string shortDescription = properties.GetString(15u); + if (!string.IsNullOrWhiteSpace(shortDescription)) + report.Paragraph(shortDescription); + string longDescription = properties.GetString(16u); + if (!string.IsNullOrWhiteSpace(longDescription) + && !string.Equals( + longDescription, + shortDescription, + StringComparison.Ordinal)) + report.Paragraph(longDescription); + } + + private static string WeaponTimeName(int weaponTime) => weaponTime switch + { + < 11 => "Very Fast", + < 31 => "Fast", + < 50 => "Average", + < 80 => "Slow", + _ => "Very Slow", + }; + + /// AppraisalSystem::InqWorkmanshipAdjective @ 0x005B48E0. + private static string WorkmanshipAdjective(int workmanship) => workmanship switch + { + <= 0 => string.Empty, + 1 => "Poorly crafted", + 2 => "Well-crafted", + 3 => "Finely crafted", + 4 => "Exquisitely crafted", + 5 => "Magnificent", + 6 => "Nearly flawless", + 7 => "Flawless", + 8 => "Utterly flawless", + 9 => "Incomparable", + _ => "Priceless", + }; + + /// ClientUISystem::DeltaTimeToString @ 0x00565E10. + private static string FormatDeltaTime(double seconds) + { + int remaining = Math.Max(0, (int)Math.Truncate(seconds)); + int months = remaining / 2_592_000; + remaining %= 2_592_000; + int days = remaining / 86_400; + remaining %= 86_400; + int hours = remaining / 3_600; + remaining %= 3_600; + int minutes = remaining / 60; + int finalSeconds = remaining % 60; + + var result = new StringBuilder(); + if (months != 0) + result.Append(months).Append("mo "); + if (days != 0) + result.Append(days).Append("d "); + if (hours != 0) + result.Append(hours).Append("h "); + if (minutes != 0) + result.Append(minutes).Append("m "); + result.Append(finalSeconds).Append("s "); + return result.ToString(); + } + + private static string FormatModifier(double modifier) + => FormatSignedPercent(modifier - 1d); + + private static string FormatSignedPercent(double modifier) + => modifier.ToString("+0%;-0%;0%", CultureInfo.CurrentCulture); + + private static string DamageTypeName(uint type) => type switch + { + 1u => "Slashing", + 2u => "Piercing", + 4u => "Bludgeoning", + 8u => "Cold", + 16u => "Fire", + 32u => "Acid", + 64u => "Electric", + 128u => "Health", + 256u => "Stamina", + 512u => "Mana", + 1024u => "Nether", + _ => $"type {type.ToString(CultureInfo.CurrentCulture)}", + }; + + private static string WeaponSubtype(int type) => type switch + { + 1 => " (Unarmed Weapon)", + 2 => " (Sword)", + 3 => " (Axe)", + 4 => " (Mace)", + 5 => " (Spear)", + 6 => " (Dagger)", + 7 => " (Staff)", + 8 => " (Bow)", + 9 => " (Crossbow)", + 10 => " (Thrown)", + _ => string.Empty, + }; + + /// AppraisalSystem::SkillToString @ 0x005B4A30. + private static string SkillName(int skill) => skill switch + { + 1 => "Axe", + 2 => "Bow", + 3 => "Crossbow", + 4 => "Dagger", + 5 => "Mace", + 6 => "Melee Defense", + 7 => "Missile Defense", + 8 => "Sling", + 9 => "Spear", + 10 => "Staff", + 11 => "Sword", + 12 => "Thrown Weapon", + 13 => "Unarmed Combat", + 14 => "Arcane Lore", + 15 => "Magic Defense", + 16 => "Mana Conversion", + 17 => "Spellcraft", + 18 => "Item Tinkering", + 19 => "Person Appraisal", + 20 => "Deception", + 21 => "Healing", + 22 => "Jump", + 23 => "Lockpick", + 24 => "Run", + 25 => "Awareness", + 26 => "Armor Repair", + 27 => "Creature Appraisal", + 28 => "Weapon Tinkering", + 29 => "Armor Tinkering", + 30 => "Magic Item Tinkering", + 31 => "Creature Enchantment", + 32 => "Item Enchantment", + 33 => "Life Magic", + 34 => "War Magic", + 35 => "Leadership", + 36 => "Loyalty", + 37 => "Fletching", + 38 => "Alchemy", + 39 => "Cooking", + 40 => "Salvaging", + 41 => "Two Handed Combat", + 42 => "Gearcraft", + 43 => "Void Magic", + 44 => "Heavy Weapons", + 45 => "Light Weapons", + 46 => "Finesse Weapons", + 47 => "Missile Weapons", + 49 => "Dual Wield", + 50 => "Recklessness", + 51 => "Sneak Attack", + 52 => "Dirty Fighting", + 53 => "Challenge", + 54 => "Summoning", + _ => $"Skill {skill.ToString(CultureInfo.CurrentCulture)}", + }; + + private static string PrimaryAttributeName(int attribute) => attribute switch + { + 1 => "Strength", + 2 => "Endurance", + 3 => "Quickness", + 4 => "Coordination", + 5 => "Focus", + 6 => "Self", + _ => $"Attribute {attribute.ToString(CultureInfo.CurrentCulture)}", + }; + + private static string SecondaryAttributeName(int attribute) => attribute switch + { + 1 => "Max Health", + 2 => "Health", + 3 => "Max Stamina", + 4 => "Stamina", + 5 => "Max Mana", + 6 => "Mana", + _ => $"Vital {attribute.ToString(CultureInfo.CurrentCulture)}", + }; + + private sealed class RetailReportBuilder + { + private readonly StringBuilder _text = new(); + + public bool IsEmpty => _text.Length == 0; + + public void Line(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return; + if (_text.Length != 0 && _text[^1] != '\n') + _text.Append('\n'); + _text.Append(value); + } + + public void Paragraph(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return; + if (_text.Length != 0) + _text.Append("\n\n"); + _text.Append(value); + } + + public void Spell(string? name, string? description, uint spellId) + { + string resolvedName = !string.IsNullOrWhiteSpace(name) + ? name + : $"Spell {spellId.ToString(CultureInfo.CurrentCulture)}"; + Paragraph( + string.IsNullOrWhiteSpace(description) + ? resolvedName + : $"{resolvedName}\n{description}"); + } + + public override string ToString() => _text.ToString().TrimEnd(); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index f38e3a78..3ce16e71 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -61,6 +61,7 @@ public sealed class AppraisalUiControllerTests Assert.Equal("Atlan Weapon", Assert.Single(title.LinesProvider()).Text); UiText itemText = Assert.IsType( layout.FindElement(AppraisalUiController.ItemTextId)); + Assert.Equal(VJustify.Top, itemText.VerticalJustify); string report = string.Join('\n', itemText.LinesProvider().Select(line => line.Text)); Assert.Contains("Value: 1,250", report); Assert.Contains("Burden: 350", report); @@ -237,6 +238,7 @@ public sealed class AppraisalUiControllerTests UiItemList list = Assert.Single( creaturePanel.Children.OfType(), candidate => candidate.Top == host.Top); + Assert.Equal(0f, background.Left); Assert.Equal(CreatureAppraisalLayeredList.TextInset, list.Left); Assert.True(host.ZOrder < viewport.ZOrder); Assert.True(viewport.ZOrder < list.ZOrder); diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs new file mode 100644 index 00000000..8d607dfb --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ItemAppraisalTextFormatterTests.cs @@ -0,0 +1,262 @@ +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[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 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) + => new( + Guid: 0x50000001u, + Flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable, + Success: true, + Properties: properties, + SpellBook: spells ?? [], + ArmorProfile: armor, + CreatureProfile: null, + WeaponProfile: weapon, + HookProfile: null, + ArmorLevels: null, + ArmorEnchantments: null, + WeaponEnchantments: null, + 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); +}