fix(client): restore retail interaction parity
Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
|
|
@ -17,6 +17,22 @@ public sealed class ChatCommandTargetStateTests
|
|||
Assert.Equal("Caith", targets.LastOutgoingTellTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TracksIndependentMonarchAndPatronReplyTargetsFromLegacyBroadcasts()
|
||||
{
|
||||
var chat = new ChatLog();
|
||||
using var targets = new ChatCommandTargetState(chat);
|
||||
|
||||
// 0x0147 does not carry a sender GUID; the incoming sender name is
|
||||
// nevertheless authoritative for retail's monarch/patron reply keys.
|
||||
chat.OnChannelBroadcast(0x4000u, "Monarch", "orders");
|
||||
chat.OnChannelBroadcast(0x2000u, "Patron", "hello");
|
||||
chat.OnChannelBroadcast(0x4000u, "New Monarch", "new orders");
|
||||
|
||||
Assert.Equal("New Monarch", targets.LastMonarchSender);
|
||||
Assert.Equal("Patron", targets.LastPatronSender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSessionForgetsTargetsButPreservesTranscript()
|
||||
{
|
||||
|
|
@ -29,6 +45,8 @@ public sealed class ChatCommandTargetStateTests
|
|||
|
||||
Assert.Null(targets.LastIncomingTellSender);
|
||||
Assert.Null(targets.LastOutgoingTellTarget);
|
||||
Assert.Null(targets.LastMonarchSender);
|
||||
Assert.Null(targets.LastPatronSender);
|
||||
Assert.Equal(2, chat.Count);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ public sealed class InventoryFailureMessagesTests
|
|||
[InlineData(
|
||||
InventoryRequestKind.SplitToContainer, "Arrows", 0x36u,
|
||||
"The Arrows can't be split - action cancelled")]
|
||||
[InlineData(
|
||||
InventoryRequestKind.Move, "Sword", 0u,
|
||||
"The Sword can't be moved")]
|
||||
[InlineData(
|
||||
InventoryRequestKind.Wield, "Sword", 0x1Du,
|
||||
"The Sword can't be wielded - you're too busy")]
|
||||
public void ComposeMatchesServerSaysAttemptFailed(
|
||||
InventoryRequestKind kind,
|
||||
string name,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Input;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using DatReaderWriter;
|
||||
using Xunit;
|
||||
|
|
@ -9,84 +10,13 @@ using Xunit;
|
|||
namespace AcDream.Core.Tests.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP8: pins <see cref="RetailActionIdentityTable"/>'s agreement
|
||||
/// with <see cref="KeyBindings.RetailDefaults"/> — for every <see cref="InputAction"/>
|
||||
/// this slice's table resolves, the UNION of DAT default bindings across every DAT
|
||||
/// row mapped to that action must equal <c>KeyBindings.RetailDefaults()</c>'s chord
|
||||
/// set for it. Per the slice contract: "investigate + report any disagreement rather
|
||||
/// than silently preferring one." Skips cleanly when the installed dats are
|
||||
/// unavailable (CI), matching every other live-DAT conformance test in this project.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Two real, byte-verified disagreements survive after the mechanism fixes</b>
|
||||
/// (2026-08-11 investigation, updated at the M2 rework — none are bugs in this
|
||||
/// slice's table; both are PRE-EXISTING <see cref="KeyBindings.RetailDefaults"/>
|
||||
/// gaps/design choices this slice does not touch, listed in
|
||||
/// <see cref="KnownRetailDefaultsDisagreements"/> with citations). A THIRD
|
||||
/// disagreement — ten CameraAlternateControls (InputMap 0x6) actions — was RETIRED
|
||||
/// at the M2 rework: <see cref="RetailActionIdentityTable"/> no longer maps InputMap
|
||||
/// 0x6 to any <see cref="InputAction"/> at all (the aliasing that produced two
|
||||
/// independent rows fighting over one live target — M2, 2026-08-11 review), so this
|
||||
/// test never sees a ctx-0x6 row and the ctx-0x5-only union now matches
|
||||
/// <c>RetailDefaults()</c> exactly for all twelve Camera actions with no allowlist
|
||||
/// entry needed:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description><b>MovementWalkMode.</b> The DAT's raw <c>QualifiedControl.Modifier</c>
|
||||
/// for the Shift-key binding is 0 (the key itself IS Shift — there is no separate
|
||||
/// "modifier" to report when the primary key and the modifier are the same physical
|
||||
/// key). <c>RetailDefaults()</c> deliberately encodes <c>Modifiers=Shift</c> anyway —
|
||||
/// its own comment (K-fix1, 2026-04-26) explains the OS echoes
|
||||
/// <c>CurrentModifiers=Shift</c> alongside a Shift key-DOWN event, so the chord must
|
||||
/// carry the flag to match at dispatch time. Not a disagreement to fix; a raw-DAT
|
||||
/// artifact this slice's reader faithfully reproduces.</description></item>
|
||||
/// <item><description><b>Quickslot 1-9's Ctrl+N chord (and its SelectQuickSlot_1-9
|
||||
/// counterpart).</b> The DAT's own default
|
||||
/// master map binds Ctrl+1..9 to the SAME action id as bare 1..9 ("Quickslot N" —
|
||||
/// <c>UseQuickSlot_N</c>), NOT to the separate "Select Quickslot N" action id
|
||||
/// (<c>SelectQuickSlot_N</c>, DAT action ids <c>0x1000004E-56</c>) — those carry NO
|
||||
/// default binding at all in the shipped DAT. <c>RetailDefaults()</c>'s own comment
|
||||
/// (citing <c>gmToolbarUI::ListenToGlobalMessage @0x004BE4E0</c>) asserts retail's
|
||||
/// CLIENT reinterprets Ctrl+N contextually as Select — a runtime behavior this raw
|
||||
/// keymap-default probe cannot see (it reads bound ACTIONS, not the dispatch
|
||||
/// function's own modifier branching). Both readings are independently retail-
|
||||
/// sourced; reconciling them needs the decompiled dispatch function, out of scope
|
||||
/// here. Reported, not silently resolved either way.</description></item>
|
||||
/// </list>
|
||||
/// Campaign KB's installed-DAT contract: all 306 user-bindable ActionMap rows
|
||||
/// have distinct live identities and their default chord sets match the two
|
||||
/// retail MasterInputMaps exactly. Missing, aliased, or guessed rows fail here.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public sealed class RetailActionIdentityRoundTripTests
|
||||
{
|
||||
/// <summary>Actions with a citation-backed, pre-existing reason their DAT-union
|
||||
/// default set legitimately differs from <see cref="KeyBindings.RetailDefaults"/>
|
||||
/// — see class doc. Every other mapped action must match exactly.</summary>
|
||||
private static readonly HashSet<InputAction> KnownRetailDefaultsDisagreements = new()
|
||||
{
|
||||
InputAction.MovementWalkMode,
|
||||
InputAction.UseQuickSlot_1,
|
||||
InputAction.UseQuickSlot_2,
|
||||
InputAction.UseQuickSlot_3,
|
||||
InputAction.UseQuickSlot_4,
|
||||
InputAction.UseQuickSlot_5,
|
||||
InputAction.UseQuickSlot_6,
|
||||
InputAction.UseQuickSlot_7,
|
||||
InputAction.UseQuickSlot_8,
|
||||
InputAction.UseQuickSlot_9,
|
||||
// Same Use-vs-Select ambiguity as the bare-numeral block above: the DAT's
|
||||
// own "Select Quickslot N" action ids carry NO default binding at all —
|
||||
// RetailDefaults()'s Ctrl+N->Select mapping rests on the decompiled
|
||||
// dispatch function's runtime modifier check, not the raw keymap default.
|
||||
InputAction.SelectQuickSlot_1,
|
||||
InputAction.SelectQuickSlot_2,
|
||||
InputAction.SelectQuickSlot_3,
|
||||
InputAction.SelectQuickSlot_4,
|
||||
InputAction.SelectQuickSlot_5,
|
||||
InputAction.SelectQuickSlot_6,
|
||||
InputAction.SelectQuickSlot_7,
|
||||
InputAction.SelectQuickSlot_8,
|
||||
InputAction.SelectQuickSlot_9,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void MappedActions_DatUnionDefaultBindings_MatchRetailDefaults()
|
||||
{
|
||||
|
|
@ -98,13 +28,44 @@ public sealed class RetailActionIdentityRoundTripTests
|
|||
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(source);
|
||||
Assert.NotNull(snapshot);
|
||||
|
||||
Assert.Equal(306, snapshot!.Rows.Count);
|
||||
var unresolvedRows = snapshot.Rows
|
||||
.Where(row => !RetailActionIdentityTable.TryResolve(
|
||||
row.InputMapId,
|
||||
row.ActionId,
|
||||
out _))
|
||||
.Select(row => $"0x{row.InputMapId:X8}/0x{row.ActionId:X8}")
|
||||
.ToArray();
|
||||
Assert.Empty(unresolvedRows);
|
||||
Assert.Equal(306, RetailActionIdentityTable.Map.Count);
|
||||
Assert.Equal(306, RetailActionIdentityTable.Map.Values.Distinct().Count());
|
||||
Assert.Equal(306, RetailActionIdentityTable.ReverseMap.Count);
|
||||
|
||||
var optionIds = new HashSet<uint>();
|
||||
foreach (RetailActionMapRow row in snapshot.Rows.Where(
|
||||
static row => row.InputMapId == 0x10000008u))
|
||||
{
|
||||
Assert.True(RetailActionIdentityTable.TryResolve(
|
||||
row.InputMapId,
|
||||
row.ActionId,
|
||||
out InputAction action));
|
||||
Assert.True(
|
||||
RetailActionIdentityTable.TryGetCharacterOptionId(
|
||||
action,
|
||||
out uint optionId),
|
||||
$"CharacterSettings row 0x{row.ActionId:X8} has no PlayerOption id");
|
||||
Assert.True(CharacterOptionTable.TryGet(optionId, out _));
|
||||
Assert.True(optionIds.Add(optionId), $"duplicate PlayerOption id 0x{optionId:X2}");
|
||||
}
|
||||
Assert.Equal(48, optionIds.Count);
|
||||
|
||||
KeyBindings retailDefaults = KeyBindings.RetailDefaults();
|
||||
|
||||
// Aggregate DAT default chords by resolved InputAction — a single action can
|
||||
// be reached by more than one DAT row (e.g. the Camera/CameraAlternate pair).
|
||||
var datChordsByAction = new Dictionary<InputAction, HashSet<KeyChord>>();
|
||||
var unresolvedScanCodes = new List<string>();
|
||||
foreach (RetailActionMapRow row in snapshot!.Rows)
|
||||
foreach (RetailActionMapRow row in snapshot.Rows)
|
||||
{
|
||||
if (!RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action))
|
||||
continue;
|
||||
|
|
@ -125,15 +86,12 @@ public sealed class RetailActionIdentityRoundTripTests
|
|||
}
|
||||
}
|
||||
|
||||
Assert.True(datChordsByAction.Count > 100,
|
||||
$"expected >100 mapped actions, got {datChordsByAction.Count}");
|
||||
Assert.Equal(306, datChordsByAction.Count);
|
||||
Assert.Empty(unresolvedScanCodes);
|
||||
|
||||
var mismatches = new List<string>();
|
||||
foreach ((InputAction action, HashSet<KeyChord> datChords) in datChordsByAction)
|
||||
{
|
||||
if (KnownRetailDefaultsDisagreements.Contains(action)) continue;
|
||||
|
||||
var acdreamChords = retailDefaults.ForAction(action).Select(b => b.Chord).ToHashSet();
|
||||
if (!datChords.SetEquals(acdreamChords))
|
||||
{
|
||||
|
|
@ -144,8 +102,7 @@ public sealed class RetailActionIdentityRoundTripTests
|
|||
}
|
||||
|
||||
Assert.True(mismatches.Count == 0,
|
||||
$"{mismatches.Count} unexpected DAT-vs-RetailDefaults() disagreements "
|
||||
+ "(not in the documented KnownRetailDefaultsDisagreements allowlist):\n"
|
||||
$"{mismatches.Count} DAT-vs-RetailDefaults() disagreements:\n"
|
||||
+ string.Join("\n", mismatches));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,38 @@ public sealed class RetailActionMapReaderTests
|
|||
Assert.Empty(row.DefaultBindings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_PreservesRetailInputMapConflictPolicy()
|
||||
{
|
||||
var actionMap = new ActionMap
|
||||
{
|
||||
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>(),
|
||||
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>
|
||||
{
|
||||
[0x10000003u] = new InputsConflictsValue
|
||||
{
|
||||
InputMap = 0x10000003u,
|
||||
ConflictingInputMaps = new List<uint>
|
||||
{
|
||||
0x10000003u,
|
||||
0x10000002u,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
var dats = new FakeDatObjectSource();
|
||||
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
|
||||
|
||||
RetailActionMapSnapshot snapshot = Assert.IsType<RetailActionMapSnapshot>(
|
||||
RetailActionMapReader.Read(dats));
|
||||
|
||||
Assert.True(snapshot.InputMapsConflict(0x10000003u, 0x10000003u));
|
||||
Assert.True(snapshot.InputMapsConflict(0x10000003u, 0x10000002u));
|
||||
Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000004u));
|
||||
Assert.True(snapshot.InputMapsConflict(0xDEADBEEFu, 0xDEADBEEFu));
|
||||
Assert.False(snapshot.InputMapsConflict(0xDEADBEEFu, 0x10000003u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailInputMapHeaders_HasAllNineteenByteVerifiedEntries()
|
||||
{
|
||||
|
|
@ -245,5 +277,12 @@ public sealed class RetailActionMapReader_LiveDatTests
|
|||
Assert.Equal(2, moveForward.DefaultBindings.Count);
|
||||
Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0x11u); // DIK_W
|
||||
Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0xC8u); // DIK_UPARROW
|
||||
|
||||
// The authored combat modes intentionally share the five attack/aim
|
||||
// keys. Retail's conflict table keeps those mode-local contexts apart;
|
||||
// Configure Keyboard must not erase one mode while editing another.
|
||||
Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000004u));
|
||||
Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000005u));
|
||||
Assert.False(snapshot.InputMapsConflict(0x10000004u, 0x10000005u));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,40 @@ public sealed class ExternalContainerStateTests
|
|||
Assert.Equal(2, delivered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenedCorpseHistoryMatchesRetailSetAndDeleteLifetime()
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
const uint corpse = 0x70000010u;
|
||||
|
||||
Assert.True(state.RequestOpen(corpse, isCorpse: true));
|
||||
Assert.True(state.HasCorpseBeenOpened(corpse));
|
||||
Assert.Equal(1, state.OpenedCorpseCount);
|
||||
|
||||
state.ApplyViewContents(corpse);
|
||||
state.ApplyClose(corpse);
|
||||
Assert.True(state.HasCorpseBeenOpened(corpse));
|
||||
Assert.True(state.SetCorpseDeleted(corpse));
|
||||
Assert.False(state.HasCorpseBeenOpened(corpse));
|
||||
|
||||
state.RequestOpen(corpse, isCorpse: true);
|
||||
Assert.True(state.Reset());
|
||||
Assert.False(state.HasCorpseBeenOpened(corpse));
|
||||
Assert.Equal(0, state.OpenedCorpseCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedGroundObjectRequestStillRecordsCorpseIdentity()
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
const uint corpse = 0x70000011u;
|
||||
|
||||
Assert.True(state.RequestOpen(corpse));
|
||||
Assert.False(state.RequestOpen(corpse, isCorpse: true));
|
||||
|
||||
Assert.True(state.HasCorpseBeenOpened(corpse));
|
||||
}
|
||||
|
||||
private static ExternalContainerState Open(uint id)
|
||||
{
|
||||
var state = new ExternalContainerState();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public sealed class InventoryContainerPlacementPolicyTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
|
||||
[Fact]
|
||||
public void FullItemCapacityRejectsNewItemButAllowsReorder()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Player,
|
||||
Name = "Player",
|
||||
ItemsCapacity = 1,
|
||||
ContainersCapacity = 7,
|
||||
});
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 2u });
|
||||
objects.MoveItem(2u, Player, 0);
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 3u });
|
||||
|
||||
Assert.Equal(
|
||||
InventoryContainerPlacementRejection.ItemCapacityFull,
|
||||
InventoryContainerPlacementPolicy.Evaluate(objects, 3u, Player, Player));
|
||||
Assert.Equal(
|
||||
InventoryContainerPlacementRejection.None,
|
||||
InventoryContainerPlacementPolicy.Evaluate(objects, 2u, Player, Player));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainerCycleAndTradeAreRejected()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 10u, Type = ItemType.Container, ItemsCapacity = 24,
|
||||
});
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 11u, Type = ItemType.Container, ItemsCapacity = 24,
|
||||
});
|
||||
objects.MoveItem(11u, 10u, 0);
|
||||
|
||||
Assert.Equal(
|
||||
InventoryContainerPlacementRejection.RecursiveContainment,
|
||||
InventoryContainerPlacementPolicy.Evaluate(objects, 10u, 11u, Player));
|
||||
|
||||
objects.Get(10u)!.TradeState = 1;
|
||||
Assert.Equal(
|
||||
InventoryContainerPlacementRejection.SourceBeingTraded,
|
||||
InventoryContainerPlacementPolicy.Evaluate(objects, 10u, Player, Player));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullMessageMatchesRetailContainerTypeBranches()
|
||||
{
|
||||
var player = new ClientObject { ObjectId = Player, Name = "Backpack" };
|
||||
var bag = new ClientObject { ObjectId = 2u, Name = "Pack" };
|
||||
Assert.Equal(
|
||||
"Backpack is completely full!",
|
||||
InventoryContainerPlacementPolicy.ComposeClientLocal(
|
||||
InventoryContainerPlacementRejection.ItemCapacityFull,
|
||||
null,
|
||||
player,
|
||||
Player));
|
||||
Assert.Equal(
|
||||
"The Pack can fit no more containers!",
|
||||
InventoryContainerPlacementPolicy.ComposeClientLocal(
|
||||
InventoryContainerPlacementRejection.ContainerCapacityFull,
|
||||
null,
|
||||
bag,
|
||||
Player));
|
||||
}
|
||||
}
|
||||
|
|
@ -147,9 +147,53 @@ public sealed class ItemInteractionPolicyTests
|
|||
Assert.Equal(ItemPolicyActionKind.Reject,
|
||||
Assert.Single(ItemInteractionPolicy.DecideUse(
|
||||
Use(direct with { TradeState = 1 })).Actions).Kind);
|
||||
Assert.Contains("wield", Assert.Single(ItemInteractionPolicy.DecideUse(
|
||||
Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("You cannot use the item because you are trading it",
|
||||
Assert.Single(ItemInteractionPolicy.DecideUse(
|
||||
Use(direct with { TradeState = 1 })).Actions).Message);
|
||||
Assert.Equal("You must wield the item to use it",
|
||||
Assert.Single(ItemInteractionPolicy.DecideUse(
|
||||
Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseObject_targetCompatibilityFailures_useRetailVerbatimMessages()
|
||||
{
|
||||
var source = OwnedDirect() with
|
||||
{
|
||||
Name = "mana stone",
|
||||
Useability = 0x00080008u,
|
||||
TargetType = (uint)ItemType.Misc,
|
||||
};
|
||||
var target = Obj(0x6002) with
|
||||
{
|
||||
Name = "armor",
|
||||
Type = ItemType.Armor,
|
||||
ContainerId = Player,
|
||||
OwnedByPlayer = true,
|
||||
};
|
||||
|
||||
var missing = ItemInteractionPolicy.DecideUse(Use(source) with
|
||||
{
|
||||
UseCurrentSelection = true,
|
||||
});
|
||||
Assert.Equal("Select your target before using the mana stone",
|
||||
Assert.Single(missing.Actions).Message);
|
||||
|
||||
var incompatible = ItemInteractionPolicy.DecideUse(Use(source) with
|
||||
{
|
||||
UseCurrentSelection = true,
|
||||
SelectedTarget = target,
|
||||
});
|
||||
Assert.Equal("Cannot use the mana stone with the armor",
|
||||
Assert.Single(incompatible.Actions).Message);
|
||||
|
||||
var traded = ItemInteractionPolicy.DecideUse(Use(source) with
|
||||
{
|
||||
UseCurrentSelection = true,
|
||||
SelectedTarget = target with { TradeState = 1 },
|
||||
});
|
||||
Assert.Equal("You can't use the mana stone on an item you are trading",
|
||||
Assert.Single(traded.Actions).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -219,7 +263,7 @@ public sealed class ItemInteractionPolicyTests
|
|||
PlayerOnGround = false,
|
||||
});
|
||||
Assert.False(airborne.ReturnValue);
|
||||
Assert.Equal(ItemPolicyActionKind.Reject, Assert.Single(airborne.Actions).Kind);
|
||||
Assert.Equal("You cannot do that in mid air", Assert.Single(airborne.Actions).Message);
|
||||
|
||||
var split = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item) with { SplitSize = 4 });
|
||||
Assert.True(split.ReturnValue);
|
||||
|
|
@ -232,8 +276,7 @@ public sealed class ItemInteractionPolicyTests
|
|||
var alreadyWorld = ItemInteractionPolicy.DecidePlacement(
|
||||
PlaceOnGround(item with { IsIn3DView = true }));
|
||||
Assert.False(alreadyWorld.ReturnValue);
|
||||
Assert.Contains("cancelled", Assert.Single(alreadyWorld.Actions).Message,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("Move cancelled", Assert.Single(alreadyWorld.Actions).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -204,6 +204,28 @@ public sealed class VendorStagingListTests
|
|||
Assert.False(list.TryGet(ItemB, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplacePreservesTheSplitPlaceholderPositionAndQuantity()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
list.Add(ItemA, 2);
|
||||
list.Add(ItemB, 9);
|
||||
const uint splitGuid = 0x60000003u;
|
||||
int fired = 0;
|
||||
list.Changed += () => fired++;
|
||||
|
||||
Assert.True(list.Replace(ItemA, splitGuid));
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
new VendorStagingEntry(splitGuid, 2),
|
||||
new VendorStagingEntry(ItemB, 9),
|
||||
},
|
||||
list.Entries);
|
||||
Assert.Equal(1, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearRemovesEveryEntryAndFiresChangedOnce()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue