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>
1067 lines
45 KiB
C#
1067 lines
45 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.IO;
|
|
using System.Text;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Player;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Core.Social;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Net.Tests;
|
|
|
|
public sealed class GameEventWiringTests
|
|
{
|
|
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;
|
|
}
|
|
|
|
private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
|
|
{
|
|
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)type);
|
|
Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
|
|
return body;
|
|
}
|
|
|
|
private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll(Func<uint>? playerGuid = null)
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, playerGuid: playerGuid);
|
|
return (dispatcher, items, combat, spellbook, chat);
|
|
}
|
|
|
|
|
|
[Fact]
|
|
public void WireAll_ChannelBroadcast_RoutesToChatLog()
|
|
{
|
|
var (d, _, _, _, chat) = MakeAll();
|
|
|
|
byte[] payload = new byte[4 + MakeString16L("Alice").Length + MakeString16L("hi").Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42);
|
|
int p = 4;
|
|
var senderBytes = MakeString16L("Alice");
|
|
Array.Copy(senderBytes, 0, payload, p, senderBytes.Length); p += senderBytes.Length;
|
|
var msgBytes = MakeString16L("hi");
|
|
Array.Copy(msgBytes, 0, payload, p, msgBytes.Length);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ChannelBroadcast, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(1, chat.Count);
|
|
var entry = chat.Snapshot()[0];
|
|
Assert.Equal(ChatKind.Channel, entry.Kind);
|
|
Assert.Equal("Alice", entry.Sender);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_UpdateHealth_RoutesToCombatState()
|
|
{
|
|
var (d, _, combat, _, _) = MakeAll();
|
|
|
|
byte[] payload = new byte[8];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xCAFE);
|
|
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.42f);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UpdateHealth, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(0.42f, combat.GetHealthPercent(0xCAFE), 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_MagicUpdateSpell_RoutesToSpellbook()
|
|
{
|
|
var (d, _, _, book, _) = MakeAll();
|
|
|
|
byte[] payload = new byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicUpdateSpell, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.True(book.Knows(0x3E1));
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_WieldObject_RoutesToClientObjectTable()
|
|
{
|
|
const uint player = 0x2000u;
|
|
var (d, items, _, _, _) = MakeAll(() => player);
|
|
items.AddOrUpdate(new ClientObject { ObjectId = 0x1000, WeenieClassId = 1 });
|
|
|
|
byte[] payload = new byte[8];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
var item = items.Get(0x1000);
|
|
Assert.NotNull(item);
|
|
Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation);
|
|
Assert.Equal(0u, item.ContainerId);
|
|
Assert.Equal(player, item.WielderId);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_WieldObject_ConfirmsOptimisticWield()
|
|
{
|
|
const uint player = 0x2000u;
|
|
var (d, items, _, _, _) = MakeAll(() => player);
|
|
items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 });
|
|
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
|
|
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
|
|
var wieldConfirmed = new List<uint>();
|
|
items.WieldConfirmed += itemId => wieldConfirmed.Add(itemId);
|
|
|
|
byte[] payload = new byte[8];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
|
|
d.Dispatch(env!.Value); // server confirms the wield
|
|
|
|
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
|
|
Assert.Equal(0u, items.Get(0x1500)!.ContainerId);
|
|
Assert.Equal(player, items.Get(0x1500)!.WielderId);
|
|
Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_ConfirmationRequest_UsesRetailTwoIntegerHeader()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
GameEvents.CharacterConfirmationRequest? received = null;
|
|
GameEventWiring.WireAll(
|
|
dispatcher,
|
|
items,
|
|
combat,
|
|
spellbook,
|
|
chat,
|
|
onConfirmationRequest: request => received = request);
|
|
|
|
byte[] text = MakeString16L("Proceed?");
|
|
byte[] payload = new byte[8 + text.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 7u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 42u);
|
|
text.CopyTo(payload.AsSpan(8));
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(
|
|
GameEventType.CharacterConfirmationRequest,
|
|
payload));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(new GameEvents.CharacterConfirmationRequest(7u, 42u, "Proceed?"), received);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_ConfirmationDone_UsesRetailTypeAndContextTuple()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
GameEvents.CharacterConfirmationDone? received = null;
|
|
GameEventWiring.WireAll(
|
|
dispatcher,
|
|
new ClientObjectTable(),
|
|
new CombatState(),
|
|
new Spellbook(),
|
|
new ChatLog(),
|
|
onConfirmationDone: done => received = done);
|
|
byte[] payload = new byte[8];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 6u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 314u);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(
|
|
GameEventType.CharacterConfirmationDone,
|
|
payload));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(new GameEvents.CharacterConfirmationDone(6u, 314u), received);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_FriendsUpdate_ReplacesAuthoritativeSocialState()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var friends = new FriendsState();
|
|
GameEventWiring.WireAll(
|
|
dispatcher,
|
|
new ClientObjectTable(),
|
|
new CombatState(),
|
|
new Spellbook(),
|
|
new ChatLog(),
|
|
friends: friends);
|
|
|
|
byte[] name = MakeString16L("Alice");
|
|
byte[] payload = new byte[4 + 12 + name.Length + 4 + 4 + 4];
|
|
int p = 0;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 1u); p += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0x50000001u); p += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 1u); p += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4;
|
|
name.CopyTo(payload.AsSpan(p)); p += name.Length;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(
|
|
payload.AsSpan(p), (uint)FriendsUpdateType.Full);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(
|
|
GameEventType.FriendsListUpdate, payload));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
FriendEntry friend = Assert.Single(friends.Snapshot());
|
|
Assert.Equal("Alice", friend.Name);
|
|
Assert.True(friend.Online);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_PopupString_RoutesToChatLog()
|
|
{
|
|
var (d, _, _, _, chat) = MakeAll();
|
|
|
|
byte[] payload = MakeString16L("A modal message");
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PopupString, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(1, chat.Count);
|
|
Assert.Equal(ChatKind.Popup, chat.Snapshot()[0].Kind);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("", "2d 4h", "You have played for 2d 4h.")]
|
|
[InlineData("Alice", "1y 2mo", "Alice has played for 1y 2mo.")]
|
|
public void WireAll_QueryAgeResponse_UsesRetailWording(
|
|
string name, string age, string expected)
|
|
{
|
|
var (d, _, _, _, chat) = MakeAll();
|
|
byte[] nameBytes = MakeString16L(name);
|
|
byte[] ageBytes = MakeString16L(age);
|
|
byte[] payload = [.. nameBytes, .. ageBytes];
|
|
|
|
var env = GameEventEnvelope.TryParse(
|
|
WrapEnvelope(GameEventType.QueryAgeResponse, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(expected, chat.Snapshot().Single().Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_PlayerDescription_PopulatesLocalPlayerStateVitals()
|
|
{
|
|
// Issue #5 — the full pipeline: synthetic 0xF7B0 envelope wrapping
|
|
// a PlayerDescription body with Health/Stam/Mana entries, dispatched
|
|
// through WireAll, lands in LocalPlayerState with the right
|
|
// ranks/start/current values.
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
var local = new LocalPlayerState();
|
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local);
|
|
|
|
// Body: empty property tables, ATTRIBUTE vector flag, all 9 attrs
|
|
// present. Primary attrs:
|
|
// Endurance (id=2): ranks=50 + start=150 → current=200
|
|
// Self (id=6): ranks=50 + start=50 → current=100
|
|
// Vitals (ranks+start = 0 — typical retail values):
|
|
// Health (id=7) cur=90 → MaxApprox = 0 + 200/2 = 100 → percent 0.9
|
|
// Stamina (id=8) cur=140 → MaxApprox = 0 + 200 = 200 → percent 0.7
|
|
// Mana (id=9) cur=50 → MaxApprox = 0 + 100 = 100 → percent 0.5
|
|
byte[] body = new byte[140];
|
|
int p = 0;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // propertyFlags
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x52u); p += 4; // weenieType
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x01u); p += 4; // vectorFlags = ATTRIBUTE
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // has_health
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1FFu); p += 4; // attribute_flags = Full
|
|
|
|
// Primary attrs in order 1..6.
|
|
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 1 Strength
|
|
WritePrimaryAttr(body, ref p, ranks: 50, start: 150, xp: 0); // 2 Endurance — current=200
|
|
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 3 Quickness
|
|
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 4 Coordination
|
|
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 5 Focus
|
|
WritePrimaryAttr(body, ref p, ranks: 50, start: 50, xp: 0); // 6 Self — current=100
|
|
// Vitals 7/8/9.
|
|
WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 90); // Health
|
|
WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 140); // Stamina
|
|
WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 50); // Mana
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, body));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
var health = local.Get(LocalPlayerState.VitalKind.Health);
|
|
var stam = local.Get(LocalPlayerState.VitalKind.Stamina);
|
|
var mana = local.Get(LocalPlayerState.VitalKind.Mana);
|
|
Assert.NotNull(health);
|
|
Assert.NotNull(stam);
|
|
Assert.NotNull(mana);
|
|
Assert.Equal(90u, health!.Value.Current);
|
|
Assert.Equal(140u, stam!.Value.Current);
|
|
Assert.Equal(50u, mana!.Value.Current);
|
|
// Primary attrs landed too — formula contributions feed the max.
|
|
Assert.Equal(200u, local.GetAttribute(LocalPlayerState.AttributeKind.Endurance)!.Value.Current);
|
|
Assert.Equal(100u, local.GetAttribute(LocalPlayerState.AttributeKind.Self)!.Value.Current);
|
|
Assert.Equal(0.9f, local.HealthPercent!.Value, precision: 3);
|
|
Assert.Equal(0.7f, local.StaminaPercent!.Value, precision: 3);
|
|
Assert.Equal(0.5f, local.ManaPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_PlayerDescription_PopulatesLocalPlayerStateSkills()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
var local = new LocalPlayerState();
|
|
int callbackRun = -1;
|
|
|
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local,
|
|
onSkillsUpdated: (run, _) => callbackRun = run,
|
|
resolveSkillFormulaBonus: (skillId, attrs) =>
|
|
{
|
|
Assert.Equal(24u, skillId);
|
|
Assert.Equal(50u, attrs[1u]);
|
|
return 80u;
|
|
});
|
|
|
|
var sb = new MemoryStream();
|
|
using var w = new BinaryWriter(sb);
|
|
w.Write(0u); // propertyFlags
|
|
w.Write(0x52u); // weenieType
|
|
w.Write(0x03u); // vectorFlags = Attribute | Skill
|
|
w.Write(0u); // has_health = false
|
|
w.Write(0x01u); // attribute_flags = Strength only
|
|
w.Write(0u); // Strength ranks
|
|
w.Write(50u); // Strength start
|
|
w.Write(0u); // Strength xp
|
|
|
|
w.Write((ushort)1); // skill count
|
|
w.Write((ushort)8); // buckets
|
|
w.Write(24u); // Run
|
|
w.Write((ushort)12);
|
|
w.Write((ushort)1); // const_one
|
|
w.Write(2u); // trained
|
|
w.Write(3456u); // xp
|
|
w.Write(30u); // init
|
|
w.Write(0u); // resistance
|
|
w.Write(1.5); // last used
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
var run = local.GetSkill(24u);
|
|
Assert.NotNull(run);
|
|
Assert.Equal(122u, run!.Value.CurrentLevel);
|
|
Assert.Equal(2u, run.Value.Status);
|
|
Assert.Equal(3456u, run.Value.Xp);
|
|
Assert.Equal(122, callbackRun);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_PlayerDescription_FeedsSpellbook()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
var local = new LocalPlayerState();
|
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local);
|
|
|
|
// Body: SPELL vector flag + a spell table with 2 entries.
|
|
var sb = new MemoryStream();
|
|
using var w = new BinaryWriter(sb);
|
|
w.Write(0u); // propertyFlags
|
|
w.Write(0x52u); // weenieType
|
|
w.Write(0x100u); // vectorFlags = SPELL only
|
|
w.Write(0u); // has_health = false
|
|
w.Write((ushort)2); // spell count
|
|
w.Write((ushort)64); // buckets
|
|
w.Write(0x3E1u); w.Write(2.0f);
|
|
w.Write(0x3E2u); w.Write(2.0f);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
Assert.True(spellbook.Knows(0x3E1u));
|
|
Assert.True(spellbook.Knows(0x3E2u));
|
|
}
|
|
|
|
private static void WriteVitalBlock(byte[] body, ref int p, uint ranks, uint start, uint xp, uint current)
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), ranks); p += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), start); p += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), current); p += 4;
|
|
}
|
|
|
|
private static void WritePrimaryAttr(byte[] body, ref int p, uint ranks, uint start, uint xp)
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), ranks); p += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), start); p += 4;
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4;
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_KillerNotification_AppendsCombatLine()
|
|
{
|
|
var (d, _, _, _, chat) = MakeAll();
|
|
byte[] payload = MakeString16L("You killed the drudge!");
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(1, chat.Count);
|
|
var entry = chat.Snapshot()[0];
|
|
Assert.Equal(ChatKind.Combat, entry.Kind);
|
|
Assert.Equal(CombatLineKind.Info, entry.CombatKind);
|
|
Assert.Equal("You killed the drudge!", entry.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_CombatCommenceAttack_FiresCombatStateEvent()
|
|
{
|
|
var (d, _, combat, _, _) = MakeAll();
|
|
bool commenced = false;
|
|
combat.AttackCommenced += () => commenced = true;
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.CombatCommenceAttack, Array.Empty<byte>()));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.True(commenced);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
|
|
{
|
|
var (d, _, _, book, _) = MakeAll();
|
|
book.OnEnchantmentAdded(1, 1, 100f, 0);
|
|
book.OnEnchantmentAdded(2, 2, 100f, 0);
|
|
Assert.Equal(2, book.ActiveCount);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicPurgeEnchantments, Array.Empty<byte>()));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(0, book.ActiveCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_WeenieError_RoutesToChatLog()
|
|
{
|
|
// Phase I.5: 0x028A previously had a parser
|
|
// (GameEvents.ParseWeenieError) but no dispatcher registration. The
|
|
// server fires this for plain game-logic failures (e.g. "you can't
|
|
// pick that up"). Now wired → ChatLog.OnWeenieError.
|
|
var (d, _, _, _, chat) = MakeAll();
|
|
|
|
byte[] payload = new byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x9C); // arbitrary error code
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(1, chat.Count);
|
|
var e = chat.Snapshot()[0];
|
|
Assert.Equal(ChatKind.System, e.Kind);
|
|
Assert.Equal(0x9Cu, e.ChannelId);
|
|
Assert.Contains("0x009C", e.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_WeenieErrorWithString_RoutesToChatLogWithInterpolation()
|
|
{
|
|
// Phase I.5: 0x028B carries an interpolated substring (e.g. the
|
|
// target's name in "you can't pick up the {Mana Stone}"). Now
|
|
// wired → ChatLog.OnWeenieError with the param.
|
|
var (d, _, _, _, chat) = MakeAll();
|
|
|
|
byte[] interpBytes = MakeString16L("Mana Stone");
|
|
byte[] payload = new byte[4 + interpBytes.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x42u);
|
|
Array.Copy(interpBytes, 0, payload, 4, interpBytes.Length);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieErrorWithString, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(1, chat.Count);
|
|
var e = chat.Snapshot()[0];
|
|
Assert.Equal(ChatKind.System, e.Kind);
|
|
Assert.Equal(0x42u, e.ChannelId);
|
|
Assert.Contains("Mana Stone", e.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerDescription_RegistersInventoryEntries_InClientObjectTable()
|
|
{
|
|
// Issue #13 acceptance test: after a PlayerDescription with non-empty
|
|
// Inventory is dispatched through WireAll, ClientObjectTable.ObjectCount > 0.
|
|
// Wire format: strict path (no GAMEPLAY_OPTIONS bit) so inventory +
|
|
// equipped follow directly after spellbook_filters.
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat);
|
|
|
|
Assert.Equal(0, items.ObjectCount); // pre-condition
|
|
|
|
var sb = new MemoryStream();
|
|
using var w = new BinaryWriter(sb);
|
|
w.Write(0u); // propertyFlags = 0
|
|
w.Write(0x52u); // weenieType
|
|
w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
|
|
w.Write(1u); // has_health
|
|
w.Write(0u); // attribute_flags = 0 (no attrs)
|
|
w.Write(0u); // enchantment_mask = 0
|
|
|
|
w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS → strict inv path)
|
|
w.Write(0u); // options1
|
|
w.Write(0u); // legacy hotbar list count = 0
|
|
w.Write(0u); // spellbook_filters
|
|
|
|
// Inventory: 2 entries
|
|
w.Write(2u);
|
|
w.Write(0x50000A01u); w.Write(0u); // guid, ContainerType=NonContainer
|
|
w.Write(0x50000A02u); w.Write(1u); // guid, ContainerType=Container
|
|
|
|
// Equipped: 0 entries
|
|
w.Write(0u);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(2, items.ObjectCount);
|
|
Assert.NotNull(items.Get(0x50000A01u));
|
|
Assert.NotNull(items.Get(0x50000A02u));
|
|
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
|
|
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerDescription_IndexesLoginEquipmentForDefaultCombatMode()
|
|
{
|
|
const uint playerGuid = 0x50000001u;
|
|
const uint packItemGuid = 0x50000A01u;
|
|
const uint crossbowGuid = 0x50000B01u;
|
|
const uint ammoGuid = 0x50000B02u;
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
GameEventWiring.WireAll(
|
|
dispatcher,
|
|
items,
|
|
new CombatState(),
|
|
new Spellbook(),
|
|
new ChatLog(),
|
|
playerGuid: () => playerGuid);
|
|
|
|
var sb = new MemoryStream();
|
|
using var w = new BinaryWriter(sb);
|
|
w.Write(0u); // propertyFlags
|
|
w.Write(0x52u); // weenieType
|
|
w.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
|
|
w.Write(1u); // has_health
|
|
w.Write(0u); // attribute_flags
|
|
w.Write(0u); // enchantment_mask
|
|
w.Write(0u); // option_flags
|
|
w.Write(0u); // options1
|
|
w.Write(0u); // legacy hotbar count
|
|
w.Write(0u); // spellbook_filters
|
|
w.Write(1u); // inventory count
|
|
w.Write(packItemGuid); w.Write(0u);
|
|
w.Write(2u); // equipped count
|
|
w.Write(crossbowGuid);
|
|
w.Write((uint)EquipMask.MissileWeapon);
|
|
w.Write(7u); // layering priority
|
|
w.Write(ammoGuid);
|
|
w.Write((uint)EquipMask.MissileAmmo);
|
|
w.Write(8u);
|
|
|
|
var envelope = GameEventEnvelope.TryParse(
|
|
WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
|
dispatcher.Dispatch(envelope!.Value);
|
|
|
|
ClientObject crossbow = items.Get(crossbowGuid)!;
|
|
crossbow.Type = ItemType.MissileWeapon;
|
|
crossbow.CombatUse = 2;
|
|
IReadOnlyList<ClientObject> orderedEquipment = items.GetEquippedBy(playerGuid);
|
|
|
|
Assert.Equal(new[] { packItemGuid }, items.GetContents(playerGuid));
|
|
Assert.Equal(
|
|
new[] { crossbowGuid, ammoGuid },
|
|
orderedEquipment.Select(item => item.ObjectId));
|
|
Assert.Equal(0u, crossbow.ContainerId);
|
|
Assert.Equal(playerGuid, crossbow.WielderId);
|
|
Assert.Equal(EquipMask.MissileWeapon, crossbow.CurrentlyEquippedLocation);
|
|
Assert.Equal(7u, crossbow.Priority);
|
|
Assert.Equal(
|
|
CombatMode.Missile,
|
|
CombatInputPlanner.GetDefaultCombatMode(orderedEquipment));
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_QueryItemManaResponse_RoutesValidityToItemManaState()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var itemMana = new ItemManaState();
|
|
(uint Guid, float Percent, bool Valid)? observed = null;
|
|
itemMana.ItemManaChanged += (guid, percent, valid) => observed = (guid, percent, valid);
|
|
GameEventWiring.WireAll(
|
|
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
|
itemMana: itemMana);
|
|
|
|
byte[] payload = new byte[12];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
|
|
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.375f);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u);
|
|
var envelope = GameEventEnvelope.TryParse(
|
|
WrapEnvelope(GameEventType.QueryItemManaResponse, payload));
|
|
|
|
dispatcher.Dispatch(envelope!.Value);
|
|
|
|
Assert.Equal((0x50000A01u, 0.375f, true), observed);
|
|
Assert.True(itemMana.HasMana(0x50000A01u));
|
|
}
|
|
|
|
[Fact]
|
|
public void UseDone_releasesAppBusyOwnerThroughCallback()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
uint? completedWith = null;
|
|
GameEventWiring.WireAll(
|
|
dispatcher,
|
|
new ClientObjectTable(),
|
|
new CombatState(),
|
|
new Spellbook(),
|
|
new ChatLog(),
|
|
onUseDone: error => completedWith = error);
|
|
byte[] payload = new byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1Du);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UseDone, payload));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(0x1Du, completedWith);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerDescription_ReplacesPlayerPackContents_InRetailManifestOrder()
|
|
{
|
|
const uint playerGuid = 0x50000001u;
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat,
|
|
playerGuid: () => playerGuid);
|
|
|
|
var sb = new MemoryStream();
|
|
using var w = new BinaryWriter(sb);
|
|
w.Write(0u); // propertyFlags = 0
|
|
w.Write(0x52u); // weenieType
|
|
w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
|
|
w.Write(1u); // has_health
|
|
w.Write(0u); // attribute_flags = 0 (no attrs)
|
|
w.Write(0u); // enchantment_mask = 0
|
|
|
|
w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS -> strict inv path)
|
|
w.Write(0u); // options1
|
|
w.Write(0u); // legacy hotbar list count = 0
|
|
w.Write(0u); // spellbook_filters
|
|
|
|
w.Write(2u);
|
|
w.Write(0x50000A02u); w.Write(1u);
|
|
w.Write(0x50000A01u); w.Write(0u);
|
|
w.Write(0u); // equipped count
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(new[] { 0x50000A02u, 0x50000A01u }, items.GetContents(playerGuid));
|
|
Assert.Equal(playerGuid, items.Get(0x50000A02u)!.ContainerId);
|
|
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
|
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerDescription_SeedsMembership_NotWeenieClassIdMisuse()
|
|
{
|
|
// D.5.4: PlayerDescription is a membership MANIFEST, not the data
|
|
// source. The old code set WeenieClassId = inv.ContainerType (a
|
|
// 0/1/2 discriminator), which is a misuse. After the fix, the
|
|
// registered stub has WeenieClassId == 0 and the equipped item's
|
|
// CurrentlyEquippedLocation is set to MeleeWeapon (0x1).
|
|
// Uses the SAME wire fixture as PlayerDescription_RegistersInventoryEntries_InClientObjectTable.
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat);
|
|
|
|
var sb = new MemoryStream();
|
|
using var w = new BinaryWriter(sb);
|
|
w.Write(0u); // propertyFlags = 0
|
|
w.Write(0x52u); // weenieType
|
|
w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
|
|
w.Write(1u); // has_health
|
|
w.Write(0u); // attribute_flags = 0 (no attrs)
|
|
w.Write(0u); // enchantment_mask = 0
|
|
|
|
w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS → strict inv path)
|
|
w.Write(0u); // options1
|
|
w.Write(0u); // legacy hotbar list count = 0
|
|
w.Write(0u); // spellbook_filters
|
|
|
|
// Inventory: 1 entry with ContainerType=1 (the OLD code would have
|
|
// set WeenieClassId=1; the new code must leave WeenieClassId==0).
|
|
w.Write(1u);
|
|
w.Write(0x700u); w.Write(1u); // guid=0x700, ContainerType=1
|
|
|
|
// Equipped: 1 entry with EquipLocation = MeleeWeapon (0x1).
|
|
// Wire format: guid(4) + loc(4) + priority(4) = 12 bytes per entry.
|
|
w.Write(1u);
|
|
w.Write(0x701u); w.Write((uint)EquipMask.MeleeWeapon); w.Write(0u); // guid=0x701, slot=MeleeWeapon, prio=0
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
// (a) inventory guid is registered
|
|
Assert.NotNull(items.Get(0x700u));
|
|
// (b) WeenieClassId must be 0, NOT the ContainerType discriminator (1) — misuse gone
|
|
Assert.Equal(0u, items.Get(0x700u)!.WeenieClassId);
|
|
Assert.Equal(1u, items.Get(0x700u)!.ContainerTypeHint);
|
|
// (c) equipped guid has its equip slot set
|
|
Assert.NotNull(items.Get(0x701u));
|
|
Assert.Equal(EquipMask.MeleeWeapon, items.Get(0x701u)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_PlayerDescription_invokesOnShortcuts()
|
|
{
|
|
// D.5.1 Task 4: WireAll must forward parsed.Shortcuts to the onShortcuts
|
|
// callback so the toolbar can read them without keeping a parser reference.
|
|
// Mirrors PlayerDescription_RegistersInventoryEntries_InClientObjectTable
|
|
// for the harness pattern; adds the Shortcut flag (0x1) + one 12-byte
|
|
// entry, followed by the legacy-hotbar count (0) + spellbook_filters (0)
|
|
// then empty inventory and equipped.
|
|
IReadOnlyList<ShortcutEntry>? got = null;
|
|
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat,
|
|
onShortcuts: list => got = list);
|
|
|
|
// PlayerDescription body — minimal: no property flags, ATTRIBUTE|ENCHANTMENT
|
|
// vectorFlags (so the parser sees has_health=1, attribute_flags=0,
|
|
// enchantment_mask=0 and advances past both vector blocks), then the trailer
|
|
// with option_flags=Shortcut (0x1).
|
|
//
|
|
// Trailer layout when option_flags=0x1 (Shortcut only, no SpellLists8):
|
|
// u32 option_flags = 0x1
|
|
// u32 options1 = 0
|
|
// u32 count = 1 ← shortcut block (Shortcut flag set)
|
|
// u32 idx = 0
|
|
// u32 guid = 0x5001
|
|
// u16 spellId = 0
|
|
// u16 layer = 0
|
|
// u32 legacyHotbar count = 0 ← SpellLists8 NOT set → legacy fallback
|
|
// u32 spellbook_filters = 0
|
|
// u32 inventory count = 0
|
|
// u32 equipped count = 0
|
|
var sb = new MemoryStream();
|
|
using var w = new BinaryWriter(sb);
|
|
w.Write(0u); // propertyFlags = 0
|
|
w.Write(0x52u); // weenieType
|
|
w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
|
|
w.Write(1u); // has_health
|
|
w.Write(0u); // attribute_flags = 0 (no attrs)
|
|
w.Write(0u); // enchantment_mask = 0
|
|
|
|
// Trailer
|
|
w.Write(0x00000001u); // option_flags = Shortcut
|
|
w.Write(0u); // options1
|
|
// Shortcut block (option_flags & 0x1 set):
|
|
w.Write(1u); // count = 1
|
|
w.Write(0u); // idx = 0
|
|
w.Write(0x5001u); // guid = 0x5001
|
|
w.Write((ushort)0); // spellId = 0
|
|
w.Write((ushort)0); // layer = 0
|
|
// SpellLists8 NOT set → legacy single-list fallback:
|
|
w.Write(0u); // legacy hotbar list count = 0
|
|
// No DesiredComps, no CharacterOptions2, no GameplayOptions → strict path:
|
|
w.Write(0u); // spellbook_filters = 0
|
|
w.Write(0u); // inventory count = 0
|
|
w.Write(0u); // equipped count = 0
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
Assert.NotNull(got);
|
|
Assert.Single(got!);
|
|
Assert.Equal(0x5001u, got![0].ObjectId);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_PlayerDescription_UpsertsPlayerPropertiesIntoClientObject()
|
|
{
|
|
// PD with a PropertyInt32 table carrying EncumbranceVal(5)=1500. The handler
|
|
// must land it in the player ClientObject so the burden bar reads the wire value.
|
|
const uint playerGuid = 0x50000001u;
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ClientObjectTable();
|
|
GameEventWiring.WireAll(dispatcher, items, new CombatState(), new Spellbook(),
|
|
new ChatLog(), playerGuid: () => playerGuid);
|
|
|
|
var sb = new MemoryStream();
|
|
using var w = new BinaryWriter(sb);
|
|
w.Write(0x00000001u); // propertyFlags = PropertyInt32
|
|
w.Write(0x52u); // weenieType
|
|
// int table: u16 count, u16 buckets, then key/val pairs
|
|
w.Write((ushort)1); // count
|
|
w.Write((ushort)8); // buckets (ignored)
|
|
w.Write(5u); // key = EncumbranceVal
|
|
w.Write(1500u); // val
|
|
// vector + has_health (no vector blocks)
|
|
w.Write(0u); // vectorFlags = None
|
|
w.Write(0u); // has_health
|
|
// strict trailer
|
|
w.Write(0u); // option_flags = None
|
|
w.Write(0u); // options1
|
|
w.Write(0u); // legacy hotbar count
|
|
w.Write(0u); // spellbook_filters
|
|
w.Write(0u); // inventory count
|
|
w.Write(0u); // equipped count
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
|
dispatcher.Dispatch(env!.Value);
|
|
|
|
var player = items.Get(playerGuid);
|
|
Assert.NotNull(player);
|
|
Assert.Equal(1500, player!.Properties.Ints[5]);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_ViewContents_RecordsMembershipInClientObjectTable()
|
|
{
|
|
var (d, items, _, _, _) = MakeAll();
|
|
|
|
// containerGuid 0xC9 + 2 entries
|
|
byte[] payload = new byte[8 + 2 * 8];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x500000C9u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x50000A01u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 0x50000A02u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 1u);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
|
|
Assert.Equal(0u, items.Get(0x50000A02u)!.ContainerId);
|
|
Assert.Equal(new[] { 0x50000A01u, 0x50000A02u }, items.GetContents(0x500000C9u));
|
|
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
|
|
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_ViewContents_IsFullReplace_DropsRemovedItems()
|
|
{
|
|
var (d, items, _, _, _) = MakeAll();
|
|
|
|
// First view of container 0xC9: two items.
|
|
DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A01u, 0x50000A02u });
|
|
Assert.Equal(2, items.GetContents(0x500000C9u).Count);
|
|
|
|
// Second view: only one item — the other was removed server-side. Additive merge would keep both.
|
|
DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A02u });
|
|
Assert.Equal(new[] { 0x50000A02u }, items.GetContents(0x500000C9u));
|
|
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
|
|
}
|
|
|
|
private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids)
|
|
{
|
|
byte[] payload = new byte[8 + itemGuids.Length * 8];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), containerGuid);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)itemGuids.Length);
|
|
for (int i = 0; i < itemGuids.Length; i++)
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8 + i * 8), itemGuids[i]);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12 + i * 8), 0u); // containerType
|
|
}
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
|
|
d.Dispatch(env!.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_InventoryServerSaveFailed_RollsBackOptimisticMove()
|
|
{
|
|
var (d, items, _, _, _) = MakeAll();
|
|
// An item known to be in container 0xC0 at slot 2, then optimistically moved to 0xC1.
|
|
items.RecordMembership(0x50000B01u, containerId: 0x500000C0u);
|
|
items.MoveItem(0x50000B01u, 0x500000C0u, 2);
|
|
items.MoveItemOptimistic(0x50000B01u, 0x500000C1u, 0);
|
|
Assert.Equal(0x500000C1u, items.Get(0x50000B01u)!.ContainerId); // moved (optimistic)
|
|
|
|
// Server bounces it: InventoryServerSaveFailed (itemGuid, weenieError).
|
|
byte[] payload = new byte[8];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B01u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x1Au); // some WeenieError
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryServerSaveFailed, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(0x500000C0u, items.Get(0x50000B01u)!.ContainerId); // snapped back
|
|
Assert.Equal(2, items.Get(0x50000B01u)!.ContainerSlot);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_InventoryPutObjInContainer_ConfirmsOptimisticMove_soNoRollback()
|
|
{
|
|
var (d, items, _, _, _) = MakeAll();
|
|
items.RecordMembership(0x50000B02u, containerId: 0x500000C0u);
|
|
items.MoveItem(0x50000B02u, 0x500000C0u, 2);
|
|
items.MoveItemOptimistic(0x50000B02u, 0x500000C1u, 0); // optimistic move (pending snapshot)
|
|
|
|
// Server CONFIRMS via InventoryPutObjInContainer (item, container, placement, containerType).
|
|
byte[] payload = new byte[16];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
// ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck).
|
|
Assert.False(items.RollbackMove(0x50000B02u));
|
|
Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId);
|
|
Assert.Equal(1u, items.Get(0x50000B02u)!.ContainerTypeHint);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_InventoryPutObjInContainer_ClearsPriorWielder()
|
|
{
|
|
const uint player = 0x50000001u;
|
|
const uint pack = 0x500000C1u;
|
|
const uint bow = 0x50000B03u;
|
|
var (d, items, _, _, _) = MakeAll(() => player);
|
|
items.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = bow,
|
|
ContainerId = player,
|
|
WielderId = player,
|
|
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
|
|
});
|
|
|
|
byte[] payload = new byte[16];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), bow);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), pack);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u);
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(
|
|
GameEventType.InventoryPutObjInContainer,
|
|
payload));
|
|
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(pack, items.Get(bow)!.ContainerId);
|
|
Assert.Equal(0u, items.Get(bow)!.WielderId);
|
|
Assert.Equal(EquipMask.None, items.Get(bow)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer()
|
|
{
|
|
var (d, items, _, _, _) = MakeAll();
|
|
items.RecordMembership(0x50000A01u, containerId: 0x500000C9u);
|
|
|
|
byte[] payload = new byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_InventoryPutObjectIn3D_ConfirmsOptimisticDrop()
|
|
{
|
|
var (d, items, _, _, _) = MakeAll();
|
|
items.RecordMembership(0x50000A03u, containerId: 0x500000C9u);
|
|
items.MoveItem(0x50000A03u, 0x500000C9u, 4);
|
|
items.MoveItemOptimistic(0x50000A03u, newContainerId: 0u, newSlot: -1);
|
|
|
|
byte[] payload = new byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A03u);
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(0u, items.Get(0x50000A03u)!.ContainerId);
|
|
Assert.False(items.RollbackMove(0x50000A03u));
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_PlayerDescription_PublishesCharacterOptions()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
(uint Options1, uint Options2)? observed = null;
|
|
GameEventWiring.WireAll(
|
|
dispatcher,
|
|
new ClientObjectTable(),
|
|
new CombatState(),
|
|
new Spellbook(),
|
|
new ChatLog(),
|
|
onCharacterOptions: (options1, options2) =>
|
|
observed = (options1, options2));
|
|
|
|
var stream = new MemoryStream();
|
|
using var writer = new BinaryWriter(stream);
|
|
writer.Write(0u); // property flags
|
|
writer.Write(0x52u); // player weenie type
|
|
writer.Write(0u); // vector flags
|
|
writer.Write(0u); // has health
|
|
writer.Write(0x40u); // option flags: CharacterOptions2
|
|
writer.Write(0x50C4A54Au); // options1
|
|
writer.Write(0u); // legacy hotbar count
|
|
writer.Write(0u); // spellbook filters
|
|
writer.Write(0x948700u); // options2
|
|
writer.Write(0u); // inventory count
|
|
writer.Write(0u); // equipped count
|
|
|
|
var envelope = GameEventEnvelope.TryParse(
|
|
WrapEnvelope(GameEventType.PlayerDescription, stream.ToArray()));
|
|
dispatcher.Dispatch(envelope!.Value);
|
|
|
|
Assert.Equal((0x50C4A54Au, 0x948700u), observed);
|
|
}
|
|
|
|
}
|