Make live world proof realtime and receipt-correlated

This commit is contained in:
Meta Vens 2026-07-13 01:23:42 +01:00
commit 65f1534b9d
4 changed files with 143 additions and 38 deletions

View file

@ -186,8 +186,11 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
WitnessReceipt movementReceipt = null;
WitnessReceipt focusReceipt = null;
WitnessReceipt actionReceipt = null;
EveUnityShotReceipt combatShot = null;
string combatActionId = "";
long initialVersion = 0;
float movementDistance = 0f;
var commandReceipts = new Dictionary<string, EveUnitySceneCommandReceipt>(StringComparer.Ordinal);
try
{
@ -199,7 +202,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
surfaceId,
requiredSurfaceKind: "interactive-world",
clientRuntimeId: $"eve-unity-test-{Guid.NewGuid():N}");
var publicationDeadline = Time.realtimeSinceStartup + 10f;
var publicationDeadline = Time.realtimeSinceStartup + 30f;
while (true)
{
var published = false;
@ -225,6 +228,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
null,
provider,
provider);
runtime.ReceiptAvailable += receipt => commandReceipts[receipt.CommandId] = receipt;
var initial = runtime.Connect();
Assert.That(initial.ActiveEntities, Is.GreaterThan(0));
Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty);
@ -241,67 +245,84 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
var deadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < deadline &&
(runtime.LastReceipt == null || runtime.ActiveVersion <= initialVersion ||
(!commandReceipts.TryGetValue(request.CommandId, out var observedMovement) ||
runtime.ActiveVersion < observedMovement.SourceVersion ||
Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition) < 0.01f))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(runtime.LastReceipt, Is.Not.Null);
Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(request.CommandId));
Assert.That(commandReceipts.TryGetValue(request.CommandId, out var movementCommandReceipt), Is.True);
Assert.That(
runtime.LastReceipt.State,
movementCommandReceipt.State,
Is.EqualTo("accepted").Or.EqualTo("reconciled"),
runtime.LastReceipt.Message);
Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(initialVersion));
Assert.That(runtime.ActiveVersion, Is.GreaterThan(initialVersion));
movementCommandReceipt.Message);
Assert.That(movementCommandReceipt.SourceVersion, Is.GreaterThan(initialVersion));
Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(movementCommandReceipt.SourceVersion));
movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition);
Assert.That(movementDistance, Is.GreaterThan(0.01f));
movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt);
movementReceipt = WitnessReceipt.From("movement", movementCommandReceipt);
var movementVersion = runtime.ActiveVersion;
var focus = runtime.SubmitFocusIntent(playerId);
var focusDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < focusDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != focus.CommandId ||
runtime.ActiveVersion < runtime.LastReceipt.SourceVersion))
(!commandReceipts.TryGetValue(focus.CommandId, out var observedFocus) ||
runtime.ActiveVersion < observedFocus.SourceVersion))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(runtime.LastReceipt, Is.Not.Null);
Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(focus.CommandId));
Assert.That(runtime.LastReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"));
Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(movementVersion));
focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt);
Assert.That(commandReceipts.TryGetValue(focus.CommandId, out var focusCommandReceipt), Is.True);
Assert.That(focusCommandReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"));
Assert.That(focusCommandReceipt.SourceVersion, Is.GreaterThan(movementVersion));
focusReceipt = WitnessReceipt.From("targeting", focusCommandReceipt);
var focusVersion = runtime.ActiveVersion;
var trajectoryRenderer = root.AddComponent<EveUnityShotTrajectoryRenderer>();
runtime.ShotAvailable += shot =>
{
var playerIdentity = provider.CurrentEntityView?.Identities
.FirstOrDefault(identity => string.Equals(identity.EntityId, playerId, StringComparison.Ordinal));
if (playerIdentity != null && shot.SourceEntityIndex == playerIdentity.EntityIndex)
{
combatShot = shot;
trajectoryRenderer.Present(shot);
}
};
Assert.That(provider.CurrentInputCapability, Is.Not.Null, "The pilot world did not advertise a typed input capability document.");
Assert.That(provider.CurrentInputCapability.Actions, Is.Not.Empty, "The pilot input capability advertised no available actions.");
var advertisedAction = provider.CurrentInputCapability.Actions
.FirstOrDefault(candidate => string.Equals(candidate.Category, "weapon-group", StringComparison.Ordinal) &&
string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase))
?? provider.CurrentInputCapability.Actions
.First(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal));
.First(candidate => string.Equals(candidate.Category, "weapon-group", StringComparison.Ordinal) &&
string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase));
combatActionId = advertisedAction.ActionId;
var action = runtime.SubmitActionIntent(playerId, advertisedAction.ActionId);
var actionDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < actionDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId ||
runtime.ActiveVersion < runtime.LastReceipt.SourceVersion))
(!commandReceipts.TryGetValue(action.CommandId, out var observedAction) ||
runtime.ActiveVersion < observedAction.SourceVersion || combatShot == null))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(runtime.LastReceipt, Is.Not.Null);
Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(action.CommandId));
Assert.That(commandReceipts.TryGetValue(action.CommandId, out var actionCommandReceipt), Is.True);
Assert.That(
runtime.LastReceipt.State,
actionCommandReceipt.State,
Is.EqualTo("accepted").Or.EqualTo("reconciled"),
runtime.LastReceipt.Message);
Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(focusVersion));
Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion));
actionReceipt = WitnessReceipt.From("action", runtime.LastReceipt);
actionCommandReceipt.Message);
Assert.That(actionCommandReceipt.SourceVersion, Is.GreaterThan(focusVersion));
Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(actionCommandReceipt.SourceVersion));
Assert.That(combatShot, Is.Not.Null,
$"The daemon accepted fire but published no shot receipt through Eve. Weapon state: {DescribeWeaponStates(provider)}");
Assert.That(combatShot.ShotId, Is.Not.Empty);
var playerEntityIndex = provider.CurrentEntityView.Identities
.Single(identity => string.Equals(identity.EntityId, playerId, StringComparison.Ordinal)).EntityIndex;
Assert.That(combatShot.SourceEntityIndex, Is.EqualTo(playerEntityIndex));
Assert.That(combatShot.TargetEntityIndex, Is.GreaterThanOrEqualTo(0));
Assert.That(combatShot.PresentationKind, Is.Not.Empty);
Assert.That(trajectoryRenderer.ActiveTrajectoryCount, Is.GreaterThan(0));
actionReceipt = WitnessReceipt.From("action", actionCommandReceipt);
var light = lightObject.AddComponent<Light>();
light.type = LightType.Directional;
@ -350,7 +371,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
movementDistance,
movementReceipt,
focusReceipt,
actionReceipt);
actionReceipt,
combatActionId,
combatShot);
}
finally
{
@ -372,7 +395,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
float movementDistance,
WitnessReceipt movement,
WitnessReceipt targeting,
WitnessReceipt action)
WitnessReceipt action,
string combatActionId,
EveUnityShotReceipt combatShot)
{
var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH");
if (string.IsNullOrWhiteSpace(path)) return;
@ -383,6 +408,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
movement = movement != null,
targeting = targeting != null,
action = action != null,
combatShot = combatShot != null,
combatActionId = combatActionId ?? "",
shotId = combatShot?.ShotId ?? "",
shotOutcome = combatShot?.Outcome ?? "",
shotPresentationKind = combatShot?.PresentationKind ?? "",
shotHit = combatShot?.Hit ?? false,
initialVersion = initialVersion,
finalVersion = finalVersion,
movementDistance = movementDistance,
@ -400,6 +431,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
public bool movement;
public bool targeting;
public bool action;
public bool combatShot;
public string combatActionId;
public string shotId;
public string shotOutcome;
public string shotPresentationKind;
public bool shotHit;
public long initialVersion;
public long finalVersion;
public float movementDistance;
@ -436,6 +473,34 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
throw new InvalidOperationException($"Projected world has no entity marker '{entityId}'.");
}
private static string DescribeWeaponStates(EveUnityCultMeshPlayableWorldProvider provider)
{
return string.Join("; ", Flatten(provider.CurrentDocument.SurfaceDocument.Surface.Root)
.Where(node => string.Equals(node.Kind, "weapon.state", StringComparison.Ordinal))
.Select(node => string.Join(",", new[]
{
Prop(node, "behaviorKind"),
$"pending={Prop(node, "triggerPending")}",
$"firing={Prop(node, "firing")}",
$"charging={Prop(node, "charging")}",
$"charged={Prop(node, "charged")}",
$"lock={Prop(node, "lockProgress")}",
$"target={Prop(node, "targetEntityId")}",
$"refusal={Prop(node, "lastRefusalReason")}"
})));
}
private static string Prop(EveSurfaceComponent node, string key) =>
node.Props.TryGetValue(key, out var value) ? value : "";
private static IEnumerable<EveSurfaceComponent> Flatten(EveSurfaceComponent root)
{
yield return root;
foreach (var child in root.Children)
foreach (var descendant in Flatten(child))
yield return descendant;
}
private static EveUnitySceneProviderSurfaceDocument WorldDocument()
{
var entities = new[]

View file

@ -48,9 +48,23 @@ namespace GameCult.Eve.Surface
{
if (mesh == null) throw new ArgumentNullException(nameof(mesh));
var resolved = request ?? new EveSurfaceRequest();
var providers = await mesh.CollectionAsync<EveProviderAdvertisementDocument>(endpointId, cancellationToken)
.ConfigureAwait(false);
var match = Select(await providers.LatestAsync().ConfigureAwait(false), resolved);
IReadOnlyList<EveProviderAdvertisementDocument> providerDocuments;
if (string.IsNullOrWhiteSpace(resolved.ProviderId))
{
var providers = await mesh.CollectionAsync<EveProviderAdvertisementDocument>(endpointId, cancellationToken)
.ConfigureAwait(false);
providerDocuments = await providers.LatestAsync().ConfigureAwait(false);
}
else
{
var provider = await mesh.DocumentAsync<EveProviderAdvertisementDocument>(
endpointId,
ProviderAdvertisementRecordRef(resolved.ProviderId),
cancellationToken)
.ConfigureAwait(false);
providerDocuments = new[] { await provider.LatestAsync().ConfigureAwait(false) };
}
var match = Select(providerDocuments, resolved);
IReadOnlyList<EvePluginAdvertisementDocument> plugins = Array.Empty<EvePluginAdvertisementDocument>();
if (match.Surface.RequiresPlugins.Count > 0)
{
@ -133,5 +147,12 @@ namespace GameCult.Eve.Surface
private static bool Matches(string expected, string actual) =>
string.IsNullOrWhiteSpace(expected) || string.Equals(expected, actual, StringComparison.Ordinal);
public static string ProviderAdvertisementRecordRef(string providerId)
{
if (string.IsNullOrWhiteSpace(providerId))
throw new ArgumentException("Provider id must be non-empty.", nameof(providerId));
return $"eve:provider:{providerId.Trim()}";
}
}
}

View file

@ -56,6 +56,7 @@ namespace GameCult.Eve.UnityScene
private bool _liveSubscriptionsOpen;
private EveProviderAdvertisementDocument? _advertisement;
private EveAdvertisedSurface? _advertisedSurface;
private string _assetCatalogPointer = "";
public EveUnityCultMeshLiveProviderTransport(
string replicaPath,
@ -123,6 +124,8 @@ namespace GameCult.Eve.UnityScene
PublishReceipt(receipt);
else if (document is EveInputCapabilityDocument inputCapability)
CurrentInputCapability = inputCapability;
else if (document is EveAssetCatalogDocument assetCatalog)
ApplyAssetCatalog(assetCatalog);
}
}
@ -274,8 +277,18 @@ namespace GameCult.Eve.UnityScene
if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef))
return;
var catalog = _mesh.ReadAsync<EveAssetCatalogDocument>(_endpointId, interaction.AssetManifestRecordRef)
if (string.Equals(_assetCatalogPointer, interaction.AssetManifestRecordRef, StringComparison.Ordinal))
return;
_assetCatalogPointer = interaction.AssetManifestRecordRef;
var document = _mesh.DocumentAsync<EveAssetCatalogDocument>(_endpointId, _assetCatalogPointer)
.GetAwaiter().GetResult();
ApplyAssetCatalog(document.LatestAsync().GetAwaiter().GetResult());
_liveWatches.Add(document.Watch(catalog => _liveDocuments.Enqueue(catalog)));
}
private void ApplyAssetCatalog(EveAssetCatalogDocument catalog)
{
if (catalog == null || catalog.Version == CurrentAssetCatalogVersion)
return;

View file

@ -101,12 +101,13 @@ $daemonArguments = @(
"--client-cultmesh-host", "127.0.0.1",
"--client-cultmesh-advertise-host", "127.0.0.1",
"--client-cultmesh-port", $Port,
"--tick-interval-ms", 250,
"--tick-interval-ms", 20,
"--fixed-delta-ms", 20,
"--no-odin-announcements"
)
$env:AETHERIA_TRACE_EVE_SNAPSHOTS = "1"
$env:AETHERIA_TRACE_CLIENT_RUDP = "1"
$env:AETHERIA_TRACE_TICK_PHASES = "1"
$daemon = Start-Process -FilePath "dotnet" -ArgumentList $daemonArguments -PassThru -WindowStyle Hidden `
-RedirectStandardOutput $daemonLogPath -RedirectStandardError (Join-Path $outputRoot "aetheria-daemon.error.log")
Write-Host "Aetheria daemon PID: $($daemon.Id)"
@ -124,8 +125,8 @@ try {
if (-not $ready) { throw "Aetheria daemon did not open CultMesh port $Port. See $daemonLogPath" }
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port"
Remove-Item Env:EVEUNITY_PROVIDER_ID -ErrorAction SilentlyContinue
Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue
$env:EVEUNITY_PROVIDER_ID = "aetheria.daemon"
$env:EVEUNITY_SURFACE_ID = "aetheria.pilot"
$env:EVEUNITY_REPLICA_PATH = $replicaPath
$env:EVEUNITY_AETHERIA_CAPTURE_PATH = $capturePath
$env:EVEUNITY_ASSET_CACHE_PATH = $assetCachePath
@ -149,6 +150,11 @@ try {
if (-not (Test-Path $capturePath) -or (Get-Item $capturePath).Length -lt 1024) { throw "Live world capture is missing: $capturePath" }
if (-not (Test-Path $factsPath)) { throw "Live world witness facts are missing: $factsPath" }
$facts = Get-Content -LiteralPath $factsPath -Raw | ConvertFrom-Json
if (-not $facts.combatShot -or [string]::IsNullOrWhiteSpace([string]$facts.combatActionId) -or
[string]::IsNullOrWhiteSpace([string]$facts.shotId) -or
[string]::IsNullOrWhiteSpace([string]$facts.shotPresentationKind)) {
throw "Live world witness did not prove an advertised weapon action and authoritative shot presentation."
}
$releaseLock = Get-Content -LiteralPath (Join-Path $repoRoot "ReleaseConsumerProject\Packages\packages-lock.json") -Raw | ConvertFrom-Json
$releasedPackageClient = $projectRoot -eq "ReleaseConsumerProject"
$facts | Add-Member -NotePropertyName releasedPackageClient -NotePropertyValue $releasedPackageClient