using AcDream.Core.Net.Messages;
using AcDream.App.Input;
using AcDream.Runtime.Entities;
namespace AcDream.App.World;
internal readonly record struct LiveEntityLivenessSample(
RuntimeEntityKey Key,
uint ServerGuid,
bool IsConservativelyVisible,
bool HasNonWorldRetention);
internal readonly record struct LiveEntityPruneCandidate(
RuntimeEntityKey Key,
uint ServerGuid,
ushort Generation)
{
public LiveEntityPruneCandidate(RuntimeEntityKey key, uint serverGuid)
: this(key, serverGuid, key.Incarnation)
{
}
}
///
/// Deadline state for ACE's retained KnownObjects behavior. Retail
/// CPhysicsObj::prepare_to_leave_visibility (0x00511F40) schedules the
/// same 25-second expiry through CObjectMaint::AddObjectToBeDestroyed
/// (0x00508F70), while prepare_to_enter_world (0x00511FA0) cancels it.
///
internal sealed class LiveEntityLivenessTracker
{
internal const double DestructionTimeoutSeconds = 25.0;
private readonly Dictionary _deadlines = [];
private readonly HashSet _present = [];
private readonly List _stale = [];
private readonly List _due = [];
internal int DeadlineCount => _deadlines.Count;
internal IReadOnlyList Tick(
double now,
IReadOnlyList samples)
{
_present.Clear();
_due.Clear();
for (int i = 0; i < samples.Count; i++)
{
LiveEntityLivenessSample sample = samples[i];
_present.Add(sample.Key);
if (sample.IsConservativelyVisible || sample.HasNonWorldRetention)
{
_deadlines.Remove(sample.Key);
continue;
}
if (!_deadlines.TryGetValue(sample.Key, out double expiresAt))
{
_deadlines[sample.Key] = now + DestructionTimeoutSeconds;
continue;
}
if (expiresAt > now)
continue;
_due.Add(new LiveEntityPruneCandidate(sample.Key, sample.ServerGuid));
_deadlines.Remove(sample.Key);
}
_stale.Clear();
foreach (RuntimeEntityKey key in _deadlines.Keys)
{
if (!_present.Contains(key))
_stale.Add(key);
}
for (int i = 0; i < _stale.Count; i++)
_deadlines.Remove(_stale[i]);
return _due;
}
internal void Clear() => _deadlines.Clear();
}
///
/// App-layer owner of client-side retained-object liveness. The canonical live
/// entity remains intact while spatially resident, near the player, attached,
/// held, wielded, or in a container. A nonresident top-level world object
/// farther than 384 metres for 25 seconds is torn down through the same
/// generation-safe lifecycle as ObjectDelete. The distance is holtburger's
/// conservative ACE-compatibility envelope until exact ObjCell PVS is wired.
///
internal sealed class LiveEntityLivenessController
{
internal const float ConservativeVisibilityDistance = 384f;
private const double MaintenanceIntervalSeconds = 1.0;
private readonly LiveEntityRuntime _runtime;
private readonly ILocalPlayerIdentitySource _identity;
private readonly ILiveEntityPruneSink _prune;
private readonly DormantLiveEntityStore? _dormant;
private readonly LiveEntityLivenessTracker _tracker = new();
private readonly List _samples = new();
private double _nextMaintenanceAt;
public LiveEntityLivenessController(
LiveEntityRuntime runtime,
ILocalPlayerIdentitySource identity,
ILiveEntityPruneSink prune,
DormantLiveEntityStore? dormant = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
_prune = prune ?? throw new ArgumentNullException(nameof(prune));
_dormant = dormant;
}
public void Tick(double now)
{
if (now < _nextMaintenanceAt)
return;
_nextMaintenanceAt = now + MaintenanceIntervalSeconds;
uint playerGuid = _identity.ServerGuid;
if (playerGuid == 0
|| !_runtime.TryGetRecord(playerGuid, out LiveEntityRecord player)
|| player.Snapshot.Position is not { } playerPosition)
{
return;
}
_samples.Clear();
foreach (LiveEntityRecord record in _runtime.Records)
{
if (record.ServerGuid == playerGuid
|| record.Snapshot.Position is not { } position)
{
continue;
}
bool retained = record.ProjectionKind is LiveEntityProjectionKind.Attached
|| NonZero(record.Snapshot.ContainerId)
|| NonZero(record.Snapshot.WielderId)
|| NonZero(record.Snapshot.ParentGuid);
_samples.Add(new LiveEntityLivenessSample(
record.ProjectionKey
?? throw new InvalidOperationException(
$"Materialized liveness owner 0x{record.ServerGuid:X8}/" +
$"{record.Generation} has no exact projection key."),
record.ServerGuid,
record.IsSpatiallyVisible
|| IsWithinConservativeVisibility(playerPosition, position),
retained));
}
IReadOnlyList due = _tracker.Tick(now, _samples);
for (int i = 0; i < due.Count; i++)
{
LiveEntityPruneCandidate candidate = due[i];
if (_runtime.TryGetRecord(candidate.Key, out LiveEntityRecord current)
&& current.ServerGuid == candidate.ServerGuid)
{
_prune.Prune(candidate);
}
}
}
public void Clear()
{
_tracker.Clear();
_dormant?.Clear();
_samples.Clear();
_nextMaintenanceAt = 0;
}
internal static bool IsWithinConservativeVisibility(
CreateObject.ServerPosition player,
CreateObject.ServerPosition entity)
{
(double playerX, double playerY) = GlobalXY(player);
(double entityX, double entityY) = GlobalXY(entity);
double dx = entityX - playerX;
double dy = entityY - playerY;
double dz = entity.PositionZ - player.PositionZ;
double maximum = ConservativeVisibilityDistance;
return dx * dx + dy * dy + dz * dz <= maximum * maximum;
}
private static (double X, double Y) GlobalXY(CreateObject.ServerPosition position)
{
uint landblockX = (position.LandblockId >> 24) & 0xFFu;
uint landblockY = (position.LandblockId >> 16) & 0xFFu;
return (
landblockX * 192.0 + position.PositionX,
landblockY * 192.0 + position.PositionY);
}
private static bool NonZero(uint? value) => value.GetValueOrDefault() != 0u;
}