fix(gameplay): reconcile wield ownership and target facing

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>
This commit is contained in:
Erik 2026-07-15 08:19:23 +02:00
parent b26f84cc69
commit 7b7ffcd278
36 changed files with 3884 additions and 761 deletions

View file

@ -104,13 +104,13 @@ public sealed class GameEventWiringTests
[Fact]
public void WireAll_WieldObject_RoutesToClientObjectTable()
{
var (d, items, _, _, _) = MakeAll();
const uint player = 0x2000u;
var (d, items, _, _, _) = MakeAll(() => player);
items.AddOrUpdate(new ClientObject { ObjectId = 0x1000, WeenieClassId = 1 });
byte[] payload = new byte[12];
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
d.Dispatch(env!.Value);
@ -118,7 +118,8 @@ public sealed class GameEventWiringTests
var item = items.Get(0x1000);
Assert.NotNull(item);
Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation);
Assert.Equal(0x2000u, item.ContainerId);
Assert.Equal(0u, item.ContainerId);
Assert.Equal(player, item.WielderId);
}
[Fact]
@ -130,7 +131,7 @@ public sealed class GameEventWiringTests
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 += item => wieldConfirmed.Add(item.ObjectId);
items.WieldConfirmed += itemId => wieldConfirmed.Add(itemId);
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
@ -139,7 +140,8 @@ public sealed class GameEventWiringTests
d.Dispatch(env!.Value); // server confirms the wield
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
Assert.Equal(player, items.Get(0x1500)!.ContainerId);
Assert.Equal(0u, items.Get(0x1500)!.ContainerId);
Assert.Equal(player, items.Get(0x1500)!.WielderId);
Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
}
@ -567,6 +569,7 @@ public sealed class GameEventWiringTests
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(
@ -591,10 +594,13 @@ public sealed class GameEventWiringTests
w.Write(0u); // spellbook_filters
w.Write(1u); // inventory count
w.Write(packItemGuid); w.Write(0u);
w.Write(1u); // equipped count
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()));
@ -603,17 +609,19 @@ public sealed class GameEventWiringTests
ClientObject crossbow = items.Get(crossbowGuid)!;
crossbow.Type = ItemType.MissileWeapon;
crossbow.CombatUse = 2;
var orderedPlayerContents = items.GetContents(playerGuid)
.Select(guid => items.Get(guid)!)
.ToList();
IReadOnlyList<ClientObject> orderedEquipment = items.GetEquippedBy(playerGuid);
Assert.Equal(new[] { packItemGuid, crossbowGuid }, items.GetContents(playerGuid));
Assert.Equal(playerGuid, crossbow.ContainerId);
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(orderedPlayerContents));
CombatInputPlanner.GetDefaultCombatMode(orderedEquipment));
}
[Fact]
@ -879,8 +887,8 @@ public sealed class GameEventWiringTests
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
d.Dispatch(env!.Value);
Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId);
Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId);
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);
@ -959,6 +967,37 @@ public sealed class GameEventWiringTests
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()
{