Clear retail AutoWield on the matching authoritative WieldObject instead of waiting for unrelated rollback bookkeeping to drain. This prevents a completed switch from consuming the next weapon activation locally, including crossbows. Co-Authored-By: Codex <codex@openai.com>
772 lines
26 KiB
C#
772 lines
26 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.Core.Items;
|
|
|
|
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> Drops = new();
|
|
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
|
|
public readonly List<string> Toasts = new();
|
|
public readonly StackSplitQuantityState SplitQuantity = new();
|
|
public uint SelectedObject;
|
|
public bool NonCombatMode;
|
|
public long Now = 1_000;
|
|
|
|
public Harness()
|
|
{
|
|
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);
|
|
|
|
Controller = new ItemInteractionController(
|
|
Objects,
|
|
playerGuid: () => Player,
|
|
sendUse: Uses.Add,
|
|
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,
|
|
sendPutItemInContainer: (item, container, placement) =>
|
|
Puts.Add((item, container, placement)));
|
|
}
|
|
|
|
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 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.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.False(h.Controller.IsAnyTargetModeActive);
|
|
}
|
|
|
|
[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 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,
|
|
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);
|
|
}
|
|
|
|
[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.ValidLocations = EquipMask.Shield;
|
|
});
|
|
|
|
Assert.False(h.Controller.ActivateItem(0x50000A06u));
|
|
|
|
Assert.Empty(h.Wields);
|
|
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);
|
|
}
|
|
|
|
[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 + ConfirmWield completes AutoWield even
|
|
// though the object table still has another rollback record.
|
|
h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon);
|
|
h.Objects.ConfirmWield(bow);
|
|
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 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);
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectedPartialStackDragOutsideUi_splitsWithoutMovingOriginal()
|
|
{
|
|
var h = new Harness();
|
|
h.AddContained(0x50000A70u, item => item.StackSize = 10);
|
|
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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|