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

@ -100,6 +100,7 @@ public sealed class WeenieErrorMessagesTests
[InlineData(0x0036u, "Action cancelled!")]
[InlineData(0x003Du, "You charged too far!")]
[InlineData(0x004Au, "Ack! You killed yourself!")]
[InlineData(0x0550u, "Out of Range!")]
public void Format_CombatMovementErrors(uint code, string expected)
=> Assert.Equal(expected, WeenieErrorMessages.Format(code, null));

View file

@ -1,6 +1,7 @@
using System;
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs;
@ -27,7 +28,9 @@ namespace AcDream.Core.Tests.Input;
public class PlayerMoveToCutoverTests
{
private const uint NC = 0x8000003Du;
private const uint MissileCombat = 0x8000003Fu;
private const uint Ready = 0x41000003u;
private const uint Reload = 0x40000016u;
private const uint Walk = 0x45000005u;
private const uint Run = 0x44000007u;
private const uint TurnRight = 0x6500000Du;
@ -73,7 +76,10 @@ public class PlayerMoveToCutoverTests
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC };
mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready;
mt.StyleDefaults[(DRWMotionCommand)MissileCombat] = (DRWMotionCommand)Ready;
mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u);
mt.Cycles[(int)((MissileCombat << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u);
mt.Cycles[(int)((MissileCombat << 16) | (Reload & 0xFFFFFFu))] = MakeMd(0x301u);
mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(0x301u);
mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u);
// The MoveToManager's aux-turn steering / TurnToHeading nodes
@ -82,6 +88,7 @@ public class PlayerMoveToCutoverTests
// _DoMotion error and the manager cancels the moveto (retail
// dispatch-failure semantics), so the fixture needs one.
mt.Cycles[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u);
mt.Cycles[(int)((MissileCombat << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u);
return new AnimationSequencer(setup, mt, loader);
}
@ -102,8 +109,27 @@ public class PlayerMoveToCutoverTests
{
public required PlayerMovementController Controller;
public required MoveToManager MoveTo;
public required EntityPhysicsHost PlayerHost;
public required Dictionary<uint, IPhysicsObjHost> Hosts;
public int MoveToCompleteCount;
public WeenieError LastCompleteError;
public void AddStaticTarget(uint guid, Position position)
{
var target = new EntityPhysicsHost(
guid,
getPosition: () => position,
getVelocity: () => Vector3.Zero,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => null,
curTime: () => Controller.SimTimeSeconds,
physicsTimerTime: () => Controller.SimTimeSeconds,
getObjectA: id => Hosts.TryGetValue(id, out var host) ? host : null,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
Hosts[guid] = target;
}
}
/// <summary>The EnterPlayerModeNow bind set, verbatim shape: real sink,
@ -116,6 +142,12 @@ public class PlayerMoveToCutoverTests
var controller = new PlayerMovementController(MakeFlatEngine());
var seq = MakeSequencer();
seqOut = seq;
// Production AnimatedEntity wiring installs the completion callback
// before EnterPlayerModeNow binds the sink and calls SetPosition.
// A synchronous zero-tick completion at the CPhysicsObj movement
// boundary must therefore reach MotionInterpreter even during the
// initial placement stop.
seq.MotionDoneTarget = (m, ok) => controller.Motion.MotionDone(m, ok);
// Production (EnterPlayerModeNow) order: the sink binds BEFORE the
// initial SetPosition — SetPosition → StopCompletely needs the sink
// for its type-5 motion-table dispatch, the completable partner of
@ -130,10 +162,19 @@ public class PlayerMoveToCutoverTests
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f; // heading 90 = facing +X
var rig = new Rig { Controller = controller, MoveTo = null! };
const uint selfGuid = 0x5000000Au;
var hosts = new Dictionary<uint, IPhysicsObjHost>();
EntityPhysicsHost playerHost = null!;
var rig = new Rig
{
Controller = controller,
MoveTo = null!,
PlayerHost = null!,
Hosts = hosts,
};
var moveTo = new MoveToManager(
controller.Motion,
stopCompletely: () => controller.Motion.StopCompletely(),
stopCompletely: () => controller.StopCompletelyAtPhysicsObjectBoundary(),
getPosition: () => new Position(
controller.CellId, controller.Position, controller.BodyOrientation),
getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw),
@ -143,12 +184,28 @@ public class PlayerMoveToCutoverTests
contact: () => controller.BodyInContact,
isInterpolating: () => false,
getVelocity: () => controller.BodyVelocity,
getSelfId: () => 0x5000000Au,
setTarget: (_, _, _, _) => { },
clearTarget: () => { },
getTargetQuantum: () => 0.0,
setTargetQuantum: _ => { },
getSelfId: () => selfGuid,
setTarget: (ctx, id, radius, quantum) =>
playerHost.SetTarget(ctx, id, radius, quantum),
clearTarget: () => playerHost.ClearTarget(),
getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
setTargetQuantum: quantum => playerHost.TargetManager.SetTargetQuantum(quantum),
curTime: () => controller.SimTimeSeconds);
playerHost = new EntityPhysicsHost(
selfGuid,
getPosition: () => new Position(
controller.CellId, controller.Position, controller.BodyOrientation),
getVelocity: () => controller.BodyVelocity,
getRadius: () => 0.5f,
inContact: () => controller.BodyInContact,
minterpMaxSpeed: () => controller.Motion.GetMaxSpeed(),
curTime: () => controller.SimTimeSeconds,
physicsTimerTime: () => controller.SimTimeSeconds,
getObjectA: id => hosts.TryGetValue(id, out var host) ? host : null,
handleUpdateTarget: info => moveTo.HandleUpdateTarget(info),
interruptCurrentMovement: () =>
moveTo.CancelMoveTo(WeenieError.ActionCancelled));
hosts[selfGuid] = playerHost;
moveTo.MoveToComplete = err =>
{
rig.MoveToCompleteCount++;
@ -158,6 +215,7 @@ public class PlayerMoveToCutoverTests
controller.Motion.InterruptCurrentMovement =
() => moveTo.CancelMoveTo(WeenieError.ActionCancelled);
rig.MoveTo = moveTo;
rig.PlayerHost = playerHost;
return rig;
}
@ -290,8 +348,6 @@ public class PlayerMoveToCutoverTests
// under the production completion feed.
var rig = MakeRig(out var seq);
var c = rig.Controller;
seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok);
for (int f = 0; f < 300 && c.Motion.MotionsPending(); f++)
{
c.Update(1f / 60f, new MovementInput());
@ -317,7 +373,6 @@ public class PlayerMoveToCutoverTests
// pops; remotes 0/40).
var rig = MakeRig(out var seq);
var c = rig.Controller;
seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok);
var dest = new Vector3(104f, 96f, 50f); // 8 m dead ahead (heading 90)
// A few idle frames first — production always has render ticks
@ -355,6 +410,130 @@ public class PlayerMoveToCutoverTests
Assert.Equal(1, rig.MoveToCompleteCount);
}
[Fact]
public void ServerTurnToObject_AfterMissileReload_ProductionFeed_RotatesAndCompletes()
{
// Live bow repro (2026-07-15): ACE broadcasts MissileCombat/Reload,
// then a type-8 TurnToObject carrying StopCompletely before it
// launches the arrow. After target resolution, retail's object turn
// is exactly one TurnToHeading node, so this pins the animation-queue
// boundary without replacing TargetManager with a test-only poll.
var rig = MakeRig(out var seq);
var c = rig.Controller;
for (int f = 0; f < 120; f++)
{
c.Update(1f / 60f, new MovementInput { Run = true });
seq.Advance(1f / 60f);
}
Assert.Equal(WeenieError.None, c.Motion.DoMotion(
MissileCombat, new MovementParameters()));
Assert.Equal(WeenieError.None, c.Motion.DoInterpretedMotion(
Reload, new MovementParameters { Speed = 2f }));
const uint targetGuid = 0x80001234u;
rig.AddStaticTarget(targetGuid, new Position(
c.CellId, new Vector3(88f, 96f, 50f), Quaternion.Identity));
rig.MoveTo.PerformMovement(new MovementStruct
{
Type = MovementType.TurnToObject,
ObjectId = targetGuid,
TopLevelId = targetGuid,
Params = new MovementParameters
{
StopCompletelyFlag = true,
Speed = 1f,
},
});
float initialYaw = c.Yaw;
for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++)
{
c.Update(1f / 60f, new MovementInput { Run = true });
seq.Advance(1f / 60f);
}
string interpQueue = string.Join(", ",
c.Motion.PendingMotions.Select(n => $"0x{n.Motion:X8}"));
string animationQueue = string.Join(", ",
seq.Manager.PendingAnimations.Select(n => $"0x{n.Motion:X8}/{n.NumAnims}"));
Assert.True(initialYaw != c.Yaw,
$"turn never changed yaw; moving={rig.MoveTo.IsMovingTo()} "
+ $"complete={rig.MoveToCompleteCount} error={rig.LastCompleteError} "
+ $"interp=[{interpQueue}] animation=[{animationQueue}]");
Assert.False(rig.MoveTo.IsMovingTo());
Assert.Equal(1, rig.MoveToCompleteCount);
Assert.InRange(MoveToMath.HeadingFromYaw(c.Yaw), 269.99f, 270.01f);
Assert.False(c.Motion.MotionsPending());
}
[Fact]
public void KeyboardTurnRelease_ThenAttackStop_DrainsBeforeServerTurnToObject()
{
// Live bow repro (2026-07-15): turn the character away with a player
// input edge, release the key, begin an attack (MaybeStopCompletely),
// then receive ACE's type-8 TurnToObject. Retail routes the input
// DoMotion/StopMotion calls through CPhysicsObj -> MovementManager,
// so every edge runs the zero-tick completion sweep and leaves no
// orphan that can starve BeginTurnToHeading.
var rig = MakeRig(out var seq);
var c = rig.Controller;
for (int f = 0; f < 90; f++)
{
c.Update(1f / 60f, new MovementInput { Run = true, TurnRight = true });
seq.Advance(1f / 60f);
}
c.Update(1f / 60f, new MovementInput { Run = true });
seq.Advance(1f / 60f);
for (int f = 0; f < 120 && c.Motion.MotionsPending(); f++)
{
c.Update(1f / 60f, new MovementInput { Run = true });
seq.Advance(1f / 60f);
}
Assert.False(c.Motion.MotionsPending(),
"keyboard turn release must leave no interpreter queue orphan");
Assert.Empty(seq.Manager.PendingAnimations);
Assert.True(c.PrepareForAttackRequest());
Assert.False(c.Motion.MotionsPending(),
"attack-entry StopCompletely must synchronously drain zero-tick Ready");
Assert.Empty(seq.Manager.PendingAnimations);
const uint targetGuid = 0x80004321u;
rig.AddStaticTarget(targetGuid, new Position(
c.CellId, c.Position + new Vector3(12f, 0f, 0f), Quaternion.Identity));
float initialYaw = c.Yaw;
rig.Controller.SetLastMoveWasAutonomous(false);
rig.MoveTo.PerformMovement(new MovementStruct
{
Type = MovementType.TurnToObject,
ObjectId = targetGuid,
TopLevelId = targetGuid,
Params = new MovementParameters
{
StopCompletelyFlag = true,
Speed = 1f,
},
});
for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++)
{
c.Update(1f / 60f, new MovementInput { Run = true });
seq.Advance(1f / 60f);
}
Assert.NotEqual(initialYaw, c.Yaw);
Assert.False(rig.MoveTo.IsMovingTo());
Assert.Equal(1, rig.MoveToCompleteCount);
Assert.InRange(MoveToMath.HeadingFromYaw(c.Yaw), 89.99f, 90.01f);
Assert.False(c.Motion.MotionsPending());
Assert.Empty(seq.Manager.PendingAnimations);
}
[Fact]
public void JumpRelease_CancelsMoveTo()
{

View file

@ -54,7 +54,11 @@ public sealed class ClientObjectTableTests
repo.AddOrUpdate(item);
uint seenOld = 999, seenNew = 999;
repo.ObjectMoved += (it, oldC, newC) => { seenOld = oldC; seenNew = newC; };
repo.ObjectMoved += move =>
{
seenOld = move.Previous.ContainerId;
seenNew = move.Current.ContainerId;
};
repo.MoveItem(100, 42, newSlot: 3);
@ -71,6 +75,74 @@ public sealed class ClientObjectTableTests
Assert.False(repo.MoveItem(999, 42));
}
[Fact]
public void ApplyServerMove_PublishesCompleteOldAndNewRetailPlacements()
{
var repo = new ClientObjectTable();
const uint player = 0x50000001u, pack = 0x40000005u, itemId = 101u;
repo.AddOrUpdate(new ClientObject
{
ObjectId = itemId,
WielderId = player,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
});
ClientObjectMove? seen = null;
repo.ObjectMoved += move => seen = move;
Assert.True(repo.ApplyServerMove(itemId, pack, 0u, 3));
Assert.Equal(
new ClientObjectPlacement(0u, -1, player, EquipMask.MissileWeapon),
seen!.Value.Previous);
Assert.Equal(
new ClientObjectPlacement(pack, 3, 0u, EquipMask.None),
seen.Value.Current);
}
[Fact]
public void ConfirmedUnknownMove_PublishesGuidAndRawPlacement()
{
var repo = new ClientObjectTable();
ClientObjectMove? seen = null;
repo.ObjectMoved += move => seen = move;
Assert.False(repo.ApplyConfirmedServerMove(
0xDEADu,
0x40000001u,
newWielderId: 0u,
newSlot: 7));
Assert.Equal(0xDEADu, seen!.Value.ItemId);
Assert.Null(seen.Value.Item);
Assert.Equal(default, seen.Value.Previous);
Assert.Equal(
new ClientObjectPlacement(0x40000001u, 7, 0u, EquipMask.None),
seen.Value.Current);
}
[Fact]
public void ConfirmedUnknownWield_PublishesGuidPlacementAndCompletion()
{
var repo = new ClientObjectTable();
const uint itemId = 0xBEEFu, player = 0x50000001u;
ClientObjectMove? seen = null;
uint completed = 0u;
repo.ObjectMoved += move => seen = move;
repo.WieldConfirmed += id => completed = id;
Assert.False(repo.ApplyConfirmedServerWield(
itemId,
player,
EquipMask.MissileWeapon));
Assert.Equal(itemId, seen!.Value.ItemId);
Assert.Null(seen.Value.Item);
Assert.Equal(
new ClientObjectPlacement(0u, 0, player, EquipMask.MissileWeapon),
seen.Value.Current);
Assert.Equal(itemId, completed);
}
[Fact]
public void Remove_FiresEventAndRemoves()
{
@ -471,53 +543,156 @@ public sealed class ClientObjectTableTests
var table = new ClientObjectTable();
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u));
Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId);
Assert.Equal(0u, table.Get(0xA01u)!.ContainerId);
}
[Fact]
public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge)
public void ReplaceContents_PublishesListReplacementWithoutSyntheticMove()
{
var table = new ClientObjectTable();
int moves = 0;
uint replaced = 0u;
table.ObjectMoved += _ => moves++;
table.ContainerContentsReplaced += container => replaced = container;
table.ReplaceContents(0xC9u, new uint[] { 0xA01u });
Assert.Equal(0, moves);
Assert.Equal(0xC9u, replaced);
}
[Fact]
public void ProjectionOnlyMember_AuthoritativeMoveEvictsStaleViewedList()
{
var table = new ClientObjectTable();
const uint chest = 0x40000001u, pack = 0x50000001u, item = 0xA20u;
table.ReplaceContents(chest, new uint[] { item });
Assert.True(table.ApplyServerMove(item, pack, newWielderId: 0u, newSlot: 0));
Assert.Empty(table.GetContents(chest));
Assert.Equal(new[] { item }, table.GetContents(pack));
}
[Fact]
public void ProjectionEvictionNotification_ReentrantMoveSeesCommittedState()
{
var table = new ClientObjectTable();
const uint chest = 0x40000001u, packA = 0x50000001u;
const uint packB = 0x50000002u, item = 0xA22u;
table.ReplaceContents(chest, new uint[] { item });
table.ContainerContentsReplaced += containerId =>
{
if (containerId == chest)
Assert.True(table.ApplyServerMove(item, packB, newWielderId: 0u, newSlot: 0));
};
Assert.True(table.ApplyServerMove(item, packA, newWielderId: 0u, newSlot: 0));
Assert.Empty(table.GetContents(chest));
Assert.Empty(table.GetContents(packA));
Assert.Equal(new[] { item }, table.GetContents(packB));
Assert.Equal(packB, table.Get(item)!.ContainerId);
}
[Fact]
public void ProjectionOnlyMember_DeleteEvictsStaleViewedListAndNotifies()
{
var table = new ClientObjectTable();
const uint chest = 0x40000001u, item = 0xA21u;
table.ReplaceContents(chest, new uint[] { item });
int replacements = 0;
table.ContainerContentsReplaced += id =>
{
if (id == chest) replacements++;
};
Assert.True(table.Remove(item));
Assert.Empty(table.GetContents(chest));
Assert.Equal(1, replacements);
}
[Fact]
public void DeleteProjectionNotification_GuidReuseSurvivesCompletedTeardown()
{
var table = new ClientObjectTable();
const uint chest = 0x40000001u, item = 0xA23u, player = 0x50000001u;
table.ReplaceContents(chest, new uint[] { item });
table.ContainerContentsReplaced += containerId =>
{
if (containerId != chest) return;
table.AddOrUpdate(new ClientObject
{
ObjectId = item,
WielderId = player,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
});
};
Assert.True(table.Remove(item));
Assert.NotNull(table.Get(item));
Assert.Equal(
new[] { item },
table.GetEquippedBy(player).Select(equipped => equipped.ObjectId));
}
[Fact]
public void ReplaceContents_SecondSmallerList_ReplacesProjectionOnly()
{
var table = new ClientObjectTable();
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side
Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u));
Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering
Assert.Equal(0u, table.Get(0xA01u)!.ContainerId);
Assert.Equal(0u, table.Get(0xA03u)!.ContainerId);
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId);
Assert.Equal(0u, table.Get(0xA02u)!.ContainerId);
}
[Fact]
public void ReplaceContents_listedMemberClearsStaleEquipLocation()
public void ReplaceContents_listedMemberPreservesCanonicalPlacement()
{
var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject
{
ObjectId = 0xA10u,
ContainerId = 0x50000001u,
ContainerId = 0u,
WielderId = 0x50000001u,
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
});
table.ReplaceContents(0x50000001u, new uint[] { 0xA10u });
var item = table.Get(0xA10u)!;
Assert.Equal(0x50000001u, item.ContainerId);
Assert.Equal(0, item.ContainerSlot);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
Assert.Equal(0u, item.ContainerId);
Assert.Equal(0x50000001u, item.WielderId);
Assert.Equal(-1, item.ContainerSlot);
Assert.Equal(
EquipMask.ChestWear | EquipMask.UpperArmWear,
item.CurrentlyEquippedLocation);
}
[Fact]
public void ReplaceContents_detachedMemberClearsStaleEquipLocation()
public void ReplaceContents_omittedMemberPreservesCanonicalPlacement()
{
var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject { ObjectId = 0xA11u });
table.AddOrUpdate(new ClientObject
{
ObjectId = 0xA11u,
WielderId = 0x50000001u,
});
table.MoveItem(0xA11u, 0x50000001u, newSlot: 0,
newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear);
table.ReplaceContents(0x50000001u, Array.Empty<uint>());
var item = table.Get(0xA11u)!;
Assert.Equal(0u, item.ContainerId);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
Assert.Equal(0x50000001u, item.ContainerId);
Assert.Equal(0x50000001u, item.WielderId);
Assert.Equal(
EquipMask.ChestWear | EquipMask.UpperArmWear,
item.CurrentlyEquippedLocation);
}
[Fact]
@ -559,11 +734,130 @@ public sealed class ClientObjectTableTests
table.Ingest(FullWeenie(0xA01u, container: player));
Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player));
Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot);
Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot);
Assert.Equal(-1, table.Get(0xA01u)!.ContainerSlot);
Assert.Equal(-1, table.Get(0xA02u)!.ContainerSlot);
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
}
[Fact]
public void InitializeInventoryManifest_SeedsCanonicalOwnershipAndOrder()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
table.InitializeInventoryManifest(player, new[]
{
new ContainerContentEntry(0xA01u, 0u),
new ContainerContentEntry(0xA02u, 1u),
});
Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player));
Assert.Equal(player, table.Get(0xA01u)!.ContainerId);
Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot);
Assert.Equal(player, table.Get(0xA02u)!.ContainerId);
Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot);
}
[Fact]
public void InitializeEquipmentManifest_PreservesPackedOrderAndCanonicalOwnership()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
table.InitializeEquipmentManifest(player, new[]
{
new EquipmentManifestEntry(0xA11u, EquipMask.MeleeWeapon, 1u),
new EquipmentManifestEntry(0xA12u, EquipMask.MissileAmmo, 2u),
});
Assert.Equal(
new[] { 0xA11u, 0xA12u },
table.GetEquippedBy(player).Select(item => item.ObjectId));
Assert.Equal(0u, table.Get(0xA11u)!.ContainerId);
Assert.Equal(player, table.Get(0xA11u)!.WielderId);
Assert.Empty(table.GetContents(player));
}
[Fact]
public void InitializeEquipmentManifest_ReplacementClearsOmittedCanonicalEquipment()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
const uint weapon = 0xA11u;
table.InitializeEquipmentManifest(player, new[]
{
new EquipmentManifestEntry(weapon, EquipMask.MeleeWeapon, 1u),
});
table.Get(weapon)!.Burden = 10;
table.InitializeEquipmentManifest(player, Array.Empty<EquipmentManifestEntry>());
ClientObject item = table.Get(weapon)!;
Assert.Equal(0u, item.WielderId);
Assert.Equal(0u, item.ContainerId);
Assert.Equal(-1, item.ContainerSlot);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
Assert.Empty(table.GetEquippedBy(player));
Assert.Equal(0, table.SumCarriedBurden(player));
}
[Fact]
public void InitializeInventoryManifest_ReplacementClearsOmittedCanonicalOwnership()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
const uint itemId = 0xA01u;
table.InitializeInventoryManifest(player, new[]
{
new ContainerContentEntry(itemId, 0u),
});
table.Get(itemId)!.Burden = 10;
table.InitializeInventoryManifest(player, Array.Empty<ContainerContentEntry>());
ClientObject item = table.Get(itemId)!;
Assert.Equal(0u, item.ContainerId);
Assert.Equal(-1, item.ContainerSlot);
Assert.Equal(0u, item.WielderId);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
Assert.Empty(table.GetContents(player));
Assert.Equal(0, table.SumCarriedBurden(player));
}
[Fact]
public void PlayerDescriptionManifests_MoveItemBetweenInventoryAndEquipment()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
const uint weapon = 0xA11u;
table.InitializeInventoryManifest(player, new[]
{
new ContainerContentEntry(weapon, 0u),
});
table.InitializeEquipmentManifest(player, new[]
{
new EquipmentManifestEntry(weapon, EquipMask.MeleeWeapon, 1u),
});
Assert.Empty(table.GetContents(player));
Assert.Equal(player, table.Get(weapon)!.WielderId);
Assert.Equal(EquipMask.MeleeWeapon, table.Get(weapon)!.CurrentlyEquippedLocation);
table.InitializeEquipmentManifest(player, Array.Empty<EquipmentManifestEntry>());
table.InitializeInventoryManifest(player, new[]
{
new ContainerContentEntry(weapon, 0u),
});
Assert.Empty(table.GetEquippedBy(player));
Assert.Equal(new[] { weapon }, table.GetContents(player));
Assert.Equal(player, table.Get(weapon)!.ContainerId);
Assert.Equal(0u, table.Get(weapon)!.WielderId);
}
[Fact]
public void Reindex_UnknownSlotAppendsAfterKnownSnapshotSlots()
{
@ -692,8 +986,8 @@ public sealed class ClientObjectTableTests
table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear);
var o = table.Get(0x940u)!;
Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant)
Assert.Equal(player, o.ContainerId); // contained-by-wielder (matches the WieldObject 0x0023 confirm)
Assert.Equal(0u, o.WielderId); // optimistic wield does NOT write WielderId (ContainerId-based model)
Assert.Equal(player, o.ContainerId); // temporary pre-confirm placement projection
Assert.Equal(0u, o.WielderId); // authoritative WieldObject later sets WielderId + clears ContainerId
}
[Fact]
@ -731,6 +1025,89 @@ public sealed class ClientObjectTableTests
public void WieldItemOptimistic_unknownItem_false()
=> Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear));
[Fact]
public void ApplyServerMove_UpdatesAuthoritativeWielderAcrossWieldAndUnwield()
{
var table = new ClientObjectTable();
const uint item = 0x9430u, player = 0x50000001u, pack = 0x40000005u;
table.AddOrUpdate(new ClientObject
{
ObjectId = item,
ContainerId = player,
WielderId = player,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
});
Assert.True(table.ApplyServerMove(item, pack, newWielderId: 0u, newSlot: 2));
Assert.Equal(pack, table.Get(item)!.ContainerId);
Assert.Equal(0u, table.Get(item)!.WielderId);
Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation);
Assert.True(table.ApplyServerMove(
item,
newContainerId: 0u,
newWielderId: player,
newEquipLocation: EquipMask.MissileWeapon));
Assert.Equal(0u, table.Get(item)!.ContainerId);
Assert.Equal(player, table.Get(item)!.WielderId);
Assert.Equal(EquipMask.MissileWeapon, table.Get(item)!.CurrentlyEquippedLocation);
}
[Fact]
public void ApplyConfirmedServerWield_ReentrantMoveKeepsNewPendingRequest()
{
var table = new ClientObjectTable();
const uint item = 0x9431u, player = 0x50000001u, pack = 0x40000005u;
table.AddOrUpdate(new ClientObject { ObjectId = item, ContainerId = pack, ContainerSlot = 0 });
Assert.True(table.WieldItemOptimistic(item, player, EquipMask.MissileWeapon));
bool queuedFromNotification = false;
table.ObjectMoved += move =>
{
if (queuedFromNotification || move.Current.WielderId != player) return;
queuedFromNotification = true;
Assert.True(table.MoveItemOptimistic(item, pack, newSlot: 0));
};
Assert.True(table.ApplyConfirmedServerWield(item, player, EquipMask.MissileWeapon));
Assert.True(queuedFromNotification);
Assert.True(table.RollbackMove(item));
Assert.Equal(0u, table.Get(item)!.ContainerId);
Assert.Equal(player, table.Get(item)!.WielderId);
Assert.Equal(EquipMask.MissileWeapon, table.Get(item)!.CurrentlyEquippedLocation);
}
[Fact]
public void GetEquippedBy_PreservesRetailHeadInsertionOrder()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
table.AddOrUpdate(new ClientObject
{
ObjectId = 0x9432u,
ContainerId = 0u,
WielderId = player,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
});
table.AddOrUpdate(new ClientObject
{
ObjectId = 0x9433u,
ContainerId = player,
WielderId = 0u,
CurrentlyEquippedLocation = EquipMask.MissileAmmo,
});
table.AddOrUpdate(new ClientObject
{
ObjectId = 0x9434u,
ContainerId = player,
CurrentlyEquippedLocation = EquipMask.None,
});
Assert.Equal(
new[] { 0x9433u, 0x9432u },
table.GetEquippedBy(player).Select(item => item.ObjectId));
}
[Fact]
public void RejectMove_withoutOptimisticMutation_stillPublishesServerFailure()
{
@ -766,7 +1143,7 @@ public sealed class ClientObjectTableTests
}
[Fact]
public void ConfirmWield_publishesIndependentlyOfRollbackOutstandingCount()
public void ApplyConfirmedServerWield_publishesIndependentlyOfRollbackOutstandingCount()
{
var table = new ClientObjectTable();
const uint item = 0x944u, player = 0x50000001u, pack = 0x40000005u;
@ -775,12 +1152,20 @@ public sealed class ClientObjectTableTests
table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon);
table.MoveItemOptimistic(item, pack, 0);
var confirmed = new List<uint>();
table.WieldConfirmed += moved => confirmed.Add(moved.ObjectId);
table.WieldConfirmed += itemId => confirmed.Add(itemId);
table.ConfirmWield(item);
Assert.True(table.ApplyConfirmedServerWield(
item,
player,
EquipMask.MeleeWeapon));
Assert.Equal(new[] { item }, confirmed);
Assert.Equal(0u, table.Get(item)!.ContainerId);
Assert.Equal(player, table.Get(item)!.WielderId);
Assert.True(table.RollbackMove(item));
Assert.Equal(pack, table.Get(item)!.ContainerId);
Assert.Equal(0u, table.Get(item)!.WielderId);
Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation);
}
[Fact]

View file

@ -92,6 +92,44 @@ public sealed class MovementManagerTests
Assert.Null(mm.MoveTo);
}
[Fact]
public void PerformMovement_InterpTypes_ForwardOriginalParametersObject()
{
// CMotionInterp::PerformMovement @ 0x00528E80 forwards
// MovementStruct::params verbatim. This is observable at the player
// input boundary: rebuilding the block from MovementStruct's legacy
// scalar fields loses its speed, hold key, and control flags.
var (mm, h, calls) = MakeFacade();
int interrupts = 0;
h.Interp.InterruptCurrentMovement = () => interrupts++;
var parameters = new MovementParameters
{
Speed = 0.37f,
SetHoldKey = true,
HoldKeyToApply = HoldKey.Run,
CancelMoveTo = false,
ModifyRawState = true,
ModifyInterpretedState = true,
};
var result = mm.PerformMovement(new MovementStruct
{
Type = MovementType.RawCommand,
Motion = MotionCommand.TurnRight,
// Deliberately contradictory legacy fields: Params wins.
Speed = 9f,
ModifyRawState = false,
ModifyInterpretedState = false,
Params = parameters,
});
Assert.Equal(WeenieError.None, result);
Assert.Equal(0.37f, h.Interp.RawState.TurnSpeed);
Assert.Equal(HoldKey.Run, h.Interp.RawState.CurrentHoldKey);
Assert.Equal(0, interrupts);
Assert.Equal(0, calls[0]);
}
[Fact]
public void PerformMovement_MoveToTypes_LazyCreate_AndRouteToMoveTo()
{

View file

@ -191,7 +191,8 @@ internal sealed class RemoteChaseHarness
{
var mtm = new MoveToManager(
Interp,
stopCompletely: () => Interp.StopCompletely(),
stopCompletely: () => Movement.PerformMovement(
new MovementStruct { Type = MovementType.StopCompletely }),
getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation),
getHeading: () => MoveToMath.GetHeading(Body.Orientation),
setHeading: (h, _) => Body.Orientation =