feat(ui): port retail item interaction policy

This commit is contained in:
Erik 2026-07-11 01:23:31 +02:00
parent 0cf780478a
commit a8da4fd05a
20 changed files with 1110 additions and 104 deletions

View file

@ -387,6 +387,7 @@ public sealed class ItemInteractionControllerTests
h.AddContained(0x50000A09u, item => item.Useability = ItemUseability.Contained);
h.Controller.ActivateItem(0x50000A09u);
h.Controller.CompleteUse(0);
h.Now += 199;
h.Controller.ActivateItem(0x50000A09u);
h.Now += 1;
@ -394,4 +395,22 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(new[] { 0x50000A09u, 0x50000A09u }, h.Uses);
}
[Fact]
public void UseDone_releasesRetailBusyGate()
{
var h = new Harness();
h.AddContained(0x50000A0Au, item => item.Useability = ItemUseability.Contained);
Assert.True(h.Controller.ActivateItem(0x50000A0Au));
h.Now += 200;
Assert.False(h.Controller.ActivateItem(0x50000A0Au));
Assert.Equal(1, h.Controller.BusyCount);
h.Controller.CompleteUse(0);
h.Now += 200; // rejected busy attempt still advanced retail's throttle timestamp
Assert.True(h.Controller.ActivateItem(0x50000A0Au));
Assert.Equal(2, h.Uses.Count);
}
}

View file

@ -449,6 +449,27 @@ public sealed class GameEventWiringTests
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
}
[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()
{

View file

@ -172,6 +172,22 @@ public sealed class CreateObjectTests
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
}
[Fact]
public void TryParse_CombatUseFlag_CapturesByte()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x5000000Du,
name: "Sword",
itemType: (uint)ItemType.MeleeWeapon,
weenieFlags: 0x00000200u,
combatUse: 2);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal((byte)2, parsed.Value.CombatUse);
}
// -----------------------------------------------------------------------
// Retail radar: PublicWeenieDesc carries two independently gated bytes.
// Absence is distinct from an explicitly transmitted zero because zero is
@ -566,6 +582,7 @@ public sealed class CreateObjectTests
float? workmanship = null,
byte? radarBlipColor = null,
byte? radarBehavior = null,
byte? combatUse = null,
ushort movementSeq = 0)
{
var bytes = new List<byte>();
@ -618,7 +635,7 @@ public sealed class CreateObjectTests
}
if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, targetType ?? 0u); // TargetType u32
if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32
if ((weenieFlags & 0x00000200u) != 0) bytes.Add(0); // CombatUse sbyte/1 byte
if ((weenieFlags & 0x00000200u) != 0) bytes.Add(combatUse ?? 0); // CombatUse sbyte/1 byte
if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16
if ((weenieFlags & 0x00000800u) != 0) WriteU16(bytes, maxStructure ?? 0); // MaxStructure u16
if ((weenieFlags & 0x00001000u) != 0) WriteU16(bytes, stackSize ?? 0); // StackSize u16

View file

@ -61,6 +61,8 @@ public sealed class ObjectTableWiringTests
TargetType = (uint)ItemType.Creature,
RadarBlipColor = 4,
RadarBehavior = 3,
ObjectDescriptionFlags = 0x20800200u,
CombatUse = 2,
};
var d = ObjectTableWiring.ToWeenieData(spawn);
@ -104,6 +106,8 @@ public sealed class ObjectTableWiringTests
Assert.Equal((uint)ItemType.Creature, d.TargetType);
Assert.Equal((byte)4, d.RadarBlipColor);
Assert.Equal((byte)3, d.RadarBehavior);
Assert.Equal(0x20800200u, d.PublicWeenieBitfield);
Assert.Equal((byte)2, d.CombatUse);
}
// -------------------------------------------------------------------------

View file

