feat(inventory): port retail weapon switching
Sequence primary weapon replacement through the server-confirmed AutoWield transaction: return the occupied weapon to the player pack, wait for its move event, then wield the requested item. Route authoritative CombatMode property updates so peace stays peace and war adopts the new weapon stance without client-synthesized animation. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
8e9c538519
commit
17b5712d53
14 changed files with 736 additions and 155 deletions
|
|
@ -18,11 +18,13 @@ public sealed class ItemInteractionControllerTests
|
|||
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()
|
||||
|
|
@ -54,7 +56,10 @@ public sealed class ItemInteractionControllerTests
|
|||
toast: Toasts.Add,
|
||||
sendSplitToWorld: (item, amount) => SplitDrops.Add((item, amount)),
|
||||
selectedObjectId: () => SelectedObject,
|
||||
stackSplitQuantity: SplitQuantity);
|
||||
stackSplitQuantity: SplitQuantity,
|
||||
inNonCombatMode: () => NonCombatMode,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
Puts.Add((item, container, placement)));
|
||||
}
|
||||
|
||||
public ItemInteractionController Controller { get; }
|
||||
|
|
@ -412,6 +417,112 @@ public sealed class ItemInteractionControllerTests
|
|||
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.True(h.Controller.ActivateItem(bow));
|
||||
|
||||
Assert.Single(h.Puts);
|
||||
Assert.Empty(h.Wields);
|
||||
}
|
||||
|
||||
[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 InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ public sealed class RetailUiInteractionFlowTests
|
|||
private const uint Player = 0x50000001u;
|
||||
private const uint Hauberk = 0x50001001u;
|
||||
private const uint HealthKit = 0x50001002u;
|
||||
private const uint Sword = 0x50001003u;
|
||||
private const uint Bow = 0x50001004u;
|
||||
private const uint SlotsButtonId = 0x100005BEu;
|
||||
private const uint ChestArmorSlotId = 0x100005ACu;
|
||||
// USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF — the classic healing-kit value.
|
||||
|
|
@ -32,6 +34,7 @@ public sealed class RetailUiInteractionFlowTests
|
|||
public readonly List<uint> Uses = 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 AcDream.Core.Selection.SelectionState Selection = new();
|
||||
public readonly List<uint> Drops = new();
|
||||
public long Now = 10_000;
|
||||
|
|
@ -165,7 +168,9 @@ public sealed class RetailUiInteractionFlowTests
|
|||
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
|
||||
sendWield: (item, mask) => Wields.Add((item, mask)),
|
||||
sendDrop: Drops.Add,
|
||||
nowMs: () => Now);
|
||||
nowMs: () => Now,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
Puts.Add((item, container, placement)));
|
||||
|
||||
InventoryController.Bind(
|
||||
Layout,
|
||||
|
|
@ -285,6 +290,46 @@ public sealed class RetailUiInteractionFlowTests
|
|||
Assert.True(state.Success, state.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryDoubleClick_bow_replacesSwordOnlyAfterConfirmedUnwield()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Sword,
|
||||
Name = "Sword",
|
||||
Type = ItemType.MeleeWeapon,
|
||||
CombatUse = 1,
|
||||
ValidLocations = EquipMask.MeleeWeapon,
|
||||
IconId = 0x06001234u,
|
||||
});
|
||||
h.Objects.MoveItem(Sword, Player, -1, EquipMask.MeleeWeapon);
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Bow,
|
||||
Name = "Bow",
|
||||
Type = ItemType.MissileWeapon,
|
||||
CombatUse = 1,
|
||||
ValidLocations = EquipMask.MissileWeapon,
|
||||
IconId = 0x06001235u,
|
||||
});
|
||||
h.Objects.MoveItem(Bow, Player, 0);
|
||||
h.BindInventoryInteraction();
|
||||
var probe = h.Probe();
|
||||
|
||||
Assert.True(probe.DoubleClickItem(Bow, ItemDragSource.Inventory));
|
||||
Assert.Equal(new[] { (Sword, Player, 0) }, h.Puts);
|
||||
Assert.Empty(h.Wields);
|
||||
Assert.Equal(EquipMask.MeleeWeapon,
|
||||
h.Objects.Get(Sword)!.CurrentlyEquippedLocation);
|
||||
|
||||
h.Objects.MoveItem(Sword, Player, 0, EquipMask.None);
|
||||
|
||||
Assert.Equal(new[] { (Bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
|
||||
Assert.Equal(EquipMask.MissileWeapon,
|
||||
h.Objects.Get(Bow)!.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryTargetUse_thenMainPackClick_selfTargetsThroughUiRoot()
|
||||
{
|
||||
|
|
|
|||
42
tests/AcDream.Core.Net.Tests/CombatStateWiringTests.cs
Normal file
42
tests/AcDream.Core.Net.Tests/CombatStateWiringTests.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance pins for retail ClientCombatSystem::OnQualityChanged
|
||||
/// (0x0056C1B0): player PropertyInt 40 is the authoritative combat mode.
|
||||
/// </summary>
|
||||
public sealed class CombatStateWiringTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((int)CombatMode.NonCombat)]
|
||||
[InlineData((int)CombatMode.Melee)]
|
||||
[InlineData((int)CombatMode.Missile)]
|
||||
[InlineData((int)CombatMode.Magic)]
|
||||
public void CombatModeProperty_appliesConcreteRetailMode(int value)
|
||||
{
|
||||
var combat = new CombatState();
|
||||
|
||||
Assert.True(CombatStateWiring.ApplyPlayerIntProperty(
|
||||
combat,
|
||||
CombatStateWiring.CombatModePropertyId,
|
||||
value));
|
||||
|
||||
Assert.Equal((CombatMode)value, combat.CurrentMode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(39u, (int)CombatMode.Missile)]
|
||||
[InlineData(40u, (int)CombatMode.Undef)]
|
||||
[InlineData(40u, (int)CombatMode.ValidCombat)]
|
||||
[InlineData(40u, 0x10)]
|
||||
public void NonCombatQualityOrInvalidValue_isIgnored(uint property, int value)
|
||||
{
|
||||
var combat = new CombatState();
|
||||
|
||||
Assert.False(CombatStateWiring.ApplyPlayerIntProperty(
|
||||
combat, property, value));
|
||||
|
||||
Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
|
||||
}
|
||||
}
|
||||
|
|
@ -693,6 +693,40 @@ public sealed class ClientObjectTableTests
|
|||
public void WieldItemOptimistic_unknownItem_false()
|
||||
=> Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear));
|
||||
|
||||
[Fact]
|
||||
public void RejectMove_withoutOptimisticMutation_stillPublishesServerFailure()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
MoveRequestFailure? seen = null;
|
||||
table.MoveRequestFailed += failure => seen = failure;
|
||||
|
||||
Assert.False(table.RejectMove(0xDEADu, 0x04FFu));
|
||||
|
||||
Assert.Equal(
|
||||
new MoveRequestFailure(0xDEADu, 0x04FFu, RolledBack: false),
|
||||
seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectMove_withOptimisticMutation_rollsBackAndPublishesFailure()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint item = 0x943u, player = 0x50000001u, pack = 0x40000005u;
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = item });
|
||||
table.MoveItem(item, pack, 2);
|
||||
table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon);
|
||||
MoveRequestFailure? seen = null;
|
||||
table.MoveRequestFailed += failure => seen = failure;
|
||||
|
||||
Assert.True(table.RejectMove(item, 0x04FFu));
|
||||
|
||||
Assert.Equal(pack, table.Get(item)!.ContainerId);
|
||||
Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation);
|
||||
Assert.Equal(
|
||||
new MoveRequestFailure(item, 0x04FFu, RolledBack: true),
|
||||
seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WieldThenMove_oneConfirm_rollsBackToPreWield() // outstanding-count across wield+move (shared RecordPending)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue