fix(interaction): restore retail loot placement and world-drop projection
This commit is contained in:
parent
4d095be286
commit
921712f412
24 changed files with 1581 additions and 34 deletions
216
src/AcDream.App/World/InventoryWorldDropProjectionController.cs
Normal file
216
src/AcDream.App/World/InventoryWorldDropProjectionController.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges retail's pending split-to-3D identity into canonical live-entity
|
||||
/// registration when an ACE owner receives the result's Position before a
|
||||
/// CreateObject.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail records <c>splitClassID</c>, <c>splitStackSize</c>, and
|
||||
/// <c>splitTime</c> in <c>ACCWeenieObject::UIAttemptSplitTo3D @
|
||||
/// 0x0058D850</c>, then recognizes the created stack from
|
||||
/// <c>ACCWeenieObject::DeclareValid @ 0x0058E340</c>. ACE currently omits
|
||||
/// CreateObject for the initiating player but sends the new GUID's F748
|
||||
/// Position. This owner accepts only that otherwise-impossible packet while
|
||||
/// the exact retail pending-split identity is live; arbitrary unknown Position
|
||||
/// packets remain rejected by the ordinary authority gate.
|
||||
/// </remarks>
|
||||
internal sealed class InventoryWorldDropProjectionController : IDisposable
|
||||
{
|
||||
private readonly ItemInteractionController _interaction;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly LiveEntityRuntime _runtime;
|
||||
private readonly LiveEntityHydrationController _hydration;
|
||||
private readonly PendingSplitToWorldProjection _pending;
|
||||
private readonly Func<double> _now;
|
||||
private bool _disposed;
|
||||
|
||||
public InventoryWorldDropProjectionController(
|
||||
ItemInteractionController interaction,
|
||||
ClientObjectTable objects,
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityHydrationController hydration,
|
||||
Func<double> now)
|
||||
{
|
||||
_interaction = interaction
|
||||
?? throw new ArgumentNullException(nameof(interaction));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_hydration = hydration
|
||||
?? throw new ArgumentNullException(nameof(hydration));
|
||||
_now = now ?? throw new ArgumentNullException(nameof(now));
|
||||
_pending = new PendingSplitToWorldProjection();
|
||||
|
||||
_interaction.WorldDropDispatched += OnWorldDropDispatched;
|
||||
_objects.MoveRequestFailed += OnMoveRequestFailed;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
}
|
||||
|
||||
public bool TryRecoverUnknownPosition(
|
||||
WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
bool known = _runtime.TryGetSnapshot(update.Guid, out _);
|
||||
if (known
|
||||
|| !_pending.TryResolve(update, _now(), out WorldSession.EntitySpawn spawn))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Consume before the synchronous CreateObject graph runs. Re-entrant
|
||||
// callbacks cannot bind the same split intent to a second GUID.
|
||||
_hydration.OnCreate(spawn);
|
||||
return _runtime.TryGetSnapshot(update.Guid, out _);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
_objects.MoveRequestFailed -= OnMoveRequestFailed;
|
||||
_interaction.WorldDropDispatched -= OnWorldDropDispatched;
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
private void OnWorldDropDispatched(WorldDropDispatch dispatch)
|
||||
{
|
||||
bool hasSource = _runtime.TryGetSnapshot(
|
||||
dispatch.Request.ItemId,
|
||||
out WorldSession.EntitySpawn source);
|
||||
if (dispatch.Request.Kind is not InventoryRequestKind.SplitToWorld
|
||||
|| dispatch.Amount == 0
|
||||
|| !hasSource)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pending.Record(
|
||||
dispatch.Request.Token,
|
||||
source,
|
||||
dispatch.Amount,
|
||||
_now());
|
||||
}
|
||||
|
||||
private void OnMoveRequestFailed(MoveRequestFailure failure) =>
|
||||
_pending.ClearSource(failure.ItemId);
|
||||
|
||||
private void OnObjectsCleared() => _pending.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-independent pending identity for retail split-to-3D
|
||||
/// recognition. Retail owns one static split identity and replaces it on the
|
||||
/// next request; this owner intentionally does the same.
|
||||
/// </summary>
|
||||
internal sealed class PendingSplitToWorldProjection
|
||||
{
|
||||
internal const double RetailRecognitionSeconds = 10.0;
|
||||
|
||||
private PendingSplit? _value;
|
||||
|
||||
internal bool HasPending => _value is not null;
|
||||
|
||||
internal void Record(
|
||||
ulong requestToken,
|
||||
WorldSession.EntitySpawn source,
|
||||
uint amount,
|
||||
double now)
|
||||
{
|
||||
if (requestToken == 0
|
||||
|| source.Guid == 0
|
||||
|| amount == 0
|
||||
|| !double.IsFinite(now))
|
||||
{
|
||||
_value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_value = new PendingSplit(requestToken, source, amount, now);
|
||||
}
|
||||
|
||||
internal bool TryResolve(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
double now,
|
||||
out WorldSession.EntitySpawn spawn)
|
||||
{
|
||||
spawn = default;
|
||||
if (_value is not { } pending)
|
||||
return false;
|
||||
|
||||
double age = now - pending.RequestTime;
|
||||
if (!double.IsFinite(age)
|
||||
|| age < 0
|
||||
|| age >= RetailRecognitionSeconds)
|
||||
{
|
||||
_value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (update.Guid == 0 || update.Guid == pending.Source.Guid)
|
||||
return false;
|
||||
|
||||
_value = null;
|
||||
spawn = BuildSpawn(pending.Source, pending.Amount, update);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void ClearSource(uint sourceGuid)
|
||||
{
|
||||
if (_value is { } pending && pending.Source.Guid == sourceGuid)
|
||||
_value = null;
|
||||
}
|
||||
|
||||
internal void Clear() => _value = null;
|
||||
|
||||
private static WorldSession.EntitySpawn BuildSpawn(
|
||||
WorldSession.EntitySpawn source,
|
||||
uint amount,
|
||||
WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
PhysicsSpawnData? physics = source.Physics is { } sourcePhysics
|
||||
? sourcePhysics with
|
||||
{
|
||||
Position = update.Position,
|
||||
Parent = null,
|
||||
Velocity = update.Velocity,
|
||||
Timestamps = sourcePhysics.Timestamps with
|
||||
{
|
||||
Position = update.PositionSequence,
|
||||
Teleport = update.TeleportSequence,
|
||||
ForcePosition = update.ForcePositionSequence,
|
||||
Instance = update.InstanceSequence,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
return source with
|
||||
{
|
||||
Guid = update.Guid,
|
||||
Position = update.Position,
|
||||
StackSize = checked((int)amount),
|
||||
ContainerId = 0u,
|
||||
WielderId = 0u,
|
||||
CurrentWieldedLocation = 0u,
|
||||
InstanceSequence = update.InstanceSequence,
|
||||
MovementSequence = 0,
|
||||
ServerControlSequence = 0,
|
||||
PositionSequence = update.PositionSequence,
|
||||
ParentGuid = null,
|
||||
ParentLocation = null,
|
||||
PlacementId = update.PlacementId,
|
||||
Physics = physics,
|
||||
};
|
||||
}
|
||||
|
||||
private readonly record struct PendingSplit(
|
||||
ulong RequestToken,
|
||||
WorldSession.EntitySpawn Source,
|
||||
uint Amount,
|
||||
double RequestTime);
|
||||
}
|
||||
|
|
@ -676,6 +676,66 @@ AppearanceSynchronization:
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs the App projection for a canonical object whose accepted
|
||||
/// retail Position moved it from inventory-only ownership into the world.
|
||||
/// Logical identity, timestamps, setup defaults, and retained item state
|
||||
/// already exist; this crosses only the ordinary materialization/ready
|
||||
/// boundary.
|
||||
/// </summary>
|
||||
public bool RecoverCanonicalProjection(
|
||||
RuntimeEntityRecord expectedCanonical,
|
||||
ulong positionAuthorityVersion,
|
||||
out LiveEntityRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedCanonical);
|
||||
lock (_datLock)
|
||||
{
|
||||
record = null!;
|
||||
if (!_runtime.IsCurrentPositionAuthority(
|
||||
expectedCanonical,
|
||||
positionAuthorityVersion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_runtime.TryGetProjection(
|
||||
expectedCanonical,
|
||||
out record))
|
||||
{
|
||||
return _runtime.IsCurrentPositionAuthority(
|
||||
record,
|
||||
positionAuthorityVersion);
|
||||
}
|
||||
|
||||
WorldSession.EntitySpawn canonicalSpawn =
|
||||
expectedCanonical.Snapshot;
|
||||
if (canonicalSpawn.Position is null)
|
||||
return false;
|
||||
|
||||
InitializeOriginAndRecoverLoaded(canonicalSpawn);
|
||||
if (!_runtime.IsCurrentPositionAuthority(
|
||||
expectedCanonical,
|
||||
positionAuthorityVersion)
|
||||
|| !ProjectExact(
|
||||
expectedCanonical,
|
||||
canonicalSpawn,
|
||||
LiveProjectionPurpose.SpatialRecovery,
|
||||
expectedCanonical.CreateIntegrationVersion)
|
||||
|| !_runtime.TryGetProjection(
|
||||
expectedCanonical,
|
||||
out record))
|
||||
{
|
||||
record = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
return _runtime.IsCurrentPositionAuthority(
|
||||
record,
|
||||
positionAuthorityVersion);
|
||||
}
|
||||
}
|
||||
|
||||
public bool OnEntityReady(LiveEntityReadyCandidate candidate)
|
||||
{
|
||||
if (!PublishReady(candidate))
|
||||
|
|
|
|||
|
|
@ -1154,6 +1154,29 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a current top-level world projection even when its spatial
|
||||
/// bucket is pending/nonresident. Unlike interaction eligibility, this
|
||||
/// does not impose render visibility; unlike <see cref="TryGetWorldEntity"/>,
|
||||
/// it rejects a retained leave-world owner whose pose is historical.
|
||||
/// </summary>
|
||||
public bool TryGetSpatiallyProjectedRecord(
|
||||
uint serverGuid,
|
||||
out LiveEntityRecord record)
|
||||
{
|
||||
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord found)
|
||||
&& found.WorldEntity is not null
|
||||
&& found.ProjectionKind is LiveEntityProjectionKind.World
|
||||
&& found.IsSpatiallyProjected)
|
||||
{
|
||||
record = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
record = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a top-level object that currently participates in picking,
|
||||
/// targeting, radar, and wire-driven MoveTo/Sticky establishment.
|
||||
|
|
@ -1762,6 +1785,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
&& ReferenceEquals(current, record)
|
||||
&& current.PositionAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentPositionAuthority(
|
||||
RuntimeEntityRecord canonical,
|
||||
ulong authorityVersion) =>
|
||||
_directory.IsCurrent(canonical)
|
||||
&& canonical.PositionAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentStateAuthority(
|
||||
LiveEntityRecord record,
|
||||
ulong authorityVersion) =>
|
||||
|
|
@ -1783,6 +1812,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
&& ReferenceEquals(current, record)
|
||||
&& current.VelocityAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentVelocityAuthority(
|
||||
RuntimeEntityRecord canonical,
|
||||
ulong authorityVersion) =>
|
||||
_directory.IsCurrent(canonical)
|
||||
&& canonical.VelocityAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentMovementAuthority(
|
||||
LiveEntityRecord record,
|
||||
ulong authorityVersion) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue