acdream/tests/AcDream.Core.Net.Tests/Messages/AppraiseInfoParserTests.cs
Erik a9f366718d feat(net): AppraiseInfoParser — ArmorProfile/CreatureProfile/WeaponProfile + enchantment bitfields
Completes the deferred-in-previous-commit profile blob deserializers.
The AppraiseInfo wire format has 10 flags; previous commit handled the
6 property tables + SpellBook; this adds the 7 remaining structured
blobs:

- ArmorProfile: 8× f32 per-damage-type protection values
  (Slashing / Piercing / Bludgeoning / Cold / Fire / Acid / Nether /
  Lightning).
- ArmorLevel: 9× i32 per-body-part AL
  (Head / Chest / Abdomen / UpperArm / LowerArm / Hand / UpperLeg /
  LowerLeg / Foot).
- WeaponProfile: 10 mixed fields — u32 DamageType / WeaponTime /
  WeaponSkill / Damage, f64 DamageVariance / DamageMod / WeaponLength /
  MaxVelocity / WeaponOffense, u32 MaxVelocityEstimated.
- CreatureProfile: flag-gated — always u32 Flags + Health + HealthMax,
  optional 10× u32 attributes + vitals (flag 0x08 = ShowAttributes),
  optional 2× u16 highlight/color (flag 0x01 = HasBuffsDebuffs).
- Enchantment bitfields (ArmorEnchantmentBitfield /
  WeaponEnchantmentBitfield / ResistEnchantmentBitfield): each 2× u16
  (highlight, color).

HookProfile (flag 0x200) still deferred — needs its own structure port.

Parsed record expanded to carry all these; callers that previously
consumed PropertyBundle + SpellBook keep working, new fields are
nullable record-struct payloads.

Tests (+6): ArmorProfile round-trip, ArmorLevels, WeaponProfile with
mixed primitives, CreatureProfile with + without attributes flag,
ArmorEnchantment bitfield.

Build green, 172 Core.Net tests pass (up from 166).

Ref: ACE AppraiseInfo.cs:735-778 (writer), ArmorProfile.cs / ArmorLevel.cs /
WeaponProfile.cs / CreatureProfile.cs (structure writers).
Ref: r08 §4 opcode 0x00C9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:24:35 +02:00

325 lines
11 KiB
C#

