refused-drop yellow notice Item 4 (confirmation dialogs missing text + names): the missing retail mechanism was StringTable template substitution - an entry is N+1 literal fragments interleaved with N named variables, composed by StringTable::GetString @0x004300D0 (no-metalanguage branch @0x004303B7). ACE sends the bare player name for types 1/4; retail's OWN CLIENT wraps it. Ported as DatStringResolver.ResolveTemplate (PLAYER hash 0x05506DA2, the exact compute_str_hash space; Chorizite stores the variable hashes directly): - Server-driven type 4 -> ID_Fellowship_FellowshipRequest, type 1 -> ID_Allegiance_AcceptSwearConfirmation, injected into GameplayConfirmationController; null resolve falls back to the bare wire message, never invented English. The 2/3/5/6 " Continue?" family never consults the composer. - Local Swear/Break/Kick: the bind-time fragment-0 latch (which showed the dangling "Do you wish to swear to ") is replaced by click-time ResolveTemplate with the target's name. All five templates verified token-free in the installed DAT - this is NOT a StringTableMetaLanguage port (AD-81's engine caveat stands). Item 5 (refused drop shows nothing; retail shows yellow top-center text): the prevRequest latch was ALREADY ported (InventoryTransactionState); what was missing was the consumer. InventoryTransactionState now raises RequestFailed(request, weenieError) when a 0x00A0 clears the latch; ItemInteractionController composes ServerSaysAttemptFailed @0x0058EAE0's "The <item> can't be <verb>" (verb table + suffix map ported verbatim in Core's InventoryFailureMessages, NAME_PLURAL for merge/split) and routes it as LogTextType 0x1A ClientLocal -> the SpewBox, retail's yellow top-center line. The dispatcher's second leg (@0x0055B342) also runs: outside the 7-code exclusion set, WeenieErrorMessages resolves per-code text/destination; 0x426 AttunedItem has no row in either place beyond the verb line - faithful single-line output. Register: AD-85 narrowed to its numeric-field item, AD-81 amended (the token-free interleave is now ported; meta-token engine + FormatName remain), AD-93 filed (wire-guid-match vs retail's latched-guid preference; no Move/Wield latch kinds). Tests: +2 InventoryTransactionState failure-latch, +5 ResolveTemplate (constructed StringTable fixtures), +1 composer injection, +1 end-to-end refused-drop line. Core 4,697/1 skip, App 4,983/3 skips. Research: docs/research/2026-08-13-confirm-and-weenie-error-display.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2545 lines
88 KiB
C#
2545 lines
88 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
public sealed class ItemInteractionControllerTests
|
|
{
|
|
private const uint Player = 0x50000001u;
|
|
private const uint Pack = 0x50000010u;
|
|
// USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF (acclient.h ITEM_USEABLE;
|
|
// ACE Usable.SourceContainedTargetRemoteOrSelf) — the classic healing-kit value.
|
|
private const uint HealthKitUseability = 0x00220008u;
|
|
|
|
private sealed class Harness
|
|
{
|
|
public readonly ClientObjectTable Objects = new();
|
|
public readonly List<uint> Uses = new();
|
|
public readonly List<uint> Examines = new();
|
|
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
|
public readonly List<(uint Item, uint Mask)> Wields = new();
|
|
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
|
|
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new();
|
|
public readonly List<uint> ExternalRequests = new();
|
|
public readonly List<(uint Item, uint Container, int Placement)> BackpackPlacements = new();
|
|
public readonly List<uint> Drops = new();
|
|
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
|
|
public readonly List<(uint Target, uint Item, uint Amount)> Gives = new();
|
|
public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new();
|
|
// F4 (Slice 6 review): simulates the composition-root sendBuy
|
|
// delegate's "no live session / not in world" no-op case —
|
|
// TryBuy must see this as false and release the reservation
|
|
// rather than mark it dispatched for a request nothing sent.
|
|
public bool SendBuySucceeds = true;
|
|
// Slice 6b/6c: the batched Buy All / Sell send delegates — same
|
|
// "no live session" no-op simulation shape as SendBuySucceeds.
|
|
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
|
|
public bool SendBuyAllSucceeds = true;
|
|
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
|
|
public bool SendSellSucceeds = true;
|
|
public readonly List<string> Toasts = new();
|
|
public readonly List<string> SystemMessages = new();
|
|
public readonly List<(string Text, RetailLogTextType Type)> InterfaceTexts = new();
|
|
public readonly List<CombatMode> CombatModeRequests = new();
|
|
public readonly CombatState Combat = new();
|
|
public readonly StackSplitQuantityState SplitQuantity = new();
|
|
public readonly InventoryTransactionState SharedTransactions;
|
|
public readonly RuntimeInteractionTransactionState RuntimeTransactions;
|
|
public uint SelectedObject;
|
|
public bool NonCombatMode;
|
|
public bool DragOnPlayerOpensSecureTrade = true;
|
|
public uint GroundObject;
|
|
public long Now = 1_000;
|
|
|
|
public Harness(
|
|
Action<uint, ItemUseRequestReservation>? requestUse = null)
|
|
{
|
|
Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = Player,
|
|
Name = "Player",
|
|
Type = ItemType.Creature,
|
|
});
|
|
Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = Pack,
|
|
Name = "Backpack",
|
|
Type = ItemType.Container,
|
|
ItemsCapacity = 24,
|
|
});
|
|
Objects.MoveItem(Pack, Player, 0);
|
|
SharedTransactions = new InventoryTransactionState(Objects);
|
|
RuntimeTransactions = new RuntimeInteractionTransactionState(
|
|
SharedTransactions);
|
|
|
|
Controller = new ItemInteractionController(
|
|
Objects,
|
|
RuntimeTransactions,
|
|
new InteractionState(),
|
|
playerGuid: () => Player,
|
|
sendUse: requestUse is null ? Uses.Add : null,
|
|
sendExamine: Examines.Add,
|
|
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
|
|
sendWield: (item, mask) => Wields.Add((item, mask)),
|
|
sendDrop: Drops.Add,
|
|
nowMs: () => Now,
|
|
toast: Toasts.Add,
|
|
sendSplitToWorld: (item, amount) => SplitDrops.Add((item, amount)),
|
|
selectedObjectId: () => SelectedObject,
|
|
stackSplitQuantity: SplitQuantity,
|
|
inNonCombatMode: () => NonCombatMode,
|
|
groundObjectId: () => GroundObject,
|
|
placeInBackpack: (item, container, placement) =>
|
|
BackpackPlacements.Add((item, container, placement)),
|
|
sendPutItemInContainer: (item, container, placement) =>
|
|
Puts.Add((item, container, placement)),
|
|
sendGive: (target, item, amount) => Gives.Add((target, item, amount)),
|
|
dragOnPlayerOpensSecureTrade: () => DragOnPlayerOpensSecureTrade,
|
|
systemMessage: SystemMessages.Add,
|
|
sendSplitToContainer: (item, container, placement, amount) =>
|
|
SplitPuts.Add((item, container, placement, amount)),
|
|
requestExternalContainer: id =>
|
|
{
|
|
GroundObject = id;
|
|
ExternalRequests.Add(id);
|
|
},
|
|
combatState: Combat,
|
|
sendChangeCombatMode: CombatModeRequests.Add,
|
|
requestUse: requestUse,
|
|
sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) =>
|
|
{
|
|
if (!SendBuySucceeds)
|
|
return false;
|
|
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId));
|
|
return true;
|
|
},
|
|
sendBuyAll: (vendorGuid, items, alternateCurrencyId) =>
|
|
{
|
|
if (!SendBuyAllSucceeds)
|
|
return false;
|
|
BuyAlls.Add((vendorGuid, items, alternateCurrencyId));
|
|
return true;
|
|
},
|
|
sendSell: (vendorGuid, items) =>
|
|
{
|
|
if (!SendSellSucceeds)
|
|
return false;
|
|
Sells.Add((vendorGuid, items));
|
|
return true;
|
|
},
|
|
interfaceText: (text, type) => InterfaceTexts.Add((text, type)));
|
|
}
|
|
|
|
public ItemInteractionController Controller { get; }
|
|
|
|
public ClientObject AddContained(uint id, Action<ClientObject>? configure = null)
|
|
{
|
|
var item = new ClientObject
|
|
{
|
|
ObjectId = id,
|
|
Name = $"Item {id:X}",
|
|
Type = ItemType.Misc,
|
|
IconId = 0x06001234u,
|
|
};
|
|
configure?.Invoke(item);
|
|
Objects.AddOrUpdate(item);
|
|
Objects.MoveItem(id, Pack, Objects.GetContents(Pack).Count);
|
|
return item;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TargetedItem_entersTargetModeAndMarksPendingSource()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
|
|
|
|
Assert.True(h.Controller.ActivateItem(0x50000A01u));
|
|
|
|
Assert.True(h.Controller.IsTargetModeActive);
|
|
Assert.True(h.Controller.IsPendingSource(0x50000A01u));
|
|
Assert.Equal(InteractionModeKind.UseItemOnTarget,
|
|
h.Controller.InteractionState.Current.Kind);
|
|
Assert.Equal(0x50000A01u,
|
|
h.Controller.InteractionState.Current.SourceObjectId);
|
|
Assert.Empty(h.UseWithTarget);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetSession_RetryNotifiesEveryStateObserver()
|
|
{
|
|
var h = new Harness();
|
|
h.Controller.InteractionState.EnterExamine();
|
|
h.Controller.IncrementBusyCount();
|
|
bool fail = true;
|
|
int delivered = 0;
|
|
h.Controller.StateChanged += () =>
|
|
{
|
|
if (fail)
|
|
{
|
|
fail = false;
|
|
throw new InvalidOperationException("transient");
|
|
}
|
|
};
|
|
h.Controller.StateChanged += () => delivered++;
|
|
|
|
Assert.Throws<AggregateException>(h.Controller.ResetSession);
|
|
Assert.Equal(1, delivered);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
Assert.Equal(InteractionMode.None, h.Controller.InteractionState.Current);
|
|
|
|
h.Controller.ResetSession();
|
|
Assert.Equal(2, delivered);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExternalContainerUse_RequestsGroundObjectAndSendsUse()
|
|
{
|
|
var h = new Harness();
|
|
const uint chest = 0x70000001u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = chest,
|
|
Type = ItemType.Container,
|
|
ItemsCapacity = 24,
|
|
Useability = ItemUseability.Remote,
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(chest));
|
|
|
|
Assert.Equal(new uint[] { chest }, h.Uses);
|
|
Assert.Equal(new uint[] { chest }, h.ExternalRequests);
|
|
Assert.Equal(chest, h.GroundObject);
|
|
}
|
|
|
|
[Fact]
|
|
public void CorpseUse_UsesStuckContainerInsteadOfTryingToPickItUp()
|
|
{
|
|
var h = new Harness();
|
|
const uint corpse = 0x70000002u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = corpse,
|
|
Type = ItemType.Container,
|
|
ItemsCapacity = 24,
|
|
Useability = ItemUseability.Remote,
|
|
PublicWeenieBitfield = (uint)(
|
|
PublicWeenieFlags.Openable
|
|
| PublicWeenieFlags.Stuck
|
|
| PublicWeenieFlags.Corpse),
|
|
});
|
|
|
|
Assert.True(h.Controller.UseSelectedOrEnterMode(corpse));
|
|
|
|
Assert.Equal(new uint[] { corpse }, h.Uses);
|
|
Assert.Equal(new uint[] { corpse }, h.ExternalRequests);
|
|
Assert.Empty(h.Puts);
|
|
}
|
|
|
|
[Fact]
|
|
public void DoubleClickItemInOpenCorpse_placesThatItemInBackpack()
|
|
{
|
|
var h = new Harness();
|
|
const uint corpse = 0x70000012u;
|
|
const uint loot = 0x70000013u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = corpse,
|
|
Type = ItemType.Container,
|
|
ItemsCapacity = 24,
|
|
PublicWeenieBitfield = (uint)(
|
|
PublicWeenieFlags.Openable
|
|
| PublicWeenieFlags.Stuck
|
|
| PublicWeenieFlags.Corpse),
|
|
});
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = loot,
|
|
Name = "Loot",
|
|
Type = ItemType.Misc,
|
|
});
|
|
h.Objects.MoveItem(loot, corpse, 0);
|
|
h.GroundObject = corpse;
|
|
|
|
Assert.True(h.Controller.ActivateItem(loot));
|
|
|
|
Assert.Equal(new[] { (loot, Player, 0) }, h.BackpackPlacements);
|
|
Assert.Empty(h.Uses);
|
|
Assert.Empty(h.Wields);
|
|
}
|
|
|
|
[Fact]
|
|
public void DropOwnedItemOnOpenExternalContainer_SendsPutWithoutOptimisticMove()
|
|
{
|
|
var h = new Harness();
|
|
const uint itemId = 0x50000A33u;
|
|
const uint chest = 0x70000001u;
|
|
ClientObject item = h.AddContained(itemId);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = chest,
|
|
Type = ItemType.Container,
|
|
ItemsCapacity = 24,
|
|
PublicWeenieBitfield = (uint)PublicWeenieFlags.Openable,
|
|
});
|
|
h.GroundObject = chest;
|
|
var cell = new UiItemSlot();
|
|
cell.SetItem(itemId, 0u);
|
|
var payload = new ItemDragPayload(itemId, ItemDragSource.Inventory, 0, cell);
|
|
|
|
Assert.True(h.Controller.PlaceIn3D(payload, chest));
|
|
|
|
Assert.Equal(new[] { (itemId, chest, 0) }, h.Puts);
|
|
Assert.Equal(Pack, item.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void PrimaryClickRouter_distinguishesInactiveSuccessAndConsumedRejection()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A01u, item =>
|
|
{
|
|
item.Useability = HealthKitUseability;
|
|
item.TargetType = (uint)ItemType.Creature;
|
|
});
|
|
h.AddContained(0x50000A02u, item => item.Type = ItemType.Armor);
|
|
|
|
Assert.Equal(ItemPrimaryClickResult.NotActive,
|
|
h.Controller.OfferPrimaryClick(Player));
|
|
|
|
h.Controller.ActivateItem(0x50000A01u);
|
|
Assert.Equal(ItemPrimaryClickResult.ConsumedRejected,
|
|
h.Controller.OfferPrimaryClick(0x50000A02u));
|
|
Assert.False(h.Controller.IsTargetModeActive);
|
|
|
|
h.Now += 200;
|
|
h.Controller.ActivateItem(0x50000A01u);
|
|
Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess,
|
|
h.Controller.OfferSelfPrimaryClick());
|
|
Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToolbarUse_withoutSelection_armsOneShotUseMode()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A10u, item => item.Useability = ItemUseability.Contained);
|
|
|
|
Assert.True(h.Controller.UseSelectedOrEnterMode(0u));
|
|
Assert.True(h.Controller.IsAnyTargetModeActive);
|
|
Assert.Equal(InteractionModeKind.Use, h.Controller.InteractionState.Current.Kind);
|
|
|
|
Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess,
|
|
h.Controller.OfferPrimaryClick(0x50000A10u));
|
|
Assert.Equal(new[] { 0x50000A10u }, h.Uses);
|
|
Assert.False(h.Controller.IsAnyTargetModeActive);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToolbarExamine_usesSelectionOrArmsOneShotExamineMode()
|
|
{
|
|
var h = new Harness();
|
|
|
|
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player));
|
|
Assert.Equal(new[] { Player }, h.Examines);
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
|
|
Assert.True(h.Controller.ExamineSelectedOrEnterMode(0u));
|
|
Assert.Equal(InteractionModeKind.Examine, h.Controller.InteractionState.Current.Kind);
|
|
Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess,
|
|
h.Controller.OfferPrimaryClick(Pack));
|
|
Assert.Equal(new[] { Player, Pack }, h.Examines);
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
Assert.False(h.Controller.IsAnyTargetModeActive);
|
|
}
|
|
|
|
[Fact]
|
|
public void BorrowedTransactionOwnerIsExactAndOutlivesController()
|
|
{
|
|
var h = new Harness();
|
|
|
|
h.Controller.IncrementBusyCount();
|
|
|
|
Assert.Equal(1, h.SharedTransactions.BusyCount);
|
|
h.Controller.Dispose();
|
|
Assert.False(h.SharedTransactions.IsDisposed);
|
|
|
|
h.SharedTransactions.ClearBusy();
|
|
Assert.Equal(0, h.SharedTransactions.BusyCount);
|
|
h.SharedTransactions.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void BorrowedTransactionOwnerRejectsDifferentObjectTable()
|
|
{
|
|
var objects = new ClientObjectTable();
|
|
using var transactions =
|
|
new InventoryTransactionState(new ClientObjectTable());
|
|
using var runtimeTransactions =
|
|
new RuntimeInteractionTransactionState(transactions);
|
|
|
|
Assert.Throws<ArgumentException>(() => new ItemInteractionController(
|
|
objects,
|
|
runtimeTransactions,
|
|
new InteractionState(),
|
|
playerGuid: () => Player,
|
|
sendUse: null,
|
|
sendUseWithTarget: null,
|
|
sendWield: null,
|
|
sendDrop: null));
|
|
}
|
|
|
|
[Fact]
|
|
public void AppraisalResponse_releasesOneBusyReferenceAndAcceptsCurrentRefresh()
|
|
{
|
|
var h = new Harness();
|
|
|
|
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player));
|
|
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Pack));
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
|
|
Assert.False(h.Controller.AcceptAppraisalResponse(Player).Accepted);
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
|
|
var first = h.Controller.AcceptAppraisalResponse(Pack);
|
|
Assert.True(first.Accepted);
|
|
Assert.True(first.FirstResponse);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
Assert.Equal(Pack, h.Controller.CurrentAppraisalId);
|
|
|
|
var refresh = h.Controller.AcceptAppraisalResponse(Pack);
|
|
Assert.True(refresh.Accepted);
|
|
Assert.False(refresh.FirstResponse);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshCurrentAppraisal_sendsWithoutAcquiringBusyReference()
|
|
{
|
|
var h = new Harness();
|
|
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Pack));
|
|
Assert.True(h.Controller.AcceptAppraisalResponse(Pack).Accepted);
|
|
|
|
Assert.True(h.Controller.RefreshCurrentAppraisal());
|
|
|
|
Assert.Equal(new[] { Pack, Pack }, h.Examines);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A01u, item =>
|
|
{
|
|
item.Useability = HealthKitUseability;
|
|
item.TargetType = (uint)ItemType.Creature; // kits target creatures/players
|
|
});
|
|
|
|
h.Controller.ActivateItem(0x50000A01u);
|
|
Assert.True(h.Controller.AcquireSelfTarget());
|
|
|
|
Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget);
|
|
Assert.False(h.Controller.IsTargetModeActive);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActivateTargetItemDuringTargetMode_usesClickedItemAsTarget()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A01u, item =>
|
|
{
|
|
item.Useability = 0x00080008u; // TARGET_CONTAINED tool
|
|
item.TargetType = (uint)ItemType.Misc; // kind gate must pass for the send
|
|
});
|
|
h.AddContained(0x50000A02u);
|
|
|
|
h.Controller.ActivateItem(0x50000A01u);
|
|
Assert.True(h.Controller.ActivateItem(0x50000A02u));
|
|
|
|
Assert.Equal(new[] { (0x50000A01u, 0x50000A02u) }, h.UseWithTarget);
|
|
Assert.False(h.Controller.IsTargetModeActive);
|
|
}
|
|
|
|
// ── Retail target-compat gate (ItemHolder::IsTargetCompatibleWithTargetingObject 0x00588070) ──
|
|
|
|
[Fact]
|
|
public void TargetCompat_wrongKind_refusesAndClearsMode()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A01u, item =>
|
|
{
|
|
item.Useability = HealthKitUseability;
|
|
item.TargetType = (uint)ItemType.Creature;
|
|
});
|
|
h.AddContained(0x50000A02u, item => item.Type = ItemType.Armor); // a coat
|
|
|
|
h.Controller.ActivateItem(0x50000A01u);
|
|
Assert.False(h.Controller.ActivateItem(0x50000A02u)); // kind gate: Creature mask & Armor = 0
|
|
|
|
Assert.Empty(h.UseWithTarget);
|
|
Assert.False(h.Controller.IsTargetModeActive);
|
|
}
|
|
|
|
[Fact]
|
|
public void IsCurrentTargetCompatible_appliesKindGateForCursor()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A01u, item =>
|
|
{
|
|
item.Useability = HealthKitUseability;
|
|
item.TargetType = (uint)ItemType.Creature;
|
|
});
|
|
h.AddContained(0x50000A02u, item => item.Type = ItemType.Armor);
|
|
|
|
h.Controller.ActivateItem(0x50000A01u);
|
|
|
|
Assert.True(h.Controller.IsCurrentTargetCompatible(Player)); // self: Self bit + Creature kind
|
|
Assert.False(h.Controller.IsCurrentTargetCompatible(0x50000A02u)); // coat: yellow-over-items bug pin
|
|
}
|
|
|
|
[Fact]
|
|
public void SelfTarget_requiresSelfBit()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A01u, item =>
|
|
{
|
|
item.Useability = 0x00200008u; // TARGET_REMOTE only — no Self bit
|
|
item.TargetType = (uint)ItemType.Creature;
|
|
});
|
|
|
|
h.Controller.ActivateItem(0x50000A01u);
|
|
Assert.False(h.Controller.AcquireSelfTarget()); // IsUseable_SelfTarget 0x004fcd30
|
|
Assert.Empty(h.UseWithTarget);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingTargetTypeMask_matchesNothing()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability); // no TargetType on wire
|
|
|
|
h.Controller.ActivateItem(0x50000A01u);
|
|
Assert.False(h.Controller.AcquireSelfTarget()); // retail: 0 mask & anything == 0
|
|
Assert.Empty(h.UseWithTarget);
|
|
}
|
|
|
|
[Fact]
|
|
public void ContainedTarget_refusesNonOwnedWorldObject()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A01u, item =>
|
|
{
|
|
item.Useability = 0x00080008u; // Contained is the least-limited target bit
|
|
item.TargetType = (uint)ItemType.Misc;
|
|
});
|
|
// right KIND, but out in the world — Contained requires our inventory:
|
|
h.Objects.AddOrUpdate(new ClientObject { ObjectId = 0x60000001u, Type = ItemType.Misc });
|
|
|
|
h.Controller.ActivateItem(0x50000A01u);
|
|
Assert.False(h.Controller.AcquireTarget(0x60000001u));
|
|
Assert.Empty(h.UseWithTarget);
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectUseItem_sendsUse()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A03u, item => item.Useability = ItemUseability.Contained);
|
|
|
|
Assert.True(h.Controller.ActivateItem(0x50000A03u));
|
|
|
|
Assert.Equal(new[] { 0x50000A03u }, h.Uses);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZeroValuedUseabilityGem_sendsUseLikeBlackmoorsFavor()
|
|
{
|
|
var h = new Harness();
|
|
const uint favor = 0x50000A30u;
|
|
h.AddContained(favor, item =>
|
|
{
|
|
item.Name = "Blackmoor's Favor";
|
|
item.Type = ItemType.Gem;
|
|
item.Useability = ItemUseability.Undef;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(favor));
|
|
|
|
Assert.Equal(new[] { favor }, h.Uses);
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ContainerItem_sendsUseToOpen()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A04u, item =>
|
|
{
|
|
item.Type = ItemType.Container;
|
|
item.ItemsCapacity = 12;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(0x50000A04u));
|
|
|
|
Assert.Equal(new[] { 0x50000A04u }, h.Uses);
|
|
}
|
|
|
|
[Fact]
|
|
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A05u, item =>
|
|
{
|
|
item.Type = ItemType.Clothing;
|
|
item.ValidLocations = EquipMask.HeadWear;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(0x50000A05u));
|
|
|
|
Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields);
|
|
var equipped = h.Objects.Get(0x50000A05u)!;
|
|
Assert.Equal(Player, equipped.ContainerId);
|
|
Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically()
|
|
{
|
|
var h = new Harness();
|
|
const EquipMask coatMask =
|
|
EquipMask.ChestWear
|
|
| EquipMask.UpperArmWear
|
|
| EquipMask.LowerArmWear;
|
|
h.AddContained(0x50000A15u, item =>
|
|
{
|
|
item.Type = ItemType.Clothing;
|
|
item.ValidLocations = coatMask;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(0x50000A15u));
|
|
|
|
Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields);
|
|
var equipped = h.Objects.Get(0x50000A15u)!;
|
|
Assert.Equal(Player, equipped.ContainerId);
|
|
Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoWearItemWithOverlappingSlotButDifferentPriority_sendsFullMask()
|
|
{
|
|
var h = new Harness();
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x50000AF1u,
|
|
Type = ItemType.Clothing,
|
|
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
|
|
Priority = 0x00000001u,
|
|
});
|
|
h.Objects.MoveItem(0x50000AF1u, Player, -1, EquipMask.UpperArmWear);
|
|
const EquipMask coatMask =
|
|
EquipMask.ChestWear
|
|
| EquipMask.UpperArmWear
|
|
| EquipMask.LowerArmWear;
|
|
h.AddContained(0x50000A16u, item =>
|
|
{
|
|
item.Type = ItemType.Clothing;
|
|
item.ValidLocations = coatMask;
|
|
item.Priority = 0x00000002u;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(0x50000A16u));
|
|
|
|
Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields);
|
|
Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoWearItemWithOverlappingSlotAndPriority_sendsNothing()
|
|
{
|
|
var h = new Harness();
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x50000AF2u,
|
|
Name = "Chainmail Hauberk",
|
|
Type = ItemType.Clothing,
|
|
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
|
|
Priority = 0x00000004u,
|
|
});
|
|
h.Objects.MoveItem(0x50000AF2u, Player, -1, EquipMask.UpperArmWear);
|
|
const EquipMask coatMask =
|
|
EquipMask.ChestWear
|
|
| EquipMask.UpperArmWear
|
|
| EquipMask.LowerArmWear;
|
|
h.AddContained(0x50000A17u, item =>
|
|
{
|
|
item.Type = ItemType.Clothing;
|
|
item.ValidLocations = coatMask;
|
|
item.Priority = 0x00000004u;
|
|
});
|
|
|
|
Assert.False(h.Controller.ActivateItem(0x50000A17u));
|
|
|
|
Assert.Empty(h.Wields);
|
|
Assert.Equal(Pack, h.Objects.Get(0x50000A17u)!.ContainerId);
|
|
Assert.Equal(
|
|
new[] { "You must remove your Chainmail Hauberk to wear that" },
|
|
h.SystemMessages);
|
|
Assert.Empty(h.Toasts);
|
|
}
|
|
|
|
[Fact]
|
|
public void EquippableItemWithNoFreeSlot_sendsNothing()
|
|
{
|
|
var h = new Harness();
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x50000AF0u,
|
|
Type = ItemType.Armor,
|
|
CurrentlyEquippedLocation = EquipMask.Shield,
|
|
});
|
|
h.Objects.MoveItem(0x50000AF0u, Player, -1, EquipMask.Shield);
|
|
h.AddContained(0x50000A06u, item =>
|
|
{
|
|
item.Type = ItemType.Armor;
|
|
item.CombatUse = 4; // COMBAT_USE_SHIELD
|
|
item.ValidLocations = EquipMask.Shield;
|
|
item.Useability = ItemUseability.No;
|
|
});
|
|
|
|
bool activated = h.Controller.ActivateItem(0x50000A06u);
|
|
|
|
Assert.Empty(h.Wields);
|
|
Assert.False(activated);
|
|
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void WeaponReplacement_inPeaceOrWar_unwieldsThenWieldsAfterServerConfirm(
|
|
bool nonCombatMode)
|
|
{
|
|
var h = new Harness { NonCombatMode = nonCombatMode };
|
|
const uint sword = 0x50000B01u;
|
|
const uint bow = 0x50000B02u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = sword,
|
|
Name = "Sword",
|
|
Type = ItemType.MeleeWeapon,
|
|
CombatUse = 1,
|
|
ValidLocations = EquipMask.MeleeWeapon,
|
|
});
|
|
h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon);
|
|
h.AddContained(bow, item =>
|
|
{
|
|
item.Name = "Bow";
|
|
item.Type = ItemType.MissileWeapon;
|
|
item.CombatUse = 1;
|
|
item.ValidLocations = EquipMask.MissileWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(bow));
|
|
|
|
Assert.Equal(new[] { (sword, Player, 0) }, h.Puts);
|
|
Assert.Empty(h.Wields);
|
|
Assert.Equal(EquipMask.MeleeWeapon,
|
|
h.Objects.Get(sword)!.CurrentlyEquippedLocation);
|
|
Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId);
|
|
|
|
// Authoritative 0x0022: only now does retail retry AutoWield.
|
|
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
|
|
|
|
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
|
|
Assert.Equal(EquipMask.None,
|
|
h.Objects.Get(sword)!.CurrentlyEquippedLocation);
|
|
Assert.Equal(EquipMask.MissileWeapon,
|
|
h.Objects.Get(bow)!.CurrentlyEquippedLocation);
|
|
Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(CombatMode.NonCombat, false)]
|
|
[InlineData(CombatMode.Magic, true)]
|
|
public void WandToBow_reassertsMissileModeOnlyForActiveCombatTransaction(
|
|
CombatMode startingMode,
|
|
bool expectsMissileMode)
|
|
{
|
|
var h = new Harness();
|
|
h.Combat.SetCombatMode(startingMode);
|
|
const uint wand = 0x50000B91u;
|
|
const uint bow = 0x50000B92u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = wand,
|
|
Name = "Training Wand",
|
|
Type = ItemType.Caster,
|
|
CombatUse = 2,
|
|
ValidLocations = EquipMask.Held,
|
|
});
|
|
h.Objects.MoveItem(wand, Player, -1, EquipMask.Held);
|
|
h.AddContained(bow, item =>
|
|
{
|
|
item.Name = "Shortbow";
|
|
item.Type = ItemType.MissileWeapon;
|
|
item.CombatUse = 2;
|
|
item.ValidLocations = EquipMask.MissileWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(bow));
|
|
Assert.Equal(new[] { (wand, Player, 0) }, h.Puts);
|
|
|
|
// ACE reports several transitional combat modes while putting the
|
|
// wand away. The transaction retains the initial active-combat intent
|
|
// rather than consulting this intermediate state on its second pass.
|
|
h.Combat.SetCombatMode(CombatMode.Melee);
|
|
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
|
|
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
|
|
Assert.Empty(h.CombatModeRequests);
|
|
|
|
Assert.True(h.Objects.ApplyConfirmedServerWield(
|
|
bow,
|
|
Player,
|
|
EquipMask.MissileWeapon));
|
|
Assert.Empty(h.CombatModeRequests);
|
|
|
|
// The first authoritative post-wield mode is the transaction's server
|
|
// settlement stream. Local ACE publishes Peace -> Missile -> Peace;
|
|
// only the trailing Peace is late enough to accept the reassertion.
|
|
if (expectsMissileMode)
|
|
{
|
|
h.Combat.SetCombatMode(CombatMode.NonCombat);
|
|
Assert.Empty(h.CombatModeRequests);
|
|
h.Combat.SetCombatMode(CombatMode.Missile);
|
|
Assert.Empty(h.CombatModeRequests);
|
|
h.Combat.SetCombatMode(CombatMode.NonCombat);
|
|
}
|
|
else
|
|
{
|
|
h.Combat.SetCombatMode(CombatMode.Missile);
|
|
}
|
|
|
|
if (expectsMissileMode)
|
|
Assert.Equal(new[] { CombatMode.Missile }, h.CombatModeRequests);
|
|
else
|
|
Assert.Empty(h.CombatModeRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public void BowToWand_inActiveCombat_reassertsMagicAfterAceTrailingPeace()
|
|
{
|
|
var h = new Harness();
|
|
h.Combat.SetCombatMode(CombatMode.Missile);
|
|
const uint bow = 0x50000B93u;
|
|
const uint wand = 0x50000B94u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = bow,
|
|
Name = "Shortbow",
|
|
Type = ItemType.MissileWeapon,
|
|
CombatUse = 2,
|
|
ValidLocations = EquipMask.MissileWeapon,
|
|
});
|
|
h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon);
|
|
h.AddContained(wand, item =>
|
|
{
|
|
item.Name = "Training Wand";
|
|
item.Type = ItemType.Caster;
|
|
item.CombatUse = 2;
|
|
item.ValidLocations = EquipMask.Held;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(wand));
|
|
Assert.Equal(new[] { (bow, Player, 0) }, h.Puts);
|
|
|
|
// ACE begins the old weapon's stance shuffle before confirming the
|
|
// replacement wield.
|
|
h.Combat.SetCombatMode(CombatMode.Melee);
|
|
h.Combat.SetCombatMode(CombatMode.NonCombat);
|
|
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
|
|
Assert.Equal(new[] { (wand, (uint)EquipMask.Held) }, h.Wields);
|
|
|
|
Assert.True(h.Objects.ApplyConfirmedServerWield(
|
|
wand,
|
|
Player,
|
|
EquipMask.Held));
|
|
h.Combat.SetCombatMode(CombatMode.Magic);
|
|
Assert.Empty(h.CombatModeRequests);
|
|
|
|
// The queued dequip callback publishes one final Peace after ACE has
|
|
// already selected Magic. Reassert only at that ordered boundary.
|
|
h.Combat.SetCombatMode(CombatMode.NonCombat);
|
|
|
|
Assert.Equal(new[] { CombatMode.Magic }, h.CombatModeRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExplicitCombatRequest_cancelsPendingAutoWieldSettlement()
|
|
{
|
|
var h = new Harness();
|
|
h.Combat.SetCombatMode(CombatMode.Missile);
|
|
const uint bow = 0x50000B95u;
|
|
const uint wand = 0x50000B96u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = bow,
|
|
Type = ItemType.MissileWeapon,
|
|
ValidLocations = EquipMask.MissileWeapon,
|
|
});
|
|
h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon);
|
|
h.AddContained(wand, item =>
|
|
{
|
|
item.Type = ItemType.Caster;
|
|
item.ValidLocations = EquipMask.Held;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(wand));
|
|
h.Combat.SetCombatMode(CombatMode.NonCombat);
|
|
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
|
|
Assert.True(h.Objects.ApplyConfirmedServerWield(
|
|
wand,
|
|
Player,
|
|
EquipMask.Held));
|
|
h.Combat.SetCombatMode(CombatMode.Magic);
|
|
|
|
h.Controller.NotifyExplicitCombatModeRequest();
|
|
h.Combat.SetCombatMode(CombatMode.NonCombat);
|
|
|
|
Assert.Empty(h.CombatModeRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActiveWeaponReplacement_serverSettlesDirectly_doesNotReassertLaterPeace()
|
|
{
|
|
var h = new Harness();
|
|
h.Combat.SetCombatMode(CombatMode.Magic);
|
|
const uint wand = 0x50000BA1u;
|
|
const uint bow = 0x50000BA2u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = wand,
|
|
Type = ItemType.Caster,
|
|
CombatUse = 2,
|
|
ValidLocations = EquipMask.Held,
|
|
});
|
|
h.Objects.MoveItem(wand, Player, -1, EquipMask.Held);
|
|
h.AddContained(bow, item =>
|
|
{
|
|
item.Type = ItemType.MissileWeapon;
|
|
item.CombatUse = 2;
|
|
item.ValidLocations = EquipMask.MissileWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(bow));
|
|
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
|
|
Assert.True(h.Objects.ApplyConfirmedServerWield(
|
|
bow, Player, EquipMask.MissileWeapon));
|
|
|
|
h.Combat.SetCombatMode(CombatMode.Missile);
|
|
h.Combat.SetCombatMode(CombatMode.NonCombat);
|
|
|
|
Assert.Empty(h.CombatModeRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public void PreviouslyWieldedBow_AfterAuthoritativeUnwield_DoubleClickWieldsAgain()
|
|
{
|
|
var h = new Harness();
|
|
const uint bow = 0x50000B03u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = bow,
|
|
Name = "Bow",
|
|
Type = ItemType.MissileWeapon,
|
|
CombatUse = 1,
|
|
ValidLocations = EquipMask.MissileWeapon,
|
|
ContainerId = Player,
|
|
WielderId = Player,
|
|
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
|
|
});
|
|
|
|
// Retail ServerSaysMoveItem clears _wielderID when this bow leaves
|
|
// the paper doll. This is the login-existing-bow regression: leaving
|
|
// the old player id here makes DetermineUseResult say "already wielded."
|
|
Assert.True(h.Objects.ApplyServerMove(
|
|
bow,
|
|
Pack,
|
|
newWielderId: 0u,
|
|
newSlot: 0));
|
|
|
|
Assert.True(h.Controller.ActivateItem(bow));
|
|
|
|
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
|
|
}
|
|
|
|
[Fact]
|
|
public void WeaponReplacement_doesNotOverlapRequestsWhileAwaitingServer()
|
|
{
|
|
var h = new Harness();
|
|
const uint sword = 0x50000B11u;
|
|
const uint bow = 0x50000B12u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = sword,
|
|
Type = ItemType.MeleeWeapon,
|
|
CombatUse = 1,
|
|
ValidLocations = EquipMask.MeleeWeapon,
|
|
});
|
|
h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon);
|
|
h.AddContained(bow, item =>
|
|
{
|
|
item.Type = ItemType.MissileWeapon;
|
|
item.CombatUse = 1;
|
|
item.ValidLocations = EquipMask.MissileWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(bow));
|
|
h.Now += 200;
|
|
Assert.False(h.Controller.ActivateItem(bow));
|
|
|
|
Assert.Single(h.Puts);
|
|
Assert.Empty(h.Wields);
|
|
}
|
|
|
|
[Fact]
|
|
public void WeaponReplacement_doesNotOverlapWhileAwaitingFinalWieldConfirmation()
|
|
{
|
|
var h = new Harness();
|
|
const uint sword = 0x50000B15u;
|
|
const uint bow = 0x50000B16u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = sword,
|
|
Type = ItemType.MeleeWeapon,
|
|
CombatUse = 1,
|
|
ValidLocations = EquipMask.MeleeWeapon,
|
|
});
|
|
h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon);
|
|
h.AddContained(bow, item =>
|
|
{
|
|
item.Type = ItemType.MissileWeapon;
|
|
item.CombatUse = 2;
|
|
item.ValidLocations = EquipMask.MissileWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(bow));
|
|
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
|
|
Assert.Single(h.Wields);
|
|
|
|
// Bow is optimistic but ACE has not sent WieldObject yet. Retail's
|
|
// waiting state consumes this attempted reverse switch.
|
|
h.Now += 200;
|
|
Assert.False(h.Controller.ActivateItem(sword));
|
|
Assert.Single(h.Puts);
|
|
Assert.Single(h.Wields);
|
|
|
|
// A separate reconciliation record for the same item must not delay
|
|
// retail's exact IR_WIELD completion boundary.
|
|
Assert.True(h.Objects.WieldItemOptimistic(
|
|
bow, Player, EquipMask.MissileWeapon));
|
|
|
|
// Authoritative WieldObject completes AutoWield even
|
|
// though the object table still has another rollback record.
|
|
Assert.True(h.Objects.ApplyConfirmedServerWield(
|
|
bow,
|
|
Player,
|
|
EquipMask.MissileWeapon));
|
|
h.Now += 200;
|
|
Assert.True(h.Controller.ActivateItem(sword));
|
|
Assert.Equal(
|
|
new[] { (sword, Player, 0), (bow, Player, 0) },
|
|
h.Puts);
|
|
}
|
|
|
|
[Fact]
|
|
public void WeaponReplacement_serverReject_clearsPendingTransactionForRetry()
|
|
{
|
|
var h = new Harness();
|
|
const uint sword = 0x50000B21u;
|
|
const uint bow = 0x50000B22u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = sword,
|
|
Type = ItemType.MeleeWeapon,
|
|
CombatUse = 1,
|
|
ValidLocations = EquipMask.MeleeWeapon,
|
|
});
|
|
h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon);
|
|
h.AddContained(bow, item =>
|
|
{
|
|
item.Type = ItemType.MissileWeapon;
|
|
item.CombatUse = 1;
|
|
item.ValidLocations = EquipMask.MissileWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(bow));
|
|
Assert.False(h.Objects.RejectMove(sword, 0x04FFu));
|
|
h.Now += 200;
|
|
Assert.True(h.Controller.ActivateItem(bow));
|
|
|
|
Assert.Equal(2, h.Puts.Count);
|
|
Assert.Empty(h.Wields);
|
|
Assert.Equal(EquipMask.MeleeWeapon,
|
|
h.Objects.Get(sword)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void WeaponReplacement_bow_removesPrimaryThenIncompatibleShield()
|
|
{
|
|
var h = new Harness();
|
|
const uint sword = 0x50000B31u;
|
|
const uint shield = 0x50000B32u;
|
|
const uint bow = 0x50000B33u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = sword,
|
|
Type = ItemType.MeleeWeapon,
|
|
CombatUse = 1,
|
|
ValidLocations = EquipMask.MeleeWeapon,
|
|
});
|
|
h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = shield,
|
|
Type = ItemType.Armor,
|
|
CombatUse = 4,
|
|
ValidLocations = EquipMask.Shield,
|
|
});
|
|
h.Objects.MoveItem(shield, Player, -1, EquipMask.Shield);
|
|
h.AddContained(bow, item =>
|
|
{
|
|
item.Type = ItemType.MissileWeapon;
|
|
item.CombatUse = 2;
|
|
item.AmmoType = 1;
|
|
item.ValidLocations = EquipMask.MissileWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(bow));
|
|
Assert.Equal(new[] { (sword, Player, 0) }, h.Puts);
|
|
|
|
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
|
|
Assert.Equal(
|
|
new[] { (sword, Player, 0), (shield, Player, 0) },
|
|
h.Puts);
|
|
Assert.Empty(h.Wields);
|
|
|
|
h.Objects.MoveItem(shield, Player, 0, EquipMask.None);
|
|
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
|
|
}
|
|
|
|
[Fact]
|
|
public void WeaponReplacement_bow_removesMismatchedAmmoAfterPrimaryBlocker()
|
|
{
|
|
var h = new Harness();
|
|
const uint sword = 0x50000B41u;
|
|
const uint arrows = 0x50000B42u;
|
|
const uint bow = 0x50000B43u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = sword,
|
|
Type = ItemType.MeleeWeapon,
|
|
CombatUse = 1,
|
|
ValidLocations = EquipMask.MeleeWeapon,
|
|
});
|
|
h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = arrows,
|
|
Type = ItemType.MissileWeapon,
|
|
CombatUse = 3,
|
|
AmmoType = 2,
|
|
ValidLocations = EquipMask.MissileAmmo,
|
|
});
|
|
h.Objects.MoveItem(arrows, Player, -1, EquipMask.MissileAmmo);
|
|
h.AddContained(bow, item =>
|
|
{
|
|
item.Type = ItemType.MissileWeapon;
|
|
item.CombatUse = 2;
|
|
item.AmmoType = 1;
|
|
item.ValidLocations = EquipMask.MissileWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.ActivateItem(bow));
|
|
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
|
|
Assert.Equal(
|
|
new[] { (sword, Player, 0), (arrows, Player, 0) },
|
|
h.Puts);
|
|
|
|
h.Objects.MoveItem(arrows, Player, 0, EquipMask.None);
|
|
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
|
|
}
|
|
|
|
[Fact]
|
|
public void PaperdollWeaponSwitch_toSword_keepsEquippedArrows()
|
|
{
|
|
var h = new Harness();
|
|
const uint bow = 0x50000B51u;
|
|
const uint arrows = 0x50000B52u;
|
|
const uint sword = 0x50000B53u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = bow,
|
|
Name = "Shortbow",
|
|
Type = ItemType.MissileWeapon,
|
|
CombatUse = 2,
|
|
AmmoType = 1,
|
|
ValidLocations = EquipMask.MissileWeapon,
|
|
});
|
|
h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = arrows,
|
|
Name = "Arrows",
|
|
Type = ItemType.MissileWeapon,
|
|
CombatUse = 3,
|
|
AmmoType = 1,
|
|
ValidLocations = EquipMask.MissileAmmo,
|
|
});
|
|
h.Objects.MoveItem(arrows, Player, -1, EquipMask.MissileAmmo);
|
|
h.AddContained(sword, item =>
|
|
{
|
|
item.Name = "Katar";
|
|
item.Type = ItemType.MeleeWeapon;
|
|
item.CombatUse = 1;
|
|
item.ValidLocations = EquipMask.MeleeWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.WieldFromPaperdoll(sword, EquipMask.MeleeWeapon));
|
|
Assert.Equal(new[] { (bow, Player, 0) }, h.Puts);
|
|
Assert.Empty(h.Wields);
|
|
Assert.Equal(new[] { "Moving Shortbow to your backpack" }, h.SystemMessages);
|
|
|
|
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
|
|
|
|
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields);
|
|
Assert.Equal(EquipMask.MissileAmmo,
|
|
h.Objects.Get(arrows)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void PaperdollWeaponSwitch_fromWandToSword_movesHeldCasterFirst()
|
|
{
|
|
var h = new Harness();
|
|
const uint wand = 0x50000B54u;
|
|
const uint sword = 0x50000B55u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = wand,
|
|
Name = "Wand",
|
|
Type = ItemType.Caster,
|
|
ValidLocations = EquipMask.Held,
|
|
});
|
|
h.Objects.MoveItem(wand, Player, -1, EquipMask.Held);
|
|
h.AddContained(sword, item =>
|
|
{
|
|
item.Name = "Katar";
|
|
item.Type = ItemType.MeleeWeapon;
|
|
item.CombatUse = 1;
|
|
item.ValidLocations = EquipMask.MeleeWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.WieldFromPaperdoll(sword, EquipMask.MeleeWeapon));
|
|
Assert.Equal(new[] { (wand, Player, 0) }, h.Puts);
|
|
Assert.Empty(h.Wields);
|
|
Assert.Equal(new[] { "Moving Wand to your backpack" }, h.SystemMessages);
|
|
|
|
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
|
|
|
|
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields);
|
|
}
|
|
|
|
[Fact]
|
|
public void PaperdollWeaponSwitch_toCrossbow_movesIncompatibleArrowsAfterBow()
|
|
{
|
|
var h = new Harness();
|
|
const uint bow = 0x50000B61u;
|
|
const uint arrows = 0x50000B62u;
|
|
const uint crossbow = 0x50000B63u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = bow,
|
|
Name = "Shortbow",
|
|
Type = ItemType.MissileWeapon,
|
|
CombatUse = 2,
|
|
AmmoType = 1,
|
|
ValidLocations = EquipMask.MissileWeapon,
|
|
});
|
|
h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = arrows,
|
|
Name = "Arrows",
|
|
Type = ItemType.MissileWeapon,
|
|
CombatUse = 3,
|
|
AmmoType = 1,
|
|
ValidLocations = EquipMask.MissileAmmo,
|
|
});
|
|
h.Objects.MoveItem(arrows, Player, -1, EquipMask.MissileAmmo);
|
|
h.AddContained(crossbow, item =>
|
|
{
|
|
item.Name = "Heavy Crossbow";
|
|
item.Type = ItemType.MissileWeapon;
|
|
item.CombatUse = 2;
|
|
item.AmmoType = 2;
|
|
item.ValidLocations = EquipMask.MissileWeapon;
|
|
});
|
|
|
|
Assert.True(h.Controller.WieldFromPaperdoll(
|
|
crossbow, EquipMask.MissileWeapon));
|
|
Assert.Equal(new[] { (bow, Player, 0) }, h.Puts);
|
|
|
|
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
|
|
|
|
Assert.Equal(
|
|
new[] { (bow, Player, 0), (arrows, Player, 0) },
|
|
h.Puts);
|
|
Assert.Empty(h.Wields);
|
|
|
|
h.Objects.MoveItem(arrows, Player, 0, EquipMask.None);
|
|
|
|
Assert.Equal(
|
|
new[] { (crossbow, (uint)EquipMask.MissileWeapon) },
|
|
h.Wields);
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"Moving Shortbow to your backpack",
|
|
"Moving Arrows to your backpack",
|
|
},
|
|
h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void PaperdollDrop_relocatesAlreadyEquippedItemToExplicitCompatibleSlot()
|
|
{
|
|
var h = new Harness();
|
|
const uint ring = 0x50000B71u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = ring,
|
|
Name = "Ring",
|
|
Type = ItemType.Jewelry,
|
|
ValidLocations = EquipMask.FingerWearLeft | EquipMask.FingerWearRight,
|
|
});
|
|
h.Objects.MoveItem(ring, Player, -1, EquipMask.FingerWearLeft);
|
|
|
|
Assert.True(h.Controller.WieldFromPaperdoll(ring, EquipMask.FingerWearRight));
|
|
|
|
Assert.Equal(
|
|
new[] { (ring, (uint)EquipMask.FingerWearRight) },
|
|
h.Wields);
|
|
Assert.Equal(
|
|
EquipMask.FingerWearRight,
|
|
h.Objects.Get(ring)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void PaperdollDrop_movesExplicitDestinationBlockerBeforeRelocatingEquippedItem()
|
|
{
|
|
var h = new Harness();
|
|
const uint leftRing = 0x50000B72u;
|
|
const uint rightRing = 0x50000B73u;
|
|
EquipMask valid = EquipMask.FingerWearLeft | EquipMask.FingerWearRight;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = leftRing,
|
|
Name = "Left Ring",
|
|
Type = ItemType.Jewelry,
|
|
ValidLocations = valid,
|
|
});
|
|
h.Objects.MoveItem(leftRing, Player, -1, EquipMask.FingerWearLeft);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = rightRing,
|
|
Name = "Right Ring",
|
|
Type = ItemType.Jewelry,
|
|
ValidLocations = valid,
|
|
});
|
|
h.Objects.MoveItem(rightRing, Player, -1, EquipMask.FingerWearRight);
|
|
|
|
Assert.True(h.Controller.WieldFromPaperdoll(leftRing, EquipMask.FingerWearRight));
|
|
Assert.Equal(new[] { (rightRing, Player, 0) }, h.Puts);
|
|
Assert.Empty(h.Wields);
|
|
Assert.Equal(new[] { "Moving Right Ring to your backpack" }, h.SystemMessages);
|
|
|
|
h.Objects.MoveItem(rightRing, Player, 0, EquipMask.None);
|
|
|
|
Assert.Equal(
|
|
new[] { (leftRing, (uint)EquipMask.FingerWearRight) },
|
|
h.Wields);
|
|
Assert.Equal(
|
|
EquipMask.FingerWearRight,
|
|
h.Objects.Get(leftRing)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void BlocksUseOfShield_matchesRetailCombatUseAndAmmoRules()
|
|
{
|
|
Assert.True(AutoWieldController.BlocksUseOfShield(new ClientObject
|
|
{
|
|
CombatUse = 2,
|
|
AmmoType = 1,
|
|
}));
|
|
Assert.False(AutoWieldController.BlocksUseOfShield(new ClientObject
|
|
{
|
|
CombatUse = 2,
|
|
AmmoType = 0,
|
|
}));
|
|
Assert.True(AutoWieldController.BlocksUseOfShield(new ClientObject
|
|
{
|
|
CombatUse = 5,
|
|
}));
|
|
Assert.True(AutoWieldController.BlocksUseOfShield(new ClientObject
|
|
{
|
|
Type = ItemType.Caster,
|
|
}));
|
|
}
|
|
|
|
[Fact]
|
|
public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A07u);
|
|
var payload = new ItemDragPayload(
|
|
0x50000A07u,
|
|
ItemDragSource.Inventory,
|
|
SourceSlot: 0,
|
|
SourceCell: new UiItemSlot());
|
|
|
|
Assert.True(h.Controller.DropToWorld(payload));
|
|
|
|
Assert.Equal(new[] { 0x50000A07u }, h.Drops);
|
|
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-13 social gate round 2, item 5: a refused drop (0x00A0,
|
|
/// error 0x426 AttunedItem) composes ServerSaysAttemptFailed's
|
|
/// "The <item> can't be dropped" and routes it as ClientLocal —
|
|
/// the SpewBox's yellow top-center line. 0x426 has no HandleFailureEvent
|
|
/// row, so exactly ONE line appears; a failure with no latched request
|
|
/// shows nothing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RefusedDrop_ComposesCantBeDroppedLine_AsClientLocal()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x50000A07u;
|
|
h.AddContained(item);
|
|
Assert.True(h.Controller.DropToWorld(new ItemDragPayload(
|
|
item,
|
|
ItemDragSource.Inventory,
|
|
SourceSlot: 0,
|
|
SourceCell: new UiItemSlot())));
|
|
|
|
h.Objects.RejectMove(item, 0x426u);
|
|
|
|
(string text, RetailLogTextType type) = Assert.Single(h.InterfaceTexts);
|
|
Assert.Equal($"The Item {item:X} can't be dropped", text);
|
|
Assert.Equal(RetailLogTextType.ClientLocal, type);
|
|
|
|
// No latched request → retail shows nothing (and 0x426 stays out of
|
|
// the generic failure table).
|
|
h.InterfaceTexts.Clear();
|
|
h.Objects.RejectMove(item, 0x426u);
|
|
Assert.Empty(h.InterfaceTexts);
|
|
}
|
|
|
|
[Fact]
|
|
public void InventoryDragOnNpc_sendsGiveWithoutOptimisticInventoryMutation()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x50000A71u;
|
|
const uint npc = 0x50001001u;
|
|
h.AddContained(item);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = npc,
|
|
Name = "Starter Exit Warden",
|
|
Type = ItemType.Creature,
|
|
});
|
|
var payload = new ItemDragPayload(
|
|
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
|
|
|
|
Assert.True(h.Controller.PlaceIn3D(payload, npc));
|
|
|
|
Assert.Equal(new[] { (npc, item, 1u) }, h.Gives);
|
|
Assert.Empty(h.Drops);
|
|
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
|
|
Assert.Equal(1, h.Objects.Get(item)!.StackSize);
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectedPartialStackDragOnNpc_sendsSelectedGiveAmount()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x50000A72u;
|
|
const uint npc = 0x50001002u;
|
|
h.AddContained(item, value => value.StackSize = 10);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = npc,
|
|
Type = ItemType.Creature,
|
|
});
|
|
h.SelectedObject = item;
|
|
h.SplitQuantity.Reset(10u);
|
|
h.SplitQuantity.SetValue(3u);
|
|
var payload = new ItemDragPayload(
|
|
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
|
|
|
|
Assert.True(h.Controller.PlaceIn3D(payload, npc));
|
|
|
|
Assert.Equal(new[] { (npc, item, 3u) }, h.Gives);
|
|
Assert.Equal(10, h.Objects.Get(item)!.StackSize);
|
|
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void InventoryDragOnNonCreature_usesRetailGroundFallback()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x50000A73u;
|
|
const uint scenery = 0x50002001u;
|
|
h.AddContained(item);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = scenery,
|
|
Type = ItemType.Misc,
|
|
});
|
|
var payload = new ItemDragPayload(
|
|
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
|
|
|
|
Assert.True(h.Controller.PlaceIn3D(payload, scenery));
|
|
|
|
Assert.Empty(h.Gives);
|
|
Assert.Equal(new[] { item }, h.Drops);
|
|
Assert.Equal(0u, h.Objects.Get(item)!.ContainerId);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true, false)]
|
|
[InlineData(false, true)]
|
|
public void InventoryDragOnPlayer_honorsRetailSecureTradeOption(
|
|
bool opensSecureTrade,
|
|
bool sendsGive)
|
|
{
|
|
var h = new Harness { DragOnPlayerOpensSecureTrade = opensSecureTrade };
|
|
const uint item = 0x50000A74u;
|
|
const uint targetPlayer = 0x50003001u;
|
|
h.AddContained(item);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = targetPlayer,
|
|
Type = ItemType.Creature,
|
|
PublicWeenieBitfield = (uint)PublicWeenieFlags.Player,
|
|
});
|
|
var payload = new ItemDragPayload(
|
|
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
|
|
|
|
bool result = h.Controller.PlaceIn3D(payload, targetPlayer);
|
|
|
|
Assert.Equal(sendsGive, result);
|
|
if (sendsGive)
|
|
Assert.Equal(new[] { (targetPlayer, item, 1u) }, h.Gives);
|
|
else
|
|
{
|
|
Assert.Empty(h.Gives);
|
|
Assert.Contains(h.Toasts, text => text.Contains("Secure trade", StringComparison.Ordinal));
|
|
}
|
|
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectedPartialStackDragOutsideUi_splitsWithoutMovingOriginal()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A70u, item => item.StackSize = 10);
|
|
WorldDropDispatch? dispatched = null;
|
|
h.Controller.WorldDropDispatched += value => dispatched = value;
|
|
h.SelectedObject = 0x50000A70u;
|
|
h.SplitQuantity.Reset(10u);
|
|
h.SplitQuantity.SetValue(1u);
|
|
var payload = new ItemDragPayload(
|
|
0x50000A70u,
|
|
ItemDragSource.Inventory,
|
|
SourceSlot: 0,
|
|
SourceCell: new UiItemSlot());
|
|
|
|
Assert.True(h.Controller.DropToWorld(payload));
|
|
|
|
Assert.Equal(new[] { (0x50000A70u, 1u) }, h.SplitDrops);
|
|
Assert.Empty(h.Drops);
|
|
Assert.Equal(Pack, h.Objects.Get(0x50000A70u)!.ContainerId);
|
|
Assert.Equal(10, h.Objects.Get(0x50000A70u)!.StackSize);
|
|
Assert.NotNull(dispatched);
|
|
Assert.Equal(InventoryRequestKind.SplitToWorld, dispatched.Value.Request.Kind);
|
|
Assert.Equal(0x50000A70u, dispatched.Value.Request.ItemId);
|
|
Assert.Equal(1u, dispatched.Value.Amount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToolbarShortcutDragOutsideUi_doesNotDropRealItem()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A08u);
|
|
var payload = new ItemDragPayload(
|
|
0x50000A08u,
|
|
ItemDragSource.ShortcutBar,
|
|
SourceSlot: 0,
|
|
SourceCell: new UiItemSlot());
|
|
|
|
Assert.False(h.Controller.DropToWorld(payload));
|
|
|
|
Assert.Empty(h.Drops);
|
|
Assert.Equal(Pack, h.Objects.Get(0x50000A08u)!.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActivateItem_appliesRetailUseThrottle()
|
|
{
|
|
var h = new Harness();
|
|
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;
|
|
h.Controller.ActivateItem(0x50000A09u);
|
|
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeferredUseCancellationReleasesExactlyItsBusyReference()
|
|
{
|
|
ItemUseRequestReservation? pending = null;
|
|
var h = new Harness((_, reservation) => pending = reservation);
|
|
h.AddContained(
|
|
0x50000A0Bu,
|
|
item => item.Useability = ItemUseability.Contained);
|
|
|
|
Assert.True(h.Controller.ActivateItem(0x50000A0Bu));
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
|
|
Assert.NotNull(pending);
|
|
pending.CancelBeforeDispatch();
|
|
pending.CancelBeforeDispatch();
|
|
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
Assert.True(h.Controller.CanMakeInventoryRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public void DispatchedUseReservationIsReleasedOnlyByUseDone()
|
|
{
|
|
ItemUseRequestReservation? pending = null;
|
|
var h = new Harness((_, reservation) => pending = reservation);
|
|
h.AddContained(
|
|
0x50000A0Cu,
|
|
item => item.Useability = ItemUseability.Contained);
|
|
|
|
Assert.True(h.Controller.ActivateItem(0x50000A0Cu));
|
|
Assert.NotNull(pending);
|
|
pending.MarkDispatched();
|
|
pending.CancelBeforeDispatch();
|
|
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
|
|
h.Controller.CompleteUse(0u);
|
|
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionResetInvalidatesLateUseReservationResolution()
|
|
{
|
|
ItemUseRequestReservation? stale = null;
|
|
var h = new Harness((_, reservation) => stale = reservation);
|
|
h.AddContained(
|
|
0x50000A0Du,
|
|
item => item.Useability = ItemUseability.Contained);
|
|
|
|
Assert.True(h.Controller.ActivateItem(0x50000A0Du));
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
|
|
h.Controller.ResetSession();
|
|
stale!.CancelBeforeDispatch();
|
|
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
Assert.True(h.Controller.CanMakeInventoryRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public void KeyboardPickup_PublishesPendingDestinationBeforeRequest()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x70000A0Bu;
|
|
var pending = new List<PendingBackpackPlacement>();
|
|
h.Controller.PendingBackpackPlacementRequested += pending.Add;
|
|
|
|
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
|
|
|
|
Assert.Collection(
|
|
pending,
|
|
placement =>
|
|
{
|
|
Assert.NotEqual(0u, placement.Token);
|
|
Assert.Equal(item, placement.ItemId);
|
|
Assert.Equal(Player, placement.ContainerId);
|
|
Assert.Equal(0, placement.Placement);
|
|
});
|
|
Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements);
|
|
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var request));
|
|
Assert.Equal(InventoryRequestKind.Pickup, request.Kind);
|
|
Assert.False(request.Dispatched);
|
|
|
|
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.Pickup,
|
|
item,
|
|
static () => true,
|
|
pending[0].Token));
|
|
Assert.True(h.Controller.TryGetPendingInventoryRequest(out request));
|
|
Assert.True(request.Dispatched);
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingRequestSerializesEveryLaterItemWithoutSecondWire()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x70000A0Du;
|
|
const uint laterItem = 0x70000A0Eu;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = item,
|
|
Name = "Loot",
|
|
Type = ItemType.Misc,
|
|
});
|
|
var requested = new List<PendingBackpackPlacement>();
|
|
var cancelled = new List<PendingBackpackPlacement>();
|
|
h.Controller.PendingBackpackPlacementRequested += requested.Add;
|
|
h.Controller.PendingBackpackPlacementCancelled += cancelled.Add;
|
|
|
|
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
|
|
PendingBackpackPlacement first = Assert.Single(requested);
|
|
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
|
|
Assert.True(h.Controller.PlaceWorldItemInBackpack(laterItem));
|
|
|
|
Assert.Single(requested);
|
|
Assert.Empty(cancelled);
|
|
Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements);
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"Already attempting to place Loot here",
|
|
"Already attempting to place Loot here",
|
|
},
|
|
h.SystemMessages);
|
|
Assert.True(h.Controller.TryGetPendingBackpackPlacement(item, out var current));
|
|
Assert.Equal(first, current);
|
|
|
|
h.Objects.RejectMove(item, weenieError: 0x29u);
|
|
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
|
|
Assert.Empty(cancelled);
|
|
}
|
|
|
|
[Fact]
|
|
public void BusyInventoryTransactionPreventsPickupReservationAndWireRequest()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x70000A0Fu;
|
|
h.Controller.IncrementBusyCount();
|
|
|
|
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
|
|
|
|
Assert.Empty(h.BackpackPlacements);
|
|
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
|
|
Assert.Equal(
|
|
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
|
h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingPickupPreventsContainedUseRequest()
|
|
{
|
|
var h = new Harness();
|
|
const uint pickup = 0x70000A10u;
|
|
const uint contained = 0x50000A11u;
|
|
h.AddContained(contained, item => item.Useability = ItemUseability.Contained);
|
|
|
|
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
|
|
h.Now += 200;
|
|
h.Controller.ActivateItem(contained);
|
|
|
|
Assert.Empty(h.Uses);
|
|
Assert.True(h.Controller.TryGetPendingBackpackPlacement(pickup, out _));
|
|
Assert.Equal(
|
|
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
|
h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingPickupPreventsPaperdollWieldRequest()
|
|
{
|
|
var h = new Harness();
|
|
const uint pickup = 0x70000A12u;
|
|
const uint helm = 0x50000A13u;
|
|
h.AddContained(helm, item => item.ValidLocations = EquipMask.HeadWear);
|
|
|
|
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
|
|
Assert.False(h.Controller.WieldFromPaperdoll(helm, EquipMask.HeadWear));
|
|
|
|
Assert.Empty(h.Wields);
|
|
Assert.Equal(EquipMask.None, h.Objects.Get(helm)!.CurrentlyEquippedLocation);
|
|
Assert.Equal(
|
|
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
|
h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingPickupPreventsConfirmedUseRequest()
|
|
{
|
|
var h = new Harness();
|
|
const uint pickup = 0x70000A14u;
|
|
const uint item = 0x50000A15u;
|
|
h.AddContained(item, contained => contained.Useability = ItemUseability.Contained);
|
|
|
|
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
|
|
Assert.False(h.Controller.ExecuteConfirmedUse(item));
|
|
|
|
Assert.Empty(h.Uses);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
Assert.Equal(
|
|
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
|
h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void GlobalInventoryRequestRejectsPickupUntilMatchingMoveResponse()
|
|
{
|
|
var h = new Harness();
|
|
const uint moving = 0x50000A16u;
|
|
const uint unrelated = 0x50000A17u;
|
|
const uint pickup = 0x70000A18u;
|
|
h.AddContained(moving);
|
|
h.AddContained(unrelated);
|
|
|
|
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.PutInContainer,
|
|
moving,
|
|
static () => true));
|
|
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
|
|
|
|
Assert.Empty(h.BackpackPlacements);
|
|
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
|
Assert.Equal(moving, pending.ItemId);
|
|
Assert.Equal(
|
|
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
|
h.SystemMessages);
|
|
|
|
h.Objects.ApplyConfirmedServerMove(unrelated, Pack, 0u, 0);
|
|
Assert.True(h.Controller.TryGetPendingInventoryRequest(out _));
|
|
|
|
h.Objects.ApplyConfirmedServerMove(moving, Pack, 0u, 0);
|
|
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
|
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
|
|
Assert.Equal(new[] { (pickup, Player, 0) }, h.BackpackPlacements);
|
|
}
|
|
|
|
[Fact]
|
|
public void SplitRequestReleasesOnlyOnSourceStackResponse()
|
|
{
|
|
var h = new Harness();
|
|
const uint source = 0x50000A19u;
|
|
const uint unrelated = 0x50000A1Au;
|
|
h.AddContained(source, item => item.StackSize = 10);
|
|
h.AddContained(unrelated, item => item.StackSize = 10);
|
|
|
|
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.SplitToContainer,
|
|
source,
|
|
static () => true));
|
|
h.Objects.UpdateStackSize(unrelated, 9, 0);
|
|
Assert.True(h.Controller.TryGetPendingInventoryRequest(out _));
|
|
|
|
h.Objects.UpdateStackSize(source, 9, 0);
|
|
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void MatchingInventoryFailureReleasesGlobalRequest()
|
|
{
|
|
var h = new Harness();
|
|
const uint source = 0x50000A1Bu;
|
|
h.AddContained(source);
|
|
|
|
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.PutInContainer,
|
|
source,
|
|
static () => true));
|
|
|
|
h.Objects.RejectMove(source, weenieError: 0x29u);
|
|
|
|
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
|
Assert.True(h.Controller.CanMakeInventoryRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProvisionalInventoryOwnerRejectsReentrantSecondDispatch()
|
|
{
|
|
var h = new Harness();
|
|
const uint first = 0x50000A1Cu;
|
|
const uint second = 0x50000A1Du;
|
|
h.AddContained(first);
|
|
h.AddContained(second);
|
|
bool secondDispatched = true;
|
|
|
|
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.PutInContainer,
|
|
first,
|
|
() =>
|
|
{
|
|
secondDispatched = h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.PutInContainer,
|
|
second,
|
|
static () => true);
|
|
return true;
|
|
}));
|
|
|
|
Assert.False(secondDispatched);
|
|
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
|
Assert.Equal(first, pending.ItemId);
|
|
Assert.True(pending.Dispatched);
|
|
Assert.Equal(
|
|
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
|
h.SystemMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void SynchronousMatchingResponseDoesNotResurrectProvisionalOwner()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x50000A1Eu;
|
|
h.AddContained(item);
|
|
|
|
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.PutInContainer,
|
|
item,
|
|
() => h.Objects.ApplyConfirmedServerMove(item, Player, 0u, 0)));
|
|
|
|
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
|
Assert.True(h.Controller.CanMakeInventoryRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public void OptimisticMoveKeepsGlobalOwnerUntilAuthoritativeResponse()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x50000A20u;
|
|
const uint second = 0x50000A21u;
|
|
h.AddContained(item);
|
|
h.AddContained(second);
|
|
|
|
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.PutInContainer,
|
|
item,
|
|
() => h.Objects.MoveItemOptimistic(item, Player, 0)));
|
|
|
|
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
|
Assert.Equal(item, pending.ItemId);
|
|
Assert.True(pending.Dispatched);
|
|
Assert.False(h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.PutInContainer,
|
|
second,
|
|
static () => true));
|
|
|
|
Assert.True(h.Objects.ApplyConfirmedServerMove(item, Player, 0u, 0));
|
|
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoritativeResponseReleasesGlobalOwnerBeforePublishingPlacementResolution()
|
|
{
|
|
var h = new Harness();
|
|
const uint first = 0x70000A22u;
|
|
const uint second = 0x50000A23u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = first,
|
|
Name = "Loot",
|
|
Type = ItemType.Misc,
|
|
});
|
|
h.AddContained(second);
|
|
bool secondDispatched = false;
|
|
h.Controller.PendingBackpackPlacementResolved += _ =>
|
|
secondDispatched = h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.PutInContainer,
|
|
second,
|
|
static () => true);
|
|
|
|
Assert.True(h.Controller.TryDispatchPendingBackpackPlacement(
|
|
first,
|
|
Player,
|
|
0,
|
|
InventoryRequestKind.Pickup,
|
|
static () => true));
|
|
|
|
Assert.True(h.Objects.ApplyConfirmedServerMove(first, Player, 0u, 0));
|
|
|
|
Assert.True(secondDispatched);
|
|
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
|
Assert.Equal(second, pending.ItemId);
|
|
Assert.True(pending.Dispatched);
|
|
}
|
|
|
|
[Fact]
|
|
public void FailedMoveReleasesOldOwnerOnceAndPreservesReentrantSameItemRequest()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x50000A24u;
|
|
h.AddContained(item);
|
|
bool replacementDispatched = false;
|
|
h.Controller.PendingBackpackPlacementResolved += _ =>
|
|
replacementDispatched = h.Controller.TryDispatchInventoryRequest(
|
|
InventoryRequestKind.PutInContainer,
|
|
item,
|
|
static () => true);
|
|
|
|
Assert.True(h.Controller.TryDispatchPendingBackpackPlacement(
|
|
item,
|
|
Player,
|
|
0,
|
|
InventoryRequestKind.PutInContainer,
|
|
() => h.Objects.MoveItemOptimistic(item, Player, 0)));
|
|
|
|
Assert.True(h.Objects.RejectMove(item, weenieError: 0x29u));
|
|
|
|
Assert.True(replacementDispatched);
|
|
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
|
Assert.Equal(item, pending.ItemId);
|
|
Assert.True(pending.Dispatched);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResponseClearsGlobalAndLocalStateBeforeReadinessNotification()
|
|
{
|
|
var h = new Harness();
|
|
const uint first = 0x70000A25u;
|
|
const uint second = 0x70000A26u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = first,
|
|
Name = "First loot",
|
|
Type = ItemType.Misc,
|
|
});
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = second,
|
|
Name = "Second loot",
|
|
Type = ItemType.Misc,
|
|
});
|
|
Assert.True(h.Controller.TryDispatchPendingBackpackPlacement(
|
|
first,
|
|
Player,
|
|
0,
|
|
InventoryRequestKind.Pickup,
|
|
static () => true));
|
|
|
|
var eventOrder = new List<string>();
|
|
h.Controller.PendingBackpackPlacementResolved += placement =>
|
|
{
|
|
if (placement.ItemId == first)
|
|
eventOrder.Add("resolved-first");
|
|
};
|
|
h.Controller.PendingBackpackPlacementRequested += placement =>
|
|
{
|
|
if (placement.ItemId == second)
|
|
eventOrder.Add("requested-second");
|
|
};
|
|
bool attempted = false;
|
|
bool secondDispatched = false;
|
|
h.Controller.StateChanged += () =>
|
|
{
|
|
if (attempted || !h.Controller.CanMakeInventoryRequest)
|
|
return;
|
|
attempted = true;
|
|
eventOrder.Add("ready");
|
|
secondDispatched = h.Controller.TryDispatchPendingBackpackPlacement(
|
|
second,
|
|
Player,
|
|
0,
|
|
InventoryRequestKind.Pickup,
|
|
static () => true);
|
|
};
|
|
|
|
Assert.True(h.Objects.ApplyConfirmedServerMove(first, Player, 0u, 0));
|
|
|
|
Assert.True(attempted);
|
|
Assert.True(secondDispatched);
|
|
Assert.True(h.Controller.TryGetPendingBackpackPlacement(second, out _));
|
|
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
|
Assert.Equal(second, pending.ItemId);
|
|
Assert.True(pending.Dispatched);
|
|
Assert.Equal(
|
|
new[] { "resolved-first", "ready", "requested-second" },
|
|
eventOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReentrantPendingPublicationCancellationPreventsWireDispatch()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x70000A1Fu;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = item,
|
|
Name = "Loot",
|
|
Type = ItemType.Misc,
|
|
});
|
|
bool wireDispatched = false;
|
|
h.Controller.PendingBackpackPlacementRequested += pending =>
|
|
h.Controller.CancelPendingBackpackPlacement(pending.ItemId, pending.Token);
|
|
|
|
Assert.False(h.Controller.TryDispatchPendingBackpackPlacement(
|
|
item,
|
|
Player,
|
|
0,
|
|
InventoryRequestKind.Pickup,
|
|
() =>
|
|
{
|
|
wireDispatched = true;
|
|
return true;
|
|
}));
|
|
|
|
Assert.False(wireDispatched);
|
|
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
|
|
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void ThrowingReservedDispatchWithdrawsBothOwnersBeforeRethrow()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x70000A27u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = item,
|
|
Name = "Loot",
|
|
Type = ItemType.Misc,
|
|
});
|
|
var cancelled = new List<PendingBackpackPlacement>();
|
|
h.Controller.PendingBackpackPlacementCancelled += cancelled.Add;
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
h.Controller.TryDispatchPendingBackpackPlacement(
|
|
item,
|
|
Player,
|
|
0,
|
|
InventoryRequestKind.Pickup,
|
|
static () => throw new InvalidOperationException("transport")));
|
|
|
|
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
|
|
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
|
Assert.Single(cancelled);
|
|
}
|
|
|
|
[Fact]
|
|
public void ThrowingPendingProjectionObserverRollsBackEveryOwner()
|
|
{
|
|
var h = new Harness();
|
|
const uint item = 0x70000A28u;
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = item,
|
|
Name = "Loot",
|
|
Type = ItemType.Misc,
|
|
});
|
|
bool firstObserved = false;
|
|
bool cancelled = false;
|
|
h.Controller.PendingBackpackPlacementRequested += _ =>
|
|
firstObserved = true;
|
|
h.Controller.PendingBackpackPlacementRequested += _ =>
|
|
throw new InvalidOperationException("projection");
|
|
h.Controller.PendingBackpackPlacementCancelled += _ =>
|
|
cancelled = true;
|
|
|
|
Assert.Throws<AggregateException>(() =>
|
|
h.Controller.TryDispatchPendingBackpackPlacement(
|
|
item,
|
|
Player,
|
|
0,
|
|
InventoryRequestKind.Pickup,
|
|
static () => true));
|
|
|
|
Assert.True(firstObserved);
|
|
Assert.True(cancelled);
|
|
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
|
|
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetSession_ClearsTargetBusyThrottleAndConsumedClickState()
|
|
{
|
|
var h = new Harness();
|
|
const uint kit = 0x50000A0Cu;
|
|
h.AddContained(kit, item => item.Useability = HealthKitUseability);
|
|
Assert.True(h.Controller.ActivateItem(kit));
|
|
h.Controller.IncrementBusyCount();
|
|
|
|
h.Controller.ResetSession();
|
|
|
|
Assert.False(h.Controller.IsAnyTargetModeActive);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
Assert.Equal(InteractionModeKind.None, h.Controller.InteractionState.Current.Kind);
|
|
}
|
|
|
|
// ── Slice 6.3: TryBuy ───────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void TryBuy_Succeeds_SendsBuyAndTakesTheSharedUseReservation()
|
|
{
|
|
var h = new Harness();
|
|
|
|
bool result = h.Controller.TryBuy(
|
|
vendorGuid: 0x40001000u,
|
|
itemGuid: 0x50002000u,
|
|
amount: 1,
|
|
alternateCurrencyId: 0u);
|
|
|
|
Assert.True(result);
|
|
Assert.Equal(
|
|
new[] { (0x40001000u, 0x50002000u, 1, 0u) },
|
|
h.Buys);
|
|
// BeginUseRequestReservation increments BusyCount synchronously,
|
|
// before/independent of any wire response -- this is what makes the
|
|
// Buy button disable immediately (research doc §A.4: "no second
|
|
// gate", the SAME BusyCount>0 check every other request rides).
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuy_StackedQuantity_ForwardsTheExactAmount()
|
|
{
|
|
var h = new Harness();
|
|
|
|
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002001u, 25, 0u));
|
|
|
|
Assert.Equal(25, h.Buys.Single().Amount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuy_AlternateCurrencyVendor_ForwardsTheCurrencyWcid()
|
|
{
|
|
var h = new Harness();
|
|
|
|
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0x12345678u));
|
|
|
|
Assert.Equal(0x12345678u, h.Buys.Single().AlternateCurrencyId);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuy_WhileAnotherRequestIsBusy_IsRejectedAndSendsNothing()
|
|
{
|
|
var h = new Harness();
|
|
h.Controller.IncrementBusyCount(); // simulates any other in-flight request
|
|
|
|
bool result = h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u);
|
|
|
|
Assert.False(result);
|
|
Assert.Empty(h.Buys);
|
|
Assert.Equal(1, h.Controller.BusyCount); // unchanged -- no second reservation taken
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuy_ASecondBuyWhileTheFirstIsInFlight_IsRejected()
|
|
{
|
|
var h = new Harness();
|
|
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u));
|
|
|
|
bool second = h.Controller.TryBuy(0x40001000u, 0x50002001u, 1, 0u);
|
|
|
|
Assert.False(second);
|
|
Assert.Single(h.Buys);
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0u, 0x50002000u, 1)]
|
|
[InlineData(0x40001000u, 0u, 1)]
|
|
[InlineData(0x40001000u, 0x50002000u, 0)]
|
|
[InlineData(0x40001000u, 0x50002000u, -1)]
|
|
public void TryBuy_InvalidArguments_IsRejectedWithoutTakingAReservation(
|
|
uint vendorGuid, uint itemGuid, int amount)
|
|
{
|
|
var h = new Harness();
|
|
|
|
bool result = h.Controller.TryBuy(vendorGuid, itemGuid, amount, 0u);
|
|
|
|
Assert.False(result);
|
|
Assert.Empty(h.Buys);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuy_CompleteUse_ReleasesTheReservationAndReenablesFurtherRequests()
|
|
{
|
|
// Research doc §A.4: UseDone (0x01C7) is the completion signal for
|
|
// Buy, resolved through the SAME RuntimeInteractionTransactionState.
|
|
// CompleteUse the existing UseDone handler already calls -- no new
|
|
// completion plumbing needed on the receive side.
|
|
var h = new Harness();
|
|
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u));
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
|
|
h.Controller.CompleteUse(0);
|
|
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
// The gate is free again -- a second Buy can now proceed.
|
|
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002001u, 1, 0u));
|
|
Assert.Equal(2, h.Buys.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuy_FailedUseDone_AlsoReleasesTheReservation()
|
|
{
|
|
// A.2's failure paths all still end in exactly one SendUseDoneEvent
|
|
// -- success or failure, the reservation resolves the same way.
|
|
var h = new Harness();
|
|
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u));
|
|
|
|
h.Controller.CompleteUse(0x0009u); // an arbitrary nonzero WeenieError
|
|
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuy_NoSessionToSendOn_ReleasesTheReservation_AndASubsequentBuyWorks()
|
|
{
|
|
// F4 (Slice 6 review): the composition-root sendBuy delegate
|
|
// returns false when there is no live session (or it's not in
|
|
// world) -- BEFORE this fix, sendBuy was a plain Action, so TryBuy
|
|
// could not tell "sent" apart from a silent no-op and always
|
|
// called MarkDispatched(). Since nothing was actually sent, no
|
|
// UseDone would ever arrive to balance it, leaking BusyCount and
|
|
// permanently wedging every future Use/Buy behind "You can only
|
|
// move or use one item at a time."
|
|
var h = new Harness();
|
|
h.SendBuySucceeds = false;
|
|
|
|
bool result = h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u);
|
|
|
|
Assert.False(result);
|
|
Assert.Empty(h.Buys);
|
|
// The reservation was cancelled, not dispatched -- the gate is
|
|
// free again immediately, with no UseDone needed to release it.
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
|
|
// A subsequent buy, once a session IS available, proceeds normally.
|
|
h.SendBuySucceeds = true;
|
|
bool second = h.Controller.TryBuy(0x40001000u, 0x50002001u, 1, 0u);
|
|
|
|
Assert.True(second);
|
|
Assert.Single(h.Buys);
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
}
|
|
|
|
// ── Slice 6b: TryBuyAll ──────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void TryBuyAll_Succeeds_SendsTheBatchAndTakesTheSharedUseReservation()
|
|
{
|
|
var h = new Harness();
|
|
var items = new (int Amount, uint ItemGuid)[]
|
|
{
|
|
(1, 0x50002000u),
|
|
(25, 0x50002001u),
|
|
};
|
|
|
|
bool result = h.Controller.TryBuyAll(0x40001000u, items, alternateCurrencyId: 0u);
|
|
|
|
Assert.True(result);
|
|
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> sent, uint currency) = Assert.Single(h.BuyAlls);
|
|
Assert.Equal(0x40001000u, vendorGuid);
|
|
Assert.Equal(items, sent);
|
|
Assert.Equal(0u, currency);
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuyAll_RidesTheSameOneRequestAtATimeGateAsOrdinaryUse()
|
|
{
|
|
var h = new Harness();
|
|
h.Controller.IncrementBusyCount();
|
|
|
|
bool result = h.Controller.TryBuyAll(
|
|
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u);
|
|
|
|
Assert.False(result);
|
|
Assert.Empty(h.BuyAlls);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0u)]
|
|
[InlineData(0x40001000u)]
|
|
public void TryBuyAll_ZeroVendorOrEmptyList_IsRejectedWithoutTakingAReservation(uint vendorGuid)
|
|
{
|
|
var h = new Harness();
|
|
var items = vendorGuid == 0u
|
|
? new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }
|
|
: Array.Empty<(int Amount, uint ItemGuid)>();
|
|
|
|
bool result = h.Controller.TryBuyAll(vendorGuid, items, 0u);
|
|
|
|
Assert.False(result);
|
|
Assert.Empty(h.BuyAlls);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuyAll_CompleteUse_ReleasesTheReservation()
|
|
{
|
|
var h = new Harness();
|
|
Assert.True(h.Controller.TryBuyAll(
|
|
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u));
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
|
|
h.Controller.CompleteUse(0);
|
|
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBuyAll_NoSessionToSendOn_ReleasesTheReservation()
|
|
{
|
|
var h = new Harness();
|
|
h.SendBuyAllSucceeds = false;
|
|
|
|
bool result = h.Controller.TryBuyAll(
|
|
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u);
|
|
|
|
Assert.False(result);
|
|
Assert.Empty(h.BuyAlls);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
}
|
|
|
|
// ── Slice 6c: TrySell ────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void TrySell_Succeeds_SendsTheBatchAndTakesTheSharedUseReservation()
|
|
{
|
|
var h = new Harness();
|
|
var items = new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) };
|
|
|
|
bool result = h.Controller.TrySell(0x40001000u, items);
|
|
|
|
Assert.True(result);
|
|
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> sent) = Assert.Single(h.Sells);
|
|
Assert.Equal(0x40001000u, vendorGuid);
|
|
Assert.Equal(items, sent);
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TrySell_RidesTheSameOneRequestAtATimeGateAsOrdinaryUse()
|
|
{
|
|
var h = new Harness();
|
|
h.Controller.IncrementBusyCount();
|
|
|
|
bool result = h.Controller.TrySell(
|
|
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) });
|
|
|
|
Assert.False(result);
|
|
Assert.Empty(h.Sells);
|
|
}
|
|
|
|
[Fact]
|
|
public void TrySell_ASecondSellWhileTheFirstIsInFlight_IsRejected()
|
|
{
|
|
var h = new Harness();
|
|
Assert.True(h.Controller.TrySell(
|
|
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }));
|
|
|
|
bool second = h.Controller.TrySell(
|
|
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) });
|
|
|
|
Assert.False(second);
|
|
Assert.Single(h.Sells);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0u)]
|
|
[InlineData(0x40001000u)]
|
|
public void TrySell_ZeroVendorOrEmptyList_IsRejectedWithoutTakingAReservation(uint vendorGuid)
|
|
{
|
|
var h = new Harness();
|
|
var items = vendorGuid == 0u
|
|
? new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }
|
|
: Array.Empty<(int Amount, uint ItemGuid)>();
|
|
|
|
bool result = h.Controller.TrySell(vendorGuid, items);
|
|
|
|
Assert.False(result);
|
|
Assert.Empty(h.Sells);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TrySell_CompleteUse_ReleasesTheReservationAndReenablesFurtherRequests()
|
|
{
|
|
var h = new Harness();
|
|
Assert.True(h.Controller.TrySell(
|
|
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }));
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
|
|
h.Controller.CompleteUse(0);
|
|
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
Assert.True(h.Controller.TrySell(
|
|
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) }));
|
|
Assert.Equal(2, h.Sells.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void TrySell_NoSessionToSendOn_ReleasesTheReservation_AndASubsequentSellWorks()
|
|
{
|
|
var h = new Harness();
|
|
h.SendSellSucceeds = false;
|
|
|
|
bool result = h.Controller.TrySell(
|
|
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) });
|
|
|
|
Assert.False(result);
|
|
Assert.Empty(h.Sells);
|
|
Assert.Equal(0, h.Controller.BusyCount);
|
|
|
|
h.SendSellSucceeds = true;
|
|
bool second = h.Controller.TrySell(
|
|
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) });
|
|
|
|
Assert.True(second);
|
|
Assert.Single(h.Sells);
|
|
Assert.Equal(1, h.Controller.BusyCount);
|
|
}
|
|
}
|