Compare commits

...
This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
Author SHA1 Message Date
Meta Vens
d32ccf2347 Prove authoritative cargo collection in Unity 2026-07-13 15:23:46 +01:00
Meta Vens
59edc30088 Prove generic Terminus service loop 2026-07-13 14:36:59 +01:00
Meta Vens
e44fcb0efa Lower generic docked world presentation 2026-07-13 05:33:45 +01:00
Meta Vens
65bab4d2cd Prove daemon pickup collection in generic Unity 2026-07-13 04:50:54 +01:00
Meta Vens
7029a19976 Target hostile semantic contacts in Unity witness 2026-07-13 04:43:37 +01:00
Meta Vens
3fee35512c Lower live Aetheria world through CultMesh 2026-07-13 04:20:12 +01:00
Meta Vens
65f1534b9d Make live world proof realtime and receipt-correlated 2026-07-13 01:23:42 +01:00
Meta Vens
1a27b5e041 Lower daemon identities and semantic actions in Unity 2026-07-12 23:28:01 +01:00
Meta Vens
cf9a8652a3 Define portable Eve world identity and input contracts 2026-07-12 23:27:44 +01:00
Meta Vens
838a5ac923 Report authoritative SoA entity state 2026-07-12 22:43:40 +01:00
Meta Vens
93551237fa Complete generic Unity client state handoff 2026-07-12 22:23:53 +01:00
Meta Vens
66401bedaa Separate daemon build from witness readiness 2026-07-12 22:23:52 +01:00
Meta Vens
e836884f16 Lower EveUnity through the CultMesh client 2026-07-12 21:28:39 +01:00
Meta Vens
5118387a44 Resolve Eve plugin deployments from advertisements 2026-07-12 21:19:25 +01:00
Meta Vens
10ba7e5b07 Select live Eve surfaces by runtime intent 2026-07-12 21:15:04 +01:00
Meta Vens
0dee524662 Route EveUnity provider state through CultMesh sessions 2026-07-12 20:45:25 +01:00
29 changed files with 1686 additions and 417 deletions

View file

@ -2,6 +2,7 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Eve.Surface;
@ -185,8 +186,19 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
WitnessReceipt movementReceipt = null;
WitnessReceipt focusReceipt = null;
WitnessReceipt actionReceipt = null;
WitnessReceipt tradeReceipt = null;
EveUnityShotReceipt combatShot = null;
string combatActionId = "";
string collectedPickupId = "";
bool pickupCollected = false;
bool docked = false;
bool undocked = false;
string stationObservedHostileId = "";
long initialVersion = 0;
float movementDistance = 0f;
int cargoQuantityBeforePickup = 0;
int cargoQuantityAfterPickup = 0;
var commandReceipts = new Dictionary<string, EveUnitySceneCommandReceipt>(StringComparer.Ordinal);
try
{
@ -198,7 +210,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;
@ -224,76 +236,404 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
null,
provider,
provider);
runtime.ReceiptAvailable += receipt => commandReceipts[receipt.CommandId] = receipt;
var initial = runtime.Connect();
var entityDeadline = Time.realtimeSinceStartup + 12f;
while (initial.ActiveEntities == 0 && Time.realtimeSinceStartup < entityDeadline)
{
yield return new WaitForSecondsRealtime(0.1f);
initial = runtime.Refresh();
}
Assert.That(initial.ActiveEntities, Is.GreaterThan(0));
Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty);
var playerId = runtime.ActiveWorld.PlayerEntityId;
var marker = FindMarker(root, playerId);
var seededPickup = root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>()
.SingleOrDefault(candidate => string.Equals(candidate.EntityKind, "pickup", StringComparison.Ordinal));
Assert.That(seededPickup, Is.Not.Null, "The daemon-authored salvage pickup was not lowered through the Eve entity view.");
collectedPickupId = seededPickup.EntityId;
Assert.That(
seededPickup.GetComponentsInChildren<Transform>(includeInactive: true).Length,
Is.GreaterThan(1),
"The generic client did not instantiate the provider-authored pickup asset.");
var dockAction = provider.CurrentInputCapability.Actions
.Single(candidate => string.Equals(candidate.ActionId, "pilot.dock", StringComparison.Ordinal));
var undockAction = provider.CurrentInputCapability.Actions
.Single(candidate => string.Equals(candidate.ActionId, "pilot.undock", StringComparison.Ordinal));
var realtimeAction = provider.CurrentInputCapability?.Actions
.FirstOrDefault(action => string.Equals(action.ActionId, "simulation.rate.realtime", StringComparison.Ordinal));
Assert.That(realtimeAction, Is.Not.Null, "Terminus did not advertise its user-controlled simulation clock.");
var pauseAction = provider.CurrentInputCapability.Actions
.Single(candidate => string.Equals(candidate.ActionId, "simulation.pause", StringComparison.Ordinal));
var scoopAction = provider.CurrentInputCapability.Actions
.Single(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal));
var stepAction = provider.CurrentInputCapability.Actions
.Single(candidate => string.Equals(candidate.ActionId, "simulation.step", StringComparison.Ordinal));
Assert.That(
marker.GetComponentsInChildren<Transform>(includeInactive: true).Length,
Is.GreaterThan(1),
"The generic client substituted its primitive fallback instead of the provider-authored AssetBundle prefab.");
var initialPosition = marker.transform.position;
initialVersion = runtime.ActiveVersion;
var request = runtime.SubmitMoveVectorIntent(playerId, 1f, 0f, 1f);
var deadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < deadline &&
(runtime.LastReceipt == null || runtime.ActiveVersion <= initialVersion ||
Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition) < 0.01f))
runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f);
var dock = runtime.SubmitActionIntent(playerId, dockAction.ActionId);
var resumeDock = runtime.SubmitActionIntent(playerId, stepAction.ActionId);
var dockDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < dockDeadline &&
(!commandReceipts.TryGetValue(dock.CommandId, out var observedDock) ||
runtime.ActiveVersion < observedDock.SourceVersion ||
!string.Equals(runtime.ActiveWorld.PresentationMode, "docked", StringComparison.Ordinal)))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(commandReceipts.TryGetValue(resumeDock.CommandId, out var resumeDockReceipt), Is.True);
Assert.That(resumeDockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeDockReceipt.Message);
Assert.That(commandReceipts.TryGetValue(dock.CommandId, out var dockReceipt), Is.True);
Assert.That(dockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), dockReceipt.Message);
Assert.That(runtime.ActiveWorld.PresentationMode, Is.EqualTo("docked"));
Assert.That(runtime.ActiveWorld.CameraTargetEntityId, Is.Not.Empty.And.Not.EqualTo(playerId));
Assert.That(FindMarker(root, runtime.ActiveWorld.CameraTargetEntityId), Is.Not.Null,
"The daemon did not include the authoritative dock parent in the docked SoA view.");
stationObservedHostileId = provider.CurrentEntityView.Identities
.FirstOrDefault(identity => string.Equals(identity.Faction, "raider", StringComparison.Ordinal))?.EntityId ?? "";
Assert.That(stationObservedHostileId, Is.Not.Empty,
"The docked pilot did not inherit the station's sensor-derived hostile contacts.");
Assert.That(runtime.ActiveWorld.SubjectVisible, Is.False);
Assert.That(runtime.ActiveWorld.MovementEnabled, Is.False);
Assert.That(FindMarker(root, playerId, includeInactive: true).gameObject.activeSelf, Is.False);
docked = true;
Assert.That(runtime.LastReceipt, Is.Not.Null);
Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(request.CommandId));
var servicePause = runtime.SubmitActionIntent(playerId, pauseAction.ActionId);
var servicePauseDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < servicePauseDeadline &&
(!commandReceipts.TryGetValue(servicePause.CommandId, out var observedServicePause) ||
runtime.ActiveVersion < observedServicePause.SourceVersion))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(commandReceipts.TryGetValue(servicePause.CommandId, out var servicePauseReceipt), Is.True);
Assert.That(servicePauseReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), servicePauseReceipt.Message);
var tradeAction = provider.CurrentInputCapability.Actions
.FirstOrDefault(candidate =>
string.Equals(candidate.Category, "trade", StringComparison.Ordinal) &&
string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase));
Assert.That(tradeAction, Is.Not.Null,
"The docked pilot input capability did not advertise an available daemon-authored station stock action. Actions: " +
string.Join("; ", provider.CurrentInputCapability.Actions.Select(candidate =>
$"{candidate.ActionId}[category={candidate.Category},availability={candidate.Availability},label={candidate.Label}]")));
var trade = runtime.SubmitActionIntent(playerId, tradeAction.ActionId);
var tradeDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < tradeDeadline &&
(!commandReceipts.TryGetValue(trade.CommandId, out var observedTrade) ||
runtime.ActiveVersion < observedTrade.SourceVersion))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(commandReceipts.TryGetValue(trade.CommandId, out var tradeCommandReceipt), Is.True);
Assert.That(
runtime.LastReceipt.State,
tradeCommandReceipt.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));
movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition);
Assert.That(movementDistance, Is.GreaterThan(0.01f));
movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt);
tradeCommandReceipt.Message);
tradeReceipt = WitnessReceipt.From("trade", tradeCommandReceipt);
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))
var undock = runtime.SubmitActionIntent(playerId, undockAction.ActionId);
var resumeDeparture = runtime.SubmitActionIntent(playerId, stepAction.ActionId);
var undockDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < undockDeadline &&
(!commandReceipts.TryGetValue(undock.CommandId, out var observedUndock) ||
runtime.ActiveVersion < observedUndock.SourceVersion ||
!string.Equals(runtime.ActiveWorld.PresentationMode, "world", StringComparison.Ordinal)))
{
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(resumeDeparture.CommandId, out var resumeDepartureReceipt), Is.True);
Assert.That(resumeDepartureReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeDepartureReceipt.Message);
Assert.That(commandReceipts.TryGetValue(undock.CommandId, out var undockReceipt), Is.True);
Assert.That(undockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), undockReceipt.Message);
Assert.That(runtime.ActiveWorld.PresentationMode, Is.EqualTo("world"));
Assert.That(runtime.ActiveWorld.CameraTargetEntityId, Is.EqualTo(playerId));
Assert.That(runtime.ActiveWorld.SubjectVisible, Is.True);
Assert.That(runtime.ActiveWorld.MovementEnabled, Is.True);
Assert.That(FindMarker(root, playerId).gameObject.activeSelf, Is.True);
undocked = true;
var undockPosition = marker.transform.position;
var pause = runtime.SubmitActionIntent(playerId, pauseAction.ActionId);
var pauseDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < pauseDeadline &&
(!commandReceipts.TryGetValue(pause.CommandId, out var observedPause) ||
runtime.ActiveVersion < observedPause.SourceVersion))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(commandReceipts.TryGetValue(pause.CommandId, out var pauseReceipt), Is.True);
Assert.That(pauseReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), pauseReceipt.Message);
cargoQuantityBeforePickup = ReadInt32EntitySemantic(
provider.CurrentEntityView,
playerId,
"inventory.cargo.quantity");
var scoop = runtime.SubmitActionIntent(playerId, scoopAction.ActionId);
var initialScoopDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < initialScoopDeadline &&
!commandReceipts.TryGetValue(scoop.CommandId, out _))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(commandReceipts.TryGetValue(scoop.CommandId, out var initialScoopReceipt), Is.True);
Assert.That(initialScoopReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), initialScoopReceipt.Message);
var resumeScoop = runtime.SubmitActionIntent(playerId, realtimeAction.ActionId);
var resumeScoopDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < resumeScoopDeadline &&
(!commandReceipts.TryGetValue(resumeScoop.CommandId, out var observedResumeScoop) ||
runtime.ActiveVersion < observedResumeScoop.SourceVersion))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(commandReceipts.TryGetValue(resumeScoop.CommandId, out var resumeScoopReceipt), Is.True);
Assert.That(resumeScoopReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeScoopReceipt.Message);
var collectionDeadline = Time.realtimeSinceStartup + 25f;
while (Time.realtimeSinceStartup < collectionDeadline &&
root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>()
.Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
pickupCollected = !root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>()
.Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal));
var uncollectedPickup = root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>()
.FirstOrDefault(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal));
var currentCargoQuantity = ReadInt32EntitySemantic(
provider.CurrentEntityView,
playerId,
"inventory.cargo.quantity");
Assert.That(pickupCollected, Is.True,
uncollectedPickup == null
? "The pilot bot did not collect the daemon-authored salvage."
: $"The pilot bot did not collect the daemon-authored salvage. " +
$"player={FindMarker(root, playerId).transform.position}, " +
$"pickup={uncollectedPickup.transform.position}, " +
$"distance={Vector3.Distance(FindMarker(root, playerId).transform.position, uncollectedPickup.transform.position):0.###}, " +
$"cargo={currentCargoQuantity}.");
cargoQuantityAfterPickup = ReadInt32EntitySemantic(
provider.CurrentEntityView,
playerId,
"inventory.cargo.quantity");
Assert.That(cargoQuantityAfterPickup, Is.EqualTo(cargoQuantityBeforePickup + 1),
"The pickup left the world without an authoritative cargo-unit delta in the portable Eve SoA view.");
var combatStartVersion = runtime.ActiveVersion;
var visibleMarkers = root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>();
var hostileTargets = visibleMarkers
.Where(candidate => candidate.Selectable && !candidate.Controllable &&
string.Equals(candidate.EntityKind, "ship", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(candidate.Faction, marker.Faction, StringComparison.Ordinal))
.OrderBy(candidate => Vector3.Distance(candidate.transform.position, marker.transform.position))
.ToArray();
Assert.That(hostileTargets, Is.Not.Empty,
"No hostile contact was available from the ship's own sensors immediately after undocking. " +
$"The station-only contact was {stationObservedHostileId}. Current entities: " +
string.Join(", ", visibleMarkers.Select(candidate =>
$"{candidate.EntityId}[kind={candidate.EntityKind},faction={candidate.Faction},selectable={candidate.Selectable}]")));
var targetEntityId = hostileTargets[0].EntityId;
var focus = runtime.SubmitTargetIntent(playerId, targetEntityId);
var focusVersion = runtime.ActiveVersion;
var action = runtime.SubmitActionIntent(playerId, "0");
var aimVector = hostileTargets[0].transform.position - marker.transform.position;
var aim = runtime.SubmitMoveVectorIntent(
playerId,
aimVector.x,
aimVector.z,
0.1f);
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
.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 resumeCombat = runtime.SubmitActionIntent(playerId, realtimeAction.ActionId);
var resumeCombatDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < resumeCombatDeadline &&
(!commandReceipts.TryGetValue(resumeCombat.CommandId, out var observedResumeCombat) ||
runtime.ActiveVersion < observedResumeCombat.SourceVersion))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(commandReceipts.TryGetValue(resumeCombat.CommandId, out var resumeCombatReceipt), Is.True);
Assert.That(resumeCombatReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeCombatReceipt.Message);
var actionDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < actionDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId ||
runtime.ActiveVersion < runtime.LastReceipt.SourceVersion))
(!commandReceipts.TryGetValue(focus.CommandId, out var observedFocus) ||
runtime.ActiveVersion < observedFocus.SourceVersion ||
!commandReceipts.TryGetValue(aim.CommandId, out var observedAim) ||
runtime.ActiveVersion < observedAim.SourceVersion ||
!commandReceipts.TryGetValue(action.CommandId, out var observedAction) ||
runtime.ActiveVersion < observedAction.SourceVersion ||
Vector3.Distance(FindMarker(root, playerId).transform.position, undockPosition) < 0.01f ||
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(focus.CommandId, out var focusCommandReceipt), Is.True);
Assert.That(
runtime.LastReceipt.State,
focusCommandReceipt.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);
focusCommandReceipt.Message);
Assert.That(focusCommandReceipt.SourceVersion, Is.GreaterThan(combatStartVersion));
focusReceipt = WitnessReceipt.From("targeting", focusCommandReceipt);
Assert.That(commandReceipts.TryGetValue(aim.CommandId, out var aimCommandReceipt), Is.True);
Assert.That(
aimCommandReceipt.State,
Is.EqualTo("accepted").Or.EqualTo("reconciled"),
aimCommandReceipt.Message);
movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, undockPosition);
movementReceipt = WitnessReceipt.From("movement", aimCommandReceipt);
Assert.That(movementDistance, Is.GreaterThan(0.01f));
Assert.That(commandReceipts.TryGetValue(action.CommandId, out var actionCommandReceipt), Is.True);
Assert.That(
actionCommandReceipt.State,
Is.EqualTo("accepted").Or.EqualTo("reconciled"),
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);
if (!pickupCollected)
{
var retryScoopAction = provider.CurrentInputCapability.Actions
.Single(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal));
var retryScoop = runtime.SubmitActionIntent(playerId, retryScoopAction.ActionId);
var scoopDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < scoopDeadline &&
(!commandReceipts.TryGetValue(retryScoop.CommandId, out var observedScoop) ||
runtime.ActiveVersion < observedScoop.SourceVersion))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(commandReceipts.TryGetValue(retryScoop.CommandId, out var scoopCommandReceipt), Is.True);
Assert.That(
scoopCommandReceipt.State,
Is.EqualTo("accepted").Or.EqualTo("reconciled"),
scoopCommandReceipt.Message);
pickupCollected = !root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>()
.Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal));
if (pickupCollected)
{
movementDistance = Vector3.Distance(marker.transform.position, undockPosition);
movementReceipt = WitnessReceipt.From("movement", aimCommandReceipt);
}
else
{
var initialPosition = marker.transform.position;
initialVersion = runtime.ActiveVersion;
var currentPickup = root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>()
.Single(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal));
var pickupVector = currentPickup.transform.position - marker.transform.position;
var request = runtime.SubmitMoveVectorIntent(playerId, pickupVector.x, pickupVector.z, 1f);
var deadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < deadline &&
(!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(commandReceipts.TryGetValue(request.CommandId, out var movementCommandReceipt), Is.True);
Assert.That(
movementCommandReceipt.State,
Is.EqualTo("accepted").Or.EqualTo("reconciled"),
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);
var pickupDeadline = Time.realtimeSinceStartup + 20f;
var nextSteeringUpdate = 0f;
while (Time.realtimeSinceStartup < pickupDeadline &&
root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>()
.Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)))
{
if (Time.realtimeSinceStartup >= nextSteeringUpdate)
{
var currentPlayer = FindMarker(root, playerId);
var observedPickup = root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>()
.FirstOrDefault(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal));
if (observedPickup != null)
{
var correction = observedPickup.transform.position - currentPlayer.transform.position;
runtime.SubmitMoveVectorIntent(playerId, correction.x, correction.z, 1f);
}
nextSteeringUpdate = Time.realtimeSinceStartup + 0.25f;
}
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
pickupCollected = !root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>()
.Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal));
movementReceipt = WitnessReceipt.From("movement", movementCommandReceipt);
}
Assert.That(movementDistance, Is.GreaterThan(0.01f));
var remainingPickup = root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>()
.FirstOrDefault(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal));
Assert.That(
pickupCollected,
Is.True,
remainingPickup == null
? "Ymir/daemon simulation retained a collected pickup outside the active Eve entity view."
: $"Ymir/daemon simulation produced no pickup contact. " +
$"player={marker.transform.position}, pickup={remainingPickup.transform.position}, " +
$"distance={Vector3.Distance(marker.transform.position, remainingPickup.transform.position):0.###}.");
}
var stop = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f);
var stopDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < stopDeadline &&
(!commandReceipts.TryGetValue(stop.CommandId, out var observedStop) ||
runtime.ActiveVersion < observedStop.SourceVersion))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
Assert.That(commandReceipts.TryGetValue(stop.CommandId, out var stopCommandReceipt), Is.True);
Assert.That(
stopCommandReceipt.State,
Is.EqualTo("accepted").Or.EqualTo("reconciled"),
stopCommandReceipt.Message);
var light = lightObject.AddComponent<Light>();
light.type = LightType.Directional;
@ -342,7 +682,16 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
movementDistance,
movementReceipt,
focusReceipt,
actionReceipt);
actionReceipt,
tradeReceipt,
combatActionId,
combatShot,
collectedPickupId,
pickupCollected,
cargoQuantityBeforePickup,
cargoQuantityAfterPickup,
docked,
undocked);
}
finally
{
@ -364,7 +713,16 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
float movementDistance,
WitnessReceipt movement,
WitnessReceipt targeting,
WitnessReceipt action)
WitnessReceipt action,
WitnessReceipt trade,
string combatActionId,
EveUnityShotReceipt combatShot,
string collectedPickupId,
bool pickupCollected,
int cargoQuantityBeforePickup,
int cargoQuantityAfterPickup,
bool docked,
bool undocked)
{
var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH");
if (string.IsNullOrWhiteSpace(path)) return;
@ -375,10 +733,24 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
movement = movement != null,
targeting = targeting != null,
action = action != null,
trade = trade != null,
combatShot = combatShot != null,
combatActionId = combatActionId ?? "",
shotId = combatShot?.ShotId ?? "",
shotOutcome = combatShot?.Outcome ?? "",
shotPresentationKind = combatShot?.PresentationKind ?? "",
shotHit = combatShot?.Hit ?? false,
pickupVisible = !string.IsNullOrWhiteSpace(collectedPickupId),
pickupCollected = pickupCollected,
cargoQuantityBeforePickup = cargoQuantityBeforePickup,
cargoQuantityAfterPickup = cargoQuantityAfterPickup,
docked = docked,
undocked = undocked,
pickupId = collectedPickupId ?? "",
initialVersion = initialVersion,
finalVersion = finalVersion,
movementDistance = movementDistance,
receipts = new[] { movement, targeting, action }
receipts = new[] { movement, targeting, action, trade }
};
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path)));
File.WriteAllText(path, JsonUtility.ToJson(document, true));
@ -392,12 +764,49 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
public bool movement;
public bool targeting;
public bool action;
public bool trade;
public bool combatShot;
public string combatActionId;
public string shotId;
public string shotOutcome;
public string shotPresentationKind;
public bool shotHit;
public bool pickupVisible;
public bool pickupCollected;
public int cargoQuantityBeforePickup;
public int cargoQuantityAfterPickup;
public bool docked;
public bool undocked;
public string pickupId;
public long initialVersion;
public long finalVersion;
public float movementDistance;
public WitnessReceipt[] receipts;
}
private static int ReadInt32EntitySemantic(
EveEntitySoaViewDocument document,
string entityId,
string semantic)
{
Assert.That(document, Is.Not.Null, "The provider did not publish an Eve entity SoA view.");
var entityIndex = document.Identities
.Single(identity => string.Equals(identity.EntityId, entityId, StringComparison.Ordinal))
.EntityIndex;
using var view = EveUnityEntitySoaView.Open(document);
for (var row = 0; row < view.EntityCount; row++)
{
if (!view.TryReadInt32("entity.index", row, out var rowEntityIndex) || rowEntityIndex != entityIndex)
continue;
Assert.That(view.TryReadInt32(semantic, row, out var value), Is.True,
$"The Eve entity SoA view did not expose int32 semantic '{semantic}'.");
return value;
}
Assert.Fail($"The Eve entity SoA view did not contain entity '{entityId}' ({entityIndex}).");
return 0;
}
[Serializable]
private sealed class WitnessReceipt
{
@ -418,9 +827,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
}
}
private static EveUnityPlayableWorldEntityMarker FindMarker(GameObject root, string entityId)
private static EveUnityPlayableWorldEntityMarker FindMarker(
GameObject root,
string entityId,
bool includeInactive = false)
{
foreach (var marker in root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>())
foreach (var marker in root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>(includeInactive))
{
if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal))
return marker;
@ -428,6 +840,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

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>GameCult.Eve.Surface</AssemblyName>
<Description>Portable Eve surface, provider, input, plugin, and entity-view contracts.</Description>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Runtime\EveCultMeshSurfaceSelection.cs" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8a186b6d206ffa847aa276e984162512
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -5,10 +5,11 @@
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Description>Portable Eve surface and provider contracts.</Description>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Mesh\GameCult.Mesh.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Mesh;
#nullable enable
namespace GameCult.Eve.Surface
{
public sealed class EveSurfaceRequest
{
public string ProviderId { get; set; } = "";
public string SurfaceId { get; set; } = "";
public string SurfaceKind { get; set; } = "";
public string LoweringTarget { get; set; } = "";
public bool RequireActive { get; set; } = true;
}
public sealed class EveSurfaceSelection
{
internal EveSurfaceSelection(
EveProviderAdvertisementDocument provider,
EveAdvertisedSurface advertisement,
CultMeshDocumentHandle<EveSurfaceDocument> surface,
IReadOnlyList<EvePluginAdvertisementDocument> plugins)
{
Provider = provider;
Advertisement = advertisement;
Surface = surface;
Plugins = plugins;
}
public EveProviderAdvertisementDocument Provider { get; }
public EveAdvertisedSurface Advertisement { get; }
public CultMeshDocumentHandle<EveSurfaceDocument> Surface { get; }
public IReadOnlyList<EvePluginAdvertisementDocument> Plugins { get; }
}
public static class EveCultMeshSurfaceSelection
{
public static async Task<EveSurfaceSelection> SurfaceAsync(
this CultMeshClient mesh,
string endpointId,
EveSurfaceRequest? request = null,
CancellationToken cancellationToken = default,
Action<string>? observeStage = null)
{
if (mesh == null) throw new ArgumentNullException(nameof(mesh));
var resolved = request ?? new EveSurfaceRequest();
IReadOnlyList<EveProviderAdvertisementDocument> providerDocuments;
if (string.IsNullOrWhiteSpace(resolved.ProviderId))
{
observeStage?.Invoke("provider-collection-opening");
var providers = await mesh.CollectionAsync<EveProviderAdvertisementDocument>(endpointId, cancellationToken)
.ConfigureAwait(false);
observeStage?.Invoke("provider-collection-ready");
providerDocuments = await providers.LatestAsync().ConfigureAwait(false);
}
else
{
observeStage?.Invoke("provider-document-opening");
var provider = await mesh.DocumentAsync<EveProviderAdvertisementDocument>(
endpointId,
ProviderAdvertisementRecordRef(resolved.ProviderId),
cancellationToken)
.ConfigureAwait(false);
observeStage?.Invoke("provider-document-ready");
providerDocuments = new[] { await provider.LatestAsync().ConfigureAwait(false) };
}
observeStage?.Invoke("provider-snapshot-ready");
var match = Select(providerDocuments, resolved);
IReadOnlyList<EvePluginAdvertisementDocument> plugins = Array.Empty<EvePluginAdvertisementDocument>();
if (match.Surface.RequiresPlugins.Count > 0)
{
var advertisements = await mesh.CollectionAsync<EvePluginAdvertisementDocument>(endpointId, cancellationToken)
.ConfigureAwait(false);
plugins = ResolvePlugins(match.Surface, await advertisements.LatestAsync().ConfigureAwait(false));
observeStage?.Invoke("plugin-snapshot-ready");
}
observeStage?.Invoke("surface-document-opening");
var surface = await mesh.DocumentAsync<EveSurfaceDocument>(
endpointId,
match.Surface.RecordRef,
cancellationToken)
.ConfigureAwait(false);
observeStage?.Invoke("surface-document-ready");
return new EveSurfaceSelection(match.Provider, match.Surface, surface, plugins);
}
public static IReadOnlyList<EvePluginAdvertisementDocument> ResolvePlugins(
EveAdvertisedSurface surface,
IEnumerable<EvePluginAdvertisementDocument> advertisements)
{
if (surface == null) throw new ArgumentNullException(nameof(surface));
if (advertisements == null) throw new ArgumentNullException(nameof(advertisements));
var available = advertisements
.GroupBy(plugin => plugin.PluginId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.OrderByDescending(plugin => plugin.Version, StringComparer.Ordinal).First(), StringComparer.Ordinal);
var selected = new List<EvePluginAdvertisementDocument>();
foreach (var requirement in surface.RequiresPlugins)
{
if (!available.TryGetValue(requirement.PluginId, out var plugin))
{
if (string.Equals(requirement.Availability, "required", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Eve surface '{surface.SurfaceId}' requires unavailable plugin '{requirement.PluginId}'.");
continue;
}
var claims = plugin.Schemas.Concat(plugin.ComponentKinds).Concat(plugin.Commands)
.ToHashSet(StringComparer.Ordinal);
var missing = requirement.RequiredCapabilities.Where(capability => !claims.Contains(capability)).ToArray();
if (missing.Length > 0)
{
if (string.Equals(requirement.Availability, "required", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Eve plugin '{plugin.PluginId}' does not advertise required capabilities: {string.Join(", ", missing)}.");
continue;
}
selected.Add(plugin);
}
return selected;
}
public static (EveProviderAdvertisementDocument Provider, EveAdvertisedSurface Surface) Select(
IEnumerable<EveProviderAdvertisementDocument> providers,
EveSurfaceRequest request)
{
if (providers == null) throw new ArgumentNullException(nameof(providers));
if (request == null) throw new ArgumentNullException(nameof(request));
var match = providers
.Where(provider => Matches(request.ProviderId, provider.ProviderId))
.SelectMany(provider => provider.Surfaces.Select(surface => new { Provider = provider, Surface = surface }))
.Where(candidate => Matches(request.SurfaceId, candidate.Surface.SurfaceId))
.Where(candidate => Matches(request.SurfaceKind, candidate.Surface.SurfaceKind))
.Where(candidate => !request.RequireActive || string.Equals(candidate.Surface.Status, "active", StringComparison.OrdinalIgnoreCase))
.Where(candidate => string.IsNullOrWhiteSpace(request.LoweringTarget) ||
candidate.Surface.WorldInteraction?.LoweringTargets.Any(target =>
string.Equals(target, request.LoweringTarget, StringComparison.Ordinal)) == true)
.OrderBy(candidate => candidate.Provider.ProviderId, StringComparer.Ordinal)
.ThenBy(candidate => candidate.Surface.SurfaceId, StringComparer.Ordinal)
.FirstOrDefault();
if (match == null)
throw new InvalidOperationException(
$"No advertised Eve surface matched provider='{request.ProviderId}', surface='{request.SurfaceId}', " +
$"kind='{request.SurfaceKind}', loweringTarget='{request.LoweringTarget}'.");
if (!string.Equals(match.Surface.Transport, "cultmesh-record", StringComparison.Ordinal))
throw new InvalidOperationException(
$"Eve surface '{match.Surface.SurfaceId}' uses unsupported transport '{match.Surface.Transport}'.");
if (string.IsNullOrWhiteSpace(match.Surface.RecordRef))
throw new InvalidOperationException($"Eve surface '{match.Surface.SurfaceId}' does not advertise a record reference.");
return (match.Provider, match.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

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b3c40df8a2a14d7db2f45f7018591fd3

View file

@ -1,6 +1,7 @@
using System;
using GameCult.Caching;
using MessagePack;
using GameCult.Mesh;
#nullable enable
@ -24,6 +25,21 @@ namespace GameCult.Eve.Surface
[Key(9)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty<EveEntitySoaColumn>();
[Key(10)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty<EveEntitySoaDirtyRange>();
[Key(11)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty<EveEntityRenderGroup>();
[Key(12)] public EveEntitySoaIdentity[] Identities { get; set; } = Array.Empty<EveEntitySoaIdentity>();
[Key(13)] public CultMeshBodyDescriptor? Body { get; set; }
}
[MessagePackObject]
public sealed class EveEntitySoaIdentity
{
[Key(0)] public int EntityIndex { get; set; } = -1;
[Key(1)] public string EntityId { get; set; } = "";
[Key(2)] public string Kind { get; set; } = "";
[Key(3)] public string Label { get; set; } = "";
[Key(4)] public string Faction { get; set; } = "";
[Key(5)] public bool Selectable { get; set; } = true;
[Key(6)] public bool Controllable { get; set; }
[Key(7)] public string AssetRef { get; set; } = "";
}
[MessagePackObject]

View file

@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Eve.Surface
{
[CultDocument("gamecult.eve.input_capability", SchemaId)]
[MessagePackObject]
public sealed class EveInputCapabilityDocument
{
public const string SchemaId = "gamecult.eve.input_capability.v1";
[Key(0)] public string Schema { get; set; } = SchemaId;
[Key(1)] public string ProviderId { get; set; } = "";
[Key(2)] public string CapabilityId { get; set; } = "";
[Key(3)] public long Version { get; set; }
[Key(4)] public EveInputActionDocument[] Actions { get; set; } = Array.Empty<EveInputActionDocument>();
[Key(5)] public EveInputProfileDocument[] DefaultProfiles { get; set; } = Array.Empty<EveInputProfileDocument>();
}
[MessagePackObject]
public sealed class EveInputActionDocument
{
[Key(0)] public string ActionId { get; set; } = "";
[Key(1)] public string Label { get; set; } = "";
[Key(2)] public string Operation { get; set; } = "";
[Key(3)] public string Context { get; set; } = "pilot";
[Key(4)] public string Category { get; set; } = "ship";
[Key(5)] public string Availability { get; set; } = "available";
[Key(6)] public string SourceRef { get; set; } = "";
[Key(7)] public Dictionary<string, string> Payload { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
}
[MessagePackObject]
public sealed class EveInputProfileDocument
{
[Key(0)] public string ProfileId { get; set; } = "";
[Key(1)] public string DeviceClass { get; set; } = "";
[Key(2)] public EveInputBindingDocument[] Bindings { get; set; } = Array.Empty<EveInputBindingDocument>();
}
[MessagePackObject]
public sealed class EveInputBindingDocument
{
[Key(0)] public string BindingId { get; set; } = "";
[Key(1)] public string ActionId { get; set; } = "";
[Key(2)] public EveInputGestureDocument Gesture { get; set; } = new EveInputGestureDocument();
[Key(3)] public bool ActionBar { get; set; }
}
[MessagePackObject]
public sealed class EveInputGestureDocument
{
[Key(0)] public string Kind { get; set; } = "direct";
[Key(1)] public string[] Controls { get; set; } = Array.Empty<string>();
[Key(2)] public int MaxStepIntervalMs { get; set; } = 650;
[Key(3)] public string CompletionControl { get; set; } = "";
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4c297c7dbb64a0f49a7f805e770c5e55

View file

@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using GameCult.Caching;
using MessagePack;
#nullable enable
namespace GameCult.Eve.Surface
{
[CultDocument("gamecult.eve.plugin_advertisement", SchemaId)]
[MessagePackObject]
public sealed class EvePluginAdvertisementDocument
{
public const string SchemaId = "gamecult.eve.plugin_advertisement.v1";
public EvePluginAdvertisementDocument(
string pluginId,
string ownerService,
string version,
string manifestAddress,
EvePluginRuntimeAdvertisement runtime,
IReadOnlyList<string> schemas,
IReadOnlyList<string> componentKinds,
IReadOnlyList<string> commands,
IReadOnlyList<string> fixtures)
: this(SchemaId, pluginId, ownerService, version, manifestAddress, runtime, schemas, componentKinds, commands, fixtures)
{
}
[SerializationConstructor]
public EvePluginAdvertisementDocument(
string schema,
string pluginId,
string ownerService,
string version,
string manifestAddress,
EvePluginRuntimeAdvertisement runtime,
IReadOnlyList<string> schemas,
IReadOnlyList<string> componentKinds,
IReadOnlyList<string> commands,
IReadOnlyList<string> fixtures)
{
Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
PluginId = pluginId ?? "";
OwnerService = ownerService ?? "";
Version = version ?? "";
ManifestAddress = manifestAddress ?? "";
Runtime = runtime ?? new EvePluginRuntimeAdvertisement("", "", Array.Empty<string>(), Array.Empty<string>(), null);
Schemas = schemas ?? Array.Empty<string>();
ComponentKinds = componentKinds ?? Array.Empty<string>();
Commands = commands ?? Array.Empty<string>();
Fixtures = fixtures ?? Array.Empty<string>();
}
[Key(0)] public string Schema { get; }
[Key(1), CultName] public string PluginId { get; }
[Key(2)] public string OwnerService { get; }
[Key(3)] public string Version { get; }
[Key(4)] public string ManifestAddress { get; }
[Key(5)] public EvePluginRuntimeAdvertisement Runtime { get; }
[Key(6)] public IReadOnlyList<string> Schemas { get; }
[Key(7)] public IReadOnlyList<string> ComponentKinds { get; }
[Key(8)] public IReadOnlyList<string> Commands { get; }
[Key(9)] public IReadOnlyList<string> Fixtures { get; }
}
[MessagePackObject]
public sealed class EvePluginRuntimeAdvertisement
{
[SerializationConstructor]
public EvePluginRuntimeAdvertisement(
string invocationModel,
string contract,
IReadOnlyList<string> transports,
IReadOnlyList<string> authority,
EvePluginSidecarAdvertisement? sidecar)
{
InvocationModel = invocationModel ?? "";
Contract = contract ?? "";
Transports = transports ?? Array.Empty<string>();
Authority = authority ?? Array.Empty<string>();
Sidecar = sidecar;
}
[Key(0)] public string InvocationModel { get; }
[Key(1)] public string Contract { get; }
[Key(2)] public IReadOnlyList<string> Transports { get; }
[Key(3)] public IReadOnlyList<string> Authority { get; }
[Key(4)] public EvePluginSidecarAdvertisement? Sidecar { get; }
}
[MessagePackObject]
public sealed class EvePluginSidecarAdvertisement
{
[SerializationConstructor]
public EvePluginSidecarAdvertisement(
string processKind,
string protocol,
string requestSchema,
string responseSchema,
IReadOnlyList<string> operations,
string commandEnvelope,
string receiptSchema,
string stateAuthority)
{
ProcessKind = processKind ?? "";
Protocol = protocol ?? "";
RequestSchema = requestSchema ?? "";
ResponseSchema = responseSchema ?? "";
Operations = operations ?? Array.Empty<string>();
CommandEnvelope = commandEnvelope ?? "";
ReceiptSchema = receiptSchema ?? "";
StateAuthority = stateAuthority ?? "";
}
[Key(0)] public string ProcessKind { get; }
[Key(1)] public string Protocol { get; }
[Key(2)] public string RequestSchema { get; }
[Key(3)] public string ResponseSchema { get; }
[Key(4)] public IReadOnlyList<string> Operations { get; }
[Key(5)] public string CommandEnvelope { get; }
[Key(6)] public string ReceiptSchema { get; }
[Key(7)] public string StateAuthority { get; }
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a5b91c7defe47d2a38940a460aa59c5

View file

@ -119,7 +119,8 @@ namespace GameCult.Eve.Surface
string transport,
string status,
string surfaceKind,
EveWorldInteractionAdvertisement? worldInteraction = null)
EveWorldInteractionAdvertisement? worldInteraction = null,
IReadOnlyList<EvePluginRequirement>? requiresPlugins = null)
{
SurfaceId = surfaceId ?? "";
Schema = schema ?? "";
@ -128,6 +129,7 @@ namespace GameCult.Eve.Surface
Status = status ?? "";
SurfaceKind = surfaceKind ?? "";
WorldInteraction = worldInteraction;
RequiresPlugins = requiresPlugins ?? Array.Empty<EvePluginRequirement>();
}
[Key(0)] public string SurfaceId { get; }
@ -137,6 +139,32 @@ namespace GameCult.Eve.Surface
[Key(4)] public string Status { get; }
[Key(5)] public string SurfaceKind { get; }
[Key(6)] public EveWorldInteractionAdvertisement? WorldInteraction { get; }
[Key(7)] public IReadOnlyList<EvePluginRequirement> RequiresPlugins { get; }
}
[MessagePackObject]
public sealed class EvePluginRequirement
{
[SerializationConstructor]
public EvePluginRequirement(
string pluginId,
string versionRange,
string availability,
IReadOnlyList<string> requiredCapabilities,
IReadOnlyList<string> optionalCapabilities)
{
PluginId = pluginId ?? "";
VersionRange = versionRange ?? "";
Availability = string.IsNullOrWhiteSpace(availability) ? "required" : availability;
RequiredCapabilities = requiredCapabilities ?? Array.Empty<string>();
OptionalCapabilities = optionalCapabilities ?? Array.Empty<string>();
}
[Key(0)] public string PluginId { get; }
[Key(1)] public string VersionRange { get; }
[Key(2)] public string Availability { get; }
[Key(3)] public IReadOnlyList<string> RequiredCapabilities { get; }
[Key(4)] public IReadOnlyList<string> OptionalCapabilities { get; }
}
[MessagePackObject]

View file

@ -4,6 +4,8 @@ using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Eve.Surface;
@ -30,15 +32,18 @@ namespace GameCult.Eve.UnityScene
typeof(EveCommandReceiptDocument),
typeof(EveAssetCatalogDocument),
typeof(EveEntitySoaViewDocument),
typeof(EveInputCapabilityDocument),
typeof(CultMeshCdnArtifactManifest),
typeof(CultMeshCdnArtifactChunk)
};
private readonly string _replicaPath;
private readonly string _endpoint;
private readonly EveUnityCultMeshProviderSelection _selection;
private readonly CultMeshClient _mesh;
private readonly string _endpointId;
private readonly string _providerId;
private readonly string _surfaceId;
private readonly string _runtimeId;
private CultMeshSession? _documentSession;
private readonly HashSet<string> _pendingCommandIds = new HashSet<string>(StringComparer.Ordinal);
private readonly HashSet<string> _publishedReceiptIds = new HashSet<string>(StringComparer.Ordinal);
private readonly Dictionary<string, GameObject> _prefabs = new Dictionary<string, GameObject>(StringComparer.Ordinal);
@ -46,33 +51,27 @@ namespace GameCult.Eve.UnityScene
private readonly List<AssetBundle> _assetBundles = new List<AssetBundle>();
private readonly Dictionary<string, int> _cameraCullingMasks = new Dictionary<string, int>(StringComparer.Ordinal);
private readonly ConcurrentQueue<object> _liveDocuments = new ConcurrentQueue<object>();
private CultMeshNode? _node;
private CultMeshSnapshotSession? _snapshot;
private readonly CancellationTokenSource _backgroundCancellation = new CancellationTokenSource();
private CultNetDocumentRegistry? _networkRegistry;
private CultNetShardDescriptor? _replicaShard;
private CultNetDatabaseSubscriptionClient? _subscriptions;
private readonly List<IDisposable> _liveWatches = new List<IDisposable>();
private bool _liveSubscriptionsOpen;
private EveProviderAdvertisementDocument? _advertisement;
private EveAdvertisedSurface? _advertisedSurface;
private string _assetCatalogPointer = "";
private Task? _assetCatalogTask;
private EveEntitySoaViewDocument? _deferredEntityView;
public EveUnityCultMeshLiveProviderTransport(
string replicaPath,
string endpoint,
string providerId,
string surfaceId,
EveUnityCultMeshProviderSelection selection,
string runtimeId = "eve-unity")
{
_replicaPath = string.IsNullOrWhiteSpace(replicaPath)
? throw new ArgumentException("Replica path must be non-empty.", nameof(replicaPath))
: Path.GetFullPath(replicaPath);
_endpoint = string.IsNullOrWhiteSpace(endpoint)
? throw new ArgumentException("CultMesh endpoint must be non-empty.", nameof(endpoint))
: endpoint.Trim();
_providerId = string.IsNullOrWhiteSpace(providerId)
? throw new ArgumentException("Provider id must be non-empty.", nameof(providerId))
: providerId.Trim();
_surfaceId = string.IsNullOrWhiteSpace(surfaceId)
? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId))
: surfaceId.Trim();
_ = replicaPath;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_mesh = selection.Mesh;
_endpointId = selection.EndpointId;
_providerId = selection.ProviderId;
_surfaceId = selection.SurfaceId;
_runtimeId = string.IsNullOrWhiteSpace(runtimeId) ? "eve-unity" : runtimeId.Trim();
CurrentSurfaceDocument = EmptySurfaceDocument();
CurrentAssetManifestDocument = EmptyAssetManifest();
@ -88,6 +87,8 @@ namespace GameCult.Eve.UnityScene
public EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; private set; }
public EveInputCapabilityDocument? CurrentInputCapability { get; private set; }
public event Action<EveUnitySceneProviderSurfaceDocument>? SurfaceDocumentAvailable;
public event Action<EveUnityPlayableWorldAssetManifestDocument>? AssetManifestDocumentAvailable;
@ -119,11 +120,24 @@ namespace GameCult.Eve.UnityScene
while (_liveDocuments.TryDequeue(out var document))
{
if (document is EveEntitySoaViewDocument entityView)
EntityViewAvailable?.Invoke(entityView);
{
if (!string.IsNullOrWhiteSpace(_assetCatalogPointer) && CurrentAssetCatalogVersion < 0)
_deferredEntityView = entityView;
else
EntityViewAvailable?.Invoke(entityView);
}
else if (document is EveSurfaceDocument surface)
PublishSurface(surface);
else if (document is EveCommandReceiptDocument receipt)
PublishReceipt(receipt);
else if (document is EveInputCapabilityDocument inputCapability)
CurrentInputCapability = inputCapability;
else if (document is EveAssetCatalogDocument assetCatalog)
ApplyAssetCatalog(assetCatalog);
else if (document is PreparedAssetCatalog preparedAssetCatalog)
ApplyPreparedAssetCatalog(preparedAssetCatalog);
else if (document is BackgroundFailure failure)
throw new InvalidOperationException(failure.Operation + " failed.", failure.Error);
}
}
@ -146,6 +160,12 @@ namespace GameCult.Eve.UnityScene
throw new InvalidOperationException("Eve command invocations require an idempotency key.");
var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId);
if (string.IsNullOrWhiteSpace(interaction.ReceiptRecordRef))
throw new InvalidOperationException("The provider advertisement does not publish a receipt record reference.");
var receiptKey = ChildRecordKey(interaction.ReceiptRecordRef, commandId);
var receipt = _mesh.DocumentAsync<EveCommandReceiptDocument>(_endpointId, receiptKey)
.GetAwaiter().GetResult();
_liveWatches.Add(receipt.Watch(value => _liveDocuments.Enqueue(value)));
var message = _networkRegistry!.CreateRawDocumentPutMessage(
$"eve-unity-{commandId}",
new CultRecordHandle<EveSurfaceCommandRequest>(new CultRecordKey(recordKey)),
@ -155,29 +175,60 @@ namespace GameCult.Eve.UnityScene
SourceRuntimeId = _runtimeId,
SourceRole = "eve-unity"
});
new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions())
.ForwardPutAsync(_replicaShard!, message)
.GetAwaiter()
.GetResult();
message.ShardId = RemoteShardId;
message.ShardEpoch = 1;
_documentSession!.SendCultNet(message);
_pendingCommandIds.Add(commandId);
_ = AwaitReceiptAsync(receiptKey, commandId, _backgroundCancellation.Token);
}
private async Task AwaitReceiptAsync(string receiptKey, string commandId, CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 60 && !cancellationToken.IsCancellationRequested; attempt++)
{
try
{
var receipt = await _mesh.ReadAsync<EveCommandReceiptDocument>(
_endpointId,
receiptKey,
TimeSpan.FromSeconds(2),
cancellationToken)
.ConfigureAwait(false);
if (string.Equals(receipt.CommandId, commandId, StringComparison.Ordinal))
{
_liveDocuments.Enqueue(receipt);
return;
}
}
catch (InvalidOperationException)
{
}
try
{
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
}
}
public void Dispose()
{
_backgroundCancellation.Cancel();
foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false);
_assetBundles.Clear();
_prefabs.Clear();
_nativeAssets.Clear();
_node?.Dispose();
_snapshot?.Dispose();
_subscriptions?.Dispose();
_node = null;
_snapshot = null;
_subscriptions = null;
foreach (var watch in _liveWatches) watch.Dispose();
_liveWatches.Clear();
_mesh.Dispose();
_documentSession = null;
_networkRegistry = null;
_replicaShard = null;
_pendingCommandIds.Clear();
_publishedReceiptIds.Clear();
_backgroundCancellation.Dispose();
}
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
@ -201,83 +252,24 @@ namespace GameCult.Eve.UnityScene
private void EnsureOpen()
{
if (_node != null)
if (_documentSession != null)
return;
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(WireDocumentTypes);
_networkRegistry = CultMesh.CreateCultNetDocumentRegistry(WireDocumentTypes, cacheRegistry);
_replicaShard = new CultNetShardDescriptor(
RemoteShardId,
_providerId,
epoch: 1,
isPrimary: false,
schemaIds: WireDocumentTypes.Select(type => cacheRegistry.GetRequired(type).SchemaId),
primaryEndpoints: new[] { _endpoint });
_node = CultMesh.CreateNodeAsync(
_replicaPath,
new CultMeshNodeOptions
{
StartServer = false,
EnableDurableShardLogs = true,
CacheOptions = new CultCacheOpenOptions
{
Registry = cacheRegistry,
PullOnOpen = false,
StoreFlushOnDispose = true,
UseDirectoryStore = true
},
DatabaseOptions = new CultNetDatabaseOptions
{
RuntimeId = _runtimeId,
Shards = new[] { _replicaShard },
DocumentRegistry = _networkRegistry
}
})
.GetAwaiter()
.GetResult();
_snapshot = CultMesh.SnapshotSession(
_endpoint,
new CultMeshSnapshotRequestOptions
{
ShardId = RemoteShardId,
ShardEpoch = 1,
ConnectTimeout = TimeSpan.FromSeconds(5),
ResponseTimeout = TimeSpan.FromSeconds(10),
MessageIdPrefix = "eve-unity",
RudpRuntimeId = _runtimeId,
RudpMaxFragmentBytes = 1024
},
_networkRegistry);
_documentSession = _mesh.ConnectAsync(_endpointId, CultMeshProtocols.Documents)
.GetAwaiter().GetResult();
}
private void ResolveAdvertisement()
{
var documents = _snapshot!
.FetchDocumentsAsync<EveProviderAdvertisementDocument>()
.GetAwaiter()
.GetResult();
_advertisement = documents.FirstOrDefault(document =>
string.Equals(document.ProviderId, _providerId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"Provider '{_providerId}' did not publish an Eve advertisement.");
_advertisedSurface = _advertisement.Surfaces.FirstOrDefault(surface =>
string.Equals(surface.SurfaceId, _surfaceId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"Provider '{_providerId}' did not advertise surface '{_surfaceId}'.");
if (!string.Equals(_advertisedSurface.Transport, "cultmesh-record", StringComparison.Ordinal))
throw new InvalidOperationException("EveUnity CultMesh transport requires a cultmesh-record surface advertisement.");
if (string.IsNullOrWhiteSpace(_advertisedSurface.RecordRef))
throw new InvalidOperationException("The provider surface advertisement does not publish a record reference.");
_advertisement = _selection.Surface.Provider;
_advertisedSurface = _selection.Surface.Advertisement;
}
private void RefreshSurface()
{
var surface = _snapshot!
.FetchDocumentsAsync<EveSurfaceDocument>(
recordKeys: new[] { _advertisedSurface!.RecordRef },
schemaIds: new[] { EveSurfaceDocument.SchemaId })
.GetAwaiter()
.GetResult()
.FirstOrDefault()
?? throw new InvalidOperationException("The advertised Eve surface record was not available.");
var surface = _selection.Surface.Surface.LatestAsync().GetAwaiter().GetResult();
PublishSurface(surface);
}
@ -302,21 +294,9 @@ namespace GameCult.Eve.UnityScene
private void EnsureLiveSubscriptions()
{
if (_subscriptions != null) return;
var endpoint = new Uri(_endpoint);
var client = CultNetSchemaClients.CreateForEndpoint(_endpoint);
client.Connect(endpoint.Host, endpoint.Port);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!client.Connected && DateTime.UtcNow < deadline)
System.Threading.Thread.Sleep(1);
if (!client.Connected)
{
client.Dispose();
throw new TimeoutException($"Timed out connecting live Eve state subscription to '{_endpoint}'.");
}
_subscriptions = new CultNetDatabaseSubscriptionClient(client, _node!.Database.Cache, _networkRegistry!);
_subscriptions.Changed += OnReplicatedDocumentChanged;
if (_liveSubscriptionsOpen) return;
_liveSubscriptionsOpen = true;
_liveWatches.Add(_selection.Surface.Surface.Watch(surface => _liveDocuments.Enqueue(surface)));
var lowered = new EveUnitySceneSurfaceLowerer()
.Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface);
string entityViewPointer = lowered.PlayableWorld == null
@ -324,31 +304,19 @@ namespace GameCult.Eve.UnityScene
: lowered.PlayableWorld.EntityViewPointerId;
if (!string.IsNullOrWhiteSpace(entityViewPointer))
{
_subscriptions.SubscribeAsync(
"eve-unity-entity-view",
recordKeys: new[] { entityViewPointer },
schemaIds: new[] { EveEntitySoaViewDocument.SchemaId })
var entities = _mesh.DocumentAsync<EveEntitySoaViewDocument>(_endpointId, entityViewPointer)
.GetAwaiter().GetResult();
if (_node.Database.Cache.Get(new CultRecordKey(entityViewPointer)) is EveEntitySoaViewDocument current)
_liveDocuments.Enqueue(current);
_liveDocuments.Enqueue(entities.LatestAsync().GetAwaiter().GetResult());
_liveWatches.Add(entities.Watch(view => _liveDocuments.Enqueue(view)));
}
var inputCapabilityPointer = lowered.PlayableWorld?.InputCapabilityPointerId ?? "";
if (!string.IsNullOrWhiteSpace(inputCapabilityPointer))
{
var inputs = _mesh.DocumentAsync<EveInputCapabilityDocument>(_endpointId, inputCapabilityPointer)
.GetAwaiter().GetResult();
CurrentInputCapability = inputs.LatestAsync().GetAwaiter().GetResult();
_liveWatches.Add(inputs.Watch(document => _liveDocuments.Enqueue(document)));
}
_subscriptions.SubscribeAsync(
"eve-unity-surface",
recordKeys: new[] { _advertisedSurface!.RecordRef },
schemaIds: new[] { EveSurfaceDocument.SchemaId },
includeSnapshot: false)
.GetAwaiter().GetResult();
_subscriptions.SubscribeAsync(
"eve-unity-receipts",
schemaIds: new[] { EveCommandReceiptDocument.SchemaId },
includeSnapshot: false)
.GetAwaiter().GetResult();
}
private void OnReplicatedDocumentChanged(CultNetReplicatedDocumentChange change)
{
if (change.Document is EveEntitySoaViewDocument or EveSurfaceDocument or EveCommandReceiptDocument)
_liveDocuments.Enqueue(change.Document);
}
private void RefreshAssetCatalog()
@ -357,16 +325,55 @@ namespace GameCult.Eve.UnityScene
if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef))
return;
var catalog = _snapshot!
.FetchDocumentsAsync<EveAssetCatalogDocument>(
recordKeys: new[] { interaction.AssetManifestRecordRef },
schemaIds: new[] { EveAssetCatalogDocument.SchemaId })
.GetAwaiter()
.GetResult()
.FirstOrDefault();
if (catalog == null || catalog.Version == CurrentAssetCatalogVersion)
if (string.Equals(_assetCatalogPointer, interaction.AssetManifestRecordRef, StringComparison.Ordinal))
return;
_assetCatalogPointer = interaction.AssetManifestRecordRef;
var platform = CurrentBundlePlatform();
var cacheRoot = AssetCacheRoot();
_assetCatalogTask = ObserveBackgroundAsync(
OpenAssetCatalogAsync(_assetCatalogPointer, platform, cacheRoot),
"CultMesh asset catalog preparation");
}
private async Task ObserveBackgroundAsync(Task operation, string name)
{
try
{
await operation.ConfigureAwait(false);
}
catch (Exception error)
{
_liveDocuments.Enqueue(new BackgroundFailure(name, error));
}
}
private async Task OpenAssetCatalogAsync(string pointer, string platform, string cacheRoot)
{
var document = await _mesh.DocumentAsync<EveAssetCatalogDocument>(_endpointId, pointer).ConfigureAwait(false);
var catalog = await document.LatestAsync().ConfigureAwait(false);
_liveDocuments.Enqueue(await PrepareAssetCatalogAsync(catalog, platform, cacheRoot).ConfigureAwait(false));
_liveWatches.Add(document.Watch(next =>
{
_ = Task.Run(async () =>
_liveDocuments.Enqueue(await PrepareAssetCatalogAsync(next, platform, cacheRoot).ConfigureAwait(false)));
}));
}
private void ApplyAssetCatalog(EveAssetCatalogDocument catalog)
{
if (catalog == null || catalog.Version == CurrentAssetCatalogVersion) return;
var platform = CurrentBundlePlatform();
var cacheRoot = AssetCacheRoot();
_assetCatalogTask = Task.Run(async () =>
_liveDocuments.Enqueue(await PrepareAssetCatalogAsync(catalog, platform, cacheRoot).ConfigureAwait(false)));
}
private void ApplyPreparedAssetCatalog(PreparedAssetCatalog prepared)
{
var catalog = prepared.Catalog;
if (catalog.Version == CurrentAssetCatalogVersion) return;
foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false);
_assetBundles.Clear();
_prefabs.Clear();
@ -378,7 +385,7 @@ namespace GameCult.Eve.UnityScene
Asset = asset,
Variant = asset.Variants.FirstOrDefault(variant =>
string.Equals(variant.RuntimeId, "unity-scene", StringComparison.Ordinal) &&
string.Equals(variant.Platform, CurrentBundlePlatform(), StringComparison.Ordinal))
string.Equals(variant.Platform, prepared.Platform, StringComparison.Ordinal))
})
.Where(selection => selection.Variant != null)
.ToArray();
@ -386,16 +393,7 @@ namespace GameCult.Eve.UnityScene
foreach (var group in selected.GroupBy(selection => selection.Variant!.Uri, StringComparer.Ordinal))
{
var variant = group.First().Variant!;
var manifest = _snapshot!.FetchDocumentsAsync<CultMeshCdnArtifactManifest>(
recordKeys: new[] { variant.Uri },
schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactManifest })
.GetAwaiter()
.GetResult();
var descriptor = manifest.FirstOrDefault();
if (descriptor == null)
throw new InvalidOperationException($"Provider asset bundle manifest '{variant.Uri}' was not available.");
var bytes = ReadCachedBundle(descriptor, variant);
VerifyBundle(bytes, variant);
var bytes = prepared.BundleBytes[variant.Uri];
var bundle = AssetBundle.LoadFromMemory(bytes);
if (bundle == null)
throw new InvalidOperationException($"Unity could not load provider asset bundle '{variant.Uri}'.");
@ -417,6 +415,34 @@ namespace GameCult.Eve.UnityScene
}
CurrentAssetCatalogVersion = catalog.Version;
if (_deferredEntityView != null)
{
var deferred = _deferredEntityView;
_deferredEntityView = null;
EntityViewAvailable?.Invoke(deferred);
}
}
private async Task<PreparedAssetCatalog> PrepareAssetCatalogAsync(
EveAssetCatalogDocument catalog,
string platform,
string cacheRoot)
{
var variants = catalog.Assets
.SelectMany(asset => asset.Variants)
.Where(variant => string.Equals(variant.RuntimeId, "unity-scene", StringComparison.Ordinal) &&
string.Equals(variant.Platform, platform, StringComparison.Ordinal))
.GroupBy(variant => variant.Uri, StringComparer.Ordinal)
.Select(group => group.First())
.ToArray();
var bundles = new Dictionary<string, byte[]>(StringComparer.Ordinal);
foreach (var variant in variants)
{
var descriptor = await _mesh.ReadAsync<CultMeshCdnArtifactManifest>(_endpointId, variant.Uri)
.ConfigureAwait(false);
bundles[variant.Uri] = await ReadCachedBundleAsync(descriptor, variant, cacheRoot).ConfigureAwait(false);
}
return new PreparedAssetCatalog(catalog, platform, bundles);
}
private void ReadCameraPolicies(IEnumerable<EveAssetVariant> variants)
@ -454,18 +480,26 @@ namespace GameCult.Eve.UnityScene
}
private byte[] ReadBundle(CultMeshCdnArtifactManifest manifest)
=> ReadBundleAsync(manifest).GetAwaiter().GetResult();
private async Task<byte[]> ReadBundleAsync(CultMeshCdnArtifactManifest manifest)
{
var bytes = new byte[checked((int)manifest.SizeBytes)];
var references = manifest.Chunks.OrderBy(chunk => chunk.Offset).ToArray();
for (var index = 0; index < references.Length; index += 2)
var batches = references.Select((reference, index) => new { reference, index })
.GroupBy(value => value.index / 2)
.Select(group => group.Select(value => value.reference).ToArray())
.ToArray();
using var concurrency = new SemaphoreSlim(2, 2);
var downloads = batches.Select(async batch =>
{
var batch = references.Skip(index).Take(2).ToArray();
var chunks = _snapshot!
.FetchDocumentsAsync<CultMeshCdnArtifactChunk>(
recordKeys: batch.Select(reference => reference.RecordKey).ToArray(),
schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactChunk })
.GetAwaiter()
.GetResult();
await concurrency.WaitAsync().ConfigureAwait(false);
try
{
var chunks = await _mesh.ReadManyAsync<CultMeshCdnArtifactChunk>(
_endpointId,
batch.Select(reference => reference.RecordKey).ToArray())
.ConfigureAwait(false);
foreach (var reference in batch)
{
var chunk = chunks.FirstOrDefault(candidate =>
@ -476,15 +510,24 @@ namespace GameCult.Eve.UnityScene
throw new InvalidDataException($"Provider asset chunk '{reference.RecordKey}' size did not match its manifest.");
Buffer.BlockCopy(chunk.Payload, 0, bytes, checked((int)reference.Offset), reference.SizeBytes);
}
}
}
finally
{
concurrency.Release();
}
}).ToArray();
await Task.WhenAll(downloads).ConfigureAwait(false);
return bytes;
}
private byte[] ReadCachedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant)
=> ReadCachedBundleAsync(manifest, variant, AssetCacheRoot()).GetAwaiter().GetResult();
private async Task<byte[]> ReadCachedBundleAsync(
CultMeshCdnArtifactManifest manifest,
EveAssetVariant variant,
string cacheRoot)
{
var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH");
if (string.IsNullOrWhiteSpace(cacheRoot))
cacheRoot = Path.Combine(Application.persistentDataPath, "EveUnity", "assets");
Directory.CreateDirectory(cacheRoot);
var hash = variant.ContentHash.StartsWith("sha256:", StringComparison.Ordinal)
? variant.ContentHash.Substring("sha256:".Length)
@ -497,7 +540,7 @@ namespace GameCult.Eve.UnityScene
return cached;
}
var bytes = ReadBundle(manifest);
var bytes = await ReadBundleAsync(manifest).ConfigureAwait(false);
VerifyBundle(bytes, variant);
var temporaryPath = cachePath + ".tmp";
File.WriteAllBytes(temporaryPath, bytes);
@ -506,6 +549,40 @@ namespace GameCult.Eve.UnityScene
return bytes;
}
private static string AssetCacheRoot()
{
var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH");
return string.IsNullOrWhiteSpace(cacheRoot)
? Path.Combine(Application.persistentDataPath, "EveUnity", "assets")
: cacheRoot;
}
private sealed class PreparedAssetCatalog
{
public PreparedAssetCatalog(EveAssetCatalogDocument catalog, string platform, Dictionary<string, byte[]> bundleBytes)
{
Catalog = catalog;
Platform = platform;
BundleBytes = bundleBytes;
}
public EveAssetCatalogDocument Catalog { get; }
public string Platform { get; }
public Dictionary<string, byte[]> BundleBytes { get; }
}
private sealed class BackgroundFailure
{
public BackgroundFailure(string operation, Exception error)
{
Operation = operation;
Error = error;
}
public string Operation { get; }
public Exception Error { get; }
}
private static void VerifyBundle(byte[] bytes, EveAssetVariant variant)
{
if (bytes.LongLength != variant.SizeBytes)

View file

@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
using UnityEngine;
@ -16,6 +17,7 @@ namespace GameCult.Eve.UnityScene
IEveUnitySceneCommandReceiptSource,
IEveUnityProviderRefreshSource,
IEveUnityEntitySoaViewDocumentSource,
IEveUnityInputCapabilityDocumentSource,
IEveUnityCameraRenderPolicySource,
IEveUnityNativeAssetProvider
{
@ -29,10 +31,13 @@ namespace GameCult.Eve.UnityScene
private EveUnityCultMeshLiveProviderTransport? _transport;
private EveUnitySceneLiveProviderBridge? _bridge;
private Task<EveUnityCultMeshProviderSelection>? _discovery;
private readonly ConcurrentQueue<EveEntitySoaViewDocument> _pendingEntityViews = new ConcurrentQueue<EveEntitySoaViewDocument>();
public EveEntitySoaViewDocument? CurrentEntityView { get; private set; }
public EveInputCapabilityDocument? CurrentInputCapability => _transport?.CurrentInputCapability;
public event Action<EveEntitySoaViewDocument>? EntityViewAvailable;
public EveUnityCultMeshProviderSelection? Selection { get; private set; }
@ -90,6 +95,8 @@ namespace GameCult.Eve.UnityScene
Bridge.Refresh();
else
Bridge.Connect();
_transport?.PumpLiveEvents();
DrainEntityViews();
}
public void Submit(EveSurfaceCommandRequest request)
@ -117,16 +124,26 @@ namespace GameCult.Eve.UnityScene
private void OnDestroy()
{
var transportOwnedConnection = _transport != null;
_bridge?.Dispose();
_transport?.Dispose();
_bridge = null;
_transport = null;
Selection = null;
if (!transportOwnedConnection && _discovery?.Status == TaskStatus.RanToCompletion)
_discovery.Result.Mesh.Dispose();
_discovery = null;
}
private void Update()
{
CompleteDiscoveryOnMainThread();
_transport?.PumpLiveEvents();
DrainEntityViews();
}
private void DrainEntityViews()
{
EveEntitySoaViewDocument? latest = null;
while (_pendingEntityViews.TryDequeue(out var next)) latest = next;
if (latest == null) return;
@ -150,20 +167,35 @@ namespace GameCult.Eve.UnityScene
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint.");
Selection = new EveUnityCultMeshProviderDiscovery().Discover(
rendezvousEndpoint,
providerFilter,
surfaceFilter,
surfaceKind,
verseFilter);
if (_discovery == null)
_discovery = new EveUnityCultMeshProviderDiscovery().DiscoverAsync(
rendezvousEndpoint,
providerFilter,
surfaceFilter,
surfaceKind,
verseFilter);
CompleteDiscoveryOnMainThread();
if (_transport == null)
throw new InvalidOperationException("EveUnity CultMesh provider discovery is still in progress.");
}
private void CompleteDiscoveryOnMainThread()
{
if (_transport != null || _discovery == null || !_discovery.IsCompleted)
return;
if (_discovery.IsFaulted)
throw _discovery.Exception?.GetBaseException()
?? new InvalidOperationException("EveUnity CultMesh provider discovery failed.");
if (_discovery.IsCanceled)
throw new InvalidOperationException("EveUnity CultMesh provider discovery was cancelled.");
Selection = _discovery.Result;
var resolvedReplicaPath = string.IsNullOrWhiteSpace(replicaPath)
? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}.cc")
: replicaPath;
_transport = new EveUnityCultMeshLiveProviderTransport(
resolvedReplicaPath,
Selection.Endpoint,
Selection.ProviderId,
Selection.SurfaceId,
Selection,
runtimeId);
_transport.EntityViewAvailable += view => _pendingEntityViews.Enqueue(view);
_bridge = new EveUnitySceneLiveProviderBridge(_transport);

View file

@ -1,10 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
using GameCult.Mesh;
using GameCult.Networking;
using UnityEngine;
#nullable enable
@ -12,127 +11,65 @@ namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityCultMeshProviderSelection
{
public EveUnityCultMeshProviderSelection(
string endpoint,
string verseId,
string providerId,
string surfaceId,
string surfaceKind)
internal EveUnityCultMeshProviderSelection(
string endpointId,
CultMeshClient mesh,
EveSurfaceSelection surface)
{
Endpoint = endpoint ?? "";
VerseId = verseId ?? "";
ProviderId = providerId ?? "";
SurfaceId = surfaceId ?? "";
SurfaceKind = surfaceKind ?? "";
EndpointId = endpointId;
Mesh = mesh;
Surface = surface;
}
public string Endpoint { get; }
public string VerseId { get; }
public string ProviderId { get; }
public string SurfaceId { get; }
public string SurfaceKind { get; }
public string EndpointId { get; }
public string VerseId => Surface.Provider.VerseId;
public string ProviderId => Surface.Provider.ProviderId;
public string SurfaceId => Surface.Advertisement.SurfaceId;
public string SurfaceKind => Surface.Advertisement.SurfaceKind;
public CultMeshClient Mesh { get; }
public EveSurfaceSelection Surface { get; }
}
public sealed class EveUnityCultMeshProviderDiscovery
{
private static readonly Type[] AdvertisementDocumentTypes =
{
typeof(EveProviderAdvertisementDocument)
};
public EveUnityCultMeshProviderSelection Discover(
public async Task<EveUnityCultMeshProviderSelection> DiscoverAsync(
string rendezvousEndpoint,
string providerId = "",
string surfaceId = "",
string surfaceKind = "interactive-world",
string verseId = "")
string verseId = "",
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint));
var endpointId = !string.IsNullOrWhiteSpace(verseId)
? verseId
: !string.IsNullOrWhiteSpace(providerId) ? providerId : "eve.providers";
var response = RunNetwork(() => CultMesh.CreateVerseDiscoveryClient().FetchAsync(
rendezvousEndpoint,
new CultMeshVerseCatalogRequestMessage
{
VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId },
TransportVersion = "cultmesh.v0"
}));
var candidates = response.Verses
.Where(verse => string.IsNullOrWhiteSpace(verseId) ||
string.Equals(verse.VerseId, verseId, StringComparison.Ordinal))
.SelectMany(verse => (verse.DiscoveryEndpoints ?? Array.Empty<string>())
.Select(endpoint => new { Verse = verse, Endpoint = endpoint }))
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Endpoint))
.ToArray();
if (candidates.Length == 0)
throw new InvalidOperationException("The rendezvous endpoint advertised no compatible Verse endpoints.");
var failures = new List<string>();
foreach (var candidate in candidates)
var mesh = new CultMeshClient(new CultMeshClientOptions
{
try
{
var advertisement = FetchAdvertisements(candidate.Endpoint)
.Where(document => string.IsNullOrWhiteSpace(providerId) ||
string.Equals(document.ProviderId, providerId, StringComparison.Ordinal))
.Select(document => new
{
Document = document,
Surface = document.Surfaces.FirstOrDefault(surface =>
(string.IsNullOrWhiteSpace(surfaceId) ||
string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)) &&
(string.IsNullOrWhiteSpace(surfaceKind) ||
string.Equals(surface.SurfaceKind, surfaceKind, StringComparison.Ordinal)))
})
.FirstOrDefault(match => match.Surface != null);
if (advertisement?.Surface == null)
continue;
return new EveUnityCultMeshProviderSelection(
candidate.Endpoint,
candidate.Verse.VerseId,
advertisement.Document.ProviderId,
advertisement.Surface.SurfaceId,
advertisement.Surface.SurfaceKind);
}
catch (Exception error)
{
failures.Add($"{candidate.Endpoint}: {error.Message}");
}
}
var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'";
var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}";
throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}");
}
private static EveProviderAdvertisementDocument[] FetchAdvertisements(string endpoint)
{
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(AdvertisementDocumentTypes);
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(AdvertisementDocumentTypes, cacheRegistry);
var snapshot = CultMesh.SnapshotEndpoint(
endpoint,
new CultMeshSnapshotEndpointOptions
{
Context = CultMesh.Verse("eve.discovery", "eve-unity").Context,
DocumentRegistry = networkRegistry,
Request = new CultMeshSnapshotRequestOptions
RendezvousEndpoints = new[] { rendezvousEndpoint }
});
try
{
var surface = await mesh.SurfaceAsync(
endpointId,
new EveSurfaceRequest
{
ShardId = "provider",
ShardEpoch = 1,
ConnectTimeout = TimeSpan.FromSeconds(5),
ResponseTimeout = TimeSpan.FromSeconds(10),
MessageIdPrefix = "eve-unity-discovery",
RudpRuntimeId = "eve-unity.discovery",
RudpMaxFragmentBytes = 1024
}
});
return RunNetwork(() => snapshot.FetchDocumentsAsync<EveProviderAdvertisementDocument>())
.ToArray();
ProviderId = providerId,
SurfaceId = surfaceId,
SurfaceKind = surfaceKind,
LoweringTarget = "unity-scene"
},
cancellationToken,
stage => Debug.Log($"EveUnity CultMesh discovery: {stage}")).ConfigureAwait(false);
return new EveUnityCultMeshProviderSelection(endpointId, mesh, surface);
}
catch
{
mesh.Dispose();
throw;
}
}
private static T RunNetwork<T>(Func<Task<T>> operation) =>
Task.Run(operation).GetAwaiter().GetResult();
}
}

