using AcDream.Runtime.Physics;
using AcDream.Runtime.World;
using AcDream.App.Physics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Plugins;
using AcDream.Core.World;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.World;
///
/// Graphical projection sink for canonical Runtime SetPosition receipts. It
/// owns only App-facing world, effect-pose, cached-local-shadow, selection,
/// and renderer/VFX visibility projections. Runtime physics, shadows, body
/// state, clocks, and worksets were committed before this sink is invoked.
///
/// This adapter deliberately owns no subscription. The exact graphical
/// session route owns the shared Runtime observer and its generation-scoped
/// update-thread retry lease.
///
internal sealed class RuntimePlacementPresentationSink
: IRuntimePlacementProjectionSink
{
private readonly LiveEntityRuntime _liveEntities;
private readonly RuntimeWorldTransitState _transit;
private readonly WorldGameState _worldState;
private readonly WorldEvents _worldEvents;
private readonly EntityEffectPoseRegistry _effectPoses;
private readonly LocalPlayerShadowSynchronizer _localPlayerShadowSync;
private readonly Func _localPlayerGuid;
private readonly Action _clearSelectionForUnavailableEntity;
private readonly Action[] _visibilitySinks;
public RuntimePlacementPresentationSink(
LiveEntityRuntime liveEntities,
RuntimeWorldTransitState transit,
WorldGameState worldState,
WorldEvents worldEvents,
EntityEffectPoseRegistry effectPoses,
LocalPlayerShadowSynchronizer localPlayerShadowSync,
Func localPlayerGuid,
Action clearSelectionForUnavailableEntity,
IEnumerable>? visibilitySinks = null)
{
_liveEntities = liveEntities
?? throw new ArgumentNullException(nameof(liveEntities));
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
_worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents));
_effectPoses = effectPoses
?? throw new ArgumentNullException(nameof(effectPoses));
// AP-145 fix (2026-08-05, #318, architecture review A2): BOTH the
// Place and Withdraw halves now route the local player's shadow
// exclusively through this ONE publisher (SyncPose / Suspend), which
// owns the LocalPlayerShadowState cache internally — this sink no
// longer needs a direct reference to the cache at all.
_localPlayerShadowSync = localPlayerShadowSync
?? throw new ArgumentNullException(nameof(localPlayerShadowSync));
_localPlayerGuid = localPlayerGuid
?? throw new ArgumentNullException(nameof(localPlayerGuid));
_clearSelectionForUnavailableEntity = clearSelectionForUnavailableEntity
?? throw new ArgumentNullException(
nameof(clearSelectionForUnavailableEntity));
_visibilitySinks = visibilitySinks?.ToArray()
?? Array.Empty>();
if (_visibilitySinks.Any(static sink => sink is null))
throw new ArgumentException(
"Presentation visibility sinks cannot contain null.",
nameof(visibilitySinks));
}
public bool TryApply(in RuntimePlacementProjectionSnapshot projection)
{
if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted)
{
// C3c: the initial-Create completion receipt is the graphical
// binding point for a residence-driven placement (the F1
// acknowledge-and-ignore behavior applied only while
// PublishExecutorCompletion had zero production callers;
// RuntimeInitialCreateContinuationExecutor's Released arm is one
// today).
return TryApplyInitialCreateCompletion(in projection);
}
if (projection.Kind
is RuntimePlacementProjectionKind.WithdrawalRestored)
{
return TryApplyWithdrawalRestoration(in projection);
}
if (projection.Kind is RuntimePlacementProjectionKind.Place
or RuntimePlacementProjectionKind.Withdraw
&& _liveEntities.HasActiveInitialCreateResidence(
projection.Token.Entity))
{
// C3c: a Place/Withdraw for an entity still holding its
// initial-create residence belongs to the first-entry conductor
// machinery, which acknowledges its own receipts at the exact
// FIFO head. Leave it there — the drive controller's pump
// consumes it; applying or acknowledging here would starve the
// conductor's own acknowledgement stage forever.
return false;
}
if (projection.Kind is RuntimePlacementProjectionKind.Place
&& !_transit.IsCurrentPlacementAuthority(
projection.Token.Portal,
projection.Token.ExactCellId))
{
// B2 review fix (2026-08-05): acknowledge-and-ignore, same shape
// as Discard/ExecutorCompleted/WithdrawalRestored above. A Place
// whose portal authority went stale (the transit ended or was
// superseded WHILE a DeferredCell park sat outstanding — the
// residual A1's readiness-hold does not close, since it only
// protects the ORDINARY in-flight case) must not be left
// refused at the FIFO head: RuntimePlacementProjectionSubscription
// .OnPlacement never calls Acknowledge on a `false` return, so a
// refused receipt wedges EVERY later entity's placement receipt
// behind it forever. This runs SYNCHRONOUSLY at publish
// (RuntimeAcceptedPositionDriveController's A2 re-validation, by
// contrast, only runs downstream of a receipt this gate ALREADY
// let through — it cannot protect this path). The canonical
// body already committed via RetryDeferred; there is simply no
// live presentation authority left to apply it to.
return true;
}
if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection))
return false;
if (projection.Kind is RuntimePlacementProjectionKind.Discard)
{
// Discard cancels only an unacknowledged observation - no
// world/presentation mutation. Must NOT fall through to the
// record-lookup gate below (that gate legitimately rejects for
// OTHER reasons, and this sink's caller
// (RuntimePlacementProjectionSubscription) treats a false return
// as "leave at the FIFO head" - a rejected Discard would
// permanently wedge the whole ordered stream).
return true;
}
if (!_liveEntities.TryGetRecord(
projection.Token.Entity,
out LiveEntityRecord record)
|| record.WorldEntity is not { } entity)
{
return false;
}
return projection.Kind switch
{
RuntimePlacementProjectionKind.Place =>
TryPublishPlace(record, entity),
RuntimePlacementProjectionKind.Withdraw =>
TryPublishWithdrawal(record, entity),
_ => false,
};
}
///
/// C3c: binds one completed initial-Create drain's presentation. A
/// celless completion (a route that performed no SetPosition — a
/// deferred-parent child staying invisible until its parent replay, or a
/// positionless create) and a missing/superseded sidecar are
/// acknowledge-only; the sidecar's own materialization self-projects
/// from canonical state in those cases. Pending (not-yet-loaded)
/// destination buckets are allowed — the legacy Create path's own
/// semantics — so this receipt can never wedge the ordered stream behind
/// an unloaded graphical backend.
///
private bool TryApplyInitialCreateCompletion(
in RuntimePlacementProjectionSnapshot projection)
{
if (projection.Token.ExactCellId == 0u)
return true;
if (!_liveEntities.TryApplyInitialCreateCompletionPresentation(
in projection))
{
return false;
}
if (!_liveEntities.TryGetRecord(
projection.Token.Entity,
out LiveEntityRecord record)
|| record.WorldEntity is not { } entity)
{
return true;
}
return TryPublishPlace(record, entity);
}
///
/// Rolls back a this
/// sink already applied, after Runtime restored the canonical half of the
/// park it belonged to. Every registration
/// dropped is re-installed by its exact
/// mirror image - the graphical bucket and
/// projection visibility through
/// LiveEntityRuntime.TryApplyRuntimePlacementProjection, then plugin
/// world state, the world-event stream, the effect-pose registry, the
/// local-player shadow, and the presentation visibility sinks here.
///
/// Always acknowledges. Like Discard and ExecutorCompleted
/// this receipt is not Operation-backed, and its caller
/// (RuntimePlacementProjectionSubscription) treats a false return as
/// "leave at the FIFO head" - which would wedge the entire ordered stream
/// for every entity. Every way the restore below can decline is an
/// entity that is gone, displaced by a newer incarnation, or not
/// materialized, i.e. one with no prior projection left to restore; the
/// replacement projects itself through its own receipts.
///
/// Selection is deliberately not re-established. The
/// withdrawal's _clearSelectionForUnavailableEntity is a
/// user-intent mutation, not a projection registration; re-selecting an
/// object on the player's behalf would invent input. Recorded as AD-63 in
/// the divergence register.
///
private bool TryApplyWithdrawalRestoration(
in RuntimePlacementProjectionSnapshot projection)
{
if (_liveEntities.TryApplyRuntimePlacementProjection(in projection)
&& _liveEntities.TryGetRecord(
projection.Token.Entity,
out LiveEntityRecord record)
&& record.WorldEntity is { } entity)
{
_ = TryPublishPlace(record, entity);
}
return true;
}
private bool TryPublishPlace(LiveEntityRecord record, WorldEntity entity)
{
if (!IsCurrent(record, entity))
return false;
WorldEntitySnapshot snapshot = Snapshot(entity);
_worldState.Add(snapshot);
if (!IsCurrent(record, entity))
return false;
_worldEvents.UpsertCurrent(snapshot);
if (!IsCurrent(record, entity))
return false;
_effectPoses.PublishMeshRefs(entity);
if (!IsCurrent(record, entity))
return false;
if (record.ServerGuid == _localPlayerGuid())
{
// AP-145 fix (2026-08-05, #318): route through the SAME
// publisher ordinary per-tick movement uses
// (LocalPlayerShadowSynchronizer.SyncPose), not a direct
// LocalPlayerShadowState.Set. The old direct write updated only
// the dedup cache, never PhysicsEngine.ShadowObjects — the
// portal jump's real collision shadow stayed at the SOURCE cell
// until an unrelated movement tick happened to drift far enough
// to defeat SyncPose's own dedup check (which the direct write
// had just pre-seeded with the destination pose, so even that
// recovery could silently miss). SyncPose both publishes the
// real ShadowObjects row (via Register, which first deregisters
// any prior cell rows — no stale source-cell row, no duplicate)
// and records the dedup cache as its own last step, in the
// correct order. force:true because this IS the authoritative
// placement commit, not an ordinary per-tick refresh — it must
// never be skipped by the dedup path.
//
// Behaviour-change nuance (architecture review, 2026-08-05):
// routing through SyncPose means Place now inherits SyncPose's
// own admission guard — IsHidden(...), cellId == 0, or
// !IsCurrentVisibleProjection(entity) (not the current spatial
// root / not a current record) — none of which the old direct
// .Set() call ever consulted. Under any of those conditions
// SyncPose calls Suspend(entity) instead of publishing: the real
// ShadowObjects row is REMOVED and the cache is cleared, where
// the old write would have left a stale ShadowObjects row in
// place and simply overwritten the cache. This is the correct,
// symmetric behaviour — it is exactly what the very next
// ordinary per-tick Sync call would do in the same situation —
// and it is covered by the same player-only gate this method
// already had, but it IS new: a Place that lands while the
// record is momentarily not the current visible spatial root
// (a narrow, low-frequency window) now suspends the real shadow
// where it previously left a possibly-stale one untouched.
_localPlayerShadowSync.SyncPose(
entity,
entity.Position,
entity.Rotation,
record.FullCellId,
force: true);
}
for (int i = 0; i < _visibilitySinks.Length; i++)
{
_visibilitySinks[i](record, true);
if (!IsCurrent(record, entity))
return false;
}
return true;
}
private bool TryPublishWithdrawal(
LiveEntityRecord record,
WorldEntity entity)
{
if (!IsCurrent(record, entity))
return false;
for (int i = 0; i < _visibilitySinks.Length; i++)
{
_visibilitySinks[i](record, false);
if (!IsCurrent(record, entity))
return false;
}
_worldState.RemoveById(entity.Id);
if (!IsCurrent(record, entity))
return false;
_worldEvents.ForgetEntity(entity.Id);
if (!IsCurrent(record, entity))
return false;
_effectPoses.Remove(entity.Id);
if (!IsCurrent(record, entity))
return false;
if (record.ServerGuid == _localPlayerGuid())
{
// AP-145 fix, Withdraw half (2026-08-05, architecture review
// A2): the exact mirror of the Place-side fix. The old direct
// _localPlayerShadow.Clear() only cleared the dedup cache,
// leaving a LIVE phantom row in PhysicsEngine.ShadowObjects at
// the park's source cell for the whole park window (the #184
// shape) — every other entity's collision sweep in that cell
// would collide with a player who is, per every other acdream
// predicate, gone. Suspend() does both: real registry suspend
// (ShadowObjectRegistry.Suspend) AND the cache clear, in the
// one call LocalPlayerShadowSynchronizer already exposes for
// exactly this pairing (see its own Suspend/SyncPose split).
_localPlayerShadowSync.Suspend(entity);
}
if (!IsCurrent(record, entity))
return false;
_clearSelectionForUnavailableEntity(record.ServerGuid);
return IsCurrent(record, entity);
}
private bool IsCurrent(LiveEntityRecord record, WorldEntity entity) =>
_liveEntities.TryGetRecord(
record.ProjectionKey!.Value,
out LiveEntityRecord current)
&& ReferenceEquals(current, record)
&& ReferenceEquals(current.WorldEntity, entity);
private static WorldEntitySnapshot Snapshot(WorldEntity entity) => new(
entity.Id,
entity.SourceGfxObjOrSetupId,
entity.Position,
entity.Rotation);
}