fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -94,6 +94,40 @@ public sealed class ExternalContainerStateTests
Assert.Equal(2, delivered);
}
[Fact]
public void OpenedCorpseHistoryMatchesRetailSetAndDeleteLifetime()
{
var state = new ExternalContainerState();
const uint corpse = 0x70000010u;
Assert.True(state.RequestOpen(corpse, isCorpse: true));
Assert.True(state.HasCorpseBeenOpened(corpse));
Assert.Equal(1, state.OpenedCorpseCount);
state.ApplyViewContents(corpse);
state.ApplyClose(corpse);
Assert.True(state.HasCorpseBeenOpened(corpse));
Assert.True(state.SetCorpseDeleted(corpse));
Assert.False(state.HasCorpseBeenOpened(corpse));
state.RequestOpen(corpse, isCorpse: true);
Assert.True(state.Reset());
Assert.False(state.HasCorpseBeenOpened(corpse));
Assert.Equal(0, state.OpenedCorpseCount);
}
[Fact]
public void RepeatedGroundObjectRequestStillRecordsCorpseIdentity()
{
var state = new ExternalContainerState();
const uint corpse = 0x70000011u;
Assert.True(state.RequestOpen(corpse));
Assert.False(state.RequestOpen(corpse, isCorpse: true));
Assert.True(state.HasCorpseBeenOpened(corpse));
}
private static ExternalContainerState Open(uint id)
{
var state = new ExternalContainerState();

View file

@ -0,0 +1,76 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class InventoryContainerPlacementPolicyTests
{
private const uint Player = 0x50000001u;
[Fact]
public void FullItemCapacityRejectsNewItemButAllowsReorder()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
ItemsCapacity = 1,
ContainersCapacity = 7,
});
objects.AddOrUpdate(new ClientObject { ObjectId = 2u });
objects.MoveItem(2u, Player, 0);
objects.AddOrUpdate(new ClientObject { ObjectId = 3u });
Assert.Equal(
InventoryContainerPlacementRejection.ItemCapacityFull,
InventoryContainerPlacementPolicy.Evaluate(objects, 3u, Player, Player));
Assert.Equal(
InventoryContainerPlacementRejection.None,
InventoryContainerPlacementPolicy.Evaluate(objects, 2u, Player, Player));
}
[Fact]
public void ContainerCycleAndTradeAreRejected()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 10u, Type = ItemType.Container, ItemsCapacity = 24,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 11u, Type = ItemType.Container, ItemsCapacity = 24,
});
objects.MoveItem(11u, 10u, 0);
Assert.Equal(
InventoryContainerPlacementRejection.RecursiveContainment,
InventoryContainerPlacementPolicy.Evaluate(objects, 10u, 11u, Player));
objects.Get(10u)!.TradeState = 1;
Assert.Equal(
InventoryContainerPlacementRejection.SourceBeingTraded,
InventoryContainerPlacementPolicy.Evaluate(objects, 10u, Player, Player));
}
[Fact]
public void FullMessageMatchesRetailContainerTypeBranches()
{
var player = new ClientObject { ObjectId = Player, Name = "Backpack" };
var bag = new ClientObject { ObjectId = 2u, Name = "Pack" };
Assert.Equal(
"Backpack is completely full!",
InventoryContainerPlacementPolicy.ComposeClientLocal(
InventoryContainerPlacementRejection.ItemCapacityFull,
null,
player,
Player));
Assert.Equal(
"The Pack can fit no more containers!",
InventoryContainerPlacementPolicy.ComposeClientLocal(
InventoryContainerPlacementRejection.ContainerCapacityFull,
null,
bag,
Player));
}
}

View file

@ -147,9 +147,53 @@ public sealed class ItemInteractionPolicyTests
Assert.Equal(ItemPolicyActionKind.Reject,
Assert.Single(ItemInteractionPolicy.DecideUse(
Use(direct with { TradeState = 1 })).Actions).Kind);
Assert.Contains("wield", Assert.Single(ItemInteractionPolicy.DecideUse(
Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message,
StringComparison.OrdinalIgnoreCase);
Assert.Equal("You cannot use the item because you are trading it",
Assert.Single(ItemInteractionPolicy.DecideUse(
Use(direct with { TradeState = 1 })).Actions).Message);
Assert.Equal("You must wield the item to use it",
Assert.Single(ItemInteractionPolicy.DecideUse(
Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message);
}
[Fact]
public void UseObject_targetCompatibilityFailures_useRetailVerbatimMessages()
{
var source = OwnedDirect() with
{
Name = "mana stone",
Useability = 0x00080008u,
TargetType = (uint)ItemType.Misc,
};
var target = Obj(0x6002) with
{
Name = "armor",
Type = ItemType.Armor,
ContainerId = Player,
OwnedByPlayer = true,
};
var missing = ItemInteractionPolicy.DecideUse(Use(source) with
{
UseCurrentSelection = true,
});
Assert.Equal("Select your target before using the mana stone",
Assert.Single(missing.Actions).Message);
var incompatible = ItemInteractionPolicy.DecideUse(Use(source) with
{
UseCurrentSelection = true,
SelectedTarget = target,
});
Assert.Equal("Cannot use the mana stone with the armor",
Assert.Single(incompatible.Actions).Message);
var traded = ItemInteractionPolicy.DecideUse(Use(source) with
{
UseCurrentSelection = true,
SelectedTarget = target with { TradeState = 1 },
});
Assert.Equal("You can't use the mana stone on an item you are trading",
Assert.Single(traded.Actions).Message);
}
[Fact]
@ -219,7 +263,7 @@ public sealed class ItemInteractionPolicyTests
PlayerOnGround = false,
});
Assert.False(airborne.ReturnValue);
Assert.Equal(ItemPolicyActionKind.Reject, Assert.Single(airborne.Actions).Kind);
Assert.Equal("You cannot do that in mid air", Assert.Single(airborne.Actions).Message);
var split = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item) with { SplitSize = 4 });
Assert.True(split.ReturnValue);
@ -232,8 +276,7 @@ public sealed class ItemInteractionPolicyTests
var alreadyWorld = ItemInteractionPolicy.DecidePlacement(
PlaceOnGround(item with { IsIn3DView = true }));
Assert.False(alreadyWorld.ReturnValue);
Assert.Contains("cancelled", Assert.Single(alreadyWorld.Actions).Message,
StringComparison.OrdinalIgnoreCase);
Assert.Equal("Move cancelled", Assert.Single(alreadyWorld.Actions).Message);
}
[Fact]

View file

@ -204,6 +204,28 @@ public sealed class VendorStagingListTests
Assert.False(list.TryGet(ItemB, out _));
}
[Fact]
public void ReplacePreservesTheSplitPlaceholderPositionAndQuantity()
{
var list = new VendorStagingList();
list.Add(ItemA, 2);
list.Add(ItemB, 9);
const uint splitGuid = 0x60000003u;
int fired = 0;
list.Changed += () => fired++;
Assert.True(list.Replace(ItemA, splitGuid));
Assert.Equal(
new[]
{
new VendorStagingEntry(splitGuid, 2),
new VendorStagingEntry(ItemB, 9),
},
list.Entries);
Assert.Equal(1, fired);
}
[Fact]
public void ClearRemovesEveryEntryAndFiresChangedOnce()
{