@ -0,0 +1,289 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class ItemInteractionPolicyTests
{
private const uint Player = 0x50000001u;
private const uint GroundContainer = 0x70000001u;
[Fact]
public void DetermineUseResult_looseItemAndComponentPack_matchRetailPickupBranch()
{
Assert.Equal(ItemPrimaryUseResult.PlaceInBackpack,
Determine(Obj(0x6001)));
var requiresSlot = Obj(0x6002) with
{
Flags = PublicWeenieFlags.RequiresPackSlot,
ItemsCapacity = 12,
};
Assert.Equal(ItemPrimaryUseResult.None, Determine(requiresSlot));
Assert.Equal(ItemPrimaryUseResult.PlaceInBackpack,
Determine(requiresSlot with { IsComponentPack = true }));
}
[Fact]
public void DetermineUseResult_onlyAllowsContainedPickupFromViewedGroundContainer()
{
var item = Obj(0x6001) with { ContainerId = GroundContainer };
Assert.Equal(ItemPrimaryUseResult.None,
ItemInteractionPolicy.DetermineUseResult(item, Player, 0));
Assert.Equal(ItemPrimaryUseResult.PlaceInBackpack,
ItemInteractionPolicy.DetermineUseResult(item, Player, GroundContainer));
}
[Fact]
public void DetermineUseResult_portsAllOwnedPrimaryClassifications()
{
var owned = Obj(0x6001) with
{
ContainerId = Player,
OwnedByPlayer = true,
Flags = PublicWeenieFlags.RequiresPackSlot,
};
Assert.Equal(ItemPrimaryUseResult.WieldRight,
Determine(owned with { CombatUse = 1 }));
Assert.Equal(ItemPrimaryUseResult.WieldLeft,
Determine(owned with { CombatUse = 1, Flags = owned.Flags | PublicWeenieFlags.WieldLeft }));
Assert.Equal(ItemPrimaryUseResult.AutoSort,
Determine(owned with { ValidLocations = EquipMask.HeadWear }));
Assert.Equal(ItemPrimaryUseResult.OpenSalvage,
Determine(owned with { Type = ItemType.TinkeringTool }));
Assert.Equal(ItemPrimaryUseResult.ItemUse,
Determine(owned with { Useability = ItemUseability.Contained }));
}
[Fact]
public void DetermineUseResult_nonOwnedGameAndPlayerPaths_areDistinct()
{
Assert.Equal(ItemPrimaryUseResult.BeginGame,
Determine(Obj(0x6001) with
{
Type = ItemType.Gameboard,
Flags = PublicWeenieFlags.RequiresPackSlot,
}));
Assert.Equal(ItemPrimaryUseResult.OpenSecureTrade,
Determine(Obj(0x6002) with
{
Flags = PublicWeenieFlags.Player | PublicWeenieFlags.RequiresPackSlot,
}));
}
[Fact]
public void UseObject_excludesResultEightFromEarlyClassifiedBound_thenStillWieldsLeft()
{
var source = Obj(0x6001) with
{
ContainerId = Player,
OwnedByPlayer = true,
CombatUse = 1,
Flags = PublicWeenieFlags.RequiresPackSlot | PublicWeenieFlags.WieldLeft,
};
var decision = ItemInteractionPolicy.DecideUse(Use(source));
Assert.Equal(new[] { ItemPolicyActionKind.WieldLeft },
decision.Actions.Select(a => a.Kind));
}
[Fact]
public void UseObject_readyVendorTradeAndWieldedSourceGates_areOrdered()
{
var direct = OwnedDirect();
Assert.Empty(ItemInteractionPolicy.DecideUse(
Use(direct) with { ReadyForInventoryRequest = false }).Actions);
Assert.Empty(ItemInteractionPolicy.DecideUse(
Use(direct) with { ActiveVendorId = 0x7001, Source = direct with { ContainerId = 0x7001 } }).Actions);
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);
}
[Fact]
public void UseObject_targetedUse_appliesKindAndLocationGate_thenSendsAndMarksBusy()
{
var source = OwnedDirect() with
{
Useability = 0x00080008u,
TargetType = (uint)ItemType.Misc,
};
var target = Obj(0x6002) with
{
ContainerId = Player,
OwnedByPlayer = true,
Type = ItemType.Misc,
};
var decision = ItemInteractionPolicy.DecideUse(Use(source) with
{
UseCurrentSelection = true,
SelectedTarget = target,
});
Assert.Equal(new[]
{
ItemPolicyActionKind.SendUseWithTarget,
ItemPolicyActionKind.IncrementBusy,
}, decision.Actions.Select(a => a.Kind));
}
[Theory]
[InlineData(PublicWeenieFlags.PlayerKillerSwitch, ItemPolicyActionKind.ConfirmPlayerKillerSwitch)]
[InlineData(PublicWeenieFlags.NonPlayerKillerSwitch, ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch)]
[InlineData(PublicWeenieFlags.VolatileRare, ItemPolicyActionKind.ConfirmVolatileRare)]
public void UseObject_confirmationFlags_blockWireUse(
PublicWeenieFlags flag,
ItemPolicyActionKind expected)
{
var decision = ItemInteractionPolicy.DecideUse(
Use(OwnedDirect() with { Flags = flag | PublicWeenieFlags.RequiresPackSlot }));
Assert.Equal(expected, Assert.Single(decision.Actions).Kind);
}
[Fact]
public void Placement_vendorAndSecureTrade_actButPreserveRetailFalseReturn()
{
var item = OwnedDirect() with { StackSize = 10, MaxSplitSize = 10 };
var vendor = Obj(0x7001) with { Flags = PublicWeenieFlags.Vendor };
var sale = ItemInteractionPolicy.DecidePlacement(Place(item, vendor));
Assert.False(sale.ReturnValue);
Assert.Equal(ItemPolicyActionKind.SellToVendor, Assert.Single(sale.Actions).Kind);
var player = Obj(0x7002) with { Flags = PublicWeenieFlags.Player };
var trade = ItemInteractionPolicy.DecidePlacement(Place(item, player));
Assert.False(trade.ReturnValue);
Assert.Equal(ItemPolicyActionKind.StartSecureTrade, Assert.Single(trade.Actions).Kind);
}
[Fact]
public void Placement_groundStackAndAirborneMatrix_matchesRetail()
{
var item = OwnedDirect() with { StackSize = 10, MaxSplitSize = 10 };
var airborne = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item) with
{
PlayerOnGround = false,
});
Assert.False(airborne.ReturnValue);
Assert.Equal(ItemPolicyActionKind.Reject, Assert.Single(airborne.Actions).Kind);
var split = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item) with { SplitSize = 4 });
Assert.True(split.ReturnValue);
Assert.Equal(ItemPolicyActionKind.SplitToWorld, Assert.Single(split.Actions).Kind);
var full = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item));
Assert.True(full.ReturnValue);
Assert.Equal(ItemPolicyActionKind.DropToWorld, Assert.Single(full.Actions).Kind);
var alreadyWorld = ItemInteractionPolicy.DecidePlacement(
PlaceOnGround(item with { IsIn3DView = true }));
Assert.False(alreadyWorld.ReturnValue);
Assert.Contains("cancelled", Assert.Single(alreadyWorld.Actions).Message,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Placement_containerRequiresOpenableAndCurrentGroundObject()
{
var item = OwnedDirect();
var container = Obj(GroundContainer) with { IsContainer = true };
var locked = ItemInteractionPolicy.DecidePlacement(Place(item, container) with
{
GroundObjectId = GroundContainer,
});
Assert.False(locked.ReturnValue);
var open = container with { Flags = PublicWeenieFlags.Openable };
var closedView = ItemInteractionPolicy.DecidePlacement(Place(item, open));
Assert.False(closedView.ReturnValue);
var placed = ItemInteractionPolicy.DecidePlacement(Place(item, open) with
{
GroundObjectId = GroundContainer,
});
Assert.True(placed.ReturnValue);
Assert.Equal(ItemPolicyActionKind.PlaceInContainer, Assert.Single(placed.Actions).Kind);
}
private static ItemPrimaryUseResult Determine(ItemPolicyObject item)
=> ItemInteractionPolicy.DetermineUseResult(item, Player, 0);
private static ItemPolicyObject OwnedDirect()
=> Obj(0x6001) with
{
ContainerId = Player,
OwnedByPlayer = true,
Flags = PublicWeenieFlags.RequiresPackSlot,
Useability = ItemUseability.Contained,
};
private static ItemPolicyObject Obj(uint id) => new(
id,
ItemType.Misc,
PublicWeenieFlags.None,
ContainerId: 0,
WielderId: 0,
ValidLocations: EquipMask.None,
CurrentLocation: EquipMask.None,
CombatUse: 0,
ItemsCapacity: 0,
ContainersCapacity: 0,
Useability: 0,
TargetType: 0,
OwnedByPlayer: false,
IsContainer: false,
IsComponentPack: false,
TradeState: 0,
StackSize: 1,
MaxSplitSize: 1,
IsIn3DView: false);
private static ItemUsePolicyInput Use(ItemPolicyObject source) => new(
source,
Player,
GroundObjectId: 0,
ReadyForInventoryRequest: true,
ActiveVendorId: 0,
BypassClassification: false,
UseCurrentSelection: false,
SelectedTarget: null,
ConfirmVolatileRareUses: true,
InNonCombatMode: false);
private static ItemPlacementPolicyInput Place(
ItemPolicyObject item,
ItemPolicyObject target) => new(
item,
Player,
GroundObjectId: 0,
ReadyForInventoryRequest: true,
TargetId: target.Id,
Target: target,
AllowGroundFallback: false,
MergeAccepted: false,
DragOnPlayerOpensSecureTrade: true,
PlayerOnGround: true,
SplitSize: item.StackSize);
private static ItemPlacementPolicyInput PlaceOnGround(ItemPolicyObject item) => new(
item,
Player,
GroundObjectId: 0,
ReadyForInventoryRequest: true,
TargetId: 0,
Target: null,
AllowGroundFallback: true,
MergeAccepted: false,
DragOnPlayerOpensSecureTrade: true,
PlayerOnGround: true,
SplitSize: item.StackSize);
}

View file

@ -27,4 +27,16 @@ public sealed class ItemUseabilityTests
{
Assert.False(ItemUseability.IsDirectUseable(ItemUseability.No));
}
[Fact]
public void LeastLimitedSourceUse_matchesRetailPriorityAndIgnoresObjSelf()
{
Assert.Equal(ItemUseability.Remote,
ItemUseability.LeastLimitedSourceUse(
ItemUseability.Remote | ItemUseability.Contained | ItemUseability.Wielded));
Assert.Equal(ItemUseability.Wielded,
ItemUseability.LeastLimitedSourceUse(ItemUseability.Wielded | ItemUseability.Self));
Assert.Equal(ItemUseability.Undef,
ItemUseability.LeastLimitedSourceUse(ItemUseability.ObjSelf));
}
}