View file

@ -13,6 +13,11 @@ namespace GameCult.Eve.UnityScene
event Action<EveEntitySoaViewDocument> EntityViewAvailable;
}
public interface IEveUnityInputCapabilityDocumentSource
{
EveInputCapabilityDocument? CurrentInputCapability { get; }
}
/// <summary>Projects portable entity SoA generations into the ordinary Unity scene sink.</summary>
public sealed class EveUnityEntitySoaPresenter
{
@ -24,9 +29,15 @@ namespace GameCult.Eve.UnityScene
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
}
public int ActiveEntities => _known.Count;
public void Apply(EveEntitySoaViewDocument document)
{
using var view = EveUnityEntitySoaView.Open(document);
var identities = (document.Identities ?? Array.Empty<EveEntitySoaIdentity>())
.Where(identity => identity != null && identity.EntityIndex >= 0 && !string.IsNullOrWhiteSpace(identity.EntityId))
.GroupBy(identity => identity.EntityIndex)
.ToDictionary(group => group.Key, group => group.First());
var next = new HashSet<string>(StringComparer.Ordinal);
for (var row = 0; row < view.EntityCount; row++)
{
@ -37,24 +48,29 @@ namespace GameCult.Eve.UnityScene
view.TryReadFloat("render.scale", row, out var scale);
view.TryReadUInt32("render.group.id", row, out var groupId);
var renderGroup = view.RenderGroups.FirstOrDefault(group => group.GroupId == groupId);
var entityId = $"entity:{entityIndex}";
if (!identities.TryGetValue(entityIndex, out var identity))
throw new InvalidOperationException(
$"Eve entity SoA view '{document.ViewId}' does not map entity index {entityIndex} to a logical identity.");
var entityId = identity.EntityId;
next.Add(entityId);
var assetRef = renderGroup?.MeshAssetRef ?? "";
var assetRef = string.IsNullOrWhiteSpace(identity.AssetRef)
? renderGroup?.MeshAssetRef ?? ""
: identity.AssetRef;
_sink.UpsertEntity(
new EveUnityPlayableWorldEntity(
entityId,
entityId,
"entity",
entityId,
"",
identity.Kind,
string.IsNullOrWhiteSpace(identity.Label) ? entityId : identity.Label,
identity.Faction,
assetRef,
position.x,
position.y,
position.z,
rotationRadians * 57.2957795f,
scale > 0f ? scale : renderGroup?.DefaultScale ?? 1f,
selectable: true,
controllable: false,
selectable: identity.Selectable,
controllable: identity.Controllable,
focusCommand: "",
moveCommand: "",
targetCommand: "",
@ -62,7 +78,7 @@ namespace GameCult.Eve.UnityScene
props: new Dictionary<string, string>()),
new EveUnityPlayableWorldAssetBinding(
assetRef,
"entity",
identity.Kind,
string.IsNullOrWhiteSpace(assetRef) ? "unity-generated-placeholder" : "provider-asset-ref"));
}

View file

@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO.MemoryMappedFiles;
using System.Linq;
using GameCult.Eve.Surface;
using GameCult.Mesh;
using UnityEngine;
#nullable enable
@ -11,25 +11,14 @@ namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityEntitySoaView : IDisposable
{
private readonly Dictionary<string, MemoryMappedViewAccessor> _buffers =
new Dictionary<string, MemoryMappedViewAccessor>(StringComparer.Ordinal);
private readonly ICultMeshBodyReadLease _body;
private readonly Dictionary<string, EveEntitySoaColumn> _columns;
private EveUnityEntitySoaView(EveEntitySoaViewDocument document)
private EveUnityEntitySoaView(EveEntitySoaViewDocument document, ICultMeshBodyReadLease body)
{
Document = document;
_body = body;
_columns = document.Columns.ToDictionary(column => column.Semantic, StringComparer.Ordinal);
foreach (var buffer in document.Buffers)
{
if (!string.Equals(buffer.Backend, "memory_mapped_file", StringComparison.Ordinal))
throw new NotSupportedException($"EveUnity does not support entity SoA backend '{buffer.Backend}'.");
var mapped = MemoryMappedFile.OpenExisting(buffer.Location, MemoryMappedFileRights.Read);
_buffers.Add(buffer.BufferId, mapped.CreateViewAccessor(
buffer.ByteOffset,
buffer.ByteLength,
MemoryMappedFileAccess.Read));
mapped.Dispose();
}
}
public EveEntitySoaViewDocument Document { get; }
@ -41,39 +30,51 @@ namespace GameCult.Eve.UnityScene
if (document == null) throw new ArgumentNullException(nameof(document));
if (!string.Equals(document.Schema, EveEntitySoaViewDocument.SchemaId, StringComparison.Ordinal))
throw new ArgumentException($"Expected {EveEntitySoaViewDocument.SchemaId}, got '{document.Schema}'.", nameof(document));
return new EveUnityEntitySoaView(document);
if (document.Body == null)
throw new InvalidOperationException("Eve entity SoA view did not advertise a CultMesh body.");
var request = new CultMeshBodyValidationRequest
{
BodyId = document.Body.BodyId,
SchemaId = document.Body.SchemaId,
LayoutVersion = document.Body.LayoutVersion,
ProducerEpoch = document.Body.ProducerEpoch,
AccessMode = CultMeshBodyAccessMode.ReadOnly,
NowUtc = DateTimeOffset.UtcNow
};
var body = new CultMeshSharedMemoryBodyAdapter().OpenReadOnly(document.Body, request);
return new EveUnityEntitySoaView(document, body);
}
public bool TryReadVector3(string semantic, int index, out Vector3 value)
{
value = default;
if (!TryColumn(semantic, index, "float3", 12, out var column, out var accessor)) return false;
if (!TryColumn(semantic, index, "float3", 12, out var column)) return false;
var offset = column.ByteOffset + (long)index * column.ElementStride;
value = new Vector3(accessor.ReadSingle(offset), accessor.ReadSingle(offset + 4), accessor.ReadSingle(offset + 8));
value = new Vector3(_body.ReadSingle(offset), _body.ReadSingle(offset + 4), _body.ReadSingle(offset + 8));
return true;
}
public bool TryReadFloat(string semantic, int index, out float value)
{
value = default;
if (!TryColumn(semantic, index, "float32", 4, out var column, out var accessor)) return false;
value = accessor.ReadSingle(column.ByteOffset + (long)index * column.ElementStride);
if (!TryColumn(semantic, index, "float32", 4, out var column)) return false;
value = _body.ReadSingle(column.ByteOffset + (long)index * column.ElementStride);
return true;
}
public bool TryReadInt32(string semantic, int index, out int value)
{
value = default;
if (!TryColumn(semantic, index, "int32", 4, out var column, out var accessor)) return false;
value = accessor.ReadInt32(column.ByteOffset + (long)index * column.ElementStride);
if (!TryColumn(semantic, index, "int32", 4, out var column)) return false;
value = _body.ReadInt32(column.ByteOffset + (long)index * column.ElementStride);
return true;
}
public bool TryReadUInt32(string semantic, int index, out uint value)
{
value = default;
if (!TryColumn(semantic, index, "uint32", 4, out var column, out var accessor)) return false;
value = accessor.ReadUInt32(column.ByteOffset + (long)index * column.ElementStride);
if (!TryColumn(semantic, index, "uint32", 4, out var column)) return false;
value = unchecked((uint)_body.ReadInt32(column.ByteOffset + (long)index * column.ElementStride));
return true;
}
@ -84,15 +85,14 @@ namespace GameCult.Eve.UnityScene
public bool TryReadByte(string semantic, int index, out byte value)
{
value = default;
if (!TryColumn(semantic, index, "uint8", 1, out var column, out var accessor)) return false;
value = accessor.ReadByte(column.ByteOffset + (long)index * column.ElementStride);
if (!TryColumn(semantic, index, "uint8", 1, out var column)) return false;
value = _body.ReadByte(column.ByteOffset + (long)index * column.ElementStride);
return true;
}
public void Dispose()
{
foreach (var accessor in _buffers.Values) accessor.Dispose();
_buffers.Clear();
_body.Dispose();
}
private bool TryColumn(
@ -100,15 +100,22 @@ namespace GameCult.Eve.UnityScene
int index,
string scalarType,
int minimumStride,
out EveEntitySoaColumn column,
out MemoryMappedViewAccessor accessor)
out EveEntitySoaColumn column)
{
column = null!;
accessor = null!;
if (index < 0 || !_columns.TryGetValue(semantic ?? "", out column)) return false;
if (index >= column.ElementCount || column.ElementStride < minimumStride) return false;
if (!string.Equals(column.ScalarType, scalarType, StringComparison.Ordinal)) return false;
return _buffers.TryGetValue(column.BufferId, out accessor);
return DocumentBufferContains(column);
}
private bool DocumentBufferContains(EveEntitySoaColumn column)
{
var buffer = Document.Buffers.FirstOrDefault(candidate =>
string.Equals(candidate.BufferId, column.BufferId, StringComparison.Ordinal));
if (buffer == null || buffer.ByteOffset != 0 || Document.Body == null) return false;
var columnEnd = column.ByteOffset + (long)column.ElementStride * column.ElementCount;
return column.ByteOffset >= 0 && columnEnd <= Document.Body.ByteSize;
}
}
}

