feat(char): character progression actions — Raise / Train / CombatMode

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-18 17:19:31 +02:00
parent 68efb60b49
commit d461279207
2 changed files with 139 additions and 0 deletions

View file

@ -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)));
}
}