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>
93 lines
2.5 KiB
C#
93 lines
2.5 KiB
C#
using AcDream.Core.Spells;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Spells;
|
|
|
|
public sealed class SpellbookTests
|
|
{
|
|
[Fact]
|
|
public void OnSpellLearned_FiresEvent_Idempotent()
|
|
{
|
|
var book = new Spellbook();
|
|
int events = 0;
|
|
book.SpellLearned += _ => events++;
|
|
|
|
book.OnSpellLearned(0x3E1); // Flame Bolt I
|
|
book.OnSpellLearned(0x3E1); // duplicate — no event
|
|
|
|
Assert.Equal(1, events);
|
|
Assert.Equal(1, book.LearnedCount);
|
|
Assert.True(book.Knows(0x3E1));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSpellForgotten_FiresEvent()
|
|
{
|
|
var book = new Spellbook();
|
|
book.OnSpellLearned(0x3E1);
|
|
|
|
uint seen = 0;
|
|
book.SpellForgotten += id => seen = id;
|
|
|
|
book.OnSpellForgotten(0x3E1);
|
|
Assert.Equal(0x3E1u, seen);
|
|
Assert.False(book.Knows(0x3E1));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnEnchantmentAdded_FiresEvent_StoresByLayer()
|
|
{
|
|
var book = new Spellbook();
|
|
ActiveEnchantmentRecord added = default;
|
|
book.EnchantmentAdded += r => added = r;
|
|
|
|
book.OnEnchantmentAdded(spellId: 42, layerId: 7, duration: 300f, casterGuid: 0xBEEF);
|
|
|
|
Assert.Equal(42u, added.SpellId);
|
|
Assert.Equal(7u, added.LayerId);
|
|
Assert.Equal(300f, added.Duration, 4);
|
|
Assert.Equal(1, book.ActiveCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnEnchantmentAdded_SameLayer_Replaces()
|
|
{
|
|
var book = new Spellbook();
|
|
book.OnEnchantmentAdded(42, 7, 300f, 0);
|
|
book.OnEnchantmentAdded(42, 7, 600f, 0); // refresh
|
|
|
|
Assert.Equal(1, book.ActiveCount);
|
|
foreach (var e in book.ActiveEnchantments)
|
|
Assert.Equal(600f, e.Duration, 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnEnchantmentRemoved_FiresEvent()
|
|
{
|
|
var book = new Spellbook();
|
|
book.OnEnchantmentAdded(42, 7, 300f, 0);
|
|
|
|
uint removedSpell = 0;
|
|
book.EnchantmentRemoved += r => removedSpell = r.SpellId;
|
|
|
|
book.OnEnchantmentRemoved(7, 42);
|
|
Assert.Equal(42u, removedSpell);
|
|
Assert.Equal(0, book.ActiveCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnPurgeAll_RemovesAllEnchantments_FiresPerRecord()
|
|
{
|
|
var book = new Spellbook();
|
|
book.OnEnchantmentAdded(1, 1, 100f, 0);
|
|
book.OnEnchantmentAdded(2, 2, 100f, 0);
|
|
book.OnEnchantmentAdded(3, 3, 100f, 0);
|
|
|
|
int removals = 0;
|
|
book.EnchantmentRemoved += _ => removals++;
|
|
|
|
book.OnPurgeAll();
|
|
Assert.Equal(3, removals);
|
|
Assert.Equal(0, book.ActiveCount);
|
|
}
|
|
}
|