View file

@ -21,6 +21,8 @@ namespace GameCult.Eve.UnityScene
private readonly Transform _root;
private readonly IEveUnityGameObjectAssetProvider _assetProvider;
private readonly Dictionary<string, GameObject> _instances = new Dictionary<string, GameObject>(StringComparer.Ordinal);
private string _subjectEntityId = "";
private bool _subjectVisible = true;
public EveUnityGameObjectPlayableWorldSceneSink(
Transform root,
@ -37,6 +39,9 @@ namespace GameCult.Eve.UnityScene
if (world == null) throw new ArgumentNullException(nameof(world));
if (!string.IsNullOrWhiteSpace(world.WorldRootId))
_root.name = world.WorldRootId;
_subjectEntityId = world.PlayerEntityId;
_subjectVisible = world.SubjectVisible;
ApplySubjectVisibility();
}
public void UpsertEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset)
@ -64,6 +69,7 @@ namespace GameCult.Eve.UnityScene
}
ApplyEntity(instance, entity, asset);
ApplySubjectVisibility(instance, entity.EntityId);
}
public void RemoveEntity(string entityId)
@ -140,6 +146,19 @@ namespace GameCult.Eve.UnityScene
else
UnityEngine.Object.DestroyImmediate(instance);
}
private void ApplySubjectVisibility()
{
foreach (var pair in _instances)
ApplySubjectVisibility(pair.Value, pair.Key);
}
private void ApplySubjectVisibility(GameObject instance, string entityId)
{
if (instance == null || !string.Equals(entityId, _subjectEntityId, StringComparison.Ordinal))
return;
instance.SetActive(_subjectVisible);
}
}
public sealed class EveUnitySemanticEntityPresentation : MonoBehaviour

