feat(ui): Campaign AS AS4 — society/allegiance/fellowship + configurable extras (G6/G7/G8)
Ports the last three retail extras-list row families from CharExamineUI:: SetAppraiseInfo @0x004B45F0 into CreatureAppraisalRows.BuildExtra, closing the character-path extras list end to end (AS2 header + AS3 armor-level/ ratings/legend + AS4 here). All rows are CHARACTER-path only; the monster path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) is unaffected and pinned by a controller-level regression test. Society row (gap G6, @0x004b49a1-@0x004b4c24): gated on PropertyInt 281 (Faction1Bits) being PRESENT — a literal reading of InqInt's found/not- found return, not the ground-truth doc's informal "!= 0" value test. Bit-priority if/else-if chain (Celestial Hand 0x1 -> Eldrytch Web 0x2 -> "???" when Radiant Blood's 0x4 bit is ALSO clear -> else Radiant Blood) comes straight off the decompiled branches. Rank-band suffix boundaries (1-100 Initiate / 101-300 Adept / 301-600 Knight / 601-1000 Lord / 1001-1500 Master, no suffix outside every band) read directly off the decomp's inclusive range checks @0x004b4ab9-@0x004b4b92 and match the ground-truth table exactly. Color rule: green when the LOCAL player shares the target's selected bit (checked first, so extra local bits don't override a match), red when local has a different bit but not the target's, normal when local has no society bits; the unrecognized "???" branch never gets a color (retail's ebx_3 stays at its zero initializer). The local player's own Faction1Bits comes from a new pure `localFactionBits` parameter on BuildExtra — the composer never reads state directly. Monarch/Patron/Followers cascade (gap G7, @0x004b4d97-@0x004b4f54): gated by the caller on AllegianceRank (Int 30) >= 1, the SAME InqInt read AS2's header AllegianceName binding already consumes. Four arms in retail's exact order: MonarchsTitle (Str 21) absent -> "Alleg. Monarch:" + clamped "%d Follower"/"%d Followers" (Int 35, singular only at exactly 1); present + PatronsTitle (Str 35) absent -> "Monarch:" only; both present and ordinally equal -> one "Monarch/Patron:" row; both present and different -> "Monarch:" then "Patron:". Configurable extras (gap G8, @0x004b58be-@0x004b5c4d): Fellowship (Str 10), Arrived in Dereth (Str 43 DateOfBirth), Time in Dereth (Int 125 Age via the ALREADY-PORTED RetailDurationText.Format — the same ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls at @0x004b59e0, reused rather than re-ported), Chess Rank (Int 181), Fishing Skill (Int 192), Deaths (Int 43 NumDeaths, <= 0 -> "Has never died" with the SAME "Deaths:" label, verified in the decomp), Titles Earned (Int 262). Each row gates independently on its own property being PRESENT (server already strips these per the target's visibility options per ground truth §3) — no client-side option or success gating added. No spacers separate these seven rows, matching the decomp exactly. Seam: AppraisalUiController takes a new `Func<int> _localFactionBits` dependency (per-call, never captured once — the secure-trade deferred-Func lesson), invoked only on the character path. AppraisalRuntimeBindings gained `LocalFactionBits`, wired in InteractionRetainedUiComposition from `d.Character.LocalPlayer.Properties.GetInt(281)` — the SAME LocalPlayerState instance CharacterSheetProvider already reads from, no new state path. AS3 NIT 11: CharacterResponse_CombatRefreshRetainsArmorLevelRows now applies a SECOND response with different armor-level values (proving the refresh re-renders from fresh data) then a THIRD with armorLevels: null (proving it clears). AS3 NIT 12: BuildExtra's XML doc now documents the full authored row order with the @0x004b5d7d legend anchor. The test Parsed() helper now ORs IdentifyResponseFlags.ArmorLevels into Flags whenever armorLevels is supplied (realism sub-nit), applying uniformly to every existing armor-level test in the file. Ground-truth doc imprecisions found while verifying against the decomp directly: (1) the Society gate is presence-of-property, not value != 0 — InqInt's return is a found/not-found bool, the summary's "!= 0" phrasing describes the common case but not the literal branch condition; (2) the Society color rule's bit-priority (same-bit match checked before the other-bits check) wasn't spelled out in the summary table, only "same/ different/none" — confirmed exact by reading all three branches (@0x004b49fd/@0x004b4a49/@0x004b4a8b). No other disagreements found. Register: docs/architecture/retail-divergence-register.md row AP-110 retires the "exhaustive character detail regions" clause from its still-lacks list with a dated 2026-08-25 narrowing note (AS2+AS3+AS4 together closed the character-path extras list); the row's other residuals (item-object preview, effective shield projection, cooldown- remaining, augmentation-cost StringInfo, creature FontInfo-list selection) are untouched. Tests: 51 new rows-level tests in CreatureAppraisalRowsTests.cs (society gate/bit-priority/band-boundaries/color-vs-local-faction, the full allegiance cascade incl. follower singular/plural/clamp, each configurable extra present/absent, "Has never died", monster-path regression, one complete ordering-pin snapshot) plus 3 new controller- level tests in AppraisalUiControllerTests.cs through the real LayoutDesc/ template binding (localFactionBits seam wired end to end, its default fallback, and the strengthened monster-path regression). Hermetic AcDream.App.Tests: 6253 passed. Full-solution hermetic run: 15,410 passed across all projects, 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
bc48e3216a
commit
4ade9b0427
7 changed files with 834 additions and 38 deletions
File diff suppressed because one or more lines are too long
|
|
@ -17,6 +17,7 @@ using AcDream.Core.Combat;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Player;
|
using AcDream.Core.Player;
|
||||||
|
using AcDream.Core.Properties;
|
||||||
using AcDream.Core.Selection;
|
using AcDream.Core.Selection;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
|
|
@ -928,7 +929,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
item,
|
item,
|
||||||
inscription),
|
inscription),
|
||||||
text =>
|
text =>
|
||||||
d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
|
d.Communication.AddText(text, RetailLogTextType.ClientLocal),
|
||||||
|
// AS4 (gap G6): the SAME LocalPlayerState instance
|
||||||
|
// characterSheet was built from above — no new state
|
||||||
|
// path, just another read of its PlayerDescription-
|
||||||
|
// backed property snapshot.
|
||||||
|
LocalFactionBits: () =>
|
||||||
|
d.Character.LocalPlayer.Properties.GetInt(
|
||||||
|
(uint)PropertyInt.Faction1Bits)),
|
||||||
Options: new OptionsRuntimeBindings(
|
Options: new OptionsRuntimeBindings(
|
||||||
CommandBus: () => late.Session.Commands,
|
CommandBus: () => late.Session.Commands,
|
||||||
// Tri-state per gmGamePlayUI::UseTime @0x004EA3A0's exact
|
// Tri-state per gmGamePlayUI::UseTime @0x004EA3A0's exact
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,17 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
/// Template every time, matching retail's own fallback branch.
|
/// Template every time, matching retail's own fallback branch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Func<uint, string?> _resolveCharacterTitle;
|
private readonly Func<uint, string?> _resolveCharacterTitle;
|
||||||
|
/// <summary>
|
||||||
|
/// AS4 (gap G6): the LOCAL player's own Faction1Bits (PropertyInt 281),
|
||||||
|
/// resolved PER CALL under the same deferred-Func discipline as
|
||||||
|
/// <see cref="_resolveCharacterTitle"/> — never captured once at mount
|
||||||
|
/// time. Feeds <see cref="CreatureAppraisalRows.BuildExtra"/>'s Society
|
||||||
|
/// row color rule (ground truth §2b row 1). Defaults to 0 (factionless)
|
||||||
|
/// so a caller that supplies nothing renders the Society row exactly as
|
||||||
|
/// if the local player belonged to no society, matching retail's own
|
||||||
|
/// "local has no bits" branch.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Func<int> _localFactionBits;
|
||||||
private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates;
|
private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates;
|
||||||
private readonly UiText _spellSchool;
|
private readonly UiText _spellSchool;
|
||||||
private readonly UiText _spellMana;
|
private readonly UiText _spellMana;
|
||||||
|
|
@ -148,7 +159,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents,
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents,
|
||||||
Func<MagicSchool, uint>? magicSkill,
|
Func<MagicSchool, uint>? magicSkill,
|
||||||
SpellExamineComponentTemplateFactory? spellComponentTemplates,
|
SpellExamineComponentTemplateFactory? spellComponentTemplates,
|
||||||
Func<uint, string?>? resolveCharacterTitle)
|
Func<uint, string?>? resolveCharacterTitle,
|
||||||
|
Func<int>? localFactionBits)
|
||||||
{
|
{
|
||||||
_layout = layout;
|
_layout = layout;
|
||||||
_objects = objects;
|
_objects = objects;
|
||||||
|
|
@ -176,6 +188,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_spellComponents = spellComponents ?? (_ => []);
|
_spellComponents = spellComponents ?? (_ => []);
|
||||||
_magicSkill = magicSkill ?? (_ => 0u);
|
_magicSkill = magicSkill ?? (_ => 0u);
|
||||||
_resolveCharacterTitle = resolveCharacterTitle ?? (_ => null);
|
_resolveCharacterTitle = resolveCharacterTitle ?? (_ => null);
|
||||||
|
_localFactionBits = localFactionBits ?? (() => 0);
|
||||||
_spellComponentTemplates = spellComponentTemplates;
|
_spellComponentTemplates = spellComponentTemplates;
|
||||||
_spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!;
|
_spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!;
|
||||||
_spellMana = (UiText)layout.FindElement(SpellManaTextId)!;
|
_spellMana = (UiText)layout.FindElement(SpellManaTextId)!;
|
||||||
|
|
@ -305,7 +318,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
||||||
Func<MagicSchool, uint>? magicSkill = null,
|
Func<MagicSchool, uint>? magicSkill = null,
|
||||||
SpellExamineComponentTemplateFactory? spellComponentTemplates = null,
|
SpellExamineComponentTemplateFactory? spellComponentTemplates = null,
|
||||||
Func<uint, string?>? resolveCharacterTitle = null)
|
Func<uint, string?>? resolveCharacterTitle = null,
|
||||||
|
Func<int>? localFactionBits = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(layout);
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
ArgumentNullException.ThrowIfNull(objects);
|
ArgumentNullException.ThrowIfNull(objects);
|
||||||
|
|
@ -358,7 +372,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
spellComponents,
|
spellComponents,
|
||||||
magicSkill,
|
magicSkill,
|
||||||
spellComponentTemplates,
|
spellComponentTemplates,
|
||||||
resolveCharacterTitle);
|
resolveCharacterTitle,
|
||||||
|
localFactionBits);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -822,6 +837,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
/// (<see cref="Tick"/> → <c>RefreshCurrentAppraisal</c>) round-trips a
|
/// (<see cref="Tick"/> → <c>RefreshCurrentAppraisal</c>) round-trips a
|
||||||
/// brand-new response through <see cref="Apply"/>, so no separate cache
|
/// brand-new response through <see cref="Apply"/>, so no separate cache
|
||||||
/// is needed for a refresh to keep rendering the same armor-level rows.
|
/// is needed for a refresh to keep rendering the same armor-level rows.
|
||||||
|
/// AS4: <see cref="_localFactionBits"/> is only invoked on the character
|
||||||
|
/// path — the monster path never renders a Society row and has no use
|
||||||
|
/// for it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RebuildCreatureStats(
|
private void RebuildCreatureStats(
|
||||||
AppraiseInfoParser.Parsed appraisal,
|
AppraiseInfoParser.Parsed appraisal,
|
||||||
|
|
@ -836,7 +854,10 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
: Array.Empty<CreatureAppraisalRow>());
|
: Array.Empty<CreatureAppraisalRow>());
|
||||||
_creatureExtra?.Rebuild(
|
_creatureExtra?.Rebuild(
|
||||||
CreatureAppraisalRows.BuildExtra(
|
CreatureAppraisalRows.BuildExtra(
|
||||||
appraisal.Properties, appraisal.ArmorLevels, character));
|
appraisal.Properties,
|
||||||
|
appraisal.ArmorLevels,
|
||||||
|
character,
|
||||||
|
character ? _localFactionBits() : 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ConfigureScrollableText(
|
private void ConfigureScrollableText(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ using System.Numerics;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Properties;
|
||||||
|
using AcDream.Core.Ui;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
|
|
@ -48,6 +50,49 @@ public static class CreatureAppraisalRows
|
||||||
private const uint DotResistRating = 0x15Eu;
|
private const uint DotResistRating = 0x15Eu;
|
||||||
private const uint LifeResistRating = 0x15Fu;
|
private const uint LifeResistRating = 0x15Fu;
|
||||||
|
|
||||||
|
// ── Campaign AS slice AS4: society/allegiance/fellowship + configurable
|
||||||
|
// extras (G6/G7/G8). Ground truth: docs/research/2026-08-25-campaign-as-
|
||||||
|
// ground-truth.md §2b rows 1-2 and 8-14. Decomp anchors: CharExamineUI::
|
||||||
|
// SetAppraiseInfo @0x004B45F0 — Society @0x004b49a1-0x004b4c24 (color
|
||||||
|
// rule sourced from the LOCAL player's own Faction1Bits, fetched a few
|
||||||
|
// lines earlier at @0x004b4971-0x004b4984 via CBaseQualities::InqInt);
|
||||||
|
// Monarch/Patron/Followers @0x004b4d97-0x004b4f54; configurable extras
|
||||||
|
// Fellowship @0x004b58be, Arrived in Dereth @0x004b593a, Time in Dereth
|
||||||
|
// @0x004b59bc, Chess Rank @0x004b5a5b, Fishing Skill @0x004b5afa, Deaths
|
||||||
|
// @0x004b5b96, Titles Earned @0x004b5c4d — all CHARACTER-path only; the
|
||||||
|
// monster path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) ends
|
||||||
|
// after its own ratings trailing spacer and never reaches any of this.
|
||||||
|
private const uint Faction1BitsProperty = (uint)PropertyInt.Faction1Bits; // 281
|
||||||
|
private const uint SocietyRankCelestialHandProperty =
|
||||||
|
(uint)PropertyInt.SocietyRankCelhan; // 287
|
||||||
|
private const uint SocietyRankEldrytchWebProperty =
|
||||||
|
(uint)PropertyInt.SocietyRankEldweb; // 288
|
||||||
|
private const uint SocietyRankRadiantBloodProperty =
|
||||||
|
(uint)PropertyInt.SocietyRankRadblo; // 289
|
||||||
|
private const int CelestialHandBit = 0x1;
|
||||||
|
private const int EldrytchWebBit = 0x2;
|
||||||
|
private const int RadiantBloodBit = 0x4;
|
||||||
|
private const int SocietyBitsMask =
|
||||||
|
CelestialHandBit | EldrytchWebBit | RadiantBloodBit;
|
||||||
|
|
||||||
|
private const uint AllegianceRankProperty = (uint)PropertyInt.AllegianceRank; // 30
|
||||||
|
private const uint AllegianceFollowersProperty =
|
||||||
|
(uint)PropertyInt.AllegianceFollowers; // 35 (Int table)
|
||||||
|
private const uint MonarchsTitleProperty =
|
||||||
|
(uint)PropertyString.MonarchsTitle; // 21 (String table)
|
||||||
|
private const uint PatronsTitleProperty =
|
||||||
|
(uint)PropertyString.PatronsTitle; // 35 (String table)
|
||||||
|
|
||||||
|
private const uint FellowshipProperty = (uint)PropertyString.Fellowship; // 10
|
||||||
|
private const uint DateOfBirthProperty = (uint)PropertyString.DateOfBirth; // 43
|
||||||
|
private const uint AgeProperty = (uint)PropertyInt.Age; // 125
|
||||||
|
private const uint ChessRankProperty = (uint)PropertyInt.ChessRank; // 181
|
||||||
|
private const uint FishingSkillProperty =
|
||||||
|
(uint)PropertyInt.FakeFishingSkill; // 192
|
||||||
|
private const uint NumDeathsProperty = (uint)PropertyInt.NumDeaths; // 43 (Int table)
|
||||||
|
private const uint NumCharacterTitlesProperty =
|
||||||
|
(uint)PropertyInt.NumCharacterTitles; // 262
|
||||||
|
|
||||||
public static IReadOnlyList<CreatureAppraisalRow> Build(
|
public static IReadOnlyList<CreatureAppraisalRow> Build(
|
||||||
AppraiseInfoParser.CreatureProfile profile,
|
AppraiseInfoParser.CreatureProfile profile,
|
||||||
bool success)
|
bool success)
|
||||||
|
|
@ -98,15 +143,26 @@ public static class CreatureAppraisalRows
|
||||||
private const int UnenchantableArmorLevel = 9999;
|
private const int UnenchantableArmorLevel = 9999;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Port of the armor-level trio + rating rows from
|
/// Port of the FULL extras list from
|
||||||
/// <c>CharExamineUI::SetAppraiseInfo @ 0x004B45F0</c> (player path) and
|
/// <c>CharExamineUI::SetAppraiseInfo @ 0x004B45F0</c> (player path) and
|
||||||
/// <c>CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0</c> (monster path,
|
/// <c>CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0</c> (monster path,
|
||||||
/// ratings only — no armor-level trio and no unenchantable legend; the
|
/// ratings only — no society, no allegiance, no armor-level trio, no
|
||||||
/// monster function ends immediately after its own trailing ratings
|
/// configurable extras, no unenchantable legend; the monster function
|
||||||
/// spacer and never touches <c>base_armor_*</c> or the
|
/// ends immediately after its own trailing ratings spacer and never
|
||||||
/// <c>u"* = Unenchantable"</c> literal). Ground truth:
|
/// touches any of that). Ground truth:
|
||||||
/// <c>docs/research/2026-08-25-campaign-as-ground-truth.md</c> §2b rows
|
/// <c>docs/research/2026-08-25-campaign-as-ground-truth.md</c> §2b (the
|
||||||
/// 3-7 + 15, ruling R3 (legend unconditional) and R4 (spacer discipline).
|
/// complete row table), rulings R3 (legend unconditional) and R4 (spacer
|
||||||
|
/// discipline).
|
||||||
|
/// <para>
|
||||||
|
/// Row order (character path only): Society (gap G6) → Monarch/Patron/
|
||||||
|
/// Followers (gap G7) → armor-level trio → ratings block → configurable
|
||||||
|
/// extras: Fellowship/Arrived in Dereth/Time in Dereth/Chess Rank/
|
||||||
|
/// Fishing Skill/Deaths/Titles Earned (gap G8) → the
|
||||||
|
/// <c>* = Unenchantable</c> legend, ALWAYS the last row
|
||||||
|
/// (@0x004b5d7d-@0x004b5ded). No separators exist between the
|
||||||
|
/// configurable-extra rows in the decomp — only the ratings family gets
|
||||||
|
/// the leading/trailing spacer treatment described below.
|
||||||
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Rating gating/spacer logic: retail's <c>CharExamineUI</c> decompile
|
/// Rating gating/spacer logic: retail's <c>CharExamineUI</c> decompile
|
||||||
/// mangles its leading-spacer flag (<c>ebx_13</c>) into unreadable
|
/// mangles its leading-spacer flag (<c>ebx_13</c>) into unreadable
|
||||||
|
|
@ -129,19 +185,38 @@ public static class CreatureAppraisalRows
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="character">
|
/// <param name="character">
|
||||||
/// <see langword="true"/> for the <c>CharExamineUI</c> (player) path,
|
/// <see langword="true"/> for the <c>CharExamineUI</c> (player) path,
|
||||||
/// which alone emits the armor-level trio and the trailing
|
/// which alone emits society/allegiance, the armor-level trio, the
|
||||||
/// "* = Unenchantable" legend; <see langword="false"/> for the
|
/// configurable extras, and the trailing "* = Unenchantable" legend;
|
||||||
/// <c>CreatureExamineUI</c> (monster) path, which never emits either.
|
/// <see langword="false"/> for the <c>CreatureExamineUI</c> (monster)
|
||||||
|
/// path, which never emits any of them.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="localFactionBits">
|
||||||
|
/// The LOCAL player's own Faction1Bits (PropertyInt 281), fetched by
|
||||||
|
/// retail via <c>CBaseQualities::InqInt</c> on the local qualities
|
||||||
|
/// object a few lines before the Society row builds
|
||||||
|
/// (@0x004b4971-@0x004b4984). The composer stays pure — callers source
|
||||||
|
/// this from whatever seam exposes the local player's live properties
|
||||||
|
/// (see <see cref="AppraisalUiController"/>'s binding). Defaults to 0
|
||||||
|
/// (factionless) for callers — mostly monster-path or pre-AS4 tests —
|
||||||
|
/// that never touch the Society row.
|
||||||
/// </param>
|
/// </param>
|
||||||
public static IReadOnlyList<CreatureAppraisalRow> BuildExtra(
|
public static IReadOnlyList<CreatureAppraisalRow> BuildExtra(
|
||||||
PropertyBundle properties,
|
PropertyBundle properties,
|
||||||
AppraiseInfoParser.ArmorLevel? armorLevels,
|
AppraiseInfoParser.ArmorLevel? armorLevels,
|
||||||
bool character)
|
bool character,
|
||||||
|
int localFactionBits = 0)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(properties);
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
|
|
||||||
var rows = new List<CreatureAppraisalRow>();
|
var rows = new List<CreatureAppraisalRow>();
|
||||||
|
|
||||||
|
if (character)
|
||||||
|
{
|
||||||
|
AddSocietyRow(rows, properties, localFactionBits);
|
||||||
|
if (Get(properties, AllegianceRankProperty) >= 1)
|
||||||
|
AddAllegianceCascade(rows, properties);
|
||||||
|
}
|
||||||
|
|
||||||
if (character && armorLevels is { } levels && HasAnyArmorLevel(levels))
|
if (character && armorLevels is { } levels && HasAnyArmorLevel(levels))
|
||||||
{
|
{
|
||||||
rows.Add(Blank());
|
rows.Add(Blank());
|
||||||
|
|
@ -196,6 +271,8 @@ public static class CreatureAppraisalRows
|
||||||
|
|
||||||
if (character)
|
if (character)
|
||||||
{
|
{
|
||||||
|
AddConfigurableExtras(rows, properties);
|
||||||
|
|
||||||
rows.Add(new CreatureAppraisalRow(
|
rows.Add(new CreatureAppraisalRow(
|
||||||
"* = Unenchantable",
|
"* = Unenchantable",
|
||||||
string.Empty,
|
string.Empty,
|
||||||
|
|
@ -205,6 +282,222 @@ public static class CreatureAppraisalRows
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Port of the Society row @0x004b49a1-@0x004b4c24 (gap G6). Gated on
|
||||||
|
/// PropertyInt 281 Faction1Bits being PRESENT on the response (matches
|
||||||
|
/// <c>AppraisalProfile::InqInt</c>'s found/not-found return, not a
|
||||||
|
/// value-nonzero test — a slightly more literal reading than the ground
|
||||||
|
/// truth doc's informal "≠ 0" phrasing). Bit-priority order (Celestial
|
||||||
|
/// Hand 0x1, then Eldrytch Web 0x2, then — ONLY when neither of those
|
||||||
|
/// AND Radiant Blood's 0x4 bit is also clear — the unrecognized
|
||||||
|
/// <c>"???"</c> fallback (<c>data_7af4e8</c>), else Radiant Blood) comes
|
||||||
|
/// straight from the decompiled if/else-if chain; a target with more
|
||||||
|
/// than one bit set resolves to whichever bit the chain checks first.
|
||||||
|
/// The unrecognized branch never sets a color (retail's <c>ebx_3</c>
|
||||||
|
/// stays at its zero initializer) — always
|
||||||
|
/// <see cref="CreatureAppraisalValueStyle.Normal"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddSocietyRow(
|
||||||
|
List<CreatureAppraisalRow> rows,
|
||||||
|
PropertyBundle properties,
|
||||||
|
int localFactionBits)
|
||||||
|
{
|
||||||
|
if (!properties.Ints.TryGetValue(Faction1BitsProperty, out int targetBits))
|
||||||
|
return;
|
||||||
|
|
||||||
|
string name;
|
||||||
|
int rank;
|
||||||
|
int targetBit;
|
||||||
|
if ((targetBits & CelestialHandBit) != 0)
|
||||||
|
{
|
||||||
|
name = "Celestial Hand";
|
||||||
|
rank = Get(properties, SocietyRankCelestialHandProperty);
|
||||||
|
targetBit = CelestialHandBit;
|
||||||
|
}
|
||||||
|
else if ((targetBits & EldrytchWebBit) != 0)
|
||||||
|
{
|
||||||
|
name = "Eldrytch Web";
|
||||||
|
rank = Get(properties, SocietyRankEldrytchWebProperty);
|
||||||
|
targetBit = EldrytchWebBit;
|
||||||
|
}
|
||||||
|
else if ((targetBits & RadiantBloodBit) == 0)
|
||||||
|
{
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Society:", Unknown, CreatureAppraisalValueStyle.Normal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
name = "Radiant Blood";
|
||||||
|
rank = Get(properties, SocietyRankRadiantBloodProperty);
|
||||||
|
targetBit = RadiantBloodBit;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Society:",
|
||||||
|
name + SocietyRankSuffix(rank),
|
||||||
|
SocietyColor(targetBit, localFactionBits)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rank-band suffix bounds @0x004b4ab9-@0x004b4b92 — five bands, each
|
||||||
|
/// inclusive on both ends; outside every band (including rank 0, the
|
||||||
|
/// value seen when <c>InqInt</c> on the rank property fails) the society
|
||||||
|
/// name stands alone with no suffix.
|
||||||
|
/// </summary>
|
||||||
|
private static string SocietyRankSuffix(int rank) => rank switch
|
||||||
|
{
|
||||||
|
>= 1 and <= 100 => " ~ Initiate",
|
||||||
|
>= 101 and <= 300 => " ~ Adept",
|
||||||
|
>= 301 and <= 600 => " ~ Knight",
|
||||||
|
>= 601 and <= 1000 => " ~ Lord",
|
||||||
|
>= 1001 and <= 1500 => " ~ Master",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Society color rule: green when the local player shares the
|
||||||
|
/// TARGET's selected bit (checked first, so a local player with
|
||||||
|
/// multiple bits set still resolves green if one of them matches);
|
||||||
|
/// red when the local player has neither of the target's bit but has
|
||||||
|
/// at least one of the other two society bits; normal (no color) when
|
||||||
|
/// the local player has no society bits at all. Same
|
||||||
|
/// InqInt-into-CBaseQualities read for all three branches
|
||||||
|
/// @0x004b49fd/@0x004b4a49/@0x004b4a8b.
|
||||||
|
/// </summary>
|
||||||
|
private static CreatureAppraisalValueStyle SocietyColor(
|
||||||
|
int targetBit, int localFactionBits)
|
||||||
|
{
|
||||||
|
if ((localFactionBits & targetBit) != 0)
|
||||||
|
return CreatureAppraisalValueStyle.Positive;
|
||||||
|
if ((localFactionBits & (SocietyBitsMask & ~targetBit)) != 0)
|
||||||
|
return CreatureAppraisalValueStyle.Negative;
|
||||||
|
return CreatureAppraisalValueStyle.Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Port of the Monarch/Patron/Followers cascade @0x004b4d97-@0x004b4f54
|
||||||
|
/// (gap G7), gated by the caller on AllegianceRank >= 1 (the SAME
|
||||||
|
/// <c>var_108</c> InqInt read retail's header AllegianceName binding
|
||||||
|
/// already consumes — see <see cref="AppraisalUiController.
|
||||||
|
/// BuildAllegianceDisplay"/>). Four arms, tested in retail's exact
|
||||||
|
/// order: MonarchsTitle (Str 21) absent → follower-count row; present +
|
||||||
|
/// PatronsTitle (Str 35) absent → Monarch-only row; both present and
|
||||||
|
/// EQUAL (ordinal — server-composed strings, no case-folding in the
|
||||||
|
/// decomp) → one combined row; both present and different → two rows.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddAllegianceCascade(
|
||||||
|
List<CreatureAppraisalRow> rows, PropertyBundle properties)
|
||||||
|
{
|
||||||
|
if (!properties.Strings.TryGetValue(
|
||||||
|
MonarchsTitleProperty, out string? monarchsTitle))
|
||||||
|
{
|
||||||
|
int followers = Get(properties, AllegianceFollowersProperty);
|
||||||
|
if (followers < 0)
|
||||||
|
followers = 0;
|
||||||
|
string unit = followers == 1 ? "Follower" : "Followers";
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Alleg. Monarch:",
|
||||||
|
$"{Number(followers)} {unit}",
|
||||||
|
CreatureAppraisalValueStyle.Normal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!properties.Strings.TryGetValue(
|
||||||
|
PatronsTitleProperty, out string? patronsTitle))
|
||||||
|
{
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Monarch:", monarchsTitle, CreatureAppraisalValueStyle.Normal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(monarchsTitle, patronsTitle, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Monarch/Patron:",
|
||||||
|
monarchsTitle,
|
||||||
|
CreatureAppraisalValueStyle.Normal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Monarch:", monarchsTitle, CreatureAppraisalValueStyle.Normal));
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Patron:", patronsTitle, CreatureAppraisalValueStyle.Normal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Port of the seven configurable-extras rows (gap G8, ground truth §2b
|
||||||
|
/// rows 8-14): Fellowship @0x004b58be, Arrived in Dereth @0x004b593a,
|
||||||
|
/// Time in Dereth @0x004b59bc, Chess Rank @0x004b5a5b, Fishing Skill
|
||||||
|
/// @0x004b5afa, Deaths @0x004b5b96, Titles Earned @0x004b5c4d. Each row
|
||||||
|
/// is independently gated on its OWN property being present on the
|
||||||
|
/// response — ACE already strips these per the TARGET's own visibility
|
||||||
|
/// options before the response is sent
|
||||||
|
/// (<c>AppraiseInfo.cs:352-366</c>), so no client-side option logic or
|
||||||
|
/// success gating belongs here. No spacers separate these rows in the
|
||||||
|
/// decomp. Time in Dereth reuses the ALREADY-PORTED
|
||||||
|
/// <see cref="RetailDurationText.Format"/> — the exact same
|
||||||
|
/// <c>ClientUISystem::DeltaTimeToString @0x00565E10</c> the decomp calls
|
||||||
|
/// here (@0x004b59e0) — rather than porting a duplicate.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddConfigurableExtras(
|
||||||
|
List<CreatureAppraisalRow> rows, PropertyBundle properties)
|
||||||
|
{
|
||||||
|
if (properties.Strings.TryGetValue(
|
||||||
|
FellowshipProperty, out string? fellowship))
|
||||||
|
{
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Fellowship:", fellowship, CreatureAppraisalValueStyle.Normal));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.Strings.TryGetValue(
|
||||||
|
DateOfBirthProperty, out string? arrived))
|
||||||
|
{
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Arrived in Dereth:",
|
||||||
|
arrived,
|
||||||
|
CreatureAppraisalValueStyle.Normal));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.Ints.TryGetValue(AgeProperty, out int ageSeconds))
|
||||||
|
{
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Time in Dereth:",
|
||||||
|
RetailDurationText.Format(ageSeconds),
|
||||||
|
CreatureAppraisalValueStyle.Normal));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.Ints.TryGetValue(ChessRankProperty, out int chessRank))
|
||||||
|
{
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Chess Rank:", Number(chessRank), CreatureAppraisalValueStyle.Normal));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.Ints.TryGetValue(FishingSkillProperty, out int fishingSkill))
|
||||||
|
{
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Fishing Skill:",
|
||||||
|
Number(fishingSkill),
|
||||||
|
CreatureAppraisalValueStyle.Normal));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.Ints.TryGetValue(NumDeathsProperty, out int deaths))
|
||||||
|
{
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Deaths:",
|
||||||
|
deaths <= 0 ? "Has never died" : Number(deaths),
|
||||||
|
CreatureAppraisalValueStyle.Normal));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.Ints.TryGetValue(
|
||||||
|
NumCharacterTitlesProperty, out int titles))
|
||||||
|
{
|
||||||
|
rows.Add(new CreatureAppraisalRow(
|
||||||
|
"Titles Earned:", Number(titles), CreatureAppraisalValueStyle.Normal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static bool HasAnyArmorLevel(AppraiseInfoParser.ArmorLevel levels)
|
private static bool HasAnyArmorLevel(AppraiseInfoParser.ArmorLevel levels)
|
||||||
=> levels.Head > 0 || levels.Chest > 0 || levels.Abdomen > 0
|
=> levels.Head > 0 || levels.Chest > 0 || levels.Abdomen > 0
|
||||||
|| levels.UpperArm > 0 || levels.LowerArm > 0 || levels.Hand > 0
|
|| levels.UpperArm > 0 || levels.LowerArm > 0 || levels.Hand > 0
|
||||||
|
|
|
||||||
|
|
@ -411,7 +411,15 @@ public sealed record ConfirmationRuntimeBindings(
|
||||||
public sealed record AppraisalRuntimeBindings(
|
public sealed record AppraisalRuntimeBindings(
|
||||||
Func<string> PlayerName,
|
Func<string> PlayerName,
|
||||||
Action<uint, string> SendSetInscription,
|
Action<uint, string> SendSetInscription,
|
||||||
Action<string> DisplaySystemMessage);
|
Action<string> DisplaySystemMessage,
|
||||||
|
// Campaign AS slice AS4 (gap G6): the LOCAL player's own Faction1Bits
|
||||||
|
// (PropertyInt 281), feeding the examination window's Society row color
|
||||||
|
// rule (CreatureAppraisalRows.BuildExtra). Sourced from the SAME
|
||||||
|
// LocalPlayerState reference CharacterSheetProvider is built from
|
||||||
|
// (d.Character.LocalPlayer in InteractionRetainedUiComposition) — no new
|
||||||
|
// state path, just another read of the existing PlayerDescription-backed
|
||||||
|
// property snapshot.
|
||||||
|
Func<int> LocalFactionBits);
|
||||||
|
|
||||||
public sealed record VendorRuntimeBindings(
|
public sealed record VendorRuntimeBindings(
|
||||||
VendorState State,
|
VendorState State,
|
||||||
|
|
@ -2147,7 +2155,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
spellComponents: _bindings.Magic.SpellComponents,
|
spellComponents: _bindings.Magic.SpellComponents,
|
||||||
magicSkill: _bindings.Magic.MagicSkill,
|
magicSkill: _bindings.Magic.MagicSkill,
|
||||||
spellComponentTemplates: spellComponentTemplates,
|
spellComponentTemplates: spellComponentTemplates,
|
||||||
resolveCharacterTitle: ResolveCharacterTitle);
|
resolveCharacterTitle: ResolveCharacterTitle,
|
||||||
|
localFactionBits: _bindings.Appraisal.LocalFactionBits);
|
||||||
if (controller is null)
|
if (controller is null)
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
|
|
|
||||||
|
|
@ -891,14 +891,13 @@ public sealed class AppraisalUiControllerTests
|
||||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
var properties = new PropertyBundle();
|
var properties = new PropertyBundle();
|
||||||
properties.Strings[5u] = "Template";
|
properties.Strings[5u] = "Template";
|
||||||
var armorLevels = new AppraiseInfoParser.ArmorLevel(
|
var firstArmorLevels = new AppraiseInfoParser.ArmorLevel(
|
||||||
Head: 50, Chest: 60, Abdomen: 70,
|
Head: 50, Chest: 60, Abdomen: 70,
|
||||||
UpperArm: 80, LowerArm: 90, Hand: 100,
|
UpperArm: 80, LowerArm: 90, Hand: 100,
|
||||||
UpperLeg: 110, LowerLeg: 120, Foot: 130);
|
UpperLeg: 110, LowerLeg: 120, Foot: 130);
|
||||||
AppraiseInfoParser.Parsed appraisal = Parsed(
|
|
||||||
properties, MinimalCreatureProfile(), armorLevels: armorLevels);
|
|
||||||
|
|
||||||
Assert.True(controller.Apply(appraisal));
|
Assert.True(controller.Apply(Parsed(
|
||||||
|
properties, MinimalCreatureProfile(), armorLevels: firstArmorLevels)));
|
||||||
controller.OnShown();
|
controller.OnShown();
|
||||||
int sentBeforeRefresh = sent.Count;
|
int sentBeforeRefresh = sent.Count;
|
||||||
|
|
||||||
|
|
@ -907,20 +906,142 @@ public sealed class AppraisalUiControllerTests
|
||||||
// The 0.75 s combat refresh fired exactly one fresh wire request.
|
// The 0.75 s combat refresh fired exactly one fresh wire request.
|
||||||
Assert.Equal(sentBeforeRefresh + 1, sent.Count);
|
Assert.Equal(sentBeforeRefresh + 1, sent.Count);
|
||||||
|
|
||||||
// The refreshed response is a brand-new Parsed value coming back
|
// AS3 NIT 11: the refresh brings back a GENUINELY DIFFERENT response
|
||||||
// through Apply — AppraiseInfoParser always parses ArmorLevels when
|
// (e.g. gear swapped mid-fight) — proving the extras list re-renders
|
||||||
// the flag is set, so nothing needs to be cached client-side for
|
// from the fresh data rather than replaying a cached copy of the
|
||||||
// the re-applied response to keep rendering the same AL rows.
|
// first response.
|
||||||
Assert.True(controller.Apply(appraisal));
|
var secondArmorLevels = new AppraiseInfoParser.ArmorLevel(
|
||||||
|
Head: 51, Chest: 61, Abdomen: 71,
|
||||||
|
UpperArm: 81, LowerArm: 91, Hand: 101,
|
||||||
|
UpperLeg: 111, LowerLeg: 121, Foot: 131);
|
||||||
|
Assert.True(controller.Apply(Parsed(
|
||||||
|
properties, MinimalCreatureProfile(), armorLevels: secondArmorLevels)));
|
||||||
|
|
||||||
|
UiItemList extraAfterSecond = CreatureExtraList(layout);
|
||||||
|
Assert.Equal(5, extraAfterSecond.GetNumUIItems());
|
||||||
|
Assert.Equal(
|
||||||
|
("Head/Chest/Groin", "AL: 51/61/71"), ExtraRow(extraAfterSecond, 1));
|
||||||
|
Assert.Equal(
|
||||||
|
("Bicep/Wrist/Hand", "AL: 81/91/101"), ExtraRow(extraAfterSecond, 2));
|
||||||
|
Assert.Equal(
|
||||||
|
("Thigh/Shin/Foot", "AL: 111/121/131"), ExtraRow(extraAfterSecond, 3));
|
||||||
|
|
||||||
|
// A THIRD response carrying no ArmorLevels blob at all (e.g. a
|
||||||
|
// response that simply didn't set the flag) clears the trio
|
||||||
|
// entirely rather than leaving the second response's rows stuck
|
||||||
|
// on screen.
|
||||||
|
Assert.True(controller.Apply(Parsed(
|
||||||
|
properties, MinimalCreatureProfile(), armorLevels: null)));
|
||||||
|
|
||||||
|
UiItemList extraAfterThird = CreatureExtraList(layout);
|
||||||
|
Assert.Equal(1, extraAfterThird.GetNumUIItems());
|
||||||
|
Assert.Equal(
|
||||||
|
("* = Unenchantable", string.Empty), ExtraRow(extraAfterThird, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Campaign AS slice AS4: society/allegiance/fellowship + configurable
|
||||||
|
// extras (G6/G7/G8). Real LayoutDesc/template binding through the
|
||||||
|
// localFactionBits seam (AppraisalRuntimeBindings.LocalFactionBits in
|
||||||
|
// production), matching the AS2/AS3 controller-level pattern.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharacterResponse_SocietyAllegianceAndFellowshipRenderThroughRealBinding()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Dww",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
});
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
var templates = new CreatureAppraisalRowTemplateFactory(
|
||||||
|
FixtureLoader.LoadExaminationRowTemplateInfos(),
|
||||||
|
NoTexture,
|
||||||
|
defaultFont: null);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { },
|
||||||
|
templates,
|
||||||
|
// The seam AppraisalRuntimeBindings.LocalFactionBits threads in
|
||||||
|
// production: the local player shares the target's Celestial
|
||||||
|
// Hand bit.
|
||||||
|
localFactionBits: () => 0x1)!;
|
||||||
|
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Strings[5u] = "Template"; // Character-view marker
|
||||||
|
properties.Ints[281u] = 0x1; // Faction1Bits: Celestial Hand
|
||||||
|
properties.Ints[287u] = 50; // society rank -> Initiate band
|
||||||
|
properties.Ints[30u] = 5; // AllegianceRank >= 1
|
||||||
|
properties.Strings[21u] = "Monarch Title"; // no PatronsTitle -> Monarch-only row
|
||||||
|
properties.Strings[10u] = "Fellows";
|
||||||
|
|
||||||
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||||
|
Assert.Equal(AppraisalView.Character, controller.ActiveView);
|
||||||
|
|
||||||
UiItemList extra = CreatureExtraList(layout);
|
UiItemList extra = CreatureExtraList(layout);
|
||||||
Assert.Equal(5, extra.GetNumUIItems());
|
Assert.Equal(4, extra.GetNumUIItems());
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
("Head/Chest/Groin", "AL: 50/60/70"), ExtraRow(extra, 1));
|
("Society:", "Celestial Hand ~ Initiate"), ExtraRow(extra, 0));
|
||||||
|
Assert.Equal(("Monarch:", "Monarch Title"), ExtraRow(extra, 1));
|
||||||
|
Assert.Equal(("Fellowship:", "Fellows"), ExtraRow(extra, 2));
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
("Bicep/Wrist/Hand", "AL: 80/90/100"), ExtraRow(extra, 2));
|
("* = Unenchantable", string.Empty), ExtraRow(extra, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharacterResponse_LocalFactionBitsDefaultsToFactionlessWhenBindingSuppliesNone()
|
||||||
|
{
|
||||||
|
// No localFactionBits argument -> AppraisalUiController's own
|
||||||
|
// fallback (() => 0), exactly like a caller that never wires the
|
||||||
|
// binding (matches R7-style defensive defaults elsewhere in this
|
||||||
|
// controller).
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Dww",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
});
|
||||||
|
using var interaction = NewInteraction(objects, []);
|
||||||
|
var templates = new CreatureAppraisalRowTemplateFactory(
|
||||||
|
FixtureLoader.LoadExaminationRowTemplateInfos(),
|
||||||
|
NoTexture,
|
||||||
|
defaultFont: null);
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => { },
|
||||||
|
() => { },
|
||||||
|
templates)!;
|
||||||
|
|
||||||
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Strings[5u] = "Template";
|
||||||
|
properties.Ints[281u] = 0x1; // Faction1Bits: Celestial Hand
|
||||||
|
|
||||||
|
Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile())));
|
||||||
|
|
||||||
|
UiItemList extra = CreatureExtraList(layout);
|
||||||
|
// Row still renders (Society doesn't gate on the local player); it
|
||||||
|
// just gets no color because the local player has no matching bit.
|
||||||
|
// No rank property was set, so there is no band suffix (rank 0 is
|
||||||
|
// outside every band).
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
("Thigh/Shin/Foot", "AL: 110/120/130"), ExtraRow(extra, 3));
|
("Society:", "Celestial Hand"), ExtraRow(extra, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -953,14 +1074,24 @@ public sealed class AppraisalUiControllerTests
|
||||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||||
// No String 5 / Int 261 marker -> monster path. ArmorLevels present
|
// No String 5 / Int 261 marker -> monster path. ArmorLevels present
|
||||||
// on the wire (a real ACE response always carries them for a
|
// on the wire (a real ACE response always carries them for a
|
||||||
// successful non-player target too) must still be ignored here.
|
// successful non-player target too) must still be ignored here, and
|
||||||
|
// so must AS4's society/allegiance/configurable-extras properties —
|
||||||
|
// a real ACE response can carry those for a non-attackable monster
|
||||||
|
// target too (ground truth §3), but CreatureExamineUI::
|
||||||
|
// SetAppraiseInfo @0x004B3FF0 never reads any of them.
|
||||||
var armorLevels = new AppraiseInfoParser.ArmorLevel(
|
var armorLevels = new AppraiseInfoParser.ArmorLevel(
|
||||||
Head: 100, Chest: 110, Abdomen: 120,
|
Head: 100, Chest: 110, Abdomen: 120,
|
||||||
UpperArm: 130, LowerArm: 140, Hand: 150,
|
UpperArm: 130, LowerArm: 140, Hand: 150,
|
||||||
UpperLeg: 160, LowerLeg: 170, Foot: 180);
|
UpperLeg: 160, LowerLeg: 170, Foot: 180);
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[281u] = 0x1; // Faction1Bits
|
||||||
|
properties.Ints[30u] = 5; // AllegianceRank
|
||||||
|
properties.Strings[21u] = "Monarch Title";
|
||||||
|
properties.Strings[10u] = "Fellows";
|
||||||
|
properties.Ints[43u] = 2; // NumDeaths
|
||||||
|
|
||||||
Assert.True(controller.Apply(Parsed(
|
Assert.True(controller.Apply(Parsed(
|
||||||
new PropertyBundle(),
|
properties,
|
||||||
MinimalCreatureProfile(),
|
MinimalCreatureProfile(),
|
||||||
armorLevels: armorLevels)));
|
armorLevels: armorLevels)));
|
||||||
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
|
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
|
||||||
|
|
@ -1389,7 +1520,8 @@ public sealed class AppraisalUiControllerTests
|
||||||
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
||||||
Func<MagicSchool, uint>? magicSkill = null,
|
Func<MagicSchool, uint>? magicSkill = null,
|
||||||
SpellExamineComponentTemplateFactory? spellComponentTemplates = null,
|
SpellExamineComponentTemplateFactory? spellComponentTemplates = null,
|
||||||
Func<uint, string?>? resolveCharacterTitle = null)
|
Func<uint, string?>? resolveCharacterTitle = null,
|
||||||
|
Func<int>? localFactionBits = null)
|
||||||
=> AppraisalUiController.Bind(
|
=> AppraisalUiController.Bind(
|
||||||
layout,
|
layout,
|
||||||
objects,
|
objects,
|
||||||
|
|
@ -1410,7 +1542,8 @@ public sealed class AppraisalUiControllerTests
|
||||||
spellComponents,
|
spellComponents,
|
||||||
magicSkill,
|
magicSkill,
|
||||||
spellComponentTemplates,
|
spellComponentTemplates,
|
||||||
resolveCharacterTitle);
|
resolveCharacterTitle,
|
||||||
|
localFactionBits);
|
||||||
|
|
||||||
private static ItemInteractionController NewInteraction(
|
private static ItemInteractionController NewInteraction(
|
||||||
ClientObjectTable objects,
|
ClientObjectTable objects,
|
||||||
|
|
@ -1434,9 +1567,12 @@ public sealed class AppraisalUiControllerTests
|
||||||
AppraiseInfoParser.ArmorLevel? armorLevels = null)
|
AppraiseInfoParser.ArmorLevel? armorLevels = null)
|
||||||
=> new(
|
=> new(
|
||||||
Guid: guid,
|
Guid: guid,
|
||||||
Flags: creature is null
|
Flags: (creature is null
|
||||||
? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
|
? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
|
||||||
: AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile,
|
: AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile)
|
||||||
|
| (armorLevels is null
|
||||||
|
? AppraiseInfoParser.IdentifyResponseFlags.None
|
||||||
|
: AppraiseInfoParser.IdentifyResponseFlags.ArmorLevels),
|
||||||
Success: success,
|
Success: success,
|
||||||
Properties: properties,
|
Properties: properties,
|
||||||
SpellBook: [],
|
SpellBook: [],
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Ui;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI.Layout;
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
|
@ -261,6 +262,334 @@ public sealed class CreatureAppraisalRowsTests
|
||||||
Assert.Equal(("* = Unenchantable", string.Empty), (only.Label, only.Value));
|
Assert.Equal(("* = Unenchantable", string.Empty), (only.Label, only.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Campaign AS slice AS4: society/allegiance/fellowship + configurable
|
||||||
|
// extras (G6/G7/G8). Ground truth: docs/research/2026-08-25-campaign-as-
|
||||||
|
// ground-truth.md §2b rows 1-2 and 8-14. Decomp anchors: Society
|
||||||
|
// @0x004b49a1-@0x004b4c24, Monarch/Patron/Followers
|
||||||
|
// @0x004b4d97-@0x004b4f54, configurable extras
|
||||||
|
// @0x004b58be-@0x004b5c4d.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SocietyRowAbsentWhenFaction1BitsPropertyNotPresent()
|
||||||
|
{
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
new PropertyBundle(), armorLevels: null, character: true);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Society:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x1, 287u, "Celestial Hand")]
|
||||||
|
[InlineData(0x2, 288u, "Eldrytch Web")]
|
||||||
|
[InlineData(0x4, 289u, "Radiant Blood")]
|
||||||
|
public void SocietyRowSelectsNameAndRankPropertyPerTargetBit(
|
||||||
|
int targetBit, uint rankPropertyId, string expectedName)
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[281u] = targetBit;
|
||||||
|
properties.Ints[rankPropertyId] = 50; // Initiate band
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true);
|
||||||
|
|
||||||
|
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:");
|
||||||
|
Assert.Equal($"{expectedName} ~ Initiate", row.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x8)]
|
||||||
|
[InlineData(0x10)]
|
||||||
|
public void SocietyRowFallsBackToUnrecognizedForUnknownBitCombinations(int bits)
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[281u] = bits;
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true, localFactionBits: 0x1);
|
||||||
|
|
||||||
|
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:");
|
||||||
|
Assert.Equal("???", row.Value);
|
||||||
|
// Retail's ebx_3 color accumulator never gets touched on this branch.
|
||||||
|
Assert.Equal(CreatureAppraisalValueStyle.Normal, row.Style);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, "")]
|
||||||
|
[InlineData(1, " ~ Initiate")]
|
||||||
|
[InlineData(100, " ~ Initiate")]
|
||||||
|
[InlineData(101, " ~ Adept")]
|
||||||
|
[InlineData(300, " ~ Adept")]
|
||||||
|
[InlineData(301, " ~ Knight")]
|
||||||
|
[InlineData(600, " ~ Knight")]
|
||||||
|
[InlineData(601, " ~ Lord")]
|
||||||
|
[InlineData(1000, " ~ Lord")]
|
||||||
|
[InlineData(1001, " ~ Master")]
|
||||||
|
[InlineData(1500, " ~ Master")]
|
||||||
|
[InlineData(1501, "")]
|
||||||
|
public void SocietyRankBandSuffixMatchesRetailInclusiveBoundaries(
|
||||||
|
int rank, string expectedSuffix)
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[281u] = 0x1; // Celestial Hand
|
||||||
|
properties.Ints[287u] = rank;
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true);
|
||||||
|
|
||||||
|
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:");
|
||||||
|
Assert.Equal("Celestial Hand" + expectedSuffix, row.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x1, 0x1, CreatureAppraisalValueStyle.Positive)]
|
||||||
|
[InlineData(0x1, 0x2, CreatureAppraisalValueStyle.Negative)]
|
||||||
|
[InlineData(0x1, 0x4, CreatureAppraisalValueStyle.Negative)]
|
||||||
|
[InlineData(0x1, 0x0, CreatureAppraisalValueStyle.Normal)]
|
||||||
|
[InlineData(0x2, 0x2, CreatureAppraisalValueStyle.Positive)]
|
||||||
|
[InlineData(0x2, 0x1, CreatureAppraisalValueStyle.Negative)]
|
||||||
|
[InlineData(0x4, 0x4, CreatureAppraisalValueStyle.Positive)]
|
||||||
|
[InlineData(0x4, 0x2, CreatureAppraisalValueStyle.Negative)]
|
||||||
|
public void SocietyColorReflectsLocalPlayerFactionBitsAgainstTarget(
|
||||||
|
int targetBit, int localFactionBits, CreatureAppraisalValueStyle expected)
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[281u] = targetBit;
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true, localFactionBits);
|
||||||
|
|
||||||
|
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:");
|
||||||
|
Assert.Equal(expected, row.Style);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SocietyColorPrioritizesSameBitMatchEvenWhenLocalHasOtherBitsToo()
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[281u] = 0x1;
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true, localFactionBits: 0x1 | 0x2);
|
||||||
|
|
||||||
|
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:");
|
||||||
|
Assert.Equal(CreatureAppraisalValueStyle.Positive, row.Style);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllegianceCascadeAbsentWhenAllegianceRankBelowOne()
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[30u] = 0;
|
||||||
|
properties.Strings[21u] = "Should Not Appear";
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:"
|
||||||
|
or "Monarch/Patron:" or "Alleg. Monarch:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, "0 Followers")]
|
||||||
|
[InlineData(1, "1 Follower")]
|
||||||
|
[InlineData(2, "2 Followers")]
|
||||||
|
[InlineData(-5, "0 Followers")]
|
||||||
|
public void AllegianceCascadeShowsClampedFollowerCountWhenMonarchsTitleAbsent(
|
||||||
|
int followers, string expected)
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[30u] = 1;
|
||||||
|
properties.Ints[35u] = followers;
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true);
|
||||||
|
|
||||||
|
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Alleg. Monarch:");
|
||||||
|
Assert.Equal(expected, row.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllegianceCascadeShowsMonarchOnlyWhenPatronsTitleAbsent()
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[30u] = 1;
|
||||||
|
properties.Strings[21u] = "Baroness Aluvia";
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"Baroness Aluvia", Assert.Single(rows, r => r.Label == "Monarch:").Value);
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label is "Patron:" or "Monarch/Patron:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllegianceCascadeCombinesMonarchAndPatronWhenTitlesAreOrdinallyEqual()
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[30u] = 1;
|
||||||
|
properties.Strings[21u] = "Same Title";
|
||||||
|
properties.Strings[35u] = "Same Title";
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"Same Title", Assert.Single(rows, r => r.Label == "Monarch/Patron:").Value);
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllegianceCascadeSplitsMonarchAndPatronWhenTitlesDiffer()
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[30u] = 1;
|
||||||
|
properties.Strings[21u] = "Monarch Title";
|
||||||
|
properties.Strings[35u] = "Patron Title";
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"Monarch Title", Assert.Single(rows, r => r.Label == "Monarch:").Value);
|
||||||
|
Assert.Equal(
|
||||||
|
"Patron Title", Assert.Single(rows, r => r.Label == "Patron:").Value);
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Monarch/Patron:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigurableExtrasEachAppearOnlyWhenTheirOwnPropertyIsPresent()
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Strings[10u] = "The Fellows";
|
||||||
|
properties.Strings[43u] = "1/1/2023"; // DateOfBirth (String table)
|
||||||
|
properties.Ints[125u] = 90; // Age, seconds
|
||||||
|
properties.Ints[181u] = 7;
|
||||||
|
properties.Ints[192u] = 42;
|
||||||
|
properties.Ints[262u] = 3;
|
||||||
|
// Int 43 (NumDeaths) deliberately absent — separate Int-table id
|
||||||
|
// from the String-table DateOfBirth id above; must not leak a row.
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"The Fellows", Assert.Single(rows, r => r.Label == "Fellowship:").Value);
|
||||||
|
Assert.Equal(
|
||||||
|
"1/1/2023",
|
||||||
|
Assert.Single(rows, r => r.Label == "Arrived in Dereth:").Value);
|
||||||
|
Assert.Equal(
|
||||||
|
RetailDurationText.Format(90),
|
||||||
|
Assert.Single(rows, r => r.Label == "Time in Dereth:").Value);
|
||||||
|
Assert.Equal("7", Assert.Single(rows, r => r.Label == "Chess Rank:").Value);
|
||||||
|
Assert.Equal("42", Assert.Single(rows, r => r.Label == "Fishing Skill:").Value);
|
||||||
|
Assert.Equal("3", Assert.Single(rows, r => r.Label == "Titles Earned:").Value);
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Deaths:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigurableExtrasAllAbsentLeavesOnlyTheLegend()
|
||||||
|
{
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
new PropertyBundle(), armorLevels: null, character: true);
|
||||||
|
|
||||||
|
CreatureAppraisalRow only = Assert.Single(rows);
|
||||||
|
Assert.Equal("* = Unenchantable", only.Label);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, "Has never died")]
|
||||||
|
[InlineData(-3, "Has never died")]
|
||||||
|
[InlineData(1, "1")]
|
||||||
|
[InlineData(5, "5")]
|
||||||
|
public void DeathsRowShowsHasNeverDiedAtOrBelowZeroButKeepsTheSameLabel(
|
||||||
|
int deaths, string expected)
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[43u] = deaths; // NumDeaths (Int table)
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: true);
|
||||||
|
|
||||||
|
CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Deaths:");
|
||||||
|
Assert.Equal(expected, row.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MonsterPathNeverGainsSocietyAllegianceOrConfigurableExtraRows()
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
properties.Ints[281u] = 0x1;
|
||||||
|
properties.Ints[30u] = 5;
|
||||||
|
properties.Strings[21u] = "Monarch Title";
|
||||||
|
properties.Strings[10u] = "Fellows";
|
||||||
|
properties.Strings[43u] = "1/1/2023";
|
||||||
|
properties.Ints[125u] = 90;
|
||||||
|
properties.Ints[181u] = 7;
|
||||||
|
properties.Ints[192u] = 42;
|
||||||
|
properties.Ints[43u] = 2;
|
||||||
|
properties.Ints[262u] = 3;
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, armorLevels: null, character: false, localFactionBits: 0x1);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Society:");
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:"
|
||||||
|
or "Monarch/Patron:" or "Alleg. Monarch:");
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Fellowship:");
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Arrived in Dereth:");
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Time in Dereth:");
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Chess Rank:");
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Fishing Skill:");
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Deaths:");
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "Titles Earned:");
|
||||||
|
Assert.DoesNotContain(rows, r => r.Label == "* = Unenchantable");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompleteCharacterExtrasOrderingMatchesRetailRowSequence()
|
||||||
|
{
|
||||||
|
var properties = new PropertyBundle();
|
||||||
|
// Society: local shares the target's Celestial Hand bit -> green.
|
||||||
|
properties.Ints[281u] = 0x1;
|
||||||
|
properties.Ints[287u] = 50;
|
||||||
|
// Allegiance cascade: monarch/patron present and different.
|
||||||
|
properties.Ints[30u] = 5;
|
||||||
|
properties.Strings[21u] = "Monarch Title";
|
||||||
|
properties.Strings[35u] = "Patron Title";
|
||||||
|
// Ratings: only the Dmg/CritDmg family fires.
|
||||||
|
properties.Ints[0x133u] = 10;
|
||||||
|
// Configurable extras: only Fellowship and Deaths present.
|
||||||
|
properties.Strings[10u] = "Fellows";
|
||||||
|
properties.Ints[43u] = 0;
|
||||||
|
var levels = new AppraiseInfoParser.ArmorLevel(
|
||||||
|
Head: 1, Chest: 0, Abdomen: 0,
|
||||||
|
UpperArm: 0, LowerArm: 0, Hand: 0,
|
||||||
|
UpperLeg: 0, LowerLeg: 0, Foot: 0);
|
||||||
|
|
||||||
|
IReadOnlyList<CreatureAppraisalRow> rows = CreatureAppraisalRows.BuildExtra(
|
||||||
|
properties, levels, character: true, localFactionBits: 0x1);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"Society:",
|
||||||
|
"Monarch:",
|
||||||
|
"Patron:",
|
||||||
|
"", // AL trio leading spacer
|
||||||
|
"Head/Chest/Groin",
|
||||||
|
"Bicep/Wrist/Hand",
|
||||||
|
"Thigh/Shin/Foot",
|
||||||
|
"", // ratings leading spacer
|
||||||
|
"Dmg/CritDmg",
|
||||||
|
"", // ratings trailing spacer
|
||||||
|
"Fellowship:",
|
||||||
|
"Deaths:",
|
||||||
|
"* = Unenchantable",
|
||||||
|
],
|
||||||
|
rows.Select(r => r.Label));
|
||||||
|
Assert.Equal(CreatureAppraisalValueStyle.Positive, rows[0].Style);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuthoredRowTemplateCarriesRetailOverlappingLabelValueGeometry()
|
public void AuthoredRowTemplateCarriesRetailOverlappingLabelValueGeometry()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue