2026-07-10 03:34:56 +01:00
using System ;
using System.Collections ;
using System.Collections.Generic ;
using System.IO ;
2026-07-14 03:59:45 +01:00
using System.Linq ;
2026-07-10 04:03:09 +01:00
using GameCult.Caching ;
using GameCult.Caching.MessagePack ;
2026-07-10 03:34:56 +01:00
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
{
2026-07-10 04:03:09 +01:00
[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" ) ) ;
}
2026-07-10 03:34:56 +01:00
[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 ) ;
}
}
2026-07-10 04:39:16 +01:00
[UnityTest]
2026-07-10 06:37:33 +01:00
[Timeout(300000)]
2026-07-10 04:39:16 +01:00
public IEnumerator GenericCultMeshClientLowersAndMovesAdvertisedWorld ( )
{
2026-07-10 11:04:26 +01:00
var rendezvousEndpoint = Environment . GetEnvironmentVariable ( "EVEUNITY_RENDEZVOUS_ENDPOINT" ) ;
if ( string . IsNullOrWhiteSpace ( rendezvousEndpoint ) )
Assert . Ignore ( "EVEUNITY_RENDEZVOUS_ENDPOINT is not configured for the live provider witness." ) ;
2026-07-10 04:39:16 +01:00
2026-07-10 11:04:26 +01:00
var providerId = Environment . GetEnvironmentVariable ( "EVEUNITY_PROVIDER_ID" ) ? ? "" ;
var surfaceId = Environment . GetEnvironmentVariable ( "EVEUNITY_SURFACE_ID" ) ? ? "" ;
2026-07-10 04:39:16 +01:00
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" ) ;
2026-07-14 03:59:45 +01:00
var mapCapturePath = Environment . GetEnvironmentVariable ( "EVEUNITY_AETHERIA_MAP_CAPTURE_PATH" ) ? ?
Path . Combine ( Application . temporaryCachePath , "aetheria-daemon-map.png" ) ;
2026-07-10 04:39:16 +01:00
var root = new GameObject ( "Generic Eve World Projection" ) ;
var cameraObject = new GameObject ( "Generic Eve Capture Camera" ) ;
2026-07-14 03:59:45 +01:00
var mapCameraObject = new GameObject ( "Generic Eve Map Camera" ) ;
2026-07-10 04:39:16 +01:00
var lightObject = new GameObject ( "Generic Eve World Light" ) ;
RenderTexture target = null ;
Texture2D pixels = null ;
2026-07-10 14:13:53 +01:00
EveUnityCultMeshPlayableWorldProvider provider = null ;
2026-07-14 03:59:45 +01:00
EveUnityPlayableWorldClientHost host = null ;
2026-07-10 04:39:16 +01:00
EveUnityPlayableWorldRuntime runtime = null ;
2026-07-10 07:14:44 +01:00
WitnessReceipt movementReceipt = null ;
WitnessReceipt focusReceipt = null ;
WitnessReceipt actionReceipt = null ;
2026-07-15 03:21:28 +01:00
EveUnityShotReceipt observedShot = null ;
EveUnityCombatPresentation observedCombat = null ;
float shieldRatioBefore = 0f ;
float hullRatioBefore = 0f ;
2026-07-10 07:14:44 +01:00
long initialVersion = 0 ;
float movementDistance = 0f ;
2026-07-10 04:39:16 +01:00
try
{
2026-07-10 14:13:53 +01:00
provider = root . AddComponent < EveUnityCultMeshPlayableWorldProvider > ( ) ;
provider . Configure (
2026-07-10 11:04:26 +01:00
rendezvousEndpoint ,
2026-07-10 14:13:53 +01:00
replicaPath ,
2026-07-10 11:04:26 +01:00
providerId ,
surfaceId ,
2026-07-10 14:13:53 +01:00
requiredSurfaceKind : "interactive-world" ,
clientRuntimeId : $"eve-unity-test-{Guid.NewGuid():N}" ) ;
2026-07-10 04:39:16 +01:00
var publicationDeadline = Time . realtimeSinceStartup + 10f ;
while ( true )
{
var published = false ;
try
{
2026-07-10 14:13:53 +01:00
provider . Refresh ( ) ;
2026-07-10 04:39:16 +01:00
published = true ;
}
catch ( InvalidOperationException ) when ( Time . realtimeSinceStartup < publicationDeadline )
{
}
if ( published ) break ;
yield return new WaitForSecondsRealtime ( 0.1f ) ;
}
2026-07-10 14:13:53 +01:00
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 ) ;
2026-07-14 03:59:45 +01:00
host = root . AddComponent < EveUnityPlayableWorldClientHost > ( ) ;
host . Configure (
2026-07-10 05:05:27 +01:00
root . transform ,
2026-07-10 14:13:53 +01:00
provider ,
provider ,
2026-07-14 03:59:45 +01:00
provider ,
2026-07-10 14:13:53 +01:00
provider ,
provider ) ;
2026-07-14 03:59:45 +01:00
host . Connect ( ) ;
2026-07-15 03:21:28 +01:00
host . ShotAvailable + = shot = > observedShot = shot ;
2026-07-14 03:59:45 +01:00
runtime = host . Runtime ;
Assert . That ( runtime , Is . Not . Null ) ;
2026-07-10 04:39:16 +01:00
Assert . That ( runtime . ActiveWorld . PlayerEntityId , Is . Not . Empty ) ;
2026-07-14 03:59:45 +01:00
var entityDeadline = Time . realtimeSinceStartup + 12f ;
while ( Time . realtimeSinceStartup < entityDeadline & &
( host . PresentedEntities ? . CurrentGeneration = = null | |
host . PresentedEntities . CurrentGeneration . Entities . Count = = 0 ) )
{
yield return new WaitForSecondsRealtime ( 0.1f ) ;
host . Refresh ( ) ;
}
Assert . That ( host . PresentedEntities ? . CurrentGeneration , Is . Not . Null ) ;
Assert . That ( host . PresentedEntities . CurrentGeneration . Entities . Count , Is . GreaterThan ( 0 ) ) ;
2026-07-10 04:39:16 +01:00
var playerId = runtime . ActiveWorld . PlayerEntityId ;
2026-07-14 03:59:45 +01:00
var playerFact = host . PresentedEntities . CurrentGeneration . Entities
. Single ( entity = > entity . EntityId = = playerId ) ;
var playerPrefab = provider . ResolvePrefab ( new EveUnityPlayableWorldAssetBinding (
playerFact . AssetRef ,
playerFact . EntityKind ,
"provider-asset-ref" ) ) ;
Assert . That ( playerPrefab , Is . Not . Null ,
$"Provider asset catalog did not resolve player AssetRef '{playerFact.AssetRef}'." ) ;
2026-07-10 04:39:16 +01:00
var marker = FindMarker ( root , playerId ) ;
2026-07-10 06:00:36 +01:00
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." ) ;
2026-07-10 04:39:16 +01:00
var initialPosition = marker . transform . position ;
2026-07-10 07:14:44 +01:00
initialVersion = runtime . ActiveVersion ;
2026-07-10 04:39:16 +01:00
var request = runtime . SubmitMoveVectorIntent ( playerId , 1f , 0f , 1f ) ;
var deadline = Time . realtimeSinceStartup + 12f ;
while ( Time . realtimeSinceStartup < deadline & &
2026-07-14 16:26:38 +01:00
( runtime . LastReceipt = = null | | runtime . LastReceipt . CommandId ! = request . CommandId | |
! string . Equals ( runtime . LastReceipt . State , "reconciled" , StringComparison . OrdinalIgnoreCase ) | |
runtime . ActiveVersion < = initialVersion | |
2026-07-10 04:39:16 +01:00
Vector3 . Distance ( FindMarker ( root , playerId ) . transform . position , initialPosition ) < 0.01f ) )
{
yield return new WaitForSecondsRealtime ( 0.1f ) ;
runtime . Refresh ( ) ;
}
2026-07-14 16:26:38 +01:00
AssertReconciledProviderReceipt (
runtime . LastReceipt ,
request . CommandId ,
provider . Selection . ProviderId ,
provider . Selection . SurfaceId ,
initialVersion ) ;
2026-07-10 04:39:16 +01:00
Assert . That ( runtime . ActiveVersion , Is . GreaterThan ( initialVersion ) ) ;
2026-07-14 16:26:38 +01:00
Assert . That ( runtime . ActiveVersion , Is . GreaterThanOrEqualTo ( runtime . LastReceipt . SourceVersion ) ) ;
2026-07-10 07:14:44 +01:00
movementDistance = Vector3 . Distance ( FindMarker ( root , playerId ) . transform . position , initialPosition ) ;
Assert . That ( movementDistance , Is . GreaterThan ( 0.01f ) ) ;
movementReceipt = WitnessReceipt . From ( "movement" , runtime . LastReceipt ) ;
2026-07-10 04:39:16 +01:00
2026-07-15 03:21:28 +01:00
var releaseVersion = runtime . ActiveVersion ;
var release = runtime . SubmitMoveVectorIntent ( playerId , 0f , 0f , 0f ) ;
var releaseDeadline = Time . realtimeSinceStartup + 12f ;
while ( Time . realtimeSinceStartup < releaseDeadline & &
( runtime . LastReceipt = = null | | runtime . LastReceipt . CommandId ! = release . CommandId | |
! string . Equals ( runtime . LastReceipt . State , "reconciled" , StringComparison . OrdinalIgnoreCase ) ) )
{
yield return new WaitForSecondsRealtime ( 0.1f ) ;
runtime . Refresh ( ) ;
}
AssertReconciledProviderReceipt ( runtime . LastReceipt , release . CommandId ,
provider . Selection . ProviderId , provider . Selection . SurfaceId , releaseVersion ) ;
2026-07-10 06:37:33 +01:00
var movementVersion = runtime . ActiveVersion ;
2026-07-15 03:21:28 +01:00
var focus = runtime . SubmitFocusIntent ( playerId ) ;
2026-07-10 06:37:33 +01:00
var focusDeadline = Time . realtimeSinceStartup + 12f ;
while ( Time . realtimeSinceStartup < focusDeadline & &
( runtime . LastReceipt = = null | | runtime . LastReceipt . CommandId ! = focus . CommandId | |
2026-07-14 16:26:38 +01:00
! string . Equals ( runtime . LastReceipt . State , "reconciled" , StringComparison . OrdinalIgnoreCase ) | |
2026-07-15 03:21:28 +01:00
runtime . ActiveVersion < runtime . LastReceipt . SourceVersion | |
string . IsNullOrWhiteSpace ( EveUnityCombatPresentation . Find ( runtime . ActiveProjection ) ? . SelectedTargetEntityId ) ) )
2026-07-10 06:37:33 +01:00
{
yield return new WaitForSecondsRealtime ( 0.1f ) ;
runtime . Refresh ( ) ;
}
2026-07-14 16:26:38 +01:00
AssertReconciledProviderReceipt (
runtime . LastReceipt ,
focus . CommandId ,
provider . Selection . ProviderId ,
provider . Selection . SurfaceId ,
movementVersion ) ;
Assert . That ( runtime . ActiveVersion , Is . GreaterThanOrEqualTo ( runtime . LastReceipt . SourceVersion ) ) ;
2026-07-10 07:14:44 +01:00
focusReceipt = WitnessReceipt . From ( "targeting" , runtime . LastReceipt ) ;
2026-07-15 03:21:28 +01:00
observedCombat = EveUnityCombatPresentation . Find ( runtime . ActiveProjection ) ;
Assert . That ( observedCombat , Is . Not . Null ) ;
Assert . That ( observedCombat . TargetVisible , Is . True ) ;
Assert . That ( observedCombat . TargetHostile , Is . True ) ;
var targetEntityId = observedCombat . SelectedTargetEntityId ;
var targetEntityIndex = observedCombat . SelectedTargetEntityIndex ;
Assert . That ( targetEntityId , Is . Not . Empty ) ;
Assert . That ( host . PresentedEntities . CurrentGeneration . Entities . Any ( entity = > entity . EntityId = = targetEntityId ) , Is . True ,
"The provider-selected combat target is absent from the advertised SoA generation." ) ;
shieldRatioBefore = observedCombat . ShieldRatio ;
hullRatioBefore = observedCombat . HullRatio ;
var targetFact = host . PresentedEntities . CurrentGeneration . Entities
. Single ( entity = > entity . EntityId = = targetEntityId ) ;
playerFact = host . PresentedEntities . CurrentGeneration . Entities
. Single ( entity = > entity . EntityId = = playerId ) ;
var aim = targetFact . Position - playerFact . Position ;
aim . y = 0 ;
Assert . That ( aim . sqrMagnitude , Is . GreaterThan ( 0.0001f ) ) ;
aim . Normalize ( ) ;
var steerVersion = runtime . ActiveVersion ;
var steer = runtime . SubmitMoveVectorIntent ( playerId , aim . x , aim . z , 0.25f ) ;
var steerDeadline = Time . realtimeSinceStartup + 12f ;
while ( Time . realtimeSinceStartup < steerDeadline & &
( runtime . LastReceipt = = null | | runtime . LastReceipt . CommandId ! = steer . CommandId | |
! string . Equals ( runtime . LastReceipt . State , "reconciled" , StringComparison . OrdinalIgnoreCase ) ) )
{
yield return new WaitForSecondsRealtime ( 0.1f ) ;
runtime . Refresh ( ) ;
}
AssertReconciledProviderReceipt ( runtime . LastReceipt , steer . CommandId ,
provider . Selection . ProviderId , provider . Selection . SurfaceId , steerVersion ) ;
2026-07-10 06:37:33 +01:00
var focusVersion = runtime . ActiveVersion ;
2026-07-15 03:21:28 +01:00
var inputDriver = root . GetComponent < EveUnityPlayableWorldInputDriver > ( ) ? ?
root . AddComponent < EveUnityPlayableWorldInputDriver > ( ) ;
inputDriver . Host = host ;
var action = inputDriver . SubmitPrimaryAction ( ) ;
Assert . That ( action , Is . Not . Null ,
"The advertised input capability did not bind a primary action for this generic client." ) ;
2026-07-10 06:37:33 +01:00
var actionDeadline = Time . realtimeSinceStartup + 12f ;
while ( Time . realtimeSinceStartup < actionDeadline & &
2026-07-15 03:21:28 +01:00
( runtime . LastReceipt = = null | | runtime . LastReceipt . CommandId ! = action ! . CommandId | |
2026-07-14 16:26:38 +01:00
! string . Equals ( runtime . LastReceipt . State , "reconciled" , StringComparison . OrdinalIgnoreCase ) | |
2026-07-10 06:37:33 +01:00
runtime . ActiveVersion < runtime . LastReceipt . SourceVersion ) )
{
yield return new WaitForSecondsRealtime ( 0.1f ) ;
runtime . Refresh ( ) ;
}
2026-07-14 16:26:38 +01:00
AssertReconciledProviderReceipt (
runtime . LastReceipt ,
2026-07-15 03:21:28 +01:00
action ! . CommandId ,
2026-07-14 16:26:38 +01:00
provider . Selection . ProviderId ,
provider . Selection . SurfaceId ,
focusVersion ) ;
2026-07-10 06:37:33 +01:00
Assert . That ( runtime . ActiveVersion , Is . GreaterThanOrEqualTo ( runtime . LastReceipt . SourceVersion ) ) ;
2026-07-10 07:14:44 +01:00
actionReceipt = WitnessReceipt . From ( "action" , runtime . LastReceipt ) ;
2026-07-10 06:37:33 +01:00
2026-07-15 03:21:28 +01:00
var shotDeadline = Time . realtimeSinceStartup + 15f ;
while ( Time . realtimeSinceStartup < shotDeadline & & observedShot = = null )
{
yield return new WaitForSecondsRealtime ( 0.05f ) ;
runtime . Refresh ( ) ;
}
Assert . That ( observedShot , Is . Not . Null ,
"A reconciled fire request never reached daemon-owned lock acquisition and shot resolution." ) ;
observedCombat = EveUnityCombatPresentation . Find ( runtime . ActiveProjection ) ;
Assert . That ( observedShot . TargetEntityIndex , Is . EqualTo ( targetEntityIndex ) ) ;
Assert . That ( observedShot . LockQuality , Is . GreaterThan ( 0.99 ) ) ;
var trajectories = root . GetComponent < EveUnityShotTrajectoryRenderer > ( ) ;
var combatRenderer = root . GetComponent < EveUnityCombatPresentationRenderer > ( ) ;
Assert . That ( trajectories , Is . Not . Null ) ;
Assert . That ( trajectories . ActiveTrajectoryCount , Is . GreaterThan ( 0 ) ) ;
Assert . That ( combatRenderer , Is . Not . Null ) ;
combatRenderer . RefreshNow ( ) ;
if ( observedCombat ! = null & & ! string . IsNullOrWhiteSpace ( observedCombat . SelectedTargetEntityId ) )
{
Assert . That ( combatRenderer . ReticleVisible , Is . True ) ;
Assert . That ( combatRenderer . LockVisible , Is . True ) ;
}
if ( observedShot . Hit & & ( observedShot . AppliedDamage > 0 | | observedShot . ShieldAbsorbedDamage > 0 ) )
Assert . That ( combatRenderer . HitMarkerVisible , Is . True ) ;
2026-07-10 04:39:16 +01:00
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 ) ;
2026-07-14 03:59:45 +01:00
camera . cullingMask = - 1 ;
2026-07-10 05:05:27 +01:00
var playerMarker = FindMarker ( root , playerId ) ;
2026-07-10 06:37:33 +01:00
var playerRenderers = playerMarker . GetComponentsInChildren < Renderer > ( ) ;
Assert . That ( playerRenderers , Is . Not . Empty , "The provider-authored player prefab has no renderable visual." ) ;
2026-07-14 03:59:45 +01:00
Assert . That ( provider . TryGetRenderChannelLayer ( "map" , out var mapLayer ) , Is . True ) ;
var mapRenderers = new List < Renderer > ( ) ;
foreach ( var renderer in root . GetComponentsInChildren < Renderer > ( includeInactive : true ) )
{
if ( renderer . gameObject . layer = = mapLayer )
mapRenderers . Add ( renderer ) ;
}
Assert . That ( mapRenderers , Is . Not . Empty , "The live provider prefab has no renderer assigned to its advertised map channel." ) ;
var cameraRig = cameraObject . AddComponent < EveUnityPlayableWorldCameraRig > ( ) ;
cameraRig . Host = host ;
cameraRig . CameraTransform = camera . transform ;
cameraRig . RenderPolicySource = provider ;
Assert . That ( cameraRig . ApplyRig ( 0f ) , Is . True ) ;
Assert . That ( camera . cullingMask & ( 1 < < mapLayer ) , Is . Zero ) ;
var mapCamera = mapCameraObject . AddComponent < Camera > ( ) ;
mapCamera . cullingMask = 1 < < mapLayer ;
2026-07-14 16:26:38 +01:00
Assert . That ( mapCamera . cullingMask , Is . EqualTo ( 1 < < mapLayer ) ) ;
2026-07-10 04:39:16 +01:00
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 ( ) ;
2026-07-14 16:26:38 +01:00
var pilotChangedPixels = CountChangedPixels ( pixels , camera . backgroundColor ) ;
Assert . That ( pilotChangedPixels , Is . GreaterThan ( 25 ) , "The pilot camera rendered no provider-authored world pixels." ) ;
2026-07-10 04:39:16 +01:00
Directory . CreateDirectory ( Path . GetDirectoryName ( Path . GetFullPath ( capturePath ) ) ) ;
File . WriteAllBytes ( capturePath , pixels . EncodeToPNG ( ) ) ;
Assert . That ( new FileInfo ( capturePath ) . Length , Is . GreaterThan ( 1024 ) ) ;
2026-07-14 03:59:45 +01:00
2026-07-15 03:21:28 +01:00
mapRenderers = mapRenderers . Where ( renderer = > renderer ! = null ) . ToList ( ) ;
Assert . That ( mapRenderers , Is . Not . Empty ,
"All advertised map-channel renderers were destroyed before map capture." ) ;
2026-07-14 03:59:45 +01:00
var mapBounds = mapRenderers [ 0 ] . bounds ;
foreach ( var renderer in mapRenderers )
mapBounds . Encapsulate ( renderer . bounds ) ;
mapCamera . clearFlags = CameraClearFlags . SolidColor ;
mapCamera . backgroundColor = camera . backgroundColor ;
mapCamera . orthographic = true ;
mapCamera . orthographicSize = Mathf . Max ( 0.1f , mapBounds . extents . x , mapBounds . extents . z ) * 1.25f ;
mapCamera . nearClipPlane = 0.01f ;
mapCamera . farClipPlane = Mathf . Max ( 10f , mapBounds . size . magnitude * 4f ) ;
mapCamera . transform . position = mapBounds . center + Vector3 . up * ( mapCamera . farClipPlane * 0.25f ) ;
mapCamera . transform . rotation = Quaternion . Euler ( 90f , 0f , 0f ) ;
mapCamera . targetTexture = target ;
mapCamera . Render ( ) ;
RenderTexture . active = target ;
pixels . ReadPixels ( new Rect ( 0 , 0 , 640 , 360 ) , 0 , 0 ) ;
pixels . Apply ( ) ;
var mapChangedPixels = CountChangedPixels ( pixels , mapCamera . backgroundColor ) ;
Assert . That ( mapChangedPixels , Is . GreaterThan ( 25 ) , "The map-only camera rendered no provider-authored map glyph pixels." ) ;
Directory . CreateDirectory ( Path . GetDirectoryName ( Path . GetFullPath ( mapCapturePath ) ) ) ;
File . WriteAllBytes ( mapCapturePath , pixels . EncodeToPNG ( ) ) ;
Assert . That ( new FileInfo ( mapCapturePath ) . Length , Is . GreaterThan ( 1024 ) ) ;
2026-07-10 07:14:44 +01:00
WriteWitnessFacts (
initialVersion ,
runtime . ActiveVersion ,
movementDistance ,
2026-07-14 16:26:38 +01:00
playerRenderers . Length ,
pilotChangedPixels ,
2026-07-14 03:59:45 +01:00
mapRenderers . Count ,
mapChangedPixels ,
( camera . cullingMask & ( 1 < < mapLayer ) ) = = 0 ,
( mapCamera . cullingMask & ( 1 < < mapLayer ) ) ! = 0 ,
2026-07-10 07:14:44 +01:00
movementReceipt ,
focusReceipt ,
2026-07-15 03:21:28 +01:00
actionReceipt ,
observedShot ,
shieldRatioBefore ,
observedCombat ? . ShieldRatio ? ? shieldRatioBefore ,
hullRatioBefore ,
observedCombat ? . HullRatio ? ? hullRatioBefore ,
( float ) observedShot . LockQuality ) ;
2026-07-10 04:39:16 +01:00
}
finally
{
RenderTexture . active = null ;
var camera = cameraObject . GetComponent < Camera > ( ) ;
if ( camera ! = null ) camera . targetTexture = null ;
2026-07-14 03:59:45 +01:00
var mapCamera = mapCameraObject . GetComponent < Camera > ( ) ;
if ( mapCamera ! = null ) mapCamera . targetTexture = null ;
2026-07-10 04:39:16 +01:00
if ( target ! = null ) UnityEngine . Object . DestroyImmediate ( target ) ;
if ( pixels ! = null ) UnityEngine . Object . DestroyImmediate ( pixels ) ;
2026-07-14 03:59:45 +01:00
host ? . Disconnect ( ) ;
2026-07-10 04:39:16 +01:00
UnityEngine . Object . DestroyImmediate ( lightObject ) ;
2026-07-14 03:59:45 +01:00
UnityEngine . Object . DestroyImmediate ( mapCameraObject ) ;
2026-07-10 04:39:16 +01:00
UnityEngine . Object . DestroyImmediate ( cameraObject ) ;
UnityEngine . Object . DestroyImmediate ( root ) ;
}
}
2026-07-10 07:14:44 +01:00
private static void WriteWitnessFacts (
long initialVersion ,
long finalVersion ,
float movementDistance ,
2026-07-14 16:26:38 +01:00
int playerRendererCount ,
int pilotChangedPixels ,
2026-07-14 03:59:45 +01:00
int mapChannelRendererCount ,
int mapChangedPixels ,
bool pilotCameraExcludesMapChannel ,
bool mapCameraIncludesMapChannel ,
2026-07-10 07:14:44 +01:00
WitnessReceipt movement ,
WitnessReceipt targeting ,
2026-07-15 03:21:28 +01:00
WitnessReceipt action ,
EveUnityShotReceipt shot ,
float shieldRatioBefore ,
float shieldRatioAfter ,
float hullRatioBefore ,
float hullRatioAfter ,
float lockProgress )
2026-07-10 07:14:44 +01:00
{
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 ,
2026-07-15 03:21:28 +01:00
combatPresentation = shot ! = null ,
shotId = shot ? . ShotId ? ? "" ,
shotHit = shot ? . Hit ? ? false ,
impactKind = shot ? . ImpactKind ? ? "" ,
appliedDamage = ( float ) ( shot ? . AppliedDamage ? ? 0 ) ,
shieldAbsorbedDamage = ( float ) ( shot ? . ShieldAbsorbedDamage ? ? 0 ) ,
shieldRatioBefore = shieldRatioBefore ,
shieldRatioAfter = shieldRatioAfter ,
hullRatioBefore = hullRatioBefore ,
hullRatioAfter = hullRatioAfter ,
lockProgress = lockProgress ,
2026-07-10 07:14:44 +01:00
initialVersion = initialVersion ,
finalVersion = finalVersion ,
movementDistance = movementDistance ,
2026-07-14 16:26:38 +01:00
playerRendererCount = playerRendererCount ,
pilotChangedPixels = pilotChangedPixels ,
2026-07-14 03:59:45 +01:00
mapChannelRendererCount = mapChannelRendererCount ,
mapChangedPixels = mapChangedPixels ,
pilotCameraExcludesMapChannel = pilotCameraExcludesMapChannel ,
mapCameraIncludesMapChannel = mapCameraIncludesMapChannel ,
2026-07-10 07:14:44 +01:00
receipts = new [ ] { movement , targeting , action }
} ;
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 ;
2026-07-15 03:21:28 +01:00
public bool combatPresentation ;
public string shotId ;
public bool shotHit ;
public string impactKind ;
public float appliedDamage ;
public float shieldAbsorbedDamage ;
public float shieldRatioBefore ;
public float shieldRatioAfter ;
public float hullRatioBefore ;
public float hullRatioAfter ;
public float lockProgress ;
2026-07-10 07:14:44 +01:00
public long initialVersion ;
public long finalVersion ;
public float movementDistance ;
2026-07-14 16:26:38 +01:00
public int playerRendererCount ;
public int pilotChangedPixels ;
2026-07-14 03:59:45 +01:00
public int mapChannelRendererCount ;
public int mapChangedPixels ;
public bool pilotCameraExcludesMapChannel ;
public bool mapCameraIncludesMapChannel ;
2026-07-10 07:14:44 +01:00
public WitnessReceipt [ ] receipts ;
}
[Serializable]
private sealed class WitnessReceipt
{
public string commandKind ;
public string commandId ;
public string state ;
2026-07-14 16:26:38 +01:00
public string receiptId ;
public string ownerRepo ;
public string authority ;
public string providerId ;
public string surfaceId ;
public bool isProviderOwned ;
2026-07-10 07:14:44 +01:00
public long sourceVersion ;
public static WitnessReceipt From ( string commandKind , EveUnitySceneCommandReceipt receipt )
{
return new WitnessReceipt
{
commandKind = commandKind ,
commandId = receipt . CommandId ,
state = receipt . State ,
2026-07-14 16:26:38 +01:00
receiptId = receipt . ReceiptId ,
ownerRepo = receipt . OwnerRepo ,
authority = receipt . Authority ,
providerId = receipt . ProviderId ,
surfaceId = receipt . SurfaceId ,
isProviderOwned = receipt . IsProviderOwned ,
2026-07-10 07:14:44 +01:00
sourceVersion = receipt . SourceVersion
} ;
}
}
2026-07-14 16:26:38 +01:00
private static void AssertReconciledProviderReceipt (
EveUnitySceneCommandReceipt receipt ,
string commandId ,
string providerId ,
string surfaceId ,
long minimumSourceVersion )
{
Assert . That ( receipt , Is . Not . Null ) ;
Assert . That ( receipt . CommandId , Is . EqualTo ( commandId ) ) ;
Assert . That ( receipt . State , Is . EqualTo ( "reconciled" ) , receipt . Message ) ;
Assert . That ( receipt . IsProviderOwned , Is . True ) ;
Assert . That ( receipt . ProviderId , Is . EqualTo ( providerId ) ) ;
Assert . That ( receipt . SurfaceId , Is . EqualTo ( surfaceId ) ) ;
Assert . That ( receipt . ReceiptId , Is . Not . Empty ) ;
Assert . That ( receipt . OwnerRepo , Is . Not . Empty ) ;
Assert . That ( receipt . Authority , Is . Not . Empty ) ;
Assert . That ( receipt . SourceVersion , Is . GreaterThan ( minimumSourceVersion ) ) ;
}
2026-07-10 04:39:16 +01:00
private static EveUnityPlayableWorldEntityMarker FindMarker ( GameObject root , string entityId )
{
foreach ( var marker in root . GetComponentsInChildren < EveUnityPlayableWorldEntityMarker > ( ) )
{
if ( string . Equals ( marker . EntityId , entityId , StringComparison . Ordinal ) )
return marker ;
}
throw new InvalidOperationException ( $"Projected world has no entity marker '{entityId}'." ) ;
}
2026-07-14 03:59:45 +01:00
private static int CountChangedPixels ( Texture2D texture , Color background )
{
var changed = 0 ;
foreach ( var pixel in texture . GetPixels ( ) )
{
if ( Mathf . Abs ( pixel . r - background . r ) +
Mathf . Abs ( pixel . g - background . g ) +
Mathf . Abs ( pixel . b - background . b ) > 0.08f )
changed + + ;
}
return changed ;
}
2026-07-10 03:34:56 +01:00
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 ) ;
}
}
}