962 lines
54 KiB
C#
962 lines
54 KiB
C#
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;
|
|
using GameCult.Eve.UnityScene;
|
|
using NUnit.Framework;
|
|
using UnityEngine;
|
|
using UnityEngine.TestTools;
|
|
|
|
namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
|
{
|
|
public sealed class GenericWorldCaptureTests
|
|
{
|
|
[Test]
|
|
public void CoreProviderDocumentsRoundTripThroughCultCacheSerialization()
|
|
{
|
|
var advertisement = new EveProviderAdvertisementDocument(
|
|
"eve.world-smoke",
|
|
"eve-world-smoke",
|
|
"eve.local",
|
|
"Generic world",
|
|
"game.daemon",
|
|
"127.0.0.1:7777",
|
|
"2026-07-10T00:00:00Z",
|
|
new EveProviderFreshness("fresh", "2026-07-10T00:00:00Z", 5000),
|
|
new[] { EveSurfaceDocument.SchemaId, EveCommandReceiptDocument.SchemaId },
|
|
Array.Empty<EveProviderWitness>(),
|
|
new[]
|
|
{
|
|
new EveAdvertisedSurface(
|
|
"eve.world-smoke.surface",
|
|
EveSurfaceDocument.SchemaId,
|
|
"surface:eve.world-smoke",
|
|
"cultmesh-record",
|
|
"active",
|
|
"interactive-world",
|
|
new EveWorldInteractionAdvertisement(
|
|
"provider-authored-world-surface",
|
|
Array.Empty<string>(),
|
|
"world.commands",
|
|
"commands:eve.world-smoke",
|
|
EveCommandReceiptDocument.SchemaId,
|
|
"receipts:eve.world-smoke",
|
|
"assets:eve.world-smoke",
|
|
new[] { "unity-scene" },
|
|
"provider-owns-world-state-command-acceptance-and-receipts"))
|
|
},
|
|
Array.Empty<EveAdvertisedCommand>());
|
|
|
|
var payload = CultDocumentMessagePackSerialization.Serialize(advertisement);
|
|
var decoded = CultDocumentMessagePackSerialization.Deserialize<EveProviderAdvertisementDocument>(payload);
|
|
|
|
Assert.That(decoded.ProviderId, Is.EqualTo("eve.world-smoke"));
|
|
Assert.That(decoded.Surfaces.Count, Is.EqualTo(1));
|
|
Assert.That(decoded.Surfaces[0].WorldInteraction.CommandRecordRef, Is.EqualTo("commands:eve.world-smoke"));
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator GenericClientRendersWorldSmokeAndEmitsCommand()
|
|
{
|
|
var hostObject = new GameObject("EveUnity Generic Client");
|
|
var sceneRoot = new GameObject("World Projection");
|
|
var provider = hostObject.AddComponent<WorldSmokeProvider>();
|
|
var host = hostObject.AddComponent<EveUnityPlayableWorldClientHost>();
|
|
var cameraObject = new GameObject("Capture Camera");
|
|
var lightObject = new GameObject("World Light");
|
|
var ground = GameObject.CreatePrimitive(PrimitiveType.Plane);
|
|
RenderTexture target = null;
|
|
Texture2D pixels = null;
|
|
|
|
try
|
|
{
|
|
provider.CurrentDocument = WorldDocument();
|
|
host.Configure(sceneRoot.transform, provider, provider);
|
|
var presentation = host.Connect();
|
|
|
|
Assert.That(presentation.ActiveEntities, Is.EqualTo(3));
|
|
Assert.That(
|
|
sceneRoot.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>(),
|
|
Has.Length.EqualTo(3));
|
|
foreach (var marker in sceneRoot.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>())
|
|
{
|
|
var renderer = marker.GetComponent<Renderer>();
|
|
renderer.material.color = marker.Controllable
|
|
? new Color(0.18f, 0.75f, 1f)
|
|
: marker.EntityKind == "enemy"
|
|
? new Color(1f, 0.25f, 0.2f)
|
|
: new Color(1f, 0.78f, 0.15f);
|
|
}
|
|
|
|
ground.name = "Projection Ground";
|
|
ground.transform.position = new Vector3(0f, -0.65f, 3f);
|
|
ground.transform.localScale = new Vector3(2.2f, 1f, 2.2f);
|
|
ground.GetComponent<Renderer>().material.color = new Color(0.12f, 0.16f, 0.18f);
|
|
|
|
host.SubmitMoveVectorIntent("player", 0f, 1f);
|
|
Assert.That(provider.Submitted, Has.Count.EqualTo(1));
|
|
Assert.That(provider.Submitted[0].ProviderId, Is.EqualTo("eve.world-smoke"));
|
|
|
|
var light = lightObject.AddComponent<Light>();
|
|
light.type = LightType.Directional;
|
|
light.intensity = 1.4f;
|
|
lightObject.transform.rotation = Quaternion.Euler(45f, -35f, 0f);
|
|
|
|
var camera = cameraObject.AddComponent<Camera>();
|
|
camera.clearFlags = CameraClearFlags.SolidColor;
|
|
camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f);
|
|
camera.transform.position = new Vector3(10f, 8f, -12f);
|
|
camera.transform.LookAt(new Vector3(0f, 1f, 3f));
|
|
target = new RenderTexture(640, 360, 24, RenderTextureFormat.ARGB32);
|
|
camera.targetTexture = target;
|
|
|
|
yield return null;
|
|
camera.Render();
|
|
RenderTexture.active = target;
|
|
pixels = new Texture2D(640, 360, TextureFormat.RGB24, false);
|
|
pixels.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
|
|
pixels.Apply();
|
|
|
|
var changedPixels = 0;
|
|
var background = camera.backgroundColor;
|
|
foreach (var pixel in pixels.GetPixels())
|
|
{
|
|
if (Mathf.Abs(pixel.r - background.r) +
|
|
Mathf.Abs(pixel.g - background.g) +
|
|
Mathf.Abs(pixel.b - background.b) > 0.08f)
|
|
changedPixels++;
|
|
}
|
|
Assert.That(changedPixels, Is.GreaterThan(500));
|
|
|
|
var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_CAPTURE_PATH");
|
|
if (string.IsNullOrWhiteSpace(capturePath))
|
|
{
|
|
capturePath = Path.GetFullPath(Path.Combine(
|
|
Application.dataPath,
|
|
"..",
|
|
"..",
|
|
"artifacts",
|
|
"playmode",
|
|
"world-smoke.png"));
|
|
}
|
|
Directory.CreateDirectory(Path.GetDirectoryName(capturePath));
|
|
File.WriteAllBytes(capturePath, pixels.EncodeToPNG());
|
|
Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(1024));
|
|
}
|
|
finally
|
|
{
|
|
RenderTexture.active = null;
|
|
var camera = cameraObject.GetComponent<Camera>();
|
|
if (camera != null) camera.targetTexture = null;
|
|
if (target != null) UnityEngine.Object.DestroyImmediate(target);
|
|
if (pixels != null) UnityEngine.Object.DestroyImmediate(pixels);
|
|
UnityEngine.Object.DestroyImmediate(lightObject);
|
|
UnityEngine.Object.DestroyImmediate(cameraObject);
|
|
UnityEngine.Object.DestroyImmediate(ground);
|
|
UnityEngine.Object.DestroyImmediate(sceneRoot);
|
|
UnityEngine.Object.DestroyImmediate(hostObject);
|
|
}
|
|
}
|
|
|
|
[UnityTest]
|
|
[Timeout(300000)]
|
|
public IEnumerator GenericCultMeshClientLowersAndMovesAdvertisedWorld()
|
|
{
|
|
var rendezvousEndpoint = Environment.GetEnvironmentVariable("EVEUNITY_RENDEZVOUS_ENDPOINT");
|
|
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
|
|
Assert.Ignore("EVEUNITY_RENDEZVOUS_ENDPOINT is not configured for the live provider witness.");
|
|
|
|
var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? "";
|
|
var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? "";
|
|
var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ??
|
|
Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc");
|
|
var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ??
|
|
Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png");
|
|
var root = new GameObject("Generic Eve World Projection");
|
|
var cameraObject = new GameObject("Generic Eve Capture Camera");
|
|
var lightObject = new GameObject("Generic Eve World Light");
|
|
RenderTexture target = null;
|
|
Texture2D pixels = null;
|
|
EveUnityCultMeshPlayableWorldProvider provider = null;
|
|
EveUnityPlayableWorldRuntime runtime = null;
|
|
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
|
|
{
|
|
provider = root.AddComponent<EveUnityCultMeshPlayableWorldProvider>();
|
|
provider.Configure(
|
|
rendezvousEndpoint,
|
|
replicaPath,
|
|
providerId,
|
|
surfaceId,
|
|
requiredSurfaceKind: "interactive-world",
|
|
clientRuntimeId: $"eve-unity-test-{Guid.NewGuid():N}");
|
|
var publicationDeadline = Time.realtimeSinceStartup + 30f;
|
|
while (true)
|
|
{
|
|
var published = false;
|
|
try
|
|
{
|
|
provider.Refresh();
|
|
published = true;
|
|
}
|
|
catch (InvalidOperationException) when (Time.realtimeSinceStartup < publicationDeadline)
|
|
{
|
|
}
|
|
if (published) break;
|
|
yield return new WaitForSecondsRealtime(0.1f);
|
|
}
|
|
Assert.That(provider.Selection, Is.Not.Null);
|
|
Assert.That(provider.Selection.VerseId, Is.Not.Empty);
|
|
Assert.That(provider.Selection.ProviderId, Is.Not.Empty);
|
|
Assert.That(provider.Selection.SurfaceId, Is.Not.Empty);
|
|
runtime = EveUnityPlayableWorldRuntime.CreateForGameObjectScene(
|
|
root.transform,
|
|
provider,
|
|
provider,
|
|
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.");
|
|
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;
|
|
|
|
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(
|
|
tradeCommandReceipt.State,
|
|
Is.EqualTo("accepted").Or.EqualTo("reconciled"),
|
|
tradeCommandReceipt.Message);
|
|
tradeReceipt = WitnessReceipt.From("trade", tradeCommandReceipt);
|
|
|
|
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(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 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 &&
|
|
(!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(commandReceipts.TryGetValue(focus.CommandId, out var focusCommandReceipt), Is.True);
|
|
Assert.That(
|
|
focusCommandReceipt.State,
|
|
Is.EqualTo("accepted").Or.EqualTo("reconciled"),
|
|
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;
|
|
light.intensity = 1.4f;
|
|
lightObject.transform.rotation = Quaternion.Euler(45f, -35f, 0f);
|
|
var camera = cameraObject.AddComponent<Camera>();
|
|
camera.clearFlags = CameraClearFlags.SolidColor;
|
|
camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f);
|
|
foreach (var entityMarker in root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>())
|
|
{
|
|
foreach (var renderer in entityMarker.GetComponentsInChildren<Renderer>())
|
|
{
|
|
renderer.material.color = entityMarker.Controllable
|
|
? new Color(0.18f, 0.75f, 1f)
|
|
: entityMarker.EntityKind == "enemy"
|
|
? new Color(1f, 0.25f, 0.2f)
|
|
: new Color(1f, 0.78f, 0.15f);
|
|
}
|
|
}
|
|
var playerMarker = FindMarker(root, playerId);
|
|
var playerRenderers = playerMarker.GetComponentsInChildren<Renderer>();
|
|
Assert.That(playerRenderers, Is.Not.Empty, "The provider-authored player prefab has no renderable visual.");
|
|
var playerBounds = playerRenderers[0].bounds;
|
|
foreach (var renderer in playerRenderers)
|
|
playerBounds.Encapsulate(renderer.bounds);
|
|
var halfFovRadians = camera.fieldOfView * 0.5f * Mathf.Deg2Rad;
|
|
var cameraDistance = Mathf.Max(8f, playerBounds.extents.magnitude / Mathf.Tan(halfFovRadians) * 1.35f);
|
|
camera.nearClipPlane = Mathf.Max(0.01f, cameraDistance * 0.001f);
|
|
camera.farClipPlane = Mathf.Max(1000f, cameraDistance + playerBounds.size.magnitude * 4f);
|
|
camera.transform.position = playerBounds.center + new Vector3(1f, 0.65f, -1.2f).normalized * cameraDistance;
|
|
camera.transform.LookAt(playerBounds.center);
|
|
target = new RenderTexture(640, 360, 24, RenderTextureFormat.ARGB32);
|
|
camera.targetTexture = target;
|
|
yield return null;
|
|
camera.Render();
|
|
RenderTexture.active = target;
|
|
pixels = new Texture2D(640, 360, TextureFormat.RGB24, false);
|
|
pixels.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
|
|
pixels.Apply();
|
|
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(capturePath)));
|
|
File.WriteAllBytes(capturePath, pixels.EncodeToPNG());
|
|
Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(1024));
|
|
WriteWitnessFacts(
|
|
initialVersion,
|
|
runtime.ActiveVersion,
|
|
movementDistance,
|
|
movementReceipt,
|
|
focusReceipt,
|
|
actionReceipt,
|
|
tradeReceipt,
|
|
combatActionId,
|
|
combatShot,
|
|
collectedPickupId,
|
|
pickupCollected,
|
|
cargoQuantityBeforePickup,
|
|
cargoQuantityAfterPickup,
|
|
docked,
|
|
undocked);
|
|
}
|
|
finally
|
|
{
|
|
RenderTexture.active = null;
|
|
var camera = cameraObject.GetComponent<Camera>();
|
|
if (camera != null) camera.targetTexture = null;
|
|
if (target != null) UnityEngine.Object.DestroyImmediate(target);
|
|
if (pixels != null) UnityEngine.Object.DestroyImmediate(pixels);
|
|
runtime?.Dispose();
|
|
UnityEngine.Object.DestroyImmediate(lightObject);
|
|
UnityEngine.Object.DestroyImmediate(cameraObject);
|
|
UnityEngine.Object.DestroyImmediate(root);
|
|
}
|
|
}
|
|
|
|
private static void WriteWitnessFacts(
|
|
long initialVersion,
|
|
long finalVersion,
|
|
float movementDistance,
|
|
WitnessReceipt movement,
|
|
WitnessReceipt targeting,
|
|
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;
|
|
var document = new WitnessFacts
|
|
{
|
|
providerAdvertisement = true,
|
|
providerAssets = true,
|
|
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, trade }
|
|
};
|
|
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path)));
|
|
File.WriteAllText(path, JsonUtility.ToJson(document, true));
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class WitnessFacts
|
|
{
|
|
public bool providerAdvertisement;
|
|
public bool providerAssets;
|
|
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
|
|
{
|
|
public string commandKind;
|
|
public string commandId;
|
|
public string state;
|
|
public long sourceVersion;
|
|
|
|
public static WitnessReceipt From(string commandKind, EveUnitySceneCommandReceipt receipt)
|
|
{
|
|
return new WitnessReceipt
|
|
{
|
|
commandKind = commandKind,
|
|
commandId = receipt.CommandId,
|
|
state = receipt.State,
|
|
sourceVersion = receipt.SourceVersion
|
|
};
|
|
}
|
|
}
|
|
|
|
private static EveUnityPlayableWorldEntityMarker FindMarker(
|
|
GameObject root,
|
|
string entityId,
|
|
bool includeInactive = false)
|
|
{
|
|
foreach (var marker in root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>(includeInactive))
|
|
{
|
|
if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal))
|
|
return marker;
|
|
}
|
|
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[]
|
|
{
|
|
Entity("player-node", "player", "player", "Vanguard", "0,0,0", "1.2", true),
|
|
Entity("enemy-node", "enemy", "enemy", "Raider", "3,0,4", "1", false),
|
|
Entity("landmark-node", "landmark", "landmark", "Beacon", "-4,0,6", "1.8", false)
|
|
};
|
|
var document = new EveSurfaceDocument(
|
|
"eve.world-smoke",
|
|
"game.runtime",
|
|
"Generic interactive world",
|
|
1,
|
|
"2026-07-10T00:00:00Z",
|
|
new EveSurfaceTree(
|
|
"eve.world-smoke.surface",
|
|
new EveSurfaceComponent(
|
|
"world.root",
|
|
"surface",
|
|
Props(),
|
|
new[]
|
|
{
|
|
new EveSurfaceComponent(
|
|
"world.scene",
|
|
"world.scene3d",
|
|
Props(
|
|
("playerEntityId", "player"),
|
|
("movementCommand", "world.move"),
|
|
("inputProfile", "third-person"),
|
|
("cameraRig", "third-person-orbit")),
|
|
entities)
|
|
}),
|
|
Array.Empty<EveStyleToken>()),
|
|
Array.Empty<EveCommandTemplate>());
|
|
return new EveUnitySceneProviderSurfaceDocument(
|
|
document,
|
|
new EveUnitySceneProviderSurfaceAdvertisement(
|
|
"eve.world-smoke.surface",
|
|
"interactive-world",
|
|
new EveUnitySceneWorldInteraction(
|
|
"provider-authored-world-surface",
|
|
"world.commands",
|
|
"gamecult.eve.command_receipt.v1",
|
|
"provider-owns-world-state-command-acceptance-and-receipts")),
|
|
"cultmesh://eve.world-smoke/surfaces/world",
|
|
1);
|
|
}
|
|
|
|
private static EveSurfaceComponent Entity(
|
|
string nodeId,
|
|
string entityId,
|
|
string entityKind,
|
|
string label,
|
|
string position,
|
|
string radius,
|
|
bool controllable)
|
|
{
|
|
return new EveSurfaceComponent(
|
|
nodeId,
|
|
"world.entity3d",
|
|
Props(
|
|
("entityId", entityId),
|
|
("entityKind", entityKind),
|
|
("label", label),
|
|
("position", position),
|
|
("radius", radius),
|
|
("assetRef", $"cultmesh://eve.world-smoke/assets/{entityKind}"),
|
|
("selectable", "true"),
|
|
("controllable", controllable ? "true" : "false"),
|
|
("moveCommand", controllable ? "world.move" : "")),
|
|
Array.Empty<EveSurfaceComponent>());
|
|
}
|
|
|
|
private static Dictionary<string, string> Props(params (string Key, string Value)[] values)
|
|
{
|
|
var props = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var value in values) props[value.Key] = value.Value;
|
|
return props;
|
|
}
|
|
|
|
private sealed class WorldSmokeProvider : MonoBehaviour,
|
|
IEveUnitySceneProviderSurfaceDocumentSource,
|
|
IEveUnitySceneCommandSink
|
|
{
|
|
public EveUnitySceneProviderSurfaceDocument CurrentDocument { get; set; }
|
|
public string SinkKind => "world-smoke-command-sink";
|
|
public List<EveSurfaceCommandRequest> Submitted { get; } = new List<EveSurfaceCommandRequest>();
|
|
public event Action<EveUnitySceneProviderSurfaceDocument> DocumentAvailable;
|
|
public void Submit(EveSurfaceCommandRequest request) => Submitted.Add(request);
|
|
}
|
|
}
|
|
}
|