using System;
using System.Buffers.Binary;
using System.IO;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class AppraiseInfoParserTests
{
/// <summary>
/// Build an AppraiseInfo payload matching ACE's wire format. Starts
/// with (guid, flags, success) then per-flag tables.
/// </summary>
private static byte[] BuildPayload(
uint guid,
AppraiseInfoParser.IdentifyResponseFlags flags,
bool success,
(uint key, int value)[]? ints = null,
(uint key, bool value)[]? bools = null,
(uint key, string value)[]? strings = null,
uint[]? spellBook = null)
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(guid);
bw.Write((uint)flags);
bw.Write((uint)(success ? 1 : 0));
if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable) && ints is not null)
{
bw.Write((ushort)ints.Length);
bw.Write((ushort)16); // numBuckets hint
foreach (var (k, v) in ints)
{
bw.Write(k);
bw.Write(v);
}
}
if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable) && bools is not null)
{
bw.Write((ushort)bools.Length);
bw.Write((ushort)8);
foreach (var (k, v) in bools)
{
bw.Write(k);
bw.Write(v ? 1u : 0u);
}
}
if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.StringStatsTable) && strings is not null)
{
bw.Write((ushort)strings.Length);
bw.Write((ushort)8);
foreach (var (k, v) in strings)
{
bw.Write(k);
WriteString16L(bw, v);
}
}
if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.SpellBook) && spellBook is not null)
{
bw.Write((uint)spellBook.Length);
foreach (var sid in spellBook) bw.Write(sid);
}
bw.Flush();
return ms.ToArray();
}
private static void WriteString16L(BinaryWriter bw, string s)
{
byte[] bytes = System.Text.Encoding.ASCII.GetBytes(s);
bw.Write((ushort)bytes.Length);
bw.Write(bytes);
int record = 2 + bytes.Length;
int pad = (4 - (record & 3)) & 3;
for (int i = 0; i < pad; i++) bw.Write((byte)0);
}
[Fact]
public void TryParse_GuidAndFlags_ExtractedCorrectly()
{
byte[] payload = BuildPayload(
guid: 0xDEADBEEFu,
flags: AppraiseInfoParser.IdentifyResponseFlags.None,
success: true);
var parsed = AppraiseInfoParser.TryParse(payload);
Assert.NotNull(parsed);
Assert.Equal(0xDEADBEEFu, parsed!.Value.Guid);
Assert.True(parsed.Value.Success);
}
[Fact]
public void TryParse_IntStatsTable_PopulatesIntProperties()
{
byte[] payload = BuildPayload(
guid: 1,
flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable,
success: true,
ints: new[] { ((uint)1, 100), ((uint)5, -50), ((uint)9, 42) });
var parsed = AppraiseInfoParser.TryParse(payload);
Assert.NotNull(parsed);
Assert.Equal(3, parsed!.Value.Properties.Ints.Count);
Assert.Equal(100, parsed.Value.Properties.Ints[1]);
Assert.Equal(-50, parsed.Value.Properties.Ints[5]);
Assert.Equal(42, parsed.Value.Properties.Ints[9]);
}
[Fact]
public void TryParse_BoolStatsTable_ConvertsU32ToBool()
{
byte[] payload = BuildPayload(
guid: 1,
flags: AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable,
success: true,
bools: new[] { ((uint)1, true), ((uint)2, false) });
var parsed = AppraiseInfoParser.TryParse(payload);
Assert.NotNull(parsed);
Assert.True(parsed!.Value.Properties.Bools[1]);
Assert.False(parsed.Value.Properties.Bools[2]);
}
[Fact]
public void TryParse_StringStatsTable_ParsesPaddedStrings()
{
byte[] payload = BuildPayload(
guid: 1,
flags: AppraiseInfoParser.IdentifyResponseFlags.StringStatsTable,
success: true,
strings: new[] { ((uint)1, "Excalibur"), ((uint)2, "Rusty Dagger") });
var parsed = AppraiseInfoParser.TryParse(payload);
Assert.NotNull(parsed);
Assert.Equal("Excalibur", parsed!.Value.Properties.Strings[1]);
Assert.Equal("Rusty Dagger", parsed.Value.Properties.Strings[2]);
}
[Fact]
public void TryParse_SpellBook_ReturnsSpellIdArray()
{
byte[] payload = BuildPayload(
guid: 1,
flags: AppraiseInfoParser.IdentifyResponseFlags.SpellBook,
success: true,
spellBook: new uint[] { 0x3E1, 0x3E2, 0x3E3 });
var parsed = AppraiseInfoParser.TryParse(payload);
Assert.NotNull(parsed);
Assert.Equal(3, parsed!.Value.SpellBook.Length);
Assert.Equal(0x3E1u, parsed.Value.SpellBook[0]);
}
[Fact]
public void TryParse_MultipleTables_AllParsed()
{
var flags = AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
| AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable
| AppraiseInfoParser.IdentifyResponseFlags.SpellBook;
byte[] payload = BuildPayload(
guid: 1, flags, success: true,
ints: new[] { ((uint)1, 100) },
bools: new[] { ((uint)2, true) },
spellBook: new uint[] { 42 });
var parsed = AppraiseInfoParser.TryParse(payload);
Assert.NotNull(parsed);
Assert.Single(parsed!.Value.Properties.Ints);
Assert.Single(parsed.Value.Properties.Bools);
Assert.Single(parsed.Value.SpellBook);
}
[Fact]
public void TryParse_Truncated_ReturnsNull()
{
Assert.Null(AppraiseInfoParser.TryParse(new byte[4]));
}
// ── Profile blobs ────────────────────────────────────────────────────────
[Fact]
public void TryParse_ArmorProfile_ParsesEightF32()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0u); // guid
bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.ArmorProfile);
bw.Write(1u); // success
// 8 float protections
bw.Write(0.1f); bw.Write(0.2f); bw.Write(0.3f); bw.Write(0.4f);
bw.Write(0.5f); bw.Write(0.6f); bw.Write(0.7f); bw.Write(0.8f);
var parsed = AppraiseInfoParser.TryParse(ms.ToArray());
Assert.NotNull(parsed);
Assert.NotNull(parsed!.Value.ArmorProfile);
var armor = parsed.Value.ArmorProfile!.Value;
Assert.Equal(0.1f, armor.SlashingProtection, 4);
Assert.Equal(0.8f, armor.LightningProtection, 4);
}
[Fact]
public void TryParse_ArmorLevels_NinePerBodyPart()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0u);
bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.ArmorLevels);
bw.Write(1u);
bw.Write(100); // Head
bw.Write(200); // Chest
bw.Write(150); // Abdomen
bw.Write(125); bw.Write(110); bw.Write(90);
bw.Write(140); bw.Write(130); bw.Write(80);
var parsed = AppraiseInfoParser.TryParse(ms.ToArray());
Assert.NotNull(parsed!.Value.ArmorLevels);
Assert.Equal(100, parsed.Value.ArmorLevels!.Value.Head);
Assert.Equal(200, parsed.Value.ArmorLevels.Value.Chest);
Assert.Equal(80, parsed.Value.ArmorLevels.Value.Foot);
}
[Fact]
public void TryParse_WeaponProfile_TenFieldsMixedPrimitives()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0u);
bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.WeaponProfile);
bw.Write(1u);
bw.Write(4u); // DamageType (Fire?)
bw.Write(30u); // WeaponTime
bw.Write(44u); // WeaponSkill
bw.Write(25u); // Damage
bw.Write(0.25); // DamageVariance (f64)
bw.Write(1.5); // DamageMod
bw.Write(1.0); // WeaponLength
bw.Write(2.0); // MaxVelocity
bw.Write(1.1); // WeaponOffense
bw.Write(5u); // MaxVelocityEstimated
var parsed = AppraiseInfoParser.TryParse(ms.ToArray());
Assert.NotNull(parsed!.Value.WeaponProfile);
var w = parsed.Value.WeaponProfile!.Value;
Assert.Equal(4u, w.DamageType);
Assert.Equal(25u, w.Damage);
Assert.Equal(1.5, w.DamageMod, 4);
Assert.Equal(5u, w.MaxVelocityEstimated);
}
[Fact]
public void TryParse_CreatureProfile_AttributesPresent_WhenShowAttributesFlag()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0u);
bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile);
bw.Write(1u);
bw.Write(0x08u); // flags: ShowAttributes only
bw.Write(500u); // Health
bw.Write(600u); // HealthMax
bw.Write(80u); // Str
bw.Write(70u); // End
bw.Write(65u); // Quic
bw.Write(60u); // Coord
bw.Write(100u); // Focus
bw.Write(100u); // Self
bw.Write(200u); // Stamina
bw.Write(150u); // Mana
bw.Write(250u); // StaminaMax
bw.Write(200u); // ManaMax
var parsed = AppraiseInfoParser.TryParse(ms.ToArray());
Assert.NotNull(parsed!.Value.CreatureProfile);
var c = parsed.Value.CreatureProfile!.Value;
Assert.Equal(0x08u, c.Flags);
Assert.Equal(500u, c.Health);
Assert.Equal(600u, c.HealthMax);
Assert.Equal((uint?)80u, c.Strength);
Assert.Equal((uint?)150u, c.Mana);
}
[Fact]
public void TryParse_CreatureProfile_NoAttributesFlag_OnlyHealth()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0u);
bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile);
bw.Write(1u);
bw.Write(0u); // flags = 0
bw.Write(300u); // Health
bw.Write(400u); // HealthMax
var parsed = AppraiseInfoParser.TryParse(ms.ToArray());
Assert.NotNull(parsed!.Value.CreatureProfile);
var c = parsed.Value.CreatureProfile!.Value;
Assert.Null(c.Strength);
Assert.Null(c.AttributeHighlights);
Assert.Equal(300u, c.Health);
}
[Fact]
public void TryParse_ArmorEnchantmentBitfield_HighlightAndColor()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0u);
bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.ArmorEnchantmentBitfield);
bw.Write(1u);
bw.Write((ushort)0x00FF);
bw.Write((ushort)0x0042);
var parsed = AppraiseInfoParser.TryParse(ms.ToArray());
Assert.NotNull(parsed!.Value.ArmorEnchantments);
Assert.Equal((ushort)0x00FF, parsed.Value.ArmorEnchantments!.Value.Highlight);
Assert.Equal((ushort)0x0042, parsed.Value.ArmorEnchantments.Value.Color);
}
}