feat(items): port retail external-container looting

Add the ClientUISystem ground-object lifecycle, authoritative root and nested ViewContents projections, replacement and close semantics, and the DAT-authored gmExternalContainerUI strip for chests and corpses.

Route double-click loot and full or partial drag transfers through the shared retail item policy without optimistic external ownership. Remove the incorrect NoLongerViewingContents behavior from owned side packs and retire AP-106/#196.

Release build succeeds and all 5,875 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 16:18:10 +02:00
parent 48a118db91
commit 20ce67b625
27 changed files with 1750 additions and 45 deletions

View file

@ -840,6 +840,8 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private readonly AcDream.Core.Items.ExternalContainerState _externalContainers = new();
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
private AcDream.App.Spells.MagicCatalog? _magicCatalog;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
@ -2110,6 +2112,10 @@ public sealed class GameWindow : IDisposable
_selection,
autoTarget: () => _persistedGameplay.AutoTarget,
selectClosestTarget: () => SelectClosestCombatTarget(showToast: false));
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
_externalContainers,
Objects,
guid => _liveSession?.SendNoLongerViewingContents(guid));
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
if (_options.RetailUi)
@ -2146,7 +2152,11 @@ public sealed class GameWindow : IDisposable
stackSplitQuantity: _stackSplitQuantity,
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
sendPutItemInContainer: (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement));
_liveSession?.SendPutItemInContainer(item, container, placement),
sendSplitToContainer: (item, container, placement, amount) =>
_liveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode
@ -2358,7 +2368,6 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
guid => _liveSession?.SendUse(guid),
guid => _liveSession?.SendNoLongerViewingContents(guid),
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
@ -2368,6 +2377,22 @@ public sealed class GameWindow : IDisposable
_liveSession?.SendStackableMerge(source, target, amount),
_itemInteractionController,
_selection),
ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings(
_externalContainers,
Objects,
(type, icon, under, over, effects) =>
iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) =>
iconComposer.GetDragIcon(type, icon, under, over, effects),
_itemInteractionController,
_selection,
guid => _liveSession?.SendUse(guid),
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
_liveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
IsWithinExternalContainerUseRange),
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController,
retailCursorManager),
@ -2863,6 +2888,7 @@ public sealed class GameWindow : IDisposable
// Attachment projections own GL-backed world registrations, so tear
// them down before dropping the canonical GUID/timestamp snapshots.
_equippedChildRenderer?.Clear();
_externalContainers.Reset();
Objects.Clear();
SpellBook.Clear();
_magicRuntime?.Reset();
@ -3025,7 +3051,11 @@ public sealed class GameWindow : IDisposable
},
onShortcuts: list => Shortcuts = list,
playerGuid: () => _playerServerGuid,
onUseDone: error => _itemInteractionController?.CompleteUse(error),
onUseDone: error =>
{
_externalContainers.ApplyUseDone(error);
_itemInteractionController?.CompleteUse(error);
},
itemMana: ItemMana,
onConfirmationRequest: request =>
_retailUiRuntime?.HandleConfirmationRequest(request),
@ -3038,7 +3068,8 @@ public sealed class GameWindow : IDisposable
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
clientTime: ClientTimerNow);
clientTime: ClientTimerNow,
externalContainers: _externalContainers);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
@ -13266,6 +13297,9 @@ public sealed class GameWindow : IDisposable
return;
}
if (IsExternalContainerTarget(guid))
_externalContainers.RequestOpen(guid);
// B.6/R4-V5: install a speculative local TurnToObject/MoveToObject
// through the player's MoveToManager so close-range Use rotates the
// body to face before the action fires. For FAR targets, ACE's
@ -13482,6 +13516,40 @@ public sealed class GameWindow : IDisposable
return distSq <= useRadius * useRadius;
}
private bool IsWithinExternalContainerUseRange(uint targetGuid)
{
if (_playerController is null
|| !_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
{
// The server remains authoritative for forced close. An entity can be
// temporarily absent from the visible-cell projection while its external
// view is still valid; do not manufacture a range exit from missing render state.
return true;
}
if (!LastSpawns.TryGetValue(targetGuid, out var spawn)
|| spawn.UseRadius is not > 0f)
return true;
float useRadius = spawn.UseRadius.Value;
float dx = entity.Position.X - _playerController.Position.X;
float dy = entity.Position.Y - _playerController.Position.Y;
return dx * dx + dy * dy <= useRadius * useRadius;
}
private bool IsExternalContainerTarget(uint guid)
{
if (Objects.Get(guid) is { } item
&& (item.ContainerTypeHint != 0u
|| item.Type.HasFlag(AcDream.Core.Items.ItemType.Container)
|| item.ItemsCapacity > 0))
{
return true;
}
return LastSpawns.TryGetValue(guid, out var spawn)
&& (spawn.ObjectDescriptionFlags.GetValueOrDefault() & 0x2000u) != 0u;
}
private void InstallSpeculativeTurnToTarget(uint targetGuid)
{
if (_playerController is not { } pc || pc.MoveTo is null) return;
@ -14784,6 +14852,8 @@ public sealed class GameWindow : IDisposable
_uiHost = null;
_itemInteractionController?.Dispose();
_itemInteractionController = null;
_externalContainerLifecycle?.Dispose();
_externalContainerLifecycle = null;
_magicRuntime = null;
_magicCatalog = null;