From d461279207f8e23a3e221958803acd02fffc5763 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 17:19:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(char):=20character=20progression=20actions?= =?UTF-8?q?=20=E2=80=94=20Raise=20/=20Train=20/=20CombatMode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outbound GameActions for XP-spending + combat-mode-change. These complete the wire surface for the character-sheet UI: the player clicks "spend XP on Strength," the panel calls BuildRaiseAttribute, the session sends it, the server responds with updated PlayerDescription or PrivateUpdateAttribute GameEvents. Wire layer: - BuildRaiseAttribute (0x0045): attrId u32, xpSpent u64. - BuildRaiseVital (0x0044): vitalId u32, xpSpent u64. - BuildRaiseSkill (0x0046): skillId u32, xpSpent u64. - BuildTrainSkill (0x0047): skillId u32, credits u32 (note: credits is u32 here, NOT u64 like the xpSpent variants). - BuildChangeCombatMode (0x0053): mode enum as u32 (Undef=0, NonCombat=1, Melee=2, Missile=3, Magic=4, Peaceful=5). Tests (5 new): byte-exact encoding of each, including the Train/ Raise size difference due to u32 vs u64 payloads. Build green, 621 tests pass (up from 616). Ref: r08 §3 rows 0x0044 / 0x0045 / 0x0046 / 0x0047 / 0x0053. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Messages/CharacterActions.cs | 79 +++++++++++++++++++ .../Messages/CharacterActionsTests.cs | 60 ++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/CharacterActions.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs diff --git a/src/AcDream.Core.Net/Messages/CharacterActions.cs b/src/AcDream.Core.Net/Messages/CharacterActions.cs new file mode 100644 index 0000000..0da9505 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterActions.cs @@ -0,0 +1,79 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Outbound character-progression + mode-change GameActions. Grouped +/// together because they share the same 0xF7B1 envelope + +/// single-opcode dispatch pattern and all target the session's player +/// (no target guid field). +/// +/// +/// References: r08 §3 rows 0x0044 / 0x0045 / 0x0046 / 0x0047 / 0x0053. +/// +/// +public static class CharacterActions +{ + public const uint GameActionEnvelope = 0xF7B1u; + + public const uint RaiseAttributeOpcode = 0x0045u; // u32 attr, u64 xpSpent + public const uint RaiseVitalOpcode = 0x0044u; // u32 vital, u64 xpSpent + public const uint RaiseSkillOpcode = 0x0046u; // u32 skillId, u64 xpSpent + public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits + public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode + + public enum CombatMode : uint + { + Undef = 0, NonCombat = 1, Melee = 2, Missile = 3, Magic = 4, Peaceful = 5, + } + + /// Spend XP to raise an attribute (Strength, Endurance, etc). + public static byte[] BuildRaiseAttribute(uint seq, uint attrId, ulong xpSpent) + => BuildAttrOrVital(seq, RaiseAttributeOpcode, attrId, xpSpent); + + /// Spend XP to raise a vital (Health, Stamina, Mana). + public static byte[] BuildRaiseVital(uint seq, uint vitalId, ulong xpSpent) + => BuildAttrOrVital(seq, RaiseVitalOpcode, vitalId, xpSpent); + + /// Spend XP to raise a skill. + public static byte[] BuildRaiseSkill(uint seq, uint skillId, ulong xpSpent) + => BuildAttrOrVital(seq, RaiseSkillOpcode, skillId, xpSpent); + + /// Spend skill credits to train (unlock) a skill. + public static byte[] BuildTrainSkill(uint seq, uint skillId, uint credits) + { + byte[] body = new byte[20]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TrainSkillOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), skillId); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), credits); + return body; + } + + /// + /// Change combat mode. Peace ↔ Melee / Missile / Magic toggles that + /// switch stance + weapon ready-state. The animation + weapon + /// wield broadcasts are server-driven from this message. + /// + public static byte[] BuildChangeCombatMode(uint seq, CombatMode mode) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), ChangeCombatModeOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)mode); + return body; + } + + private static byte[] BuildAttrOrVital(uint seq, uint sub, uint id, ulong xp) + { + byte[] body = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), id); + BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(16), xp); + return body; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs new file mode 100644 index 0000000..979aeaa --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class CharacterActionsTests +{ + [Fact] + public void BuildRaiseAttribute_HasOpcode0x0045AndXp64() + { + byte[] body = CharacterActions.BuildRaiseAttribute(seq: 1, attrId: 5, xpSpent: 12345678); + Assert.Equal(24, body.Length); + Assert.Equal(CharacterActions.RaiseAttributeOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(5u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(12345678u, + BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(16))); + } + + [Fact] + public void BuildRaiseVital_HasOpcode0x0044() + { + byte[] body = CharacterActions.BuildRaiseVital(seq: 1, vitalId: 1, xpSpent: 100); + Assert.Equal(CharacterActions.RaiseVitalOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + } + + [Fact] + public void BuildRaiseSkill_HasOpcode0x0046() + { + byte[] body = CharacterActions.BuildRaiseSkill(seq: 1, skillId: 8, xpSpent: 500); + Assert.Equal(CharacterActions.RaiseSkillOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + } + + [Fact] + public void BuildTrainSkill_U32CreditsNotU64() + { + byte[] body = CharacterActions.BuildTrainSkill(seq: 1, skillId: 8, credits: 4); + Assert.Equal(20, body.Length); // 12 + 4 + 4 vs 16 for 4+8 + Assert.Equal(CharacterActions.TrainSkillOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(4u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); + } + + [Fact] + public void BuildChangeCombatMode_EnumSerialises() + { + byte[] body = CharacterActions.BuildChangeCombatMode( + seq: 1, CharacterActions.CombatMode.Melee); + Assert.Equal(CharacterActions.ChangeCombatModeOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(2u, // Melee = 2 + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } +}