feat(spells): Phase E.5 CastSpellRequest + Spellbook/enchantment state
Completes the client-side spell loop on top of Phase F.1. Player can
send cast requests; spellbook + active-enchantment state mirrors what
the server broadcasts.
Wire layer:
- CastSpellRequest (C→S, inside 0xF7B1 GameAction):
- BuildUntargeted (0x0048): self-buffs, recalls, heal-self — 16 bytes.
- BuildTargeted (0x004A): projectile attacks, target buffs/debuffs — 20 bytes.
- GameEvents parsers added:
- 0x02C1 MagicUpdateSpell: spell-id → spellbook.
- 0x01A8 MagicRemoveSpell.
- 0x02C2 MagicUpdateEnchantment: spellId + layerId + duration + casterGuid
(summary head; full stat-mod body deferred).
- 0x02C3 MagicRemoveEnchantment: (layerId, spellId).
- 0x02C7 MagicDispelEnchantment: same shape.
Core layer:
- Spellbook: learned-spell set + active-enchantment-by-layer dict
with events (SpellLearned, SpellForgotten, EnchantmentAdded,
EnchantmentRemoved). Duplicate learn is idempotent. Same-layer
add refreshes duration. Purge fires per-record remove for UI
cleanup.
- ActiveEnchantmentRecord: (SpellId, LayerId, Duration, CasterGuid).
Tests (10 new):
- CastSpellRequest untargeted (16 bytes) + targeted (20 bytes) wire encoding.
- GameEvents: MagicUpdateSpell, MagicUpdateEnchantment,
MagicRemoveEnchantment round-trip.
- Spellbook: learn idempotent, forget, add/refresh enchantment,
remove fires event, purge-all clears + fires per-record.
Build green, 555 tests pass (up from 544).
Ref: r01 §2 (wire casts), §3 (cast state machine), §5 (stacking rules).
Ref: r08 §3 opcodes 0x0048/0x004A, §4 opcodes 0x01A8/0x02C1-0x02C8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2e3f9d7a04
commit
c95aedcd4a
5 changed files with 396 additions and 0 deletions
78
tests/AcDream.Core.Net.Tests/Messages/CastSpellTests.cs
Normal file
78
tests/AcDream.Core.Net.Tests/Messages/CastSpellTests.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class CastSpellTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildUntargeted_EmitsCorrectWireBytes()
|
||||
{
|
||||
byte[] body = CastSpellRequest.BuildUntargeted(gameActionSequence: 5, spellId: 0x3E1);
|
||||
|
||||
Assert.Equal(16, body.Length);
|
||||
Assert.Equal(CastSpellRequest.GameActionEnvelope,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
||||
Assert.Equal(5u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
||||
Assert.Equal(CastSpellRequest.UntargetedSubOpcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0x3E1u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTargeted_EmitsCorrectWireBytes()
|
||||
{
|
||||
byte[] body = CastSpellRequest.BuildTargeted(
|
||||
gameActionSequence: 7, targetGuid: 0xAAAAu, spellId: 0x3E1);
|
||||
|
||||
Assert.Equal(20, body.Length);
|
||||
Assert.Equal(CastSpellRequest.TargetedSubOpcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0xAAAAu,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
Assert.Equal(0x3E1u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMagicUpdateSpell_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u);
|
||||
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMagicUpdateEnchantment_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u); // spellId
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 7u); // layerId
|
||||
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(8), 300.0f); // duration
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0xBEEFu); // caster
|
||||
|
||||
var parsed = GameEvents.ParseMagicUpdateEnchantment(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(42u, parsed!.Value.SpellId);
|
||||
Assert.Equal(7u, parsed.Value.LayerId);
|
||||
Assert.Equal(300f, parsed.Value.Duration, 4);
|
||||
Assert.Equal(0xBEEFu, parsed.Value.CasterGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMagicRemoveEnchantment_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 7u); // layerId
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 42u); // spellId
|
||||
|
||||
var parsed = GameEvents.ParseMagicRemoveEnchantment(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(7u, parsed!.Value.LayerId);
|
||||
Assert.Equal(42u, parsed.Value.SpellId);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue