Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks. Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state. Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration. Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates. Co-Authored-By: Codex <noreply@openai.com>
243 lines
9.1 KiB
C#
243 lines
9.1 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 ParseQueryItemManaResponse_RoundTripIncludingValidity()
|
|
{
|
|
// CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0 reads all three fields.
|
|
byte[] payload = new byte[12];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
|
|
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.625f);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u);
|
|
|
|
var parsed = GameEvents.ParseQueryItemManaResponse(payload);
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid);
|
|
Assert.Equal(0.625f, parsed.Value.ManaPercent);
|
|
Assert.True(parsed.Value.Valid);
|
|
Assert.Null(GameEvents.ParseQueryItemManaResponse(payload.AsSpan(0, 8)));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|