acdream/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs
Erik b7dc91a053 feat(ui): D.2b item interaction + retail cursors + live character sheet
Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02
UI architecture review mandated before commit:

- ItemInteractionController: single owner of double-click use/equip/
  container-open, targeted-use mode (health kits), drag-out drop;
  toolbar shortcut drags don't drop the real item. ItemEquipRules for
  multi-slot (coat) coverage via equip masks.
- Cursor phase: CursorFeedbackController (semantic priority chain:
  drag > resize > window-move > target-mode > text) + RetailCursorCatalog
  (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState
  0x00564630) resolved through the portal EnumIDMap chain by
  RetailCursorResolver; RetailCursorManager applies dat cursor art to the
  OS cursor. Register row AP-72 covers the OS standard-cursor fallback.
- Character window goes live: CharacterSheetProvider owns sheet assembly,
  XP-curve/raise-cost math and the raise flow — extracted out of
  GameWindow per Code Structure Rule 1 instead of committing the ~430-line
  feature body there. Optimistic XP/credit debits go through eventful
  store APIs (new ClientObjectTable.UpdateInt64Property +
  LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw
  property-dictionary writes; register row AP-73 covers the still-missing
  raise ledger (#163).
- RetailWindowFrame: the shared nine-slice window mount recipe; the
  character window uses it, remaining windows migrate via #164.
- Status-bar buttons toggle inventory/character windows; retail row-major
  backpack ordering; WorldSession.SendUseWithTarget + raise/train sends.

GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the
sheet/raise logic is unit-tested in CharacterSheetProviderTests instead
of trapped in the god object. Build green; full suite 3,286 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:18:43 +02:00

226 lines
8.4 KiB
C#

using System;
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class GameEventDispatcherTests
{
// Helper: build a 0xF7B0 envelope with given sub-opcode + payload bytes.
private static byte[] MakeEnvelope(GameEventType type, ReadOnlySpan<byte> payload,
uint playerGuid = 0x12345678u, uint sequence = 0)
{
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
var span = body.AsSpan();
BinaryPrimitives.WriteUInt32LittleEndian(span, GameEventEnvelope.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4), playerGuid);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12), (uint)type);
payload.CopyTo(span.Slice(GameEventEnvelope.HeaderSize));
return body;
}
[Fact]
public void TryParse_ValidEnvelope_ReturnsFields()
{
byte[] body = MakeEnvelope(GameEventType.PlayerDescription,
payload: new byte[8],
playerGuid: 0xAABBCCDD, sequence: 42);
var env = GameEventEnvelope.TryParse(body);
Assert.NotNull(env);
Assert.Equal(0xAABBCCDDu, env!.Value.PlayerGuid);
Assert.Equal(42u, env.Value.Sequence);
Assert.Equal(GameEventType.PlayerDescription, env.Value.EventType);
Assert.Equal(8, env.Value.Payload.Length);
}
[Fact]
public void TryParse_WrongOuterOpcode_ReturnsNull()
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
Assert.Null(GameEventEnvelope.TryParse(body));
}
[Fact]
public void TryParse_TooShort_ReturnsNull()
{
Assert.Null(GameEventEnvelope.TryParse(new byte[10]));
}
[Fact]
public void Dispatch_CallsHandler_ForRegisteredType()
{
var dispatcher = new GameEventDispatcher();
GameEventEnvelope received = default;
int calls = 0;
dispatcher.Register(GameEventType.Tell, env => { received = env; calls++; });
var body = MakeEnvelope(GameEventType.Tell, new byte[4]);
var env = GameEventEnvelope.TryParse(body);
dispatcher.Dispatch(env!.Value);
Assert.Equal(1, calls);
Assert.Equal(GameEventType.Tell, received.EventType);
}
[Fact]
public void Dispatch_Unhandled_CountedButNoThrow()
{
var dispatcher = new GameEventDispatcher();
var body = MakeEnvelope(GameEventType.HouseProfile, Array.Empty<byte>());
var env = GameEventEnvelope.TryParse(body);
dispatcher.Dispatch(env!.Value);
dispatcher.Dispatch(env.Value);
Assert.Equal(2, dispatcher.GetUnhandledCount(GameEventType.HouseProfile));
}
[Fact]
public void Dispatch_HandlerThrows_DoesNotPropagate()
{
var dispatcher = new GameEventDispatcher();
dispatcher.Register(GameEventType.Tell, _ => throw new InvalidOperationException("bad handler"));
var body = MakeEnvelope(GameEventType.Tell, Array.Empty<byte>());
// Should not throw; error is logged instead.
dispatcher.Dispatch(GameEventEnvelope.TryParse(body)!.Value);
}
[Fact]
public void Unregister_RemovesHandler_AndMovesToUnhandled()
{
var dispatcher = new GameEventDispatcher();
int calls = 0;
dispatcher.Register(GameEventType.Tell, _ => calls++);
dispatcher.Unregister(GameEventType.Tell);
var body = MakeEnvelope(GameEventType.Tell, Array.Empty<byte>());
dispatcher.Dispatch(GameEventEnvelope.TryParse(body)!.Value);
Assert.Equal(0, calls);
Assert.Equal(1, dispatcher.GetUnhandledCount(GameEventType.Tell));
}
// ── Per-event parser tests ───────────────────────────────────────────────
private static byte[] MakeString16L(string s)
{
byte[] data = Encoding.ASCII.GetBytes(s);
int recordSize = 2 + data.Length;
int padding = (4 - (recordSize & 3)) & 3;
byte[] result = new byte[recordSize + padding];
BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
Array.Copy(data, 0, result, 2, data.Length);
return result;
}
[Fact]
public void ParseChannelBroadcast_RoundTrip()
{
// u32 channelId + string16L sender + string16L message
byte[] chan = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(chan, 42);
byte[] sender = MakeString16L("Hero");
byte[] msg = MakeString16L("hello allegiance");
byte[] payload = new byte[chan.Length + sender.Length + msg.Length];
Buffer.BlockCopy(chan, 0, payload, 0, chan.Length);
Buffer.BlockCopy(sender, 0, payload, chan.Length, sender.Length);
Buffer.BlockCopy(msg, 0, payload, chan.Length + sender.Length, msg.Length);
var parsed = GameEvents.ParseChannelBroadcast(payload);
Assert.NotNull(parsed);
Assert.Equal(42u, parsed!.Value.ChannelId);
Assert.Equal("Hero", parsed.Value.SenderName);
Assert.Equal("hello allegiance", parsed.Value.Message);
}
[Fact]
public void ParseTell_RoundTrip()
{
byte[] msg = MakeString16L("hi");
byte[] sender = MakeString16L("Alice");
byte[] tail = new byte[12]; // u32 senderGuid + u32 targetGuid + u32 chatType
BinaryPrimitives.WriteUInt32LittleEndian(tail, 0xAAu);
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 0xBBu);
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(8), 1u);
byte[] payload = new byte[msg.Length + sender.Length + tail.Length];
Buffer.BlockCopy(msg, 0, payload, 0, msg.Length);
Buffer.BlockCopy(sender, 0, payload, msg.Length, sender.Length);
Buffer.BlockCopy(tail, 0, payload, msg.Length + sender.Length, tail.Length);
var parsed = GameEvents.ParseTell(payload);
Assert.NotNull(parsed);
Assert.Equal("hi", parsed!.Value.Message);
Assert.Equal("Alice", parsed.Value.SenderName);
Assert.Equal(0xAAu, parsed.Value.SenderGuid);
Assert.Equal(0xBBu, parsed.Value.TargetGuid);
Assert.Equal(1u, parsed.Value.ChatType);
}
[Fact]
public void ParseUpdateHealth_RoundTrip()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xC0DEu);
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.42f);
var parsed = GameEvents.ParseUpdateHealth(payload);
Assert.NotNull(parsed);
Assert.Equal(0xC0DEu, parsed!.Value.TargetGuid);
Assert.Equal(0.42f, parsed.Value.HealthPercent, 4);
}
[Fact]
public void ParseWieldObject_acceptsAceEightBytePayload()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.ChestArmor);
var parsed = GameEvents.ParseWieldObject(payload);
Assert.NotNull(parsed);
Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid);
Assert.Equal((uint)EquipMask.ChestArmor, parsed.Value.EquipLoc);
Assert.Equal(0u, parsed.Value.WielderGuid);
}
[Fact]
public void ParseWeenieError_RoundTrip()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
Assert.Equal(42u, GameEvents.ParseWeenieError(payload));
}
[Fact]
public void ParseTransient_RoundTrip()
{
byte[] msg = MakeString16L("Your spell fizzled!");
byte[] chatType = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(chatType, 5u);
byte[] payload = new byte[msg.Length + 4];
Buffer.BlockCopy(msg, 0, payload, 0, msg.Length);
Buffer.BlockCopy(chatType, 0, payload, msg.Length, 4);
var parsed = GameEvents.ParseTransient(payload);
Assert.NotNull(parsed);
Assert.Equal("Your spell fizzled!", parsed!.Value.Message);
Assert.Equal(5u, parsed.Value.ChatType);
}
[Fact]
public void ParseMagicUpdateSpell_RoundTrip()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u); // Flame Bolt I
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
}
}