View file

@ -53,8 +53,8 @@ namespace GameCult.Eve.UnityScene
}
var camera = cameraTransform != null ? cameraTransform : transform;
var player = FindPlayerMarker(resolvedHost, activeWorld.PlayerEntityId);
if (player == null)
var targetMarker = FindEntityMarker(resolvedHost, activeWorld.CameraTargetEntityId);
if (targetMarker == null)
return false;
var cameraComponent = camera.GetComponent<Camera>();
@ -64,8 +64,8 @@ namespace GameCult.Eve.UnityScene
{
cameraComponent.cullingMask = cullingMask;
}
var bounds = CalculateVisualBounds(player);
var target = bounds?.center ?? player.transform.position;
var bounds = CalculateVisualBounds(targetMarker);
var target = bounds?.center ?? targetMarker.transform.position;
var radius = bounds?.extents.magnitude ?? 0f;
var verticalFov = cameraComponent != null ? cameraComponent.fieldOfView : 60f;
var framingDistance = radius > 0f
@ -84,9 +84,9 @@ namespace GameCult.Eve.UnityScene
return true;
}
private static Bounds? CalculateVisualBounds(EveUnityPlayableWorldEntityMarker player)
private static Bounds? CalculateVisualBounds(EveUnityPlayableWorldEntityMarker target)
{
var renderers = player.GetComponentsInChildren<Renderer>(includeInactive: false);
var renderers = target.GetComponentsInChildren<Renderer>(includeInactive: false);
Bounds? bounds = null;
foreach (var visual in renderers)
{
@ -121,17 +121,17 @@ namespace GameCult.Eve.UnityScene
return host;
}
private static EveUnityPlayableWorldEntityMarker? FindPlayerMarker(
private static EveUnityPlayableWorldEntityMarker? FindEntityMarker(
EveUnityPlayableWorldClientHost host,
string playerEntityId)
string entityId)
{
if (string.IsNullOrWhiteSpace(playerEntityId))
if (string.IsNullOrWhiteSpace(entityId))
return null;
var markers = host.SceneRoot.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>();
var markers = host.SceneRoot.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>(includeInactive: true);
foreach (var marker in markers)
{
if (marker != null && marker.EntityId == playerEntityId)
if (marker != null && marker.EntityId == entityId)
return marker;
}

View file

@ -39,7 +39,7 @@ namespace GameCult.Eve.UnityScene
DateTimeOffset? issuedAt = null)
{
var resolvedHost = ResolveHost();
if (resolvedHost == null || resolvedHost.ActiveWorld == null)
if (resolvedHost == null || resolvedHost.ActiveWorld == null || !resolvedHost.ActiveWorld.MovementEnabled)
return null;
var movement = EveUnityPlayableWorldMoveVector.FromCameraRelativeInput(

View file

@ -11,6 +11,7 @@ namespace GameCult.Eve.UnityScene
private readonly EveUnitySceneProviderConnection _connection;
private readonly EveUnityPlayableWorldPresenter _presenter;
private readonly IEveUnitySceneCommandReceiptSource? _receiptSource;
private readonly Func<long>? _visibleStateVersion;
private readonly EveUnityFeedbackPresenter _feedback = new EveUnityFeedbackPresenter();
private readonly EveUnityShotReceiptPresenter _shots = new EveUnityShotReceiptPresenter();
private readonly List<EveUnitySceneCommandReceipt> _pendingReceipts =
@ -21,11 +22,13 @@ namespace GameCult.Eve.UnityScene
public EveUnityPlayableWorldLiveClient(
EveUnitySceneProviderConnection connection,
EveUnityPlayableWorldPresenter presenter,
IEveUnitySceneCommandReceiptSource? receiptSource = null)
IEveUnitySceneCommandReceiptSource? receiptSource = null,
Func<long>? visibleStateVersion = null)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_presenter = presenter ?? throw new ArgumentNullException(nameof(presenter));
_receiptSource = receiptSource;
_visibleStateVersion = visibleStateVersion;
}
public EveUnitySceneProjection? ActiveProjection => _connection.ActiveProjection;
@ -36,7 +39,7 @@ namespace GameCult.Eve.UnityScene
public EveUnitySceneCommandReceipt? LastReceipt { get; private set; }
public long ActiveVersion => _connection.ActiveVersion;
public long ActiveVersion => Math.Max(_connection.ActiveVersion, _visibleStateVersion?.Invoke() ?? 0);
public string SourcePointer => _connection.SourcePointer;
@ -105,10 +108,10 @@ namespace GameCult.Eve.UnityScene
public EveSurfaceCommandRequest SubmitActionIntent(
string entityId,
string actionId,
EveInputActionDocument action,
DateTimeOffset? issuedAt = null)
{
return _connection.SubmitActionIntent(entityId, actionId, issuedAt);
return _connection.SubmitActionIntent(entityId, action, issuedAt);
}
public void Disconnect()

View file

@ -1,4 +1,5 @@
using System;
using System.Linq;
using GameCult.Eve.Surface;
using UnityEngine;
@ -13,6 +14,7 @@ namespace GameCult.Eve.UnityScene
private readonly EveUnityPlayableWorldLiveClient _client;
private readonly EveUnityPlayableWorldAssetManifestDocumentSource? _assetManifestSource;
private readonly IEveUnityEntitySoaViewDocumentSource? _entityViews;
private readonly IEveUnityInputCapabilityDocumentSource? _inputCapabilities;
private readonly EveUnityEntitySoaPresenter _entityPresenter;
private bool _entityViewsConnected;
private bool _assetManifestConnected;
@ -33,12 +35,14 @@ namespace GameCult.Eve.UnityScene
AssetManifests = assetManifests ?? new EveUnityPlayableWorldAssetManifestCache();
_surfaceSource = new EveUnitySceneProviderSurfaceDocumentSource(surfaceDocuments);
_entityViews = surfaceDocuments as IEveUnityEntitySoaViewDocumentSource;
_inputCapabilities = surfaceDocuments as IEveUnityInputCapabilityDocumentSource;
_entityPresenter = new EveUnityEntitySoaPresenter(sceneSink);
_connection = new EveUnitySceneProviderConnection(_surfaceSource, commandSink);
_client = new EveUnityPlayableWorldLiveClient(
_connection,
new EveUnityPlayableWorldPresenter(sceneSink, assetResolver ?? new EveUnityAssetRefResolver()),
receiptSource);
receiptSource,
() => _entityViews?.CurrentEntityView?.FrameId ?? 0);
if (assetManifestDocuments != null)
_assetManifestSource = new EveUnityPlayableWorldAssetManifestDocumentSource(assetManifestDocuments);
@ -52,7 +56,7 @@ namespace GameCult.Eve.UnityScene
public EveUnitySceneProjection? ActiveProjection => _client.ActiveProjection;
public EveUnityPlayableWorldPresentation? LastPresentation => _client.LastPresentation;
public EveUnityPlayableWorldPresentation? LastPresentation => ComposePresentation(_client.LastPresentation);
public EveUnitySceneCommandReceipt? LastReceipt => _client.LastReceipt;
@ -109,15 +113,17 @@ namespace GameCult.Eve.UnityScene
public EveUnityPlayableWorldPresentation Connect()
{
EnsureAssetManifestConnected();
var presentation = _client.Connect();
_client.Connect();
EnsureEntityViewsConnected();
return presentation;
return ComposePresentation(_client.LastPresentation)!;
}
public EveUnityPlayableWorldPresentation Refresh()
{
EnsureAssetManifestConnected();
return _client.Refresh();
EnsureEntityViewsConnected();
_client.Refresh();
return ComposePresentation(_client.LastPresentation)!;
}
public EveSurfaceCommandRequest SubmitMoveIntent(
@ -160,7 +166,14 @@ namespace GameCult.Eve.UnityScene
string actionId,
DateTimeOffset? issuedAt = null)
{
return _client.SubmitActionIntent(entityId, actionId, issuedAt);
var capability = _inputCapabilities?.CurrentInputCapability
?? throw new InvalidOperationException("The active Eve surface does not publish an input capability document.");
var action = (capability.Actions ?? Array.Empty<EveInputActionDocument>())
.FirstOrDefault(candidate => string.Equals(candidate.ActionId, actionId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"The active Eve input capability does not advertise action '{actionId}'.");
if (!string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Eve action '{actionId}' is not currently available.");
return _client.SubmitActionIntent(entityId, action, issuedAt);
}
public void Disconnect()
@ -207,6 +220,22 @@ namespace GameCult.Eve.UnityScene
}
private void OnEntityViewAvailable(EveEntitySoaViewDocument document) => _entityPresenter.Apply(document);
private EveUnityPlayableWorldPresentation? ComposePresentation(
EveUnityPlayableWorldPresentation? surfacePresentation)
{
if (surfacePresentation == null || _entityViews?.CurrentEntityView == null)
return surfacePresentation;
return new EveUnityPlayableWorldPresentation(
surfacePresentation.WorldRootId,
surfacePresentation.PlayerEntityId,
surfacePresentation.InputProfile,
surfacePresentation.CameraRig,
surfacePresentation.UpsertedEntities,
surfacePresentation.RemovedEntities,
_entityPresenter.ActiveEntities);
}
}
public sealed class EveUnityLivePlayableWorldAssetProvider : IEveUnityNativeAssetProvider

