Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d32ccf2347 | ||
|
|
59edc30088 | ||
|
|
e44fcb0efa | ||
|
|
65bab4d2cd | ||
|
|
7029a19976 | ||
|
|
3fee35512c | ||
|
|
65f1534b9d | ||
|
|
1a27b5e041 | ||
|
|
cf9a8652a3 | ||
|
|
838a5ac923 | ||
|
|
93551237fa | ||
|
|
66401bedaa | ||
|
|
e836884f16 | ||
|
|
5118387a44 | ||
|
|
10ba7e5b07 | ||
|
|
0dee524662 |
29 changed files with 1686 additions and 417 deletions
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8a186b6d206ffa847aa276e984162512
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b3c40df8a2a14d7db2f45f7018591fd3
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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; } = "";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4c297c7dbb64a0f49a7f805e770c5e55
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7a5b91c7defe47d2a38940a460aa59c5
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c578fb24db4b41fc88ea08bde4c42dd4
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Reference in a new issue