test(streaming): expose world reveal lifecycle
This commit is contained in:
parent
b60cb67009
commit
db45b81f75
6 changed files with 532 additions and 11 deletions
158
src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs
Normal file
158
src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
internal enum WorldRevealKind
|
||||
{
|
||||
Login,
|
||||
Portal,
|
||||
}
|
||||
|
||||
internal readonly record struct WorldRevealLifecycleSnapshot(
|
||||
long Generation,
|
||||
WorldRevealKind Kind,
|
||||
WorldRevealReadinessSnapshot Readiness,
|
||||
bool Materialized,
|
||||
bool Completed,
|
||||
bool Cancelled,
|
||||
bool WorldViewportObserved,
|
||||
int InvariantFailureCount)
|
||||
{
|
||||
public bool IsActive => Generation != 0 && !Cancelled;
|
||||
public bool IsReady => Readiness.IsReady;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observes logical login/portal reveal generations without owning rendering or
|
||||
/// changing gameplay state. The owner turns the canonical readiness decision
|
||||
/// into deterministic lifecycle markers for connected automation and records a
|
||||
/// hard diagnostic failure if normal world geometry becomes visible early.
|
||||
/// </summary>
|
||||
internal sealed class WorldRevealLifecycleTelemetry
|
||||
{
|
||||
private readonly Action<string> _log;
|
||||
private WorldRevealLifecycleSnapshot _snapshot;
|
||||
private long _nextGeneration;
|
||||
private int _portalMaterializationCount;
|
||||
|
||||
public WorldRevealLifecycleTelemetry(Action<string>? log = null)
|
||||
{
|
||||
_log = log ?? (_ => { });
|
||||
}
|
||||
|
||||
public WorldRevealLifecycleSnapshot Snapshot => _snapshot;
|
||||
public int PortalMaterializationCount => _portalMaterializationCount;
|
||||
|
||||
public long Begin(WorldRevealKind kind)
|
||||
{
|
||||
if (_snapshot.Generation != 0
|
||||
&& !_snapshot.Cancelled
|
||||
&& !_snapshot.WorldViewportObserved)
|
||||
{
|
||||
Log("superseded", _snapshot);
|
||||
}
|
||||
|
||||
long generation = checked(++_nextGeneration);
|
||||
_snapshot = new WorldRevealLifecycleSnapshot(
|
||||
generation,
|
||||
kind,
|
||||
default,
|
||||
Materialized: false,
|
||||
Completed: false,
|
||||
Cancelled: false,
|
||||
WorldViewportObserved: false,
|
||||
InvariantFailureCount: _snapshot.InvariantFailureCount);
|
||||
Log("begin", _snapshot);
|
||||
return generation;
|
||||
}
|
||||
|
||||
public void ObserveReadiness(WorldRevealReadinessSnapshot readiness)
|
||||
{
|
||||
if (_snapshot.Generation == 0 || _snapshot.Cancelled)
|
||||
return;
|
||||
|
||||
if (_snapshot.Readiness == readiness)
|
||||
return;
|
||||
|
||||
if (_snapshot.Readiness.DestinationCell != 0u
|
||||
&& readiness.DestinationCell != 0u
|
||||
&& _snapshot.Readiness.DestinationCell != readiness.DestinationCell)
|
||||
{
|
||||
FailInvariant(
|
||||
"destination-changed",
|
||||
$"from=0x{_snapshot.Readiness.DestinationCell:X8} to=0x{readiness.DestinationCell:X8}");
|
||||
}
|
||||
|
||||
_snapshot = _snapshot with { Readiness = readiness };
|
||||
Log("readiness", _snapshot);
|
||||
}
|
||||
|
||||
public void ObserveMaterialized()
|
||||
{
|
||||
if (_snapshot.Generation == 0 || _snapshot.Cancelled || _snapshot.Materialized)
|
||||
return;
|
||||
|
||||
_snapshot = _snapshot with { Materialized = true };
|
||||
if (_snapshot.Kind == WorldRevealKind.Portal)
|
||||
_portalMaterializationCount++;
|
||||
Log("materialized", _snapshot);
|
||||
}
|
||||
|
||||
public void ObserveWorldViewportVisible()
|
||||
{
|
||||
if (_snapshot.Generation == 0 || _snapshot.Cancelled || _snapshot.WorldViewportObserved)
|
||||
return;
|
||||
|
||||
if (!_snapshot.Readiness.IsReady)
|
||||
FailInvariant("viewport-before-ready", null);
|
||||
|
||||
_snapshot = _snapshot with { WorldViewportObserved = true };
|
||||
Log("world-visible", _snapshot);
|
||||
}
|
||||
|
||||
public void Complete()
|
||||
{
|
||||
if (_snapshot.Generation == 0 || _snapshot.Cancelled || _snapshot.Completed)
|
||||
return;
|
||||
|
||||
_snapshot = _snapshot with { Completed = true };
|
||||
Log("complete", _snapshot);
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
if (_snapshot.Generation == 0 || _snapshot.Cancelled)
|
||||
return;
|
||||
|
||||
_snapshot = _snapshot with { Cancelled = true };
|
||||
Log("cancel", _snapshot);
|
||||
}
|
||||
|
||||
private void FailInvariant(string reason, string? detail)
|
||||
{
|
||||
_snapshot = _snapshot with
|
||||
{
|
||||
InvariantFailureCount = checked(_snapshot.InvariantFailureCount + 1),
|
||||
};
|
||||
string suffix = string.IsNullOrWhiteSpace(detail) ? string.Empty : $" {detail}";
|
||||
_log($"[world-reveal] event=invariant-failure reason={reason}{suffix} {Describe(_snapshot)}");
|
||||
}
|
||||
|
||||
private void Log(string eventName, WorldRevealLifecycleSnapshot snapshot) =>
|
||||
_log($"[world-reveal] event={eventName} {Describe(snapshot)}");
|
||||
|
||||
private static string Describe(WorldRevealLifecycleSnapshot snapshot)
|
||||
{
|
||||
WorldRevealReadinessSnapshot ready = snapshot.Readiness;
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"generation={snapshot.Generation} kind={snapshot.Kind} " +
|
||||
$"cell=0x{ready.DestinationCell:X8} indoor={ready.IsIndoor} " +
|
||||
$"radius={ready.RequiredRenderRadius} unhydratable={ready.IsUnhydratable} " +
|
||||
$"render={ready.IsRenderNeighborhoodReady} composites={ready.AreCompositeTexturesReady} " +
|
||||
$"collision={ready.IsCollisionReady} ready={ready.IsReady} " +
|
||||
$"materialized={snapshot.Materialized} completed={snapshot.Completed} " +
|
||||
$"cancelled={snapshot.Cancelled} visible={snapshot.WorldViewportObserved} " +
|
||||
$"failures={snapshot.InvariantFailureCount}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,29 @@
|
|||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// One evaluation of the destination domains guarded by
|
||||
/// <see cref="WorldRevealReadinessBarrier"/>. Keeping the individual facts in
|
||||
/// the canonical owner lets diagnostics and automation observe readiness
|
||||
/// without duplicating (and eventually drifting from) the reveal predicate.
|
||||
/// </summary>
|
||||
internal readonly record struct WorldRevealReadinessSnapshot(
|
||||
uint DestinationCell,
|
||||
bool IsIndoor,
|
||||
bool IsUnhydratable,
|
||||
int RequiredRenderRadius,
|
||||
bool IsRenderNeighborhoodReady,
|
||||
bool AreCompositeTexturesReady,
|
||||
bool IsCollisionReady)
|
||||
{
|
||||
public bool HasDestination => DestinationCell != 0u;
|
||||
|
||||
public bool IsReady => HasDestination
|
||||
&& (IsUnhydratable
|
||||
|| (IsRenderNeighborhoodReady
|
||||
&& AreCompositeTexturesReady
|
||||
&& IsCollisionReady));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins the render-publication, texture, and collision domains that must be
|
||||
/// complete before the normal world viewport is revealed. Retail keeps
|
||||
|
|
@ -73,20 +97,46 @@ internal sealed class WorldRevealReadinessBarrier
|
|||
/// existing loud forced-placement path can expose the invalid server data.
|
||||
/// </summary>
|
||||
public bool IsReady(uint destinationCell)
|
||||
=> Evaluate(destinationCell).IsReady;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates each readiness domain once and returns the complete decision.
|
||||
/// Consumers must use this snapshot rather than reimplementing the join.
|
||||
/// </summary>
|
||||
public WorldRevealReadinessSnapshot Evaluate(uint destinationCell)
|
||||
{
|
||||
if (destinationCell == 0)
|
||||
return false;
|
||||
if (_isSpawnClaimUnhydratable(destinationCell))
|
||||
return true;
|
||||
return default;
|
||||
|
||||
bool isIndoor = IsIndoor(destinationCell);
|
||||
int radius = RequiredRenderRadius(destinationCell);
|
||||
if (!_isRenderNeighborhoodReady(destinationCell, radius)
|
||||
|| !_areCompositeTexturesReady())
|
||||
return false;
|
||||
if (_isSpawnClaimUnhydratable(destinationCell))
|
||||
{
|
||||
return new WorldRevealReadinessSnapshot(
|
||||
destinationCell,
|
||||
isIndoor,
|
||||
IsUnhydratable: true,
|
||||
radius,
|
||||
IsRenderNeighborhoodReady: false,
|
||||
AreCompositeTexturesReady: false,
|
||||
IsCollisionReady: false);
|
||||
}
|
||||
|
||||
return IsIndoor(destinationCell)
|
||||
? _isSpawnCellReady(destinationCell)
|
||||
: _isTerrainNeighborhoodReady(destinationCell, radius);
|
||||
bool renderReady = _isRenderNeighborhoodReady(destinationCell, radius);
|
||||
bool compositesReady = renderReady && _areCompositeTexturesReady();
|
||||
bool collisionReady = renderReady && compositesReady
|
||||
&& (isIndoor
|
||||
? _isSpawnCellReady(destinationCell)
|
||||
: _isTerrainNeighborhoodReady(destinationCell, radius));
|
||||
|
||||
return new WorldRevealReadinessSnapshot(
|
||||
destinationCell,
|
||||
isIndoor,
|
||||
IsUnhydratable: false,
|
||||
radius,
|
||||
renderReady,
|
||||
compositesReady,
|
||||
collisionReady);
|
||||
}
|
||||
|
||||
private static int RequiredRenderRadius(uint destinationCell) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue