feat(mosstank): add VTank-style automation PoC

This commit is contained in:
Erik 2026-08-27 18:57:21 +02:00
parent f6fe0f2a4f
commit 4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions

View file

@ -75,6 +75,7 @@ public sealed class ItemInteractionController : IDisposable
// dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell.
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? _sendBuyAll;
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? _sendSell;
private readonly Func<uint, IReadOnlyList<uint>, bool>? _sendSalvage;
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
private readonly InventoryTransactionState _transactions;
@ -120,7 +121,8 @@ public sealed class ItemInteractionController : IDisposable
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
Action<string, RetailLogTextType>? interfaceText = null,
Action<uint, uint, uint>? sendStackableMerge = null)
Action<uint, uint, uint>? sendStackableMerge = null,
Func<uint, IReadOnlyList<uint>, bool>? sendSalvage = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -155,6 +157,7 @@ public sealed class ItemInteractionController : IDisposable
_sendBuy = sendBuy;
_sendBuyAll = sendBuyAll;
_sendSell = sendSell;
_sendSalvage = sendSalvage;
_interactionState = interactionState
?? throw new ArgumentNullException(nameof(interactionState));
_runtimeTransactions = runtimeTransactions
@ -500,6 +503,197 @@ public sealed class ItemInteractionController : IDisposable
});
}
/// <summary>
/// Plugin-facing form of retail's put/split-to-container attempts. It
/// borrows this controller's exact transaction gate and wire delegates;
/// plugins supply policy, never a second optimistic inventory model.
/// </summary>
public bool TryMoveItemForAutomation(
uint itemId,
uint containerId,
uint amount = 0u,
int placement = 0)
{
if (itemId == 0u
|| containerId == 0u
|| _sendPutItemInContainer is null
|| _objects.Get(itemId) is not { } item
|| !IsOwnedByPlayer(itemId)
|| (containerId != _playerGuid() && !IsOwnedByPlayer(containerId)))
{
return false;
}
uint fullStack = (uint)Math.Max(1, item.StackSize);
uint requested = amount == 0u ? fullStack : amount;
if (requested == 0u || requested > fullStack)
return false;
if (requested < fullStack)
{
return TrySplitToContainer(
itemId,
containerId,
(uint)Math.Max(0, placement),
requested);
}
return TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
itemId,
() =>
{
_sendPutItemInContainer(itemId, containerId, placement);
return true;
});
}
/// <summary>
/// Plugin-facing retail stack merge. The shared planner performs the same
/// WCID, maximum-size, staged-trade, and transfer-size checks as a drag.
/// </summary>
public bool TryMergeItemsForAutomation(
uint sourceItemId,
uint targetItemId,
uint amount = 0u)
{
if (_sendStackableMerge is null
|| !IsOwnedByPlayer(sourceItemId)
|| !IsOwnedByPlayer(targetItemId)
|| _objects.Get(sourceItemId) is not { } source
|| _objects.Get(targetItemId) is not { } target)
{
return false;
}
int requested = amount > int.MaxValue ? int.MaxValue : (int)amount;
StackMergePlan? plan = StackMergePlanner.Plan(
ToStackMergeItem(source),
ToStackMergeItem(target),
CanMakeInventoryRequest,
requested);
if (plan is not { } merge)
return false;
return TryDispatchInventoryRequest(
InventoryRequestKind.Merge,
sourceItemId,
() =>
{
_sendStackableMerge(
merge.SourceObjectId,
merge.TargetObjectId,
merge.Amount);
MergeAttempted?.Invoke(
merge.SourceObjectId,
merge.TargetObjectId);
return true;
});
}
/// <summary>Plugin-facing retail full-stack drop or split-to-world.</summary>
public bool TryDropItemForAutomation(uint itemId, uint amount = 0u)
{
if (!IsOwnedByPlayer(itemId) || _objects.Get(itemId) is not { } item)
return false;
uint fullStack = (uint)Math.Max(1, item.StackSize);
uint requested = amount == 0u ? fullStack : amount;
if (requested == 0u || requested > fullStack)
return false;
InventoryRequestKind kind = requested < fullStack
? InventoryRequestKind.SplitToWorld
: InventoryRequestKind.DropToWorld;
return TryDispatchInventoryRequest(
kind,
itemId,
() =>
{
if (requested < fullStack)
{
if (_sendSplitToWorld is null)
return false;
_sendSplitToWorld(itemId, requested);
}
else
{
if (_sendDrop is null)
return false;
_sendDrop(itemId);
}
return true;
});
}
/// <summary>Plugin-facing retail Give attempt with an exact stack amount.</summary>
public bool TryGiveItemForAutomation(
uint itemId,
uint targetId,
uint amount = 0u)
{
if (_sendGive is null
|| targetId == 0u
|| targetId == _playerGuid()
|| _objects.Get(targetId) is null
|| !IsOwnedByPlayer(itemId)
|| _objects.Get(itemId) is not { } item)
{
return false;
}
uint fullStack = (uint)Math.Max(1, item.StackSize);
uint requested = amount == 0u ? fullStack : amount;
if (requested == 0u || requested > fullStack)
return false;
return TryDispatchInventoryRequest(
InventoryRequestKind.Give,
itemId,
() =>
{
_sendGive(targetId, itemId, requested);
return true;
});
}
/// <summary>
/// Plugin-facing form of gmSalvageUI::Salvage. Retail validates an owned
/// tinkering tool and a non-empty ordered list of suitable owned source
/// items, then sends 0x027D without entering the ordinary one-item move
/// transaction. The server owns the final material and option checks.
/// </summary>
public bool TrySalvageItemsForAutomation(
uint toolId,
IReadOnlyList<uint> itemIds)
{
if (_sendSalvage is null
|| toolId == 0u
|| itemIds is null
|| itemIds.Count == 0
|| !CanMakeInventoryRequest
|| !IsOwnedByPlayer(toolId)
|| _objects.Get(toolId) is not { } tool
|| (tool.Type & ItemType.TinkeringTool) == 0)
{
return false;
}
var distinct = new HashSet<uint>();
foreach (uint itemId in itemIds)
{
if (itemId == 0u
|| itemId == toolId
|| !distinct.Add(itemId)
|| !IsOwnedByPlayer(itemId)
|| _objects.Get(itemId) is not { } item
|| item.MaterialType is null or 0u
|| item.Structure >= 100
|| ((item.PublicWeenieBitfield ?? 0u) & 0xFF000000u) != 0u)
{
return false;
}
}
return _sendSalvage(toolId, itemIds);
}
/// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The
@ -655,6 +849,21 @@ public sealed class ItemInteractionController : IDisposable
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
}
/// <summary>
/// Plugin/automation appraisal through the one retail appraisal owner.
/// It does not mutate selection or open/raise the examination window.
/// </summary>
public bool TryAppraiseForAutomation(uint objectId)
{
if (objectId == 0u
|| _sendExamine is null
|| _objects.Get(objectId) is null)
{
return false;
}
return _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
}
/// <summary>
/// Accepts only the pending or current appraisal, matching
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
@ -750,6 +959,76 @@ public sealed class ItemInteractionController : IDisposable
return ExecuteUseActions(decision.Actions);
}
/// <summary>
/// Plugin/automation entry for an ordinary item request. Unlike interactive
/// activation, it never turns a use request into wielding, sorting, or a
/// modal target cursor, and returns true only when a wire Use was issued.
/// </summary>
public bool TryUseItemForAutomation(uint itemGuid)
{
if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item)
return false;
if (ItemUseability.IsTargeted(item.Useability ?? ItemUseability.Undef))
return false;
if (!ConsumeUseThrottle())
return false;
if (!EnsureInventoryRequestReady())
return false;
var input = new ItemUsePolicyInput(
Snapshot(item),
_playerGuid(),
_groundObjectId(),
CanMakeInventoryRequest,
_activeVendorId(),
BypassClassification: true,
UseCurrentSelection: false,
SelectedTarget: null,
ConfirmVolatileRareUses: true,
InNonCombatMode: _inNonCombatMode());
ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input);
bool sends = decision.Actions.Any(static action =>
action.Kind == ItemPolicyActionKind.SendUse);
return sends && ExecuteUseActions(decision.Actions);
}
/// <summary>
/// Plugin/automation entry for a targeted item action. It follows the same
/// retail compatibility, throttle, busy-reference and UseDone ownership as
/// choosing a target through the interactive target cursor, without
/// installing a modal cursor state that automation cannot safely own.
/// </summary>
public bool TryApplyItem(uint itemGuid, uint targetGuid)
{
if (itemGuid == 0u || targetGuid == 0u)
return false;
if (_objects.Get(itemGuid) is not { } item
|| _objects.Get(targetGuid) is not { } target)
{
return false;
}
if (!ConsumeUseThrottle())
return true;
if (!EnsureInventoryRequestReady())
return false;
var input = new ItemUsePolicyInput(
Snapshot(item),
_playerGuid(),
_groundObjectId(),
CanMakeInventoryRequest,
_activeVendorId(),
BypassClassification: true,
UseCurrentSelection: true,
SelectedTarget: Snapshot(target),
ConfirmVolatileRareUses: true,
InNonCombatMode: _inNonCombatMode());
ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input);
bool sends = decision.Actions.Any(static action =>
action.Kind == ItemPolicyActionKind.SendUseWithTarget);
return sends && ExecuteUseActions(decision.Actions);
}
/// <summary>
/// Retail keyboard pickup entry point. <c>CPlayerSystem::PlaceInBackpack</c>
/// publishes the waiting destination slot before issuing the move request,
@ -1095,6 +1374,24 @@ public sealed class ItemInteractionController : IDisposable
return _autoWield.TryWield(item, targetMask);
}
/// <summary>
/// Plugin/automation entry into the exact same AutoWield transaction used
/// by inventory activation and paperdoll drops.
/// </summary>
public bool TryWieldItem(uint itemGuid, EquipMask requestedMask = EquipMask.None)
{
if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item)
return false;
if (!EnsureInventoryRequestReady())
return false;
return requestedMask == EquipMask.None
? _autoWield.TryWield(item)
: _autoWield.TryWield(item, requestedMask);
}
public bool IsAutoWieldBusy =>
_autoWield.IsBusy || !_transactions.CanBeginRequest;
/// <summary>User combat-mode input supersedes AutoWield's retained mode.</summary>
public void NotifyExplicitCombatModeRequest()
=> _autoWield.NotifyExplicitCombatModeRequest();