View file

@ -115,17 +115,18 @@ namespace GameCult.Eve.UnityScene
public EveSurfaceCommandRequest CreateActionIntent(
string entityId,
string actionId,
EveInputActionDocument action,
DateTimeOffset? issuedAt = null)
{
var playableWorld = RequirePlayableWorld();
if (action == null) throw new ArgumentNullException(nameof(action));
var payload = new Dictionary<string, string>(action.Payload ?? new Dictionary<string, string>(), StringComparer.Ordinal)
{
["entityId"] = entityId ?? "",
["actionId"] = action.ActionId ?? ""
};
return CreatePlayableWorldIntent(
playableWorld.ActionCommand,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["entityId"] = entityId ?? "",
["actionId"] = actionId ?? ""
},
action.Operation,
payload,
issuedAt);
}

View file

@ -238,10 +238,10 @@ namespace GameCult.Eve.UnityScene
public EveSurfaceCommandRequest SubmitActionIntent(
string entityId,
string actionId,
EveInputActionDocument action,
DateTimeOffset? issuedAt = null)
{
return Submit(_session.CreateActionIntent(entityId, actionId, issuedAt));
return Submit(_session.CreateActionIntent(entityId, action, issuedAt));
}
public void Disconnect()

View file

@ -124,7 +124,12 @@ namespace GameCult.Eve.UnityScene
worldRoot.GetProp("entityViewPointerId"),
worldRoot.GetProp("entityViewSchema"),
worldRoot.GetProp("zoneRenderPointerId"),
worldRoot.GetProp("zoneRenderSchema"));
worldRoot.GetProp("zoneRenderSchema"),
worldRoot.GetProp("inputCapability"),
worldRoot.GetProp("cameraTargetEntityId"),
ParseBoolean(worldRoot.GetProp("subjectVisible", "true")),
ParseBoolean(worldRoot.GetProp("movementEnabled", "true")),
worldRoot.GetProp("presentationMode", "world"));
}
private static EveUnityPlayableWorldEntity BuildPlayableEntity(EveSurfaceComponent component)
@ -324,7 +329,12 @@ namespace GameCult.Eve.UnityScene
string entityViewPointerId = "",
string entityViewSchema = "",
string zoneRenderPointerId = "",
string zoneRenderSchema = "")
string zoneRenderSchema = "",
string inputCapabilityPointerId = "",
string cameraTargetEntityId = "",
bool subjectVisible = true,
bool movementEnabled = true,
string presentationMode = "world")
{
WorldRootId = worldRootId ?? "";
StatePointerId = statePointerId ?? "";
@ -342,6 +352,13 @@ namespace GameCult.Eve.UnityScene
EntityViewSchema = entityViewSchema ?? "";
ZoneRenderPointerId = zoneRenderPointerId ?? "";
ZoneRenderSchema = zoneRenderSchema ?? "";
InputCapabilityPointerId = inputCapabilityPointerId ?? "";
CameraTargetEntityId = string.IsNullOrWhiteSpace(cameraTargetEntityId)
? PlayerEntityId
: cameraTargetEntityId;
SubjectVisible = subjectVisible;
MovementEnabled = movementEnabled;
PresentationMode = presentationMode ?? "";
}
public string WorldRootId { get; }
@ -356,6 +373,8 @@ namespace GameCult.Eve.UnityScene
public string ZoneRenderSchema { get; }
public string InputCapabilityPointerId { get; }
public string AssetManifest { get; }
public string InputProfile { get; }
@ -366,6 +385,14 @@ namespace GameCult.Eve.UnityScene
public string PlayerEntityId { get; }
public string CameraTargetEntityId { get; }
public bool SubjectVisible { get; }
public bool MovementEnabled { get; }
public string PresentationMode { get; }
public string MovementCommand { get; }
public string FocusCommand { get; }

View file

@ -0,0 +1,135 @@
using System;
using GameCult.Eve.Surface;
using NUnit.Framework;
namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveCultMeshSurfaceSelectionTests
{
[Test]
public void Select_MatchesDeveloperIntentWithoutPhysicalTopology()
{
var provider = Provider(
new EveAdvertisedSurface(
"aetheria.pilot",
EveSurfaceDocument.SchemaId,
"eve:surface:aetheria.pilot",
"cultmesh-record",
"active",
"interactive-world",
new EveWorldInteractionAdvertisement(
"world3d",
Array.Empty<string>(),
"provider-command",
"eve:commands",
EveCommandReceiptDocument.SchemaId,
"eve:receipts",
"eve:assets",
new[] { "unity-scene", "web" },
"provider")));
var selected = EveCultMeshSurfaceSelection.Select(
new[] { provider },
new EveSurfaceRequest
{
ProviderId = "aetheria.daemon",
SurfaceKind = "interactive-world",
LoweringTarget = "unity-scene"
});
Assert.That(selected.Provider, Is.SameAs(provider));
Assert.That(selected.Surface.SurfaceId, Is.EqualTo("aetheria.pilot"));
Assert.That(selected.Surface.RecordRef, Is.EqualTo("eve:surface:aetheria.pilot"));
}
[Test]
public void Select_RejectsUnsupportedLoweringTarget()
{
var provider = Provider(new EveAdvertisedSurface(
"aetheria.pilot",
EveSurfaceDocument.SchemaId,
"eve:surface:aetheria.pilot",
"cultmesh-record",
"active",
"interactive-world",
new EveWorldInteractionAdvertisement(
"world3d", Array.Empty<string>(), "", "", "", "", "",
new[] { "unity-scene" }, "provider")));
Assert.Throws<InvalidOperationException>(() => EveCultMeshSurfaceSelection.Select(
new[] { provider },
new EveSurfaceRequest { LoweringTarget = "flutter" }));
}
[Test]
public void ResolvePlugins_KeepsIndependentRequiredAndOptionalNestedCapabilities()
{
var surface = new EveAdvertisedSurface(
"sai.stage", EveSurfaceDocument.SchemaId, "eve:surface:sai", "cultmesh-record", "active", "interface",
requiresPlugins: new[]
{
new EvePluginRequirement("sai.vn", "^0.1.0", "required", new[] { "vn.stage", "story.choose" }, Array.Empty<string>()),
new EvePluginRequirement("norn.graph", "^0.1.0", "optional-nested", Array.Empty<string>(), new[] { "embed.norn" }),
new EvePluginRequirement("tex.math", "^0.1.0", "optional-nested", Array.Empty<string>(), new[] { "embed.tex" })
});
var sai = Plugin("sai.vn", new[] { "vn.stage" }, new[] { "story.choose" });
var norn = Plugin("norn.graph", new[] { "embed.norn" }, Array.Empty<string>());
var selected = EveCultMeshSurfaceSelection.ResolvePlugins(surface, new[] { sai, norn });
Assert.That(selected, Is.EqualTo(new[] { sai, norn }));
}
[Test]
public void ResolvePlugins_RejectsMissingRequiredCapability()
{
var surface = new EveAdvertisedSurface(
"sai.stage", EveSurfaceDocument.SchemaId, "eve:surface:sai", "cultmesh-record", "active", "interface",
requiresPlugins: new[]
{
new EvePluginRequirement("sai.vn", "^0.1.0", "required", new[] { "story.choose" }, Array.Empty<string>())
});
Assert.Throws<InvalidOperationException>(() =>
EveCultMeshSurfaceSelection.ResolvePlugins(
surface,
new[] { Plugin("sai.vn", new[] { "vn.stage" }, Array.Empty<string>()) }));
}
private static EveProviderAdvertisementDocument Provider(EveAdvertisedSurface surface) =>
new EveProviderAdvertisementDocument(
"aetheria.daemon",
"aetheria",
"aetheria",
"Aetheria",
"game",
"aetheria.daemon",
"2026-07-12T00:00:00Z",
new EveProviderFreshness("fresh", "2026-07-12T00:00:00Z", 5000),
Array.Empty<string>(),
Array.Empty<EveProviderWitness>(),
new[] { surface },
Array.Empty<EveAdvertisedCommand>());
private static EvePluginAdvertisementDocument Plugin(
string pluginId,
string[] components,
string[] commands) =>
new EvePluginAdvertisementDocument(
pluginId,
pluginId.Split('.')[0],
"0.1.0",
"cultmesh://plugins/" + pluginId,
new EvePluginRuntimeAdvertisement(
"sidecar",
"gamecult.eve.plugin_abi.v1",
new[] { "cultmesh" },
new[] { "plugin" },
new EvePluginSidecarAdvertisement(
"daemon", "cultmesh", "request", "response", Array.Empty<string>(), "command", "receipt", "plugin")),
Array.Empty<string>(),
components,
commands,
Array.Empty<string>());
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c578fb24db4b41fc88ea08bde4c42dd4

View file

@ -117,6 +117,10 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(projection.PlayableWorld.InputProfile, Is.EqualTo("arpg-third-person"));
Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("third-person-orbit"));
Assert.That(projection.PlayableWorld.PlayerEntityId, Is.EqualTo("player-vanguard"));
Assert.That(projection.PlayableWorld.CameraTargetEntityId, Is.EqualTo("player-vanguard"));
Assert.That(projection.PlayableWorld.SubjectVisible, Is.True);
Assert.That(projection.PlayableWorld.MovementEnabled, Is.True);
Assert.That(projection.PlayableWorld.PresentationMode, Is.EqualTo("world"));
Assert.That(projection.PlayableWorld.MovementCommand, Is.EqualTo("aetheria.daemon.move_intent"));
Assert.That(projection.PlayableWorld.FocusCommand, Is.EqualTo("aetheria.daemon.focus"));
Assert.That(projection.PlayableWorld.TargetCommand, Is.EqualTo("aetheria.daemon.target"));
@ -338,11 +342,25 @@ namespace GameCult.Eve.UnityScene.Tests
[Test]
public void PlayableWorldRuntimeComposesProviderDocumentsAssetsReceiptsAndSceneSink()
{
var surfaceDocuments = new FakeProviderSurfaceDocumentSource(new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(),
Advertisement("aetheria.daemon.game"),
"cultmesh://aetheria/eve/surfaces/aetheria.daemon.game",
1));
var surfaceDocuments = new FakeProviderSurfaceDocumentSource(
new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(),
Advertisement("aetheria.daemon.game"),
"cultmesh://aetheria/eve/surfaces/aetheria.daemon.game",
1),
currentInputCapability: new EveInputCapabilityDocument
{
ProviderId = "aetheria",
Actions = new[]
{
new EveInputActionDocument
{
ActionId = "weapon-group.2.fire",
Operation = "aetheria.daemon.commands.FireWeaponGroup",
Payload = new Dictionary<string, string> { ["weaponGroup"] = "2" }
}
}
});
var assetDocuments = new FakeAssetManifestDocumentSource(new EveUnityPlayableWorldAssetManifestDocument(
"cultmesh://aetheria/assets/manifest",
new[]
@ -392,6 +410,14 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands"));
Assert.That(moveIntent.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1"));
var actionIntent = runtime.SubmitActionIntent("player-vanguard", "weapon-group.2.fire");
Assert.That(commandSink.Submitted.Count, Is.EqualTo(2));
Assert.That(commandSink.Submitted[1], Is.SameAs(actionIntent));
Assert.That(actionIntent.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.commands.FireWeaponGroup"));
Assert.That(actionIntent.Payload.GetString("weaponGroup"), Is.EqualTo("2"));
Assert.That(actionIntent.Payload.GetString("actionId"), Is.EqualTo("weapon-group.2.fire"));
surfaceDocuments.Publish(new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(includeRaider: false, playerPosition: "14,0,9"),
Advertisement("aetheria.daemon.game"),
@ -419,6 +445,56 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(runtime.LastReceipt.ShouldRefreshProviderSurface, Is.True);
}
[Test]
public void PlayableWorldRuntimeReportsAuthoritativeSoaEntityCountOnConnect()
{
var location = $"eve-unity-runtime-soa-{Guid.NewGuid():N}";
using var mapped = MemoryMappedFile.CreateNew(location, 16);
using (var writer = mapped.CreateViewAccessor())
{
writer.Write(0, 4f);
writer.Write(4, 5f);
writer.Write(8, 6f);
writer.Write(12, 41);
}
var entityView = new EveEntitySoaViewDocument
{
ProviderId = "aetheria",
ViewId = "pilot",
Generation = 1,
Backend = "memory_mapped_file",
Buffers = new[] { new EveEntitySoaBuffer { BufferId = "hot", Backend = "memory_mapped_file", Location = location, ByteLength = 16, Generation = 1 } },
Columns = new[]
{
new EveEntitySoaColumn { ColumnId = "position", Semantic = "transform.position", BufferId = "hot", ScalarType = "float3", ElementStride = 12, ElementCount = 1 },
new EveEntitySoaColumn { ColumnId = "entity-index", Semantic = "entity.index", BufferId = "hot", ScalarType = "int32", ByteOffset = 12, ElementStride = 4, ElementCount = 1 }
},
Identities = new[]
{
new EveEntitySoaIdentity { EntityIndex = 41, EntityId = "world:entity:41", Kind = "ship", Label = "Test ship", Controllable = true, AssetRef = "prefab.entity.player" }
}
};
var surfaceDocuments = new FakeProviderSurfaceDocumentSource(
new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(),
Advertisement("aetheria.daemon.game"),
"cultmesh://aetheria/eve/surfaces/aetheria.daemon.game",
1),
entityView);
var sceneSink = new FakePlayableWorldSceneSink();
using var runtime = new EveUnityPlayableWorldRuntime(
surfaceDocuments,
new FakeCommandSink("cultmesh-command-sink"),
sceneSink);
var presentation = runtime.Connect();
Assert.That(presentation.ActiveEntities, Is.EqualTo(1));
Assert.That(runtime.LastPresentation!.ActiveEntities, Is.EqualTo(1));
Assert.That(sceneSink.Upserts.Exists(entry => entry.entity.EntityId == "world:entity:41"), Is.True);
}
[Test]
public void LiveProviderBridgeFeedsPlayableWorldRuntimeThroughTransportPorts()
{
@ -1347,6 +1423,10 @@ namespace GameCult.Eve.UnityScene.Tests
["inputProfile"] = "arpg-third-person",
["cameraRig"] = "third-person-orbit",
["playerEntityId"] = "player-vanguard",
["cameraTargetEntityId"] = "player-vanguard",
["subjectVisible"] = "true",
["movementEnabled"] = "true",
["presentationMode"] = "world",
["movementCommand"] = "aetheria.daemon.move_intent",
["focusCommand"] = "aetheria.daemon.focus",
["targetCommand"] = "aetheria.daemon.target",
@ -1544,22 +1624,42 @@ namespace GameCult.Eve.UnityScene.Tests
}
}
private sealed class FakeProviderSurfaceDocumentSource : IEveUnitySceneProviderSurfaceDocumentSource
private sealed class FakeProviderSurfaceDocumentSource :
IEveUnitySceneProviderSurfaceDocumentSource,
IEveUnityEntitySoaViewDocumentSource,
IEveUnityInputCapabilityDocumentSource
{
public FakeProviderSurfaceDocumentSource(EveUnitySceneProviderSurfaceDocument currentDocument)
public FakeProviderSurfaceDocumentSource(
EveUnitySceneProviderSurfaceDocument currentDocument,
EveEntitySoaViewDocument? currentEntityView = null,
EveInputCapabilityDocument? currentInputCapability = null)
{
CurrentDocument = currentDocument;
CurrentEntityView = currentEntityView;
CurrentInputCapability = currentInputCapability;
}
public EveUnitySceneProviderSurfaceDocument CurrentDocument { get; private set; }
public event Action<EveUnitySceneProviderSurfaceDocument>? DocumentAvailable;
public EveEntitySoaViewDocument? CurrentEntityView { get; private set; }
public event Action<EveEntitySoaViewDocument>? EntityViewAvailable;
public EveInputCapabilityDocument? CurrentInputCapability { get; }
public void Publish(EveUnitySceneProviderSurfaceDocument document)
{
CurrentDocument = document;
DocumentAvailable?.Invoke(document);
}
public void PublishEntityView(EveEntitySoaViewDocument document)
{
CurrentEntityView = document;
EntityViewAvailable?.Invoke(document);
}
}
private sealed class FakeCommandReceiptSource : IEveUnitySceneCommandReceiptSource

View file

@ -1,6 +1,8 @@
param(
[string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe",
[string] $AetheriaRoot = "E:\Projects\Aetheria",
[string] $EveUnityRoot = "",
[string] $CultLibRoot = "E:\Projects\CultLib",
[string] $ClientProject = "ReleaseConsumerProject",
[int] $Port = 3076,
[string] $OutputDirectory = "artifacts\aetheria-daemon",
@ -11,12 +13,14 @@ param(
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$resolvedEveUnityRoot = if ([string]::IsNullOrWhiteSpace($EveUnityRoot)) { $repoRoot } else { $EveUnityRoot }
$projectRoot = $ClientProject
$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory }
$resultsPath = Join-Path $outputRoot "results.xml"
$unityLogPath = Join-Path $outputRoot "unity.log"
$bundleBuildLogPath = Join-Path $outputRoot "asset-bundle-build.log"
$daemonLogPath = Join-Path $outputRoot "aetheria-daemon.log"
$daemonBuildLogPath = Join-Path $outputRoot "aetheria-daemon-build.log"
$capturePath = Join-Path $outputRoot "aetheria-daemon-world.png"
$factsPath = Join-Path $outputRoot "witness-facts.json"
$witnessPath = Join-Path $outputRoot "runtime-witness.json"
@ -56,13 +60,21 @@ foreach ($ephemeralPath in @(
}
$import = Start-Process -FilePath "dotnet" -ArgumentList @(
"run", "--project", $importProject, "--", $AetheriaRoot, $statePath
"run", "--project", $importProject, "-p:EveUnityRoot=$resolvedEveUnityRoot", "-p:CultLibRoot=$CultLibRoot", "--", $AetheriaRoot, $statePath
) -PassThru -WindowStyle Hidden -Wait `
-RedirectStandardOutput (Join-Path $outputRoot "aetheria-import.log") `
-RedirectStandardError (Join-Path $outputRoot "aetheria-import.error.log")
if ($import.ExitCode -ne 0) {
throw "Aetheria state import failed with exit code $($import.ExitCode). See $outputRoot\aetheria-import.error.log"
}
$daemonBuild = Start-Process -FilePath "dotnet" -ArgumentList @(
"build", $daemonProject, "-p:EveUnityRoot=$resolvedEveUnityRoot", "-p:CultLibRoot=$CultLibRoot"
) -PassThru -WindowStyle Hidden -Wait `
-RedirectStandardOutput $daemonBuildLogPath `
-RedirectStandardError (Join-Path $outputRoot "aetheria-daemon-build.error.log")
if ($daemonBuild.ExitCode -ne 0) {
throw "Aetheria daemon build failed with exit code $($daemonBuild.ExitCode). See $daemonBuildLogPath"
}
if (-not $SkipAssetBundleBuild) {
$bundleBuilder = Start-Process -FilePath $UnityExe -ArgumentList @(
"-batchmode", "-quit", "-projectPath", ".",
@ -83,18 +95,19 @@ if (-not $SkipAssetBundleBuild) {
}
$daemonArguments = @(
"run", "--project", $daemonProject, "--",
"run", "--no-build", "--project", $daemonProject, "--",
"--root", $AetheriaRoot,
"--state", $statePath,
"--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)"
@ -112,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
@ -137,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