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
|
|
@ -211,7 +211,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods |
|
||||
| AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
||||
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
|
||||
| AP-108 | Paperdoll omits Aetheria, full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, and body-part selection lighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, optimistic wield, exact DAT body hit map, authoritative upper-item priority, normal selection, and target-self dispatch work | Special slots, invalid drops, doll examine/drag, and selection lighting still differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` |
|
||||
| AP-108 | Paperdoll/AutoWield still omit Aetheria, full `AutoWieldIsLegal`/dual-wield rules, incompatible-shield/ammo blocker sequencing, double-click examine/drag from the doll, and body-part selection lighting | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots and live doll work; inventory double-click now faithfully replaces the occupied primary weapon-ready group through server-confirmed dequip→wield in both peace and war | Special slots, illegal/off-hand cases, incompatible shield/ammo auto-removal, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` |
|
||||
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
|
||||
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic `gmCombatUI` now covers the active M2 melee/missile loop; Wave 10 lands each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog |
|
||||
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
|
||||
|
|
|
|||
|
|
@ -495,6 +495,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **✓ SHIPPED — Wave 4.4c exact selected-health policy.** Core ports `ObjectIsAttackable @ 0x0056A600`; CreateObject preserves second-header PetOwner; toolbar player/pet composition is exact. Self, pets, and Free-PK creatures now query health while friendly NPCs and attackable non-creatures remain name-only. AP-46 retired. The world/radar policy matrix and exact DAT paperdoll body-map + upper-item-priority route passed live 2026-07-11.
|
||||
- **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display.
|
||||
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
|
||||
- **Wave 4.5 implemented — retail primary-weapon switching (live gate pending).** Inventory double-click now routes through a focused `AutoWieldController`: the occupied `0x03500000` weapon-ready group is returned to the player container, the controller waits for authoritative `InventoryPutObjInContainer`, then sends `GetAndWieldItem` for the requested bow/sword/caster/two-hander. The same transaction runs in peace and war; ACE owns the latter's old-stance → peace → new-stance motion chain. Player PropertyInt 40 now updates `CombatState`, keeping toolbar/combat UI synchronized with ACE's selected Melee/Missile/Magic stance. Research: `docs/research/2026-07-12-retail-weapon-switch-pseudocode.md`.
|
||||
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
|
||||
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
|
||||
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
|
||||
|
|
|
|||
|
|
@ -454,6 +454,12 @@ include dungeons.
|
|||
- **L.1c held-object foundation (implemented and live-gated 2026-07-11)** — exact
|
||||
equipment-aware default combat mode (bow→Missile, caster→Magic) plus retail
|
||||
CreateObject/ParentEvent child attachment and per-frame hand-follow rendering.
|
||||
- **L.1c inventory weapon switching (implemented 2026-07-12; live gate pending)** —
|
||||
inventory double-click ports retail's two-stage `AutoWield`: return the occupied
|
||||
primary weapon to the main pack, wait for the server move confirmation, then
|
||||
wield the requested bow/sword/caster/two-hander. The shared transaction works
|
||||
in peace and war; ACE owns stance-specific motions and its PropertyInt 40 update
|
||||
now drives the client `CombatState`.
|
||||
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** —
|
||||
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
|
||||
funnel and action-stamp gate, so ACE's server-selected melee/missile action
|
||||
|
|
|
|||
108
docs/research/2026-07-12-retail-weapon-switch-pseudocode.md
Normal file
108
docs/research/2026-07-12-retail-weapon-switch-pseudocode.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Retail inventory weapon-switch pseudocode
|
||||
|
||||
## Scope
|
||||
|
||||
Inventory double-click of a weapon while another primary weapon is equipped,
|
||||
in both peace and combat modes. The UI request sequence is shared; combat-mode
|
||||
animation remains server-authoritative.
|
||||
|
||||
## Retail oracle
|
||||
|
||||
- `ItemHolder::DetermineUseResult @ 0x00588460` classifies an owned combat-use
|
||||
item as `WieldRight`.
|
||||
- `CPlayerSystem::UsingItem @ 0x00562F70` dispatches that result to
|
||||
`AutoWield(item, SLOT_SIDE_RIGHT, 0, 1, 0, 1)`.
|
||||
- `CPlayerSystem::AutoWield @ 0x00560A60` treats
|
||||
`0x03500000` (`MeleeWeapon | MissileWeapon | Held | TwoHanded`) as one
|
||||
weapon-ready group. If occupied, it records the requested item, sends the
|
||||
blocking weapon to the player container, and does not wield the requested
|
||||
item yet.
|
||||
- `CPlayerSystem::RecvNotice_ServerSaysMoveItem @ 0x00563260` retries
|
||||
`AutoWield` only after the server confirms the blocking item moved.
|
||||
- `ACCWeenieObject::UIAttemptWield @ 0x0058D590` sends
|
||||
`GetAndWieldItem(item, equipMask)`.
|
||||
- `ClientCombatSystem::OnQualityChanged @ 0x0056C1B0` reads player
|
||||
PropertyInt 40 (`CombatMode`) and applies it through `SetCombatMode`.
|
||||
|
||||
Addresses are from `docs/research/named-retail/symbols.json`; pseudo-C is in
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||||
|
||||
## Cross-reference
|
||||
|
||||
- ACE `Player_Inventory.cs` says the client normally sends the conflicting
|
||||
item's dequip before `GetAndWieldItem`. `TryShuffleStance` changes a player
|
||||
who is already in combat to Missile, Magic, or Melee after the new item is
|
||||
equipped; it leaves a player in peace mode in peace.
|
||||
- ACE `Creature_Combat.cs::SwitchCombatStyles` performs the visible
|
||||
old-stance -> peace -> new-stance motion chain and sends
|
||||
`PrivateUpdatePropertyInt(CombatMode, ...)`.
|
||||
- holtburger independently confirms the action/event shapes:
|
||||
`GetAndWieldItem { item_guid, equip_mask }`, `WieldObject`, and
|
||||
PropertyInt 40 as `CombatMode`.
|
||||
|
||||
## Faithful pseudocode
|
||||
|
||||
```text
|
||||
on inventory item double-click(item):
|
||||
decision = DetermineUseResult(item)
|
||||
if decision is WieldRight or WieldLeft or AutoSort:
|
||||
auto_wield(item)
|
||||
|
||||
auto_wield(item):
|
||||
if another auto-wield transaction is waiting for the server:
|
||||
consume the request without sending another inventory action
|
||||
|
||||
if item has no valid equip location or is already equipped:
|
||||
return false
|
||||
|
||||
if item.validLocations intersects WEAPON_READY_MASK:
|
||||
desiredMask = item.validLocations & WEAPON_READY_MASK
|
||||
blocker = player's equipped item intersecting WEAPON_READY_MASK
|
||||
|
||||
if blocker exists:
|
||||
pending = (requestedItem = item.id, blockingItem = blocker.id)
|
||||
send PutItemInContainer(blocker.id, player.id, placement = 0)
|
||||
return true
|
||||
|
||||
send_wield(item, desiredMask)
|
||||
return true
|
||||
|
||||
use the existing retail slot/auto-wear selection rules
|
||||
send_wield(item, selectedMask)
|
||||
|
||||
on server-confirmed item move(blocker):
|
||||
if blocker matches pending.blockingItem
|
||||
and blocker is now loose in the player's main pack:
|
||||
requested = pending.requestedItem
|
||||
clear pending before re-entry
|
||||
auto_wield(requested)
|
||||
|
||||
on server inventory-request failure(item, error):
|
||||
if item matches pending.blockingItem or pending.requestedItem:
|
||||
clear pending
|
||||
|
||||
send_wield(item, mask):
|
||||
update the retained inventory/paperdoll projection optimistically
|
||||
send GetAndWieldItem(item.id, mask)
|
||||
|
||||
on PrivateUpdatePropertyInt(property = CombatMode, value):
|
||||
if value is exactly NonCombat, Melee, Missile, or Magic:
|
||||
CombatState.SetCombatMode(value)
|
||||
```
|
||||
|
||||
## Animation ownership
|
||||
|
||||
The client must not synthesize a local bow-to-sword animation. In peace mode,
|
||||
ACE leaves `CombatMode` as `NonCombat`. In combat mode, ACE selects the new
|
||||
weapon's stance and sends the authoritative motion stream plus PropertyInt 40.
|
||||
The inventory controller only sequences the two wire requests and waits for
|
||||
the old weapon's confirmed move; the existing motion and equipped-child
|
||||
render paths consume the server events.
|
||||
|
||||
## Known remaining retail surface
|
||||
|
||||
This port covers the primary weapon-ready group requested here. Retail
|
||||
`AutoWield` also has extra blocker logic for incompatible shields and missile
|
||||
ammo, plus its full `AutoWieldIsLegal` requirement checks. Those pre-existing
|
||||
paperdoll/auto-wield gaps remain tracked by divergence row AP-108 rather than
|
||||
being guessed from incomplete item properties.
|
||||
|
|
@ -2032,7 +2032,9 @@ public sealed class GameWindow : IDisposable
|
|||
sendSplitToWorld: (item, amount) =>
|
||||
_liveSession?.SendStackableSplitTo3D(item, amount),
|
||||
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
|
||||
stackSplitQuantity: _stackSplitQuantity);
|
||||
stackSplitQuantity: _stackSplitQuantity,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement));
|
||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||
_itemInteractionController,
|
||||
// Retail UpdateCursorState (0x00564630) keys target-mode
|
||||
|
|
@ -2527,6 +2529,7 @@ public sealed class GameWindow : IDisposable
|
|||
// UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so
|
||||
// the table is populated before the render handler runs.
|
||||
AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid);
|
||||
AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
|
||||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||||
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||||
|
|
|
|||
288
src/AcDream.App/UI/AutoWieldController.cs
Normal file
288
src/AcDream.App/UI/AutoWieldController.cs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
using System;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPlayerSystem::AutoWield @ 0x00560A60</c> transaction owner.
|
||||
/// A conflicting primary weapon is first returned to the player container;
|
||||
/// the requested weapon is wielded only after
|
||||
/// <c>RecvNotice_ServerSaysMoveItem @ 0x00563260</c> confirms that move.
|
||||
/// </summary>
|
||||
internal sealed class AutoWieldController : IDisposable
|
||||
{
|
||||
// Retail AutoWield immediate 0x03500000: all mutually-exclusive
|
||||
// primary weapon-ready locations (shield and missile ammo are separate).
|
||||
internal const EquipMask WeaponReadyMask =
|
||||
EquipMask.MeleeWeapon
|
||||
| EquipMask.MissileWeapon
|
||||
| EquipMask.Held
|
||||
| EquipMask.TwoHanded;
|
||||
|
||||
private static readonly EquipMask[] AutoEquipOrder =
|
||||
{
|
||||
EquipMask.HeadWear,
|
||||
EquipMask.ChestWear,
|
||||
EquipMask.AbdomenWear,
|
||||
EquipMask.UpperArmWear,
|
||||
EquipMask.LowerArmWear,
|
||||
EquipMask.HandWear,
|
||||
EquipMask.UpperLegWear,
|
||||
EquipMask.LowerLegWear,
|
||||
EquipMask.FootWear,
|
||||
EquipMask.ChestArmor,
|
||||
EquipMask.AbdomenArmor,
|
||||
EquipMask.UpperArmArmor,
|
||||
EquipMask.LowerArmArmor,
|
||||
EquipMask.UpperLegArmor,
|
||||
EquipMask.LowerLegArmor,
|
||||
EquipMask.NeckWear,
|
||||
EquipMask.WristWearLeft,
|
||||
EquipMask.WristWearRight,
|
||||
EquipMask.FingerWearLeft,
|
||||
EquipMask.FingerWearRight,
|
||||
EquipMask.Shield,
|
||||
EquipMask.MissileAmmo,
|
||||
EquipMask.MeleeWeapon,
|
||||
EquipMask.MissileWeapon,
|
||||
EquipMask.Held,
|
||||
EquipMask.TwoHanded,
|
||||
EquipMask.TrinketOne,
|
||||
EquipMask.Cloak,
|
||||
EquipMask.SigilOne,
|
||||
EquipMask.SigilTwo,
|
||||
EquipMask.SigilThree,
|
||||
};
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Action<uint, uint>? _sendWield;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
||||
private readonly Action<string>? _toast;
|
||||
|
||||
private PendingSwitch? _pendingSwitch;
|
||||
private bool _disposed;
|
||||
|
||||
public AutoWieldController(
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Action<uint, uint>? sendWield,
|
||||
Action<uint, uint, int>? sendPutItemInContainer,
|
||||
Action<string>? toast)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_sendWield = sendWield;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_toast = toast;
|
||||
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.MoveRequestFailed += OnMoveRequestFailed;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute the UI-facing AutoWield request. A request received while the
|
||||
/// prior conflicting weapon is awaiting its server move is consumed, just
|
||||
/// as retail's inventory-ready gate prevents a second concurrent request.
|
||||
/// </summary>
|
||||
public bool TryWield(ClientObject item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
if (_pendingSwitch is not null)
|
||||
return true;
|
||||
if (item.ValidLocations == EquipMask.None
|
||||
|| item.CurrentlyEquippedLocation != EquipMask.None)
|
||||
return false;
|
||||
|
||||
EquipMask weaponLocation = item.ValidLocations & WeaponReadyMask;
|
||||
if (weaponLocation != EquipMask.None)
|
||||
{
|
||||
ClientObject? blocker = GetEquippedObjectAtLocation(
|
||||
WeaponReadyMask, priority: 0, item.ObjectId);
|
||||
if (blocker is not null)
|
||||
return BeginWeaponReplacement(item.ObjectId, blocker.ObjectId);
|
||||
|
||||
return SendWield(item, weaponLocation);
|
||||
}
|
||||
|
||||
EquipMask mask = BestAvailableEquipMask(item);
|
||||
if (mask == EquipMask.None)
|
||||
{
|
||||
_toast?.Invoke("That slot is already in use");
|
||||
return false;
|
||||
}
|
||||
|
||||
return SendWield(item, mask);
|
||||
}
|
||||
|
||||
private bool BeginWeaponReplacement(uint requestedItemId, uint blockingItemId)
|
||||
{
|
||||
if (_sendPutItemInContainer is null)
|
||||
{
|
||||
_toast?.Invoke("That slot is already in use");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint player = _playerGuid();
|
||||
if (player == 0)
|
||||
return false;
|
||||
|
||||
_pendingSwitch = new PendingSwitch(requestedItemId, blockingItemId);
|
||||
|
||||
// Retail AttemptToPlaceInContainer(blockingID, playerID, 0, 1, 0).
|
||||
// Do not change the local equip projection yet: the server's move event
|
||||
// is the transaction boundary and preserves its stance-specific motion.
|
||||
_sendPutItemInContainer(blockingItemId, player, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool SendWield(ClientObject item, EquipMask mask)
|
||||
{
|
||||
if (_sendWield is null)
|
||||
return false;
|
||||
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
|
||||
return false;
|
||||
|
||||
// Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590.
|
||||
_sendWield(item.ObjectId, (uint)mask);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnObjectMoved(ClientObject item, uint _, uint newContainerId)
|
||||
{
|
||||
if (_pendingSwitch is not { } pending
|
||||
|| item.ObjectId != pending.BlockingItemId
|
||||
|| newContainerId != _playerGuid()
|
||||
|| item.CurrentlyEquippedLocation != EquipMask.None)
|
||||
return;
|
||||
|
||||
// Clear before re-entry: the second AutoWield pass must see the newly
|
||||
// vacant ready slot and send GetAndWieldItem for the requested weapon.
|
||||
_pendingSwitch = null;
|
||||
if (_objects.Get(pending.RequestedItemId) is { } requested)
|
||||
TryWield(requested);
|
||||
}
|
||||
|
||||
private void OnMoveRequestFailed(MoveRequestFailure failure)
|
||||
{
|
||||
if (_pendingSwitch is { } pending
|
||||
&& (failure.ItemId == pending.BlockingItemId
|
||||
|| failure.ItemId == pending.RequestedItemId))
|
||||
_pendingSwitch = null;
|
||||
}
|
||||
|
||||
private void OnObjectRemoved(ClientObject item)
|
||||
{
|
||||
if (_pendingSwitch is { } pending
|
||||
&& (item.ObjectId == pending.BlockingItemId
|
||||
|| item.ObjectId == pending.RequestedItemId))
|
||||
_pendingSwitch = null;
|
||||
}
|
||||
|
||||
private void OnObjectsCleared()
|
||||
=> _pendingSwitch = null;
|
||||
|
||||
private EquipMask BestAvailableEquipMask(ClientObject item)
|
||||
{
|
||||
if (ItemEquipRules.IsAutoWearItem(item))
|
||||
return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
|
||||
|
||||
foreach (EquipMask mask in AutoEquipOrder)
|
||||
{
|
||||
if ((item.ValidLocations & mask) == EquipMask.None)
|
||||
continue;
|
||||
if (!EquipMaskOccupied(mask, item.ObjectId))
|
||||
return mask;
|
||||
}
|
||||
return EquipMask.None;
|
||||
}
|
||||
|
||||
private bool AutoWearIsLegal(ClientObject item)
|
||||
{
|
||||
uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId);
|
||||
if ((item.Priority & priorityMask) == 0)
|
||||
return true;
|
||||
|
||||
EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId)
|
||||
& item.ValidLocations;
|
||||
return GetEquippedObjectAtLocation(
|
||||
occupiedLocations, item.Priority, item.ObjectId) is null;
|
||||
}
|
||||
|
||||
private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
|
||||
=> GetEquippedObjectAtLocation(mask, priority: 0, exceptGuid) is not null;
|
||||
|
||||
private uint EquippedAutoWearPriorityMask(uint exceptGuid)
|
||||
{
|
||||
uint mask = 0;
|
||||
foreach (ClientObject item in _objects.Objects)
|
||||
{
|
||||
if (item.ObjectId == exceptGuid
|
||||
|| (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None
|
||||
|| !IsEquippedByPlayer(item))
|
||||
continue;
|
||||
mask |= item.Priority;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
|
||||
{
|
||||
EquipMask mask = EquipMask.None;
|
||||
foreach (ClientObject item in _objects.Objects)
|
||||
{
|
||||
if (item.ObjectId == exceptGuid
|
||||
|| (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None
|
||||
|| !IsEquippedByPlayer(item))
|
||||
continue;
|
||||
mask |= item.CurrentlyEquippedLocation;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private ClientObject? GetEquippedObjectAtLocation(
|
||||
EquipMask locationMask,
|
||||
uint priority,
|
||||
uint exceptGuid)
|
||||
{
|
||||
if (locationMask == EquipMask.None)
|
||||
return null;
|
||||
|
||||
foreach (ClientObject item in _objects.Objects)
|
||||
{
|
||||
if (item.ObjectId == exceptGuid
|
||||
|| !IsEquippedByPlayer(item)
|
||||
|| (item.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
|
||||
continue;
|
||||
if ((item.Priority & priority) != 0 || priority == 0)
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool IsEquippedByPlayer(ClientObject item)
|
||||
{
|
||||
uint player = _playerGuid();
|
||||
return item.CurrentlyEquippedLocation != EquipMask.None
|
||||
&& (item.WielderId == player || item.ContainerId == player);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_pendingSwitch = null;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.MoveRequestFailed -= OnMoveRequestFailed;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
}
|
||||
|
||||
private readonly record struct PendingSwitch(
|
||||
uint RequestedItemId,
|
||||
uint BlockingItemId);
|
||||
}
|
||||
|
|
@ -18,41 +18,6 @@ public enum ItemPrimaryClickResult
|
|||
/// </summary>
|
||||
public sealed class ItemInteractionController : IDisposable
|
||||
{
|
||||
private static readonly EquipMask[] AutoEquipOrder =
|
||||
{
|
||||
EquipMask.HeadWear,
|
||||
EquipMask.ChestWear,
|
||||
EquipMask.AbdomenWear,
|
||||
EquipMask.UpperArmWear,
|
||||
EquipMask.LowerArmWear,
|
||||
EquipMask.HandWear,
|
||||
EquipMask.UpperLegWear,
|
||||
EquipMask.LowerLegWear,
|
||||
EquipMask.FootWear,
|
||||
EquipMask.ChestArmor,
|
||||
EquipMask.AbdomenArmor,
|
||||
EquipMask.UpperArmArmor,
|
||||
EquipMask.LowerArmArmor,
|
||||
EquipMask.UpperLegArmor,
|
||||
EquipMask.LowerLegArmor,
|
||||
EquipMask.NeckWear,
|
||||
EquipMask.WristWearLeft,
|
||||
EquipMask.WristWearRight,
|
||||
EquipMask.FingerWearLeft,
|
||||
EquipMask.FingerWearRight,
|
||||
EquipMask.Shield,
|
||||
EquipMask.MissileAmmo,
|
||||
EquipMask.MeleeWeapon,
|
||||
EquipMask.MissileWeapon,
|
||||
EquipMask.Held,
|
||||
EquipMask.TwoHanded,
|
||||
EquipMask.TrinketOne,
|
||||
EquipMask.Cloak,
|
||||
EquipMask.SigilOne,
|
||||
EquipMask.SigilTwo,
|
||||
EquipMask.SigilThree,
|
||||
};
|
||||
|
||||
private const long RetailUseThrottleMs = 200;
|
||||
private const long RetailDoubleClickMs = 500;
|
||||
|
||||
|
|
@ -77,6 +42,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly InteractionState _interactionState;
|
||||
private readonly Func<uint> _selectedObjectId;
|
||||
private readonly StackSplitQuantityState? _stackSplitQuantity;
|
||||
private readonly AutoWieldController _autoWield;
|
||||
|
||||
private long _lastUseMs = long.MinValue / 2;
|
||||
private uint _consumedPrimaryClickTarget;
|
||||
|
|
@ -105,7 +71,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
Action<ItemPolicyAction>? auxiliaryAction = null,
|
||||
Action<uint, uint>? sendSplitToWorld = null,
|
||||
Func<uint>? selectedObjectId = null,
|
||||
StackSplitQuantityState? stackSplitQuantity = null)
|
||||
StackSplitQuantityState? stackSplitQuantity = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -129,6 +96,12 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_stackSplitQuantity = stackSplitQuantity;
|
||||
_interactionState = interactionState ?? new InteractionState();
|
||||
_interactionState.Changed += OnInteractionModeChanged;
|
||||
_autoWield = new AutoWieldController(
|
||||
_objects,
|
||||
_playerGuid,
|
||||
_sendWield,
|
||||
sendPutItemInContainer,
|
||||
_toast);
|
||||
}
|
||||
|
||||
public event Action? StateChanged;
|
||||
|
|
@ -364,7 +337,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
case ItemPolicyActionKind.WieldLeft:
|
||||
case ItemPolicyActionKind.AutoSort:
|
||||
if (_objects.Get(action.ObjectId) is { } item)
|
||||
acted |= TryAutoWield(item);
|
||||
acted |= _autoWield.TryWield(item);
|
||||
break;
|
||||
case ItemPolicyActionKind.OpenContainedContainer:
|
||||
case ItemPolicyActionKind.SendUse:
|
||||
|
|
@ -472,6 +445,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_interactionState.Changed -= OnInteractionModeChanged;
|
||||
_autoWield.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
|
||||
|
|
@ -491,119 +465,6 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
private bool TryAutoWield(ClientObject item)
|
||||
{
|
||||
if (item.ValidLocations == EquipMask.None
|
||||
|| item.CurrentlyEquippedLocation != EquipMask.None)
|
||||
return false;
|
||||
|
||||
EquipMask mask = BestAvailableEquipMask(item);
|
||||
if (mask == EquipMask.None)
|
||||
{
|
||||
_toast?.Invoke("That slot is already in use");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
|
||||
return false;
|
||||
|
||||
_sendWield?.Invoke(item.ObjectId, (uint)mask);
|
||||
return true;
|
||||
}
|
||||
|
||||
private EquipMask BestAvailableEquipMask(ClientObject item)
|
||||
{
|
||||
if (ItemEquipRules.IsAutoWearItem(item))
|
||||
return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
|
||||
|
||||
return FirstAvailableEquipMask(item);
|
||||
}
|
||||
|
||||
private bool AutoWearIsLegal(ClientObject item)
|
||||
{
|
||||
uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId);
|
||||
if ((item.Priority & priorityMask) == 0)
|
||||
return true;
|
||||
|
||||
EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) & item.ValidLocations;
|
||||
return GetEquippedObjectAtLocation(occupiedLocations, item.Priority, item.ObjectId) is null;
|
||||
}
|
||||
|
||||
private EquipMask FirstAvailableEquipMask(ClientObject item)
|
||||
{
|
||||
foreach (var mask in AutoEquipOrder)
|
||||
{
|
||||
if ((item.ValidLocations & mask) == EquipMask.None)
|
||||
continue;
|
||||
if (!EquipMaskOccupied(mask, item.ObjectId))
|
||||
return mask;
|
||||
}
|
||||
return EquipMask.None;
|
||||
}
|
||||
|
||||
private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
|
||||
{
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & mask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private uint EquippedAutoWearPriorityMask(uint exceptGuid)
|
||||
{
|
||||
uint mask = 0;
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
mask |= o.Priority;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
|
||||
{
|
||||
EquipMask mask = EquipMask.None;
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
mask |= o.CurrentlyEquippedLocation;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private ClientObject? GetEquippedObjectAtLocation(EquipMask locationMask, uint priority, uint exceptGuid)
|
||||
{
|
||||
if (locationMask == EquipMask.None)
|
||||
return null;
|
||||
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if (!IsEquippedByPlayer(o))
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
|
||||
continue;
|
||||
if ((o.Priority & priority) != 0 || priority == 0)
|
||||
return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsContainer(ClientObject item)
|
||||
=> item.ContainerTypeHint != 0
|
||||
|| item.Type.HasFlag(ItemType.Container)
|
||||
|
|
|
|||
47
src/AcDream.Core.Net/CombatStateWiring.cs
Normal file
47
src/AcDream.Core.Net/CombatStateWiring.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.Core.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Routes the local player's server-owned combat-mode quality into
|
||||
/// <see cref="CombatState"/>. Retail
|
||||
/// <c>ClientCombatSystem::OnQualityChanged @ 0x0056C1B0</c> reads
|
||||
/// PropertyInt 40 and calls <c>SetCombatMode</c> on every change.
|
||||
/// </summary>
|
||||
public static class CombatStateWiring
|
||||
{
|
||||
public const uint CombatModePropertyId = 40u;
|
||||
|
||||
public static void Wire(WorldSession session, CombatState combat)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
|
||||
session.PlayerIntPropertyUpdated += update =>
|
||||
ApplyPlayerIntProperty(combat, update.Property, update.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure event adapter exposed for conformance tests. Composite or unknown
|
||||
/// values are ignored: retail's quality contains one concrete mode.
|
||||
/// </summary>
|
||||
public static bool ApplyPlayerIntProperty(
|
||||
CombatState combat,
|
||||
uint property,
|
||||
int value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
if (property != CombatModePropertyId)
|
||||
return false;
|
||||
|
||||
CombatMode mode = (CombatMode)value;
|
||||
if (mode is not (CombatMode.NonCombat
|
||||
or CombatMode.Melee
|
||||
or CombatMode.Missile
|
||||
or CombatMode.Magic))
|
||||
return false;
|
||||
|
||||
combat.SetCombatMode(mode);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -314,7 +314,7 @@ public static class GameEventWiring
|
|||
string itemInfo = item is null
|
||||
? "unknown"
|
||||
: $"'{item.Name}' valid=0x{(uint)item.ValidLocations:X8} equip=0x{(uint)item.CurrentlyEquippedLocation:X8} priority=0x{item.Priority:X8} container=0x{item.ContainerId:X8} wielder=0x{item.WielderId:X8}";
|
||||
bool rolledBack = items.RollbackMove(p.Value.ItemGuid);
|
||||
bool rolledBack = items.RejectMove(p.Value.ItemGuid, p.Value.WeenieError);
|
||||
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} rolledBack={rolledBack} item={itemInfo}");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ namespace AcDream.Core.Items;
|
|||
/// </summary>
|
||||
public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType);
|
||||
|
||||
/// <summary>
|
||||
/// Server rejection of an inventory move request. <paramref name="RolledBack"/>
|
||||
/// is true when the table restored a locally optimistic move; false for
|
||||
/// server-confirmed transactions which deliberately left the local projection
|
||||
/// unchanged while waiting.
|
||||
/// </summary>
|
||||
public readonly record struct MoveRequestFailure(
|
||||
uint ItemId,
|
||||
uint WeenieError,
|
||||
bool RolledBack);
|
||||
|
||||
/// <summary>
|
||||
/// The client's table of every server object (retail <c>weenie_object_table</c> /
|
||||
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
|
||||
|
|
@ -74,12 +85,22 @@ public sealed class ClientObjectTable
|
|||
/// </summary>
|
||||
public event Action<ClientObject>? MoveRolledBack;
|
||||
|
||||
/// <summary>
|
||||
/// Fires for every InventoryServerSaveFailed response, including requests
|
||||
/// which did not mutate the table optimistically. Transaction coordinators
|
||||
/// use this to abandon server-confirmed multi-step moves without timers.
|
||||
/// </summary>
|
||||
public event Action<MoveRequestFailure>? MoveRequestFailed;
|
||||
|
||||
/// <summary>Fires when an object is removed from the session.</summary>
|
||||
public event Action<ClientObject>? ObjectRemoved;
|
||||
|
||||
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
|
||||
public event Action<ClientObject>? ObjectUpdated;
|
||||
|
||||
/// <summary>Fires after all session object and pending-move state is flushed.</summary>
|
||||
public event Action? Cleared;
|
||||
|
||||
/// <summary>PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield;
|
||||
/// the typed mirror <see cref="UpdateIntProperty"/> maintains on
|
||||
/// <see cref="ClientObject.Effects"/>.</summary>
|
||||
|
|
@ -254,6 +275,19 @@ public sealed class ClientObjectTable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconcile an InventoryServerSaveFailed response and publish the failure
|
||||
/// even when no optimistic snapshot exists. Retail's blocked AutoWield path
|
||||
/// waits for the server before changing its local item lists, so failure is
|
||||
/// still a meaningful transaction event in that case.
|
||||
/// </summary>
|
||||
public bool RejectMove(uint itemId, uint weenieError)
|
||||
{
|
||||
bool rolledBack = RollbackMove(itemId);
|
||||
MoveRequestFailed?.Invoke(new MoveRequestFailure(itemId, weenieError, rolledBack));
|
||||
return rolledBack;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a server-driven remove (destroyed item, dropped into 3D
|
||||
/// space, stolen, etc).
|
||||
|
|
@ -592,5 +626,6 @@ public sealed class ClientObjectTable
|
|||
_containers.Clear();
|
||||
_containerIndex.Clear();
|
||||
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
|
||||
Cleared?.Invoke();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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