diff --git a/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab b/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab index 431b3c1a2..5d4f837d1 100644 --- a/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab @@ -23,6 +23,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 4094535758000554889} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -30,7 +31,6 @@ Transform: m_Children: - {fileID: 9133920759869389018} m_Father: {fileID: 8489385781787202308} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &5284461483464579928 GameObject: @@ -44,7 +44,6 @@ GameObject: - component: {fileID: 2667259809042191123} - component: {fileID: 2093307059618178533} - component: {fileID: 8815906420840494942} - - component: {fileID: 8842807112802394818} - component: {fileID: 8689774408369415534} - component: {fileID: 5705448000916375656} - component: {fileID: -5496574226213385675} @@ -62,6 +61,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 5284461483464579928} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 2.76403, y: 0, z: 0} m_LocalScale: {x: 3, y: 3, z: 3} @@ -69,7 +69,6 @@ Transform: m_Children: - {fileID: 6463386671854692106} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!33 &2667259809042191123 MeshFilter: @@ -96,6 +95,11 @@ MeshRenderer: m_ReflectionProbeUsage: 1 m_RayTracingMode: 2 m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -118,9 +122,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!135 &8815906420840494942 SphereCollider: @@ -130,29 +136,19 @@ SphereCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 5284461483464579928} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Radius: 0.91187096 m_Center: {x: 0, y: 0, z: 0} ---- !u!114 &8842807112802394818 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 5284461483464579928} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 737f47c158214f0995ba2835f040ed96, type: 3} - m_Name: - m_EditorClassIdentifier: - RotationSpeed: 1 - GridOffset: 0 - GridAttraction: 10 - Gravity: 1 - Drag: 0.05 - LaunchDrag: 0.2 --- !u!54 &8689774408369415534 Rigidbody: m_ObjectHideFlags: 0 @@ -160,10 +156,21 @@ Rigidbody: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 5284461483464579928} - serializedVersion: 2 + serializedVersion: 5 m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 m_UseGravity: 0 m_IsKinematic: 1 m_Interpolate: 0 @@ -181,6 +188,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0c18f67d85af44c2b5afc8ecd15ce960, type: 3} m_Name: m_EditorClassIdentifier: + RotationSpeed: 1 + GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 @@ -204,6 +213,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 6463386671854692106} m_Modifications: - target: {fileID: 2945231343692618236, guid: e7877477dc889804ea9d04f7ffd08e9b, @@ -327,6 +337,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: e7877477dc889804ea9d04f7ffd08e9b, type: 3} --- !u!114 &3355762817961458026 stripped MonoBehaviour: diff --git a/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab b/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab index fea9f67a9..91f1c2ad3 100644 --- a/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab @@ -23,6 +23,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 3193517496555256095} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -30,7 +31,6 @@ Transform: m_Children: - {fileID: 7638477100053754924} m_Father: {fileID: 3419130779256825702} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &7643266616531142933 GameObject: @@ -44,7 +44,6 @@ GameObject: - component: {fileID: 3269639735116279291} - component: {fileID: 7427774873968414741} - component: {fileID: 674324171579366365} - - component: {fileID: 7449398549621940518} - component: {fileID: 4507103896627202189} - component: {fileID: 648083621841515580} - component: {fileID: -5443764149998352794} @@ -62,6 +61,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7643266616531142933} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 3, y: 3, z: 3} @@ -69,7 +69,6 @@ Transform: m_Children: - {fileID: 3601651121922051457} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!33 &3269639735116279291 MeshFilter: @@ -96,6 +95,11 @@ MeshRenderer: m_ReflectionProbeUsage: 1 m_RayTracingMode: 2 m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -118,9 +122,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!135 &674324171579366365 SphereCollider: @@ -130,29 +136,19 @@ SphereCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7643266616531142933} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Radius: 0.8 m_Center: {x: 0, y: 0, z: 0} ---- !u!114 &7449398549621940518 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 7643266616531142933} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 737f47c158214f0995ba2835f040ed96, type: 3} - m_Name: - m_EditorClassIdentifier: - RotationSpeed: 1 - GridOffset: 0 - GridAttraction: 10 - Gravity: 1 - Drag: 0.05 - LaunchDrag: 0.2 --- !u!114 &4507103896627202189 MonoBehaviour: m_ObjectHideFlags: 0 @@ -165,6 +161,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0c18f67d85af44c2b5afc8ecd15ce960, type: 3} m_Name: m_EditorClassIdentifier: + RotationSpeed: 1 + GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 @@ -178,10 +176,21 @@ Rigidbody: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7643266616531142933} - serializedVersion: 2 + serializedVersion: 5 m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 m_UseGravity: 0 m_IsKinematic: 1 m_Interpolate: 0 @@ -204,6 +213,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 3601651121922051457} m_Modifications: - target: {fileID: 2945231343692618236, guid: e7877477dc889804ea9d04f7ffd08e9b, @@ -327,6 +337,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: e7877477dc889804ea9d04f7ffd08e9b, type: 3} --- !u!114 &4202270197902620060 stripped MonoBehaviour: diff --git a/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab b/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab index e99ae3392..95973ba56 100644 --- a/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab @@ -23,6 +23,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 6430764275721745537} + serializedVersion: 2 m_LocalRotation: {x: 0.7069834, y: -0, z: -0, w: 0.7072302} m_LocalPosition: {x: 0, y: -0.9858858, z: 0.000344038} m_LocalScale: {x: 1, y: 1, z: 1} @@ -30,7 +31,6 @@ Transform: m_Children: - {fileID: 5373456011921669618} m_Father: {fileID: 7548484292876150345} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &7167547457198401877 GameObject: @@ -44,7 +44,6 @@ GameObject: - component: {fileID: 7390838957595368880} - component: {fileID: 6960669510133879758} - component: {fileID: 3507902894264904200} - - component: {fileID: 8747099621875196907} - component: {fileID: 1545255963217739444} - component: {fileID: -5409019571952974707} - component: {fileID: -2501508196100734606} @@ -62,6 +61,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7167547457198401877} + serializedVersion: 2 m_LocalRotation: {x: -0.7069834, y: 0, z: 0, w: 0.7072302} m_LocalPosition: {x: -0, y: 0, z: -2.9576573} m_LocalScale: {x: 3, y: 3, z: 3} @@ -69,7 +69,6 @@ Transform: m_Children: - {fileID: 4002800527770282731} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: -89.98, y: 0, z: 0} --- !u!33 &7390838957595368880 MeshFilter: @@ -96,6 +95,11 @@ MeshRenderer: m_ReflectionProbeUsage: 1 m_RayTracingMode: 2 m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -118,9 +122,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!135 &3507902894264904200 SphereCollider: @@ -130,29 +136,19 @@ SphereCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7167547457198401877} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Radius: 0.8303433 m_Center: {x: 0, y: 0, z: 0} ---- !u!114 &8747099621875196907 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 7167547457198401877} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 737f47c158214f0995ba2835f040ed96, type: 3} - m_Name: - m_EditorClassIdentifier: - RotationSpeed: 1 - GridOffset: 0 - GridAttraction: 10 - Gravity: 1 - Drag: 0.05 - LaunchDrag: 0.2 --- !u!114 &1545255963217739444 MonoBehaviour: m_ObjectHideFlags: 0 @@ -165,6 +161,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0c18f67d85af44c2b5afc8ecd15ce960, type: 3} m_Name: m_EditorClassIdentifier: + RotationSpeed: 1 + GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 @@ -178,10 +176,21 @@ Rigidbody: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7167547457198401877} - serializedVersion: 2 + serializedVersion: 5 m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 m_UseGravity: 0 m_IsKinematic: 1 m_Interpolate: 0 @@ -204,6 +213,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 4002800527770282731} m_Modifications: - target: {fileID: 2945231343692618236, guid: e7877477dc889804ea9d04f7ffd08e9b, @@ -327,6 +337,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: e7877477dc889804ea9d04f7ffd08e9b, type: 3} --- !u!114 &1928241908402748482 stripped MonoBehaviour: diff --git a/Assets/Prefabs/RPG/Pickups/Octahedron.prefab b/Assets/Prefabs/RPG/Pickups/Octahedron.prefab index 7ba7910d2..5957a7295 100644 --- a/Assets/Prefabs/RPG/Pickups/Octahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Octahedron.prefab @@ -23,6 +23,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 3932408193141279893} + serializedVersion: 2 m_LocalRotation: {x: 0.7069834, y: -0, z: -0, w: 0.7072302} m_LocalPosition: {x: 0, y: 0.9264834, z: -0.0003232956} m_LocalScale: {x: 1, y: 1, z: 1} @@ -30,7 +31,6 @@ Transform: m_Children: - {fileID: 7372978312502119311} m_Father: {fileID: 5807113421007930924} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &8555821447211356933 GameObject: @@ -44,7 +44,6 @@ GameObject: - component: {fileID: 4899765339662518078} - component: {fileID: 1691511938472729356} - component: {fileID: 3657818610818556929} - - component: {fileID: 946329779588188460} - component: {fileID: 7390748043139687254} - component: {fileID: -4611973708976329780} - component: {fileID: -8908388773057984787} @@ -62,6 +61,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 8555821447211356933} + serializedVersion: 2 m_LocalRotation: {x: -0.7069834, y: 0, z: 0, w: 0.7072302} m_LocalPosition: {x: -0, y: 0, z: 2.7794502} m_LocalScale: {x: 3, y: 3, z: 3} @@ -69,7 +69,6 @@ Transform: m_Children: - {fileID: 2388751103296260666} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: -89.98, y: 0, z: 0} --- !u!33 &4899765339662518078 MeshFilter: @@ -96,6 +95,11 @@ MeshRenderer: m_ReflectionProbeUsage: 1 m_RayTracingMode: 2 m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -118,9 +122,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!135 &3657818610818556929 SphereCollider: @@ -130,29 +136,19 @@ SphereCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 8555821447211356933} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Radius: 0.75 m_Center: {x: 0, y: 0, z: 0} ---- !u!114 &946329779588188460 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 8555821447211356933} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 737f47c158214f0995ba2835f040ed96, type: 3} - m_Name: - m_EditorClassIdentifier: - RotationSpeed: 1 - GridOffset: 0 - GridAttraction: 10 - Gravity: 1 - Drag: 0.05 - LaunchDrag: 0.2 --- !u!114 &7390748043139687254 MonoBehaviour: m_ObjectHideFlags: 0 @@ -165,6 +161,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0c18f67d85af44c2b5afc8ecd15ce960, type: 3} m_Name: m_EditorClassIdentifier: + RotationSpeed: 1 + GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 @@ -178,10 +176,21 @@ Rigidbody: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 8555821447211356933} - serializedVersion: 2 + serializedVersion: 5 m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 m_UseGravity: 0 m_IsKinematic: 1 m_Interpolate: 0 @@ -204,6 +213,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 2388751103296260666} m_Modifications: - target: {fileID: 2945231343692618236, guid: e7877477dc889804ea9d04f7ffd08e9b, @@ -327,6 +337,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: e7877477dc889804ea9d04f7ffd08e9b, type: 3} --- !u!114 &3891726626588969535 stripped MonoBehaviour: diff --git a/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab b/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab index 26c2b96dd..8688b6a3b 100644 --- a/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab @@ -23,6 +23,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 2238948151922859802} + serializedVersion: 2 m_LocalRotation: {x: 0.7069834, y: -0, z: -0, w: 0.7072302} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -30,7 +31,6 @@ Transform: m_Children: - {fileID: 3300919539099916088} m_Father: {fileID: 8414659055235772867} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &7767746557817481743 GameObject: @@ -44,7 +44,6 @@ GameObject: - component: {fileID: 2868629223358676395} - component: {fileID: 6959931328227918623} - component: {fileID: 4540815661693075899} - - component: {fileID: 4008709970769624704} - component: {fileID: 7436534270673427838} - component: {fileID: -6589342969031261525} - component: {fileID: 5724758014884646323} @@ -62,6 +61,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7767746557817481743} + serializedVersion: 2 m_LocalRotation: {x: -0.7069834, y: 0, z: 0, w: 0.7072302} m_LocalPosition: {x: -0, y: 0, z: 0} m_LocalScale: {x: 3, y: 3, z: 3} @@ -69,7 +69,6 @@ Transform: m_Children: - {fileID: 7682789761269032435} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: -89.98, y: 0, z: 0} --- !u!33 &2868629223358676395 MeshFilter: @@ -96,6 +95,11 @@ MeshRenderer: m_ReflectionProbeUsage: 1 m_RayTracingMode: 2 m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -118,9 +122,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!135 &4540815661693075899 SphereCollider: @@ -130,29 +136,19 @@ SphereCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7767746557817481743} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Radius: 0.60371214 m_Center: {x: 0, y: 0, z: 0} ---- !u!114 &4008709970769624704 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 7767746557817481743} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 737f47c158214f0995ba2835f040ed96, type: 3} - m_Name: - m_EditorClassIdentifier: - RotationSpeed: 1 - GridOffset: 0 - GridAttraction: 10 - Gravity: 1 - Drag: 0.05 - LaunchDrag: 0.2 --- !u!114 &7436534270673427838 MonoBehaviour: m_ObjectHideFlags: 0 @@ -165,6 +161,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0c18f67d85af44c2b5afc8ecd15ce960, type: 3} m_Name: m_EditorClassIdentifier: + RotationSpeed: 1 + GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 @@ -178,10 +176,21 @@ Rigidbody: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7767746557817481743} - serializedVersion: 2 + serializedVersion: 5 m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 m_UseGravity: 0 m_IsKinematic: 1 m_Interpolate: 0 @@ -204,6 +213,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 7682789761269032435} m_Modifications: - target: {fileID: 2945231343692618236, guid: e7877477dc889804ea9d04f7ffd08e9b, @@ -327,6 +337,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: e7877477dc889804ea9d04f7ffd08e9b, type: 3} --- !u!224 &3300919539099916088 stripped RectTransform: diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index a11091e71..10157af03 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -16404,13 +16404,13 @@ MonoBehaviour: LODHandler: {fileID: 1009503736} SlimeRenderer: {fileID: 1254851254} SlimeGravityCamera: {fileID: 1066823975} - SimpleCommodityPickup: {fileID: 4008709970769624704, guid: 521bfeece7dd5fd4e8f166d38493f18c, + SimpleCommodityPickup: {fileID: 7436534270673427838, guid: 521bfeece7dd5fd4e8f166d38493f18c, type: 3} - CompoundCommodityPickup: {fileID: 7449398549621940518, guid: 366909b6a01d7254d8eef01c333f8e00, + CompoundCommodityPickup: {fileID: 4507103896627202189, guid: 366909b6a01d7254d8eef01c333f8e00, type: 3} - GearPickup: {fileID: 946329779588188460, guid: d2b7e9a9cd7733041b0553682e4b61af, + GearPickup: {fileID: 7390748043139687254, guid: d2b7e9a9cd7733041b0553682e4b61af, type: 3} - WeaponPickup: {fileID: 8842807112802394818, guid: d2cec1d8c3d94074e9d2e6088e1c02d7, + WeaponPickup: {fileID: 5705448000916375656, guid: d2cec1d8c3d94074e9d2e6088e1c02d7, type: 3} MinePrefab: {fileID: 7283754118921506074, guid: b04728b2b65e057429eb6e8529299c8a, type: 3} diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs new file mode 100644 index 000000000..f67c0db10 --- /dev/null +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections; +using System.IO; +using System.Linq; +using GameCult.Caching; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using float2 = CultMath.float2; + +// Batch proof that ZoneRenderer draws Zone.FloatingItems and nothing else, in a play session with no scene of its own: +// the pickup prefabs are the ones ARPG.unity's ZoneRenderer names (so the wiring is read here too), a zone is loaded +// into a fresh ZoneRenderer, and the zone's own Release, step, expiry and TryPickUp are what move and remove the +// pickups. Zone.Release adds exactly one ItemPickup, placed at the body's position; stepping the zone moves it with the +// body; expiry and TryPickUp each destroy it; an item already in the zone when the renderer loads it is drawn too. +// +// Unity -batchmode -nographics -projectPath -executeMethod FloatingItemsDrawnSmoke.Run -logFile +// +// Do not pass -quit: Run enters play mode (a domain reload), the check runs there, and the Editor exits itself, 0 on +// success and 1 on any failure. Destroy only takes effect in a play session, which is why this is not an edit-mode check. +[InitializeOnLoad] +public static class FloatingItemsDrawnSmoke +{ + private const string Flag = "FloatingItemsDrawnSmoke.running"; + private const string Wiring = "FloatingItemsDrawnSmoke.wiring"; + private static IEnumerator _check; + private static int _frames; + private static string _work; + + static FloatingItemsDrawnSmoke() + { + if (SessionState.GetBool(Flag, false)) EditorApplication.playModeStateChanged += OnPlayMode; + } + + public static void Run() + { + try + { + SessionState.SetString(Wiring, ReadWiring()); + SessionState.SetBool(Flag, true); + EditorApplication.EnterPlaymode(); + } + catch (Exception exception) + { + Debug.LogException(exception); + EditorApplication.Exit(1); + } + } + + private static void OnPlayMode(PlayModeStateChange change) + { + if (change != PlayModeStateChange.EnteredPlayMode) return; + _work = Path.Combine(Path.GetTempPath(), $"floating-items-smoke-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_work); + _check = Check(); + EditorApplication.update += Step; + } + + private static void Step() + { + try + { + if (++_frames > 3000) throw new InvalidOperationException("The check did not finish in 3000 frames."); + if (_check.MoveNext()) return; + Finish(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + Finish(1); + } + } + + private static void Finish(int code) + { + EditorApplication.update -= Step; + SessionState.SetBool(Flag, false); + try { if (Directory.Exists(_work)) Directory.Delete(_work, true); } catch (Exception) { /* a temp directory */ } + EditorApplication.Exit(code); + } + + private static void Require(bool condition, string what) + { + if (!condition) throw new InvalidOperationException(what); + } + + private static IEnumerator Check() + { + var settings = AssetDatabase.LoadAssetAtPath("Assets/Resources/Settings.asset")?.GameplaySettings + ?? throw new InvalidOperationException("No Settings.asset."); + + // The wiring: the scene's ZoneRenderer names an ItemPickup prefab in each of the four fields, and none of those + // prefabs keeps a script that no longer exists. + var wired = SessionState.GetString(Wiring, "").Split('|').Select(entry => entry.Split('=')).Select(pair => + (Field: pair[0], Pickup: pair[1] == "" ? null : AssetDatabase.LoadAssetAtPath(pair[1])?.GetComponent())).ToArray(); + Require(wired.Length == 4, "The wiring was not carried into the play session."); + foreach (var (name, pickup) in wired) + { + Require(pickup != null, $"ZoneRenderer.{name} is not an ItemPickup in ARPG.unity."); + Require(pickup.ScanLabel != null && pickup.ScanLabelContainer != null, $"{name}'s prefab has no scan label."); + Require(!pickup.GetComponentsInChildren(true).Any(c => c == null), $"{name}'s prefab has a missing script."); + } + + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var cargoShape = new Shape(4, 4); + foreach (var cell in cargoShape.AllCoordinates) cargoShape[cell] = true; + var oneCell = new Shape(1, 1); + oneCell[new CultMath.int2(0, 0)] = true; + var cache = AetheriaStores.Open(Path.Combine(_work, "smoke.cc"), catalogWritable: true); + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new HullData { Name = "Smoke Hub", HullType = HullType.Station, Shape = shape, Durability = 1000, Mass = 1000 }); + cache.Upsert(new CargoBayData { Name = "Smoke Crate", Shape = new Shape(), InteriorShape = cargoShape, Durability = 4 }); + cache.Upsert(new GearData + { + Name = "Smoke Spare", Hardpoint = HardpointType.Tool, Shape = oneCell, Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new SimpleCommodityData { Name = "Smoke Ore", Shape = oneCell, MaxStack = 100 }); + cache.FlushAsync().Wait(); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), null, null); + EquippableItem Make(string name, int lot) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = 1, Lot = lot + }; + + // A station with a crate takes the picked-up stack. It is not admitted: the zone holds no entity for the renderer to draw. + var hubRef = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Hub")); + var taker = new OrbitalEntity(items, zone, new EquippableItem { Data = hubRef, Durability = 1000, Lot = 1 }, default, new EntitySettings()); + Require(taker.TryEquip(Make("Smoke Crate", 2), new CultMath.int2(1, 1)), "The smoke station took no crate."); + + // An item already in the zone when the renderer loads it: drawn from the load, not only from the add. + var early = zone.Release(Make("Smoke Spare", 3), new float2(5, 6), float2.zero); + + // The renderer's side. Inactive while it is configured, so it runs no Awake or Start of the scene's own. + var rootObject = new GameObject("Smoke Zone Root"); + var rendererObject = new GameObject("Smoke Zone Renderer"); + rendererObject.SetActive(false); + var renderer = rendererObject.AddComponent(); + renderer.ZoneRoot = rootObject.transform; + foreach (var (name, pickup) in wired) + typeof(ZoneRenderer).GetField(name).SetValue(renderer, pickup); + renderer.ItemManager = items; + renderer.SectorBrushes = new GameObject("Smoke Brushes").transform; + renderer.SlimeGravityCamera = new GameObject("Smoke Gravity Camera").AddComponent(); + var slimeObject = new GameObject("Smoke Slime"); + slimeObject.SetActive(false); // the real Slime needs its compute shader; LoadZone only sets its radius + renderer.SlimeRenderer = slimeObject.AddComponent(); + renderer.LoadZone(zone); + yield return null; + + ItemPickup[] Pickups() => rootObject.GetComponentsInChildren(true).Where(p => p != null).ToArray(); + void At(ItemPickup pickup, FloatingItem floating, string what) + { + var at = floating.Body.Position; + var want = new Vector3(at.x, zone.GetHeight(at), at.y); + Require(Vector3.Distance(pickup.transform.position, want) < 1e-3f, + $"{what}: the pickup is at {pickup.transform.position}, the body at {want}."); + } + + Require(Pickups().Length == 1, $"load: {Pickups().Length} pickups for one existing floating item."); + At(Pickups()[0], early, "load"); + + // Release adds exactly one pickup, at the body's position, bound to its item. + var ore = new SimpleCommodity { Data = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Ore")), Quantity = 7 }; + var released = zone.Release(ore, new float2(3, 4), new float2(10, 0)); + yield return null; + Require(Pickups().Length == 2, $"release: {Pickups().Length} pickups for two floating items."); + var drawn = Pickups().Single(p => p.Item == ore); + At(drawn, released, "release"); + + // Stepping the zone moves the sim body; the pickup follows it on its next update, with nothing of its own. + var before = drawn.transform.position; + zone.Update(.5f); + yield return null; + Require(released.Body.Position.x > 3.5f, "the sim body did not drift."); + At(drawn, released, "step"); + Require(drawn.transform.position != before, "the pickup did not move with the body."); + + // Pickup destroys exactly the picked-up item's pickup. + Require(zone.TryPickUp(taker, released.Id), "the station refused a one-cell stack."); + yield return null; + yield return null; + Require(drawn == null, "pickup: the pickup is still there."); + Require(Pickups().Length == 1 && Pickups()[0].Item == early.Item, "pickup: the wrong pickup went."); + + // Expiry destroys the pickup of an item nobody took. + zone.Update(3600f); // far past any lifetime + yield return null; + yield return null; + Require(zone.FloatingItems.Count == 0, "expiry: the item did not expire."); + Require(Pickups().Length == 0, $"expiry: {Pickups().Length} pickups left."); + + // A later release is drawn again, and clearing the renderer takes what is left. + zone.Release(Make("Smoke Spare", 4), new float2(1, 1), float2.zero); + yield return null; + Require(Pickups().Length == 1, "re-release: not drawn."); + renderer.ClearZone(); + yield return null; + yield return null; + Require(Pickups().Length == 0, "clear: pickups left after ClearZone."); + Debug.Log("FLOATING_ITEMS_SMOKE ok"); + } + + // What ARPG.unity's ZoneRenderer names in each pickup field, read from the saved scene before the play session (a + // scene cannot be opened inside one) and carried across the domain reload as the prefabs' asset paths. + private static string ReadWiring() + { + var scene = EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Additive); + try + { + var found = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None) + .Where(r => r.gameObject.scene == scene).ToArray(); + Require(found.Length == 1, $"ARPG.unity has {found.Length} ZoneRenderers."); + var wired = found[0]; + return string.Join("|", new[] + { + ("SimpleCommodityPickup", wired.SimpleCommodityPickup), ("CompoundCommodityPickup", wired.CompoundCommodityPickup), + ("GearPickup", wired.GearPickup), ("WeaponPickup", wired.WeaponPickup) + }.Select(f => f.Item1 + "=" + (f.Item2 == null ? "" : AssetDatabase.GetAssetPath(f.Item2)))); + } + finally { EditorSceneManager.CloseScene(scene, true); } + } +} diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs.meta b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs.meta new file mode 100644 index 000000000..51e4b34a7 --- /dev/null +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67544a932f794a2458741c731ef04d11 \ No newline at end of file diff --git a/Assets/Scripts/Editor/ImpactPresenterSmoke.cs b/Assets/Scripts/Editor/ImpactPresenterSmoke.cs index 42dc9d56d..b5f6f9714 100644 --- a/Assets/Scripts/Editor/ImpactPresenterSmoke.cs +++ b/Assets/Scripts/Editor/ImpactPresenterSmoke.cs @@ -64,10 +64,8 @@ public static class ImpactPresenterSmoke var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Hull")); var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); var other = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 2 }, new EntitySettings()); - zone.Entities.Add(ship); - zone.Entities.Add(other); - ship.Activate(); - other.Activate(); + zone.Admit(ship, piloted: false); + zone.Admit(other, piloted: false); // The scene's side: the tuning object with a pooled effect, and the game manager that names the player's ship. var feedbackObject = new GameObject("Smoke Impact Feedback"); diff --git a/Assets/Scripts/Editor/WireFloatingItems.cs b/Assets/Scripts/Editor/WireFloatingItems.cs new file mode 100644 index 000000000..8065401a5 --- /dev/null +++ b/Assets/Scripts/Editor/WireFloatingItems.cs @@ -0,0 +1,75 @@ +using System; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +// Strips the deleted integrator component from the pickup prefabs and points ZoneRenderer's four pickup fields in ARPG.unity at +// each prefab's ItemPickup, then saves the prefabs and the scene. Idempotent: a prefab with no missing script is left as +// it is, and the fields are set to the same values again. +// +// Unity -batchmode -projectPath -executeMethod WireFloatingItems.Run -logFile +// +// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure. The menu item runs the same wiring. +public static class WireFloatingItems +{ + private const string ScenePath = "Assets/Scenes/ARPG.unity"; + private const string PickupFolder = "Assets/Prefabs/RPG/Pickups/"; + // The field each prefab filled before this cut (read from ARPG.unity at the cut's base). Icosahedron fills none. + private static readonly (string Field, string Prefab)[] Fields = + { + ("SimpleCommodityPickup", "Tetrahedron"), + ("CompoundCommodityPickup", "Hexahedron"), + ("GearPickup", "Octahedron"), + ("WeaponPickup", "Dodecahedron"), + }; + private static readonly string[] Prefabs = { "Tetrahedron", "Hexahedron", "Octahedron", "Dodecahedron", "Icosahedron" }; + + [MenuItem("Aetheria/Wire Floating Items")] + public static void Menu() => Wire(); + + public static void Run() + { + try + { + Wire(); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + EditorApplication.Exit(1); + } + } + + private static void Wire() + { + foreach (var name in Prefabs) + { + var path = PickupFolder + name + ".prefab"; + var contents = PrefabUtility.LoadPrefabContents(path); + try + { + var removed = GameObjectUtility.RemoveMonoBehavioursWithMissingScript(contents); + if (contents.GetComponent() == null) throw new InvalidOperationException($"{path} has no ItemPickup."); + if (removed > 0) PrefabUtility.SaveAsPrefabAsset(contents, path); + Debug.Log($"WIRE_FLOATING_ITEMS {name} removed={removed}"); + } + finally { PrefabUtility.UnloadPrefabContents(contents); } + } + + var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single); + var renderer = UnityEngine.Object.FindFirstObjectByType(); + if (renderer == null) throw new InvalidOperationException("ARPG.unity has no ZoneRenderer."); + var serialized = new SerializedObject(renderer); + foreach (var (field, prefab) in Fields) + { + var pickup = AssetDatabase.LoadAssetAtPath(PickupFolder + prefab + ".prefab").GetComponent(); + var property = serialized.FindProperty(field) ?? throw new InvalidOperationException($"ZoneRenderer has no {field}."); + property.objectReferenceValue = pickup; + } + serialized.ApplyModifiedProperties(); + EditorSceneManager.MarkSceneDirty(scene); + if (!EditorSceneManager.SaveScene(scene)) throw new InvalidOperationException("The scene did not save."); + Debug.Log("WIRE_FLOATING_ITEMS ok"); + } +} diff --git a/Assets/Scripts/Editor/WireFloatingItems.cs.meta b/Assets/Scripts/Editor/WireFloatingItems.cs.meta new file mode 100644 index 000000000..c84e2a8c8 --- /dev/null +++ b/Assets/Scripts/Editor/WireFloatingItems.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9a56d7dc8471d224f9bed434b39f7c87 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 3fae48b73..87d62d3e4 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -739,7 +739,7 @@ public class ActionGameManager : MonoBehaviour if (CurrentEntity != null) { CurrentEntity.Deactivate(); - CurrentEntity.Zone.Entities.Remove(CurrentEntity); + CurrentEntity.Zone.Remove(CurrentEntity); CurrentEntity.Zone = Zone; Zone.Admit(CurrentEntity, piloted: false); } @@ -1332,9 +1332,7 @@ public class ActionGameManager : MonoBehaviour TargetShieldsFill.fillAmount = target.Shield == null ? 0 : lerp(.25f, .75f, target.Shield.Progress); } - var tractorPower = Input.Player.TractorBeam.ReadValue(); - CurrentEntity.TractorPower = - saturate(CurrentEntity.TractorPower + sign(tractorPower - CurrentEntity.TractorPower) * Time.deltaTime * 2); + CurrentEntity.TractorCommand = Input.Player.TractorBeam.ReadValue(); } } // One clock steps the zone, paused or not: a paused frame feeds it no real time. diff --git a/Assets/Scripts/Gameplay/CapabilityPresentationBinder.cs b/Assets/Scripts/Gameplay/CapabilityPresentationBinder.cs index c2ada7f9d..b72a47e3a 100644 --- a/Assets/Scripts/Gameplay/CapabilityPresentationBinder.cs +++ b/Assets/Scripts/Gameplay/CapabilityPresentationBinder.cs @@ -13,15 +13,15 @@ using UnityEngine; public class CapabilityPresentationBinder : MonoBehaviour { private readonly List _subscriptions = new List(); - private Func _grabTargetResolver; - private Func _grabInitialVelocityResolver; + private Func _grabTargetResolver; + private Func _grabInitialVelocityResolver; // Subscribes every presenter interface implemented by components on this object or its children to // the matching stream of `events`. `grabTargetResolver`/`grabInitialVelocityResolver` resolve a - // GrabEvent's plain target handle to the live Transform/velocity a grab presenter needs; the owner + // GrabEvent's target id to the live Transform/velocity a grab presenter needs; the owner // of `events` supplies them (the test scene today; the real pickup capability's caller later). - public void Bind(CapabilityEvents events, Func grabTargetResolver = null, - Func grabInitialVelocityResolver = null) + public void Bind(CapabilityEvents events, Func grabTargetResolver = null, + Func grabInitialVelocityResolver = null) { Unbind(); if (events == null) return; @@ -40,8 +40,8 @@ public class CapabilityPresentationBinder : MonoBehaviour var p = presenter; _subscriptions.Add(events.Grab.Subscribe(e => { - var target = _grabTargetResolver?.Invoke(e.TargetHandle); - var velocity = _grabInitialVelocityResolver?.Invoke(e.TargetHandle) ?? Vector3.zero; + var target = _grabTargetResolver?.Invoke(e.Target); + var velocity = _grabInitialVelocityResolver?.Invoke(e.Target) ?? Vector3.zero; p.Grab(e, target, velocity); })); } diff --git a/Assets/Scripts/Gameplay/CapabilityPresenters.cs b/Assets/Scripts/Gameplay/CapabilityPresenters.cs index 34e46dd39..1bc01100e 100644 --- a/Assets/Scripts/Gameplay/CapabilityPresenters.cs +++ b/Assets/Scripts/Gameplay/CapabilityPresenters.cs @@ -11,7 +11,7 @@ public interface IAbsorbPresenter } // Grab needs a live Transform to animate, which the engine-free GrabEvent cannot carry (its -// TargetHandle is a plain identity, not a Unity reference). The binder resolves the handle through a +// Target is a plain identity, not a Unity reference). The binder resolves the id through a // delegate supplied by the event source's owner and hands the presenter the result, along with the // initial velocity the target was already moving with (also owner-supplied, for the same reason). public interface IGrabPresenter diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 56617ab5a..2d6cbb88e 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -301,27 +301,6 @@ public class EntityInstance : MonoBehaviour { if (!this) return; _destroyed = true; - foreach (var gear in Entity.Equipment) - { - if (gear != Entity.EquippedHull && Random.value < ZoneRenderer.Settings.LootDropProbability) - { - ZoneRenderer.DropItem( - Entity.Position.ToUnity(), - Random.onUnitSphere * ZoneRenderer.Settings.LootDropVelocity, - gear.EquippableItem); - } - } - - foreach (var cargo in Entity.CargoBays) - { - foreach (var item in cargo.Cargo.Keys) - { - ZoneRenderer.DropItem( - Entity.Position.ToUnity(), - Random.onUnitSphere * ZoneRenderer.Settings.LootDropVelocity, - item); - } - } if (DestroyEffect != null) { var t = Instantiate(DestroyEffect).transform; diff --git a/Assets/Scripts/Gameplay/GameSettings.cs b/Assets/Scripts/Gameplay/GameSettings.cs index 2548f1a13..d2b3df1d8 100644 --- a/Assets/Scripts/Gameplay/GameSettings.cs +++ b/Assets/Scripts/Gameplay/GameSettings.cs @@ -11,9 +11,6 @@ using UnityEngine; public class GameSettings : ScriptableObject { public string StartingHullName = "Longinus"; - public float PickupLifetime = 30; - public float LootDropProbability = .25f; - public float LootDropVelocity = 25; public float HeatstrokePhasingFloor = 0; public float HeatstrokePhasingFrequency = 5; public float WormholeDistanceRatio; diff --git a/Assets/Scripts/Gameplay/GridObject.cs b/Assets/Scripts/Gameplay/GridObject.cs deleted file mode 100644 index 82d64199a..000000000 --- a/Assets/Scripts/Gameplay/GridObject.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using CultMath; -using CultMath.UnityBridge; -using UnityEngine; -using static CultMath.math; -using Random = UnityEngine.Random; - -public class GridObject : MonoBehaviour -{ - public float RotationSpeed; - public float GridOffset; - public float GridAttraction; - public float Gravity; - public float Drag = .1f; - public float LaunchDrag; - - private float2 _selfVelocity; - private float _timeOffset; - - public Zone Zone { get; set; } - public Vector3 Velocity { get; set; } - - private void Start() - { - _timeOffset = Random.value * 100; - } - - void Update() - { - var t = transform; - t.localRotation = Quaternion.Euler(sin(Time.time - _timeOffset * RotationSpeed) * 90, 0, cos(Time.time - _timeOffset * RotationSpeed) * 90); - - if (Zone == null) return; - - var position = t.position; - var gridHeight = Zone.GetHeight(position.Flatland().ToCultMath()) + GridOffset; - Velocity += Vector3.up * (sign(gridHeight - position.y) * GridAttraction * Time.deltaTime); - Velocity *= max(0, 1 - LaunchDrag * Time.deltaTime); - var normal = Zone.GetNormal(position.Flatland().ToCultMath()); - var force = new float2(normal.x, normal.z); - var forceMagnitude = lengthsq(force); - if (forceMagnitude > .001f) - { - var fa = 1 / (1 - forceMagnitude) - 1; - _selfVelocity += normalize(force) * Zone.Settings.GravityStrength * fa * Gravity; - } - - _selfVelocity *= max(0, 1 - Drag * Time.deltaTime); - t.position = position + (Velocity + new Vector3(_selfVelocity.x, 0, _selfVelocity.y)) * Time.deltaTime; - } -} \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/GridObject.cs.meta b/Assets/Scripts/Gameplay/GridObject.cs.meta deleted file mode 100644 index 335d81a85..000000000 --- a/Assets/Scripts/Gameplay/GridObject.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 737f47c158214f0995ba2835f040ed96 -timeCreated: 1618441439 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/ItemPickup.cs b/Assets/Scripts/Gameplay/ItemPickup.cs index 2a6621194..16805e7d3 100644 --- a/Assets/Scripts/Gameplay/ItemPickup.cs +++ b/Assets/Scripts/Gameplay/ItemPickup.cs @@ -4,9 +4,15 @@ using CultMath; using CultMath.UnityBridge; using UnityEngine; using static CultMath.math; +using Random = UnityEngine.Random; +// How one floating item looks. Zone owns the item's every fact (existence, position, expiry, pickup); once bound, +// this reads the latest sim body and draws, spun for show, and writes nothing back (rulings unity-presents-only-r3 +// and sim-speed-presentation). An unbound pickup (FieldTester's test scene) leaves its transform alone. public class ItemPickup : MonoBehaviour { + public float RotationSpeed = 1f; + public float GridOffset; public float LabelFadeDuration = .5f; public float LabelPersistDuration = 3; public float LabelDisplayAngle = 15; @@ -15,14 +21,38 @@ public class ItemPickup : MonoBehaviour public TextMeshPro ScanLabel; public ItemInstance Item { get; set; } - public ZoneRenderer ZoneRenderer { get; set; } public float3 ViewOrigin { get; set; } public float3 ViewDirection { get; set; } private float _displayTime; + private float _timeOffset; + private Zone _zone; + private FloatingItem _floating; + + public void Bind(Zone zone, FloatingItem floating) + { + _zone = zone; + _floating = floating; + Item = floating.Item; + _timeOffset = Random.value * 100; + Place(); + } + + private void Place() + { + var p = _floating.Body.Position; + transform.position = new Vector3(p.x, _zone.GetHeight(p) + GridOffset, p.y); + } private void Update() { + if (_floating != null) + { + Place(); + transform.localRotation = Quaternion.Euler( + sin(Time.time - _timeOffset * RotationSpeed) * 90, 0, cos(Time.time - _timeOffset * RotationSpeed) * 90); + } + var diff = transform.position.ToCultMath() - ViewOrigin; var toThis = normalize(diff); var viewAngle = acos(dot(toThis, ViewDirection)) * Mathf.Rad2Deg; @@ -34,9 +64,4 @@ public class ItemPickup : MonoBehaviour ScanLabel.color = c; ScanLabelContainer.rotation = Quaternion.LookRotation((-toThis).ToUnity()); } - - private void OnDestroy() - { - ZoneRenderer?.DestroyLoot(this); - } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Gameplay/ShieldManager.cs b/Assets/Scripts/Gameplay/ShieldManager.cs index 7e380c17b..eaf35f747 100644 --- a/Assets/Scripts/Gameplay/ShieldManager.cs +++ b/Assets/Scripts/Gameplay/ShieldManager.cs @@ -52,40 +52,6 @@ public class ShieldManager : MonoBehaviour, IAbsorbPresenter Debug.LogWarning("ShieldManager has no ShieldEnvelope; falling back to its own transform for the hit direction.", this); } - private void OnCollisionEnter(Collision other) - { - var otherShield = other.collider.GetComponent(); - if (!otherShield) - { - var gridObject = other.collider.GetComponent(); - if (gridObject) - { - var itemPickup = other.collider.GetComponent(); - if (itemPickup) - { - if (Entity.CargoBays.Any(c => c.TryStore(itemPickup.Item))) - { - // TODO: Pickup notification! - Destroy(itemPickup.gameObject); - } - else - { - // TODO: Pickup failed notification! - var cp = other.GetContact(0); - gridObject.Velocity += cp.normal * 25; - Debug.Log("Attempted item pickup, but no space in cargo bay!"); - } - } - else - { - Debug.Log("Shield collision occurred with grid object, but other collider isn't an item pickup!"); - } - } - else - Debug.Log("Shield collision occurred, but other collider isn't a shield or a grid object!"); - } - } - // A shot the shield took ripples it where the shot landed, drawn on the hull as it is drawn. The hull layer is // ImpactPresenter's. public void Absorb(AbsorbEvent e) diff --git a/Assets/Scripts/Gameplay/TimedDestroy.cs b/Assets/Scripts/Gameplay/TimedDestroy.cs deleted file mode 100644 index fbbd86f8c..000000000 --- a/Assets/Scripts/Gameplay/TimedDestroy.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using UnityEngine; - -public class TimedDestroy : MonoBehaviour -{ - public float Duration; - - private float _startTime; - - private void Start() - { - _startTime = Time.time; - } - - public void Update() - { - if(Time.time - _startTime > Duration) Destroy(gameObject); - } -} \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/TimedDestroy.cs.meta b/Assets/Scripts/Gameplay/TimedDestroy.cs.meta deleted file mode 100644 index 2f6bac661..000000000 --- a/Assets/Scripts/Gameplay/TimedDestroy.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 5d5539a23b814254b038a16f32f1d8a9 -timeCreated: 1618453298 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/TractorBeam.cs b/Assets/Scripts/Gameplay/TractorBeam.cs index 579cc813f..8bb8aa294 100644 --- a/Assets/Scripts/Gameplay/TractorBeam.cs +++ b/Assets/Scripts/Gameplay/TractorBeam.cs @@ -14,20 +14,9 @@ public class TractorBeam : MonoBehaviour public void Update() { transform.rotation = Quaternion.LookRotation(Direction); + // The beam draws nothing until it presents a sim grab (loot-3): no particles, and no pull; the simulation + // decides every floating item's motion. var emission = ParticleSystem.emission; - if (Power > .01f) - { - var objectFound = false; - foreach (var o in Physics.SphereCastAll(transform.position, Radius, Direction, Distance, 1 << 21)) - { - var gridObject = o.collider.GetComponent(); - if (gridObject == null) continue; - objectFound = true; - gridObject.Velocity += Vector3.Normalize(transform.position - gridObject.transform.position) * (Traction * Time.deltaTime); - } - - emission.rateOverTimeMultiplier = objectFound ? Power : 0; - } - else emission.rateOverTimeMultiplier = 0; + emission.rateOverTimeMultiplier = 0; } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index a5274d739..95cf4dd6b 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -189,6 +189,7 @@ public interface IPowerConsumer Union(45, typeof(MunitionSeekerData)), Union(46, typeof(MunitionControlData)), Union(47, typeof(MunitionFuelData)), + Union(48, typeof(PickupData)), Union(49, typeof(LaunchSuppressorData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData diff --git a/Assets/Scripts/ServerShared/Behaviors/Pickup.cs b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs new file mode 100644 index 000000000..0e4747183 --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs @@ -0,0 +1,171 @@ +using System; +using System.Linq; +using CultMath; +using MessagePack; +using Newtonsoft.Json; +using static CultMath.math; +using float2 = CultMath.float2; + +// The tractor beam: the capability that lets a ship collect floating items. It spins its beam up in sim time toward +// the pilot's command, and once the beam is up claims the free item nearest its axis and runs one timed grab +// (extend, envelop, pull) that ends in Zone.TryPickUp, the one commit of a pickup. Its stats are flat +// (Min == Max), as the other behaviours that carry their own timings. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] +public class PickupData : BehaviorData +{ + // How far along the beam's axis an item can be claimed, and how far off the axis. + [Inspectable, JsonProperty("range"), Key(1), RuntimeInspectable] + public PerformanceStat Range = new PerformanceStat(); + + [Inspectable, JsonProperty("beamRadius"), Key(2), RuntimeInspectable] + public PerformanceStat BeamRadius = new PerformanceStat(); + + // The spin-up (0..1) needed to claim an item, and to keep a grab going. + [Inspectable, JsonProperty("grabPower"), Key(3), RuntimeInspectable] + public PerformanceStat GrabPower = new PerformanceStat(); + + // Seconds of each phase of a grab. + [Inspectable, JsonProperty("extendTime"), Key(4), RuntimeInspectable] + public PerformanceStat ExtendTime = new PerformanceStat(); + + [Inspectable, JsonProperty("envelopTime"), Key(5), RuntimeInspectable] + public PerformanceStat EnvelopTime = new PerformanceStat(); + + [Inspectable, JsonProperty("pullTime"), Key(6), RuntimeInspectable] + public PerformanceStat PullTime = new PerformanceStat(); + + // Spin-up and spin-down, in power per sim second. + [Inspectable, JsonProperty("spinRate"), Key(7), RuntimeInspectable] + public PerformanceStat SpinRate = new PerformanceStat(); + + public override Behavior CreateInstance(EquippedItem item) + { + return new Pickup(this, item); + } + + // A beam is a piece of gear the pilot holds on; no consumable carries one. + public override Behavior CreateInstance(ConsumableItemEffect item) => + throw new NotSupportedException("pickup: gear only"); +} + +// Owns this ship's beam power and its one grab. Power is written only here; Zone owns the held item's pose. +public class Pickup : Behavior, IAlwaysUpdatedBehavior +{ + private readonly PickupData _data; + // Power and the grab clock are kept in double so a fixed tick count lands on the same side of a boundary + // whatever dt the zone steps by. + private double _power; + private bool _grabbing; + private FloatingBodyId _target; + private double _elapsed; + // How many of Envelop and Pull have been published so far (0, 1 or 2). + private int _published; + + public Pickup(PickupData data, EquippedItem item) : base(data, item) + { + _data = data; + } + + // The beam's spun-up state in [0, 1]: what the pilot's command has driven it to. + public float Power => (float) _power; + + public void Update(float dt) + { + var active = Item.Active.Value; + var command = active ? Math.Clamp(Entity.TractorCommand, 0f, 1f) : 0f; + var step = (double) Evaluate(_data.SpinRate) * dt; + _power = Math.Clamp(_power + Math.Clamp(command - _power, -step, step), 0.0, 1.0); + + var zone = Entity.Zone; + var grabPower = Evaluate(_data.GrabPower); + var extend = (double) Evaluate(_data.ExtendTime); + var envelop = (double) Evaluate(_data.EnvelopTime); + var pull = (double) Evaluate(_data.PullTime); + var range = Evaluate(_data.Range); + var radius = Evaluate(_data.BeamRadius); + var origin = Entity.Position.xz; + + if (!_grabbing) + { + if (!active || _power < grabPower) return; + var item = Select(zone, origin, range, radius); + if (item == null || !zone.TryHold(Entity, item.Id)) return; + _grabbing = true; + _target = item.Id; + _elapsed = 0; + _published = 0; + Publish(GrabPhase.Started, item, 0); + Publish(GrabPhase.Extend, item, (float) extend); + return; + } + + var held = zone.FloatingItems.FirstOrDefault(f => f.Id.Equals(_target)); + if (held == null || !ReferenceEquals(held.Holder, Entity)) + { + _grabbing = false; + Entity.Capabilities.PublishGrab(new GrabEvent(_target, GrabPhase.Cancelled, float3(0), 0)); + return; + } + + var pulling = _elapsed >= extend + envelop; + if (!active || _power < grabPower || (!pulling && !InBeam(held.Body.Position, origin, range, radius, out _))) + { + _grabbing = false; + zone.Free(_target); + Publish(GrabPhase.Cancelled, held, 0); + return; + } + + _elapsed += dt; + if (_published < 1 && _elapsed >= extend) { _published = 1; Publish(GrabPhase.Envelop, held, (float) envelop); } + if (_published < 2 && _elapsed >= extend + envelop) { _published = 2; Publish(GrabPhase.Pull, held, (float) pull); } + if (_published == 2) + zone.SetHeldProgress(_target, pull > 0 ? (float) ((_elapsed - extend - envelop) / pull) : 1f); + if (_elapsed < extend + envelop + pull) return; + + _grabbing = false; + if (zone.TryPickUp(Entity, _target)) Publish(GrabPhase.Completed, held, 0); + else + { + zone.Free(_target); + Publish(GrabPhase.Cancelled, held, 0); + } + } + + // The one beam region: within Range along the aim axis (never behind) and BeamRadius off it. Selection claims + // from it and the grab holds on to its item only while the item stays in it. A zero aim has no region. + private bool InBeam(float2 position, float2 origin, float range, float radius, out float off) + { + off = 0; + var aim = Entity.Aim.xz; + if (lengthsq(aim) <= 0) return false; + var axis = normalize(aim); + var to = position - origin; + var along = dot(to, axis); + off = length(to - axis * along); + return along >= 0 && along <= range && off <= radius; + } + + // The free item nearest the beam axis within the beam region that some cargo bay can store whole (the bay's own + // TryFindSpace, the test TryStore opens with); ties go to the lowest id. A zero aim selects nothing. + private FloatingItem Select(Zone zone, float2 origin, float range, float radius) + { + FloatingItem best = null; + var bestOff = float.MaxValue; + foreach (var floating in zone.FloatingItems) + { + if (floating.Holder != null) continue; + if (!InBeam(floating.Body.Position, origin, range, radius, out var off)) continue; + if (!Entity.CargoBays.Any(bay => bay.TryFindSpace(floating.Item))) continue; + if (off < bestOff || (off == bestOff && floating.Id.Value < best.Id.Value)) + { + best = floating; + bestOff = off; + } + } + return best; + } + + private void Publish(GrabPhase phase, FloatingItem item, float duration) => + Entity.Capabilities.PublishGrab(new GrabEvent(item.Id, phase, float3(item.Body.Position.x, Entity.Position.y, item.Body.Position.y), duration)); +} diff --git a/Assets/Scripts/ServerShared/Behaviors/Pickup.cs.meta b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs.meta new file mode 100644 index 000000000..34947e359 --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ac7678d240564331be7486588d28c5ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/CapabilityEvents.cs b/Assets/Scripts/ServerShared/CapabilityEvents.cs index 8ee9edc55..025975815 100644 --- a/Assets/Scripts/ServerShared/CapabilityEvents.cs +++ b/Assets/Scripts/ServerShared/CapabilityEvents.cs @@ -52,19 +52,18 @@ public enum GrabPhase Cancelled } -// The target is identified by a plain handle, not a Unity type: the simulation will own the real -// target identity (a loot drop or item instance id) once the pickup capability exists. Today's only -// publisher (the FieldShieldTest scene) hands out its own handles; nothing here interprets them. +// One phase of a grab. The target is the floating item the Pickup behaviour holds (the FieldShieldTest scene +// publishes its own handles as ids); nothing here interprets it. public readonly struct GrabEvent { - public readonly int TargetHandle; + public readonly FloatingBodyId Target; public readonly GrabPhase Phase; public readonly float3 TargetPosition; public readonly float PhaseDuration; - public GrabEvent(int targetHandle, GrabPhase phase, float3 targetPosition, float phaseDuration) + public GrabEvent(FloatingBodyId target, GrabPhase phase, float3 targetPosition, float phaseDuration) { - TargetHandle = targetHandle; + Target = target; Phase = phase; TargetPosition = targetPosition; PhaseDuration = phaseDuration; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index dc944d9e5..3b3b288f1 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -153,7 +153,11 @@ public abstract class Entity public bool OverrideShutdown { get; set; } - public float TractorPower { get; set; } + // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. + public float TractorCommand; + + // The beam's spun-up state: Pickup's, or 0 without one. + public float TractorPower => GetBehavior()?.Power ?? 0f; public bool Active { @@ -1549,7 +1553,7 @@ public abstract class Entity { bay.DockedShip = ship; ship.SetParent(this); - Zone.Entities.Remove(ship); + Zone.Remove(ship); ship.Deactivate(); ship.Docked.OnNext(this); } diff --git a/Assets/Scripts/ServerShared/FloatingBodies.cs b/Assets/Scripts/ServerShared/FloatingBodies.cs index e6a873b44..93e470676 100644 --- a/Assets/Scripts/ServerShared/FloatingBodies.cs +++ b/Assets/Scripts/ServerShared/FloatingBodies.cs @@ -42,7 +42,8 @@ public struct KinematicBody } // An item adrift in a zone: a death drop or jettisoned cargo. Zone.Release is its only adder; Zone's floating-item -// step (expiry) and TryPickUp are its only removers. Runtime state, never saved. +// step (expiry) and TryPickUp are its only removers. Zone.TryHold and Free are the only writers of Holder, the +// entity grabbing it with a tractor beam; a held item is placed by Zone, not drifted. Runtime state, never saved. public sealed class FloatingItem { public FloatingBodyId Id; @@ -50,4 +51,9 @@ public sealed class FloatingItem public KinematicBody Body; public double ReleasedAt; public float Lifetime; + // The entity holding it, or null when it floats free; HeldFrom is where it was claimed and HeldProgress how far + // along its pull to the holder (0..1). + public Entity Holder; + public float2 HeldFrom; + public float HeldProgress; } diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 1ce9ab03c..46bd5bc33 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -54,6 +54,7 @@ public class LoadoutGenerator var entity = new Ship(ItemManager, null, hull, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; OutfitEntity(entity); + EquipPickup(entity); StockConsumables(entity, ShipConsumableChance, ShipConsumableUnits); return EntitySerializer.Pack(entity); } @@ -287,6 +288,24 @@ public class LoadoutGenerator FillInterior(entity); } + // Every ship carries a tractor beam: picking up needs a pickup behaviour (scenarios ruling, "everyone gets tractor + // beams"). It goes in after the shared outfitting, which stations and turrets also run and which adds none, into + // whatever interior the hull has left; a hull with no cell for one cannot be generated. + private void EquipPickup(Entity entity) + { + var emptyShape = entity.UnoccupiedSpace; + var (product, design) = RandomProduct(2, item => + item.Behaviors.Any(b => b is PickupData) && + item.Shape.FitsWithin(emptyShape, out _, out _), required: true); + if (design == null) throw new InvalidLoadoutException("No compatible tractor beam found for ship!"); + + design.Shape.FitsWithin(emptyShape, out var rotation, out var position); + var beam = ItemManager.CreateInstance(product) as EquippableItem; + beam.Rotation = rotation; + if (!entity.TryEquip(beam, position)) + throw new InvalidLoadoutException("Failed to equip selected tractor beam!"); + } + private void EquipHardpoints(Entity entity) { var hullData = ItemManager.GetData(entity.Hull) as HullData; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 45ad983b3..29262c69c 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -15,7 +15,29 @@ using float2 = CultMath.float2; public class Zone { public Action Log; - public ReactiveCollection Entities = new ReactiveCollection(); + + // Membership, in admission order. Admit and Remove are the only writers; everyone else reads this view, which has + // no mutating member, so the death watch below follows membership by construction. + private readonly ReactiveCollection _entities = new ReactiveCollection(); + public MemberView Entities { get; } + + public sealed class MemberView : IReadOnlyReactiveCollection + { + private readonly ReactiveCollection _members; + internal MemberView(ReactiveCollection members) => _members = members; + public int Count => _members.Count; + public Entity this[int index] => _members[index]; + public int IndexOf(Entity entity) => _members.IndexOf(entity); + public bool Contains(Entity entity) => _members.Contains(entity); + public IEnumerator GetEnumerator() => _members.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public UniRx.IObservable> ObserveAdd() => _members.ObserveAdd(); + public UniRx.IObservable ObserveCountChanged(bool notifyCurrentCount = false) => _members.ObserveCountChanged(notifyCurrentCount); + public UniRx.IObservable> ObserveMove() => _members.ObserveMove(); + public UniRx.IObservable> ObserveRemove() => _members.ObserveRemove(); + public UniRx.IObservable> ObserveReplace() => _members.ObserveReplace(); + public UniRx.IObservable ObserveReset() => _members.ObserveReset(); + } // Laid mines, in lay order. Zone owns their existence: Lay is the one adder, the mine step the one remover, // and every removal is a blast. Runtime state, never saved. @@ -26,7 +48,8 @@ public class Zone public ReactiveCollection Clouds = new ReactiveCollection(); // Floating items (death drops, jettisoned cargo), in release order. Zone owns their existence: Release is the one - // adder; StepFloatingItems (expiry) and TryPickUp are the only removers. Runtime state, never saved. + // adder; StepFloatingItems (expiry) and TryPickUp are the only removers. TryHold, Free and Remove-of-the-holder + // are the only writers of FloatingItem.Holder. Runtime state, never saved. public ReactiveCollection FloatingItems = new ReactiveCollection(); public Dictionary Planets = new Dictionary(); public Dictionary PlanetInstances = new Dictionary(); @@ -105,30 +128,13 @@ public class Zone // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(CombatSeed, LootStream, n). private uint _deaths; - // One death subscription per member entity, owned here: a membership record and its subscription are created - // together, and only when the entity is not already a member. Leaving Entities (Remove) or resetting it (Clear) - // disposes it; nothing else touches it. + // One death subscription per member entity: Admit creates it together with the membership, Remove disposes it + // together with the membership, and nothing else touches either. private readonly Dictionary _deathWatches = new Dictionary(); - private void WatchDeath(Entity entity) - { - if (_deathWatches.ContainsKey(entity)) return; - _deathWatches.Add(entity, entity.Death.Subscribe(_ => { DropLoot(entity); Entities.Remove(entity); entity.Deactivate(); })); - } - - private void UnwatchDeath(Entity entity) - { - if (_deathWatches.Remove(entity, out var watch)) watch.Dispose(); - } - - private void UnwatchAll() - { - foreach (var watch in _deathWatches.Values) watch.Dispose(); - _deathWatches.Clear(); - } - public Zone(ItemManager itemManager, PlanetSettings settings, ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy) { + Entities = new MemberView(_entities); _time = pack.Time; GalaxyZone = galaxyZone; Galaxy = galaxy; @@ -144,11 +150,8 @@ public class Zone // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. // The death watch lives exactly as long as the entity's membership in this zone: installed on admission, // disposed on removal (death, dock, jump), so an entity admitted again never holds a second one. - Entities.ObserveAdd().Subscribe(add => WatchDeath(add.Value)); - Entities.ObserveRemove().Subscribe(remove => UnwatchDeath(remove.Value)); - Entities.ObserveReset().Subscribe(_ => UnwatchAll()); // The one record of a removal for observers (ruling reveal-ends-on-perception): read by the next Sense pass. - Entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz, + _entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz, remove.Value.Visibility))); Targets.Add(new EntityTargets()); @@ -205,17 +208,32 @@ public class Zone // TODO: Associate planets with stored entities for planetary colonies } - // The one admission into this zone (docs/scenarios-cut.md, 2.2): the only writer of Entities.Add and the only + // The one admission into this zone (docs/scenarios-cut.md, 2.2): the only writer of membership and the only // creator of agents. Construction, new-run staging (RunStart), warp arrival, undock and the console's spawned // turret all join through here. A piloted entity gets the zone's Minion; an unpiloted one gets no agent at all. - // Only a ship can be piloted. The Death subscription above covers every join, whichever caller admits it. + // Only a ship can be piloted. The death watch is installed here, for every join, whichever caller admits it. + // An entity that is already a member is refused: it would hold a second watch and a second agent. public void Admit(Entity entity, bool piloted) { - Entities.Add(entity); + if (_entities.Contains(entity)) throw new InvalidOperationException("entity: already a member of this zone"); + _entities.Add(entity); + _deathWatches.Add(entity, entity.Death.Subscribe(_ => { DropLoot(entity); Remove(entity); entity.Deactivate(); })); entity.Activate(); if (piloted) Agents.Add(CreateAgent((Ship) entity)); } + // The one removal from this zone (death, dock, jump): membership and its death watch leave together. False, and + // nothing changes, when the entity is not a member. The caller deactivates; Remove does not. + public bool Remove(Entity entity) + { + if (!_entities.Remove(entity)) return false; + _deathWatches.Remove(entity, out var watch); + watch.Dispose(); + foreach (var floating in FloatingItems) + if (ReferenceEquals(floating.Holder, entity)) Free(floating.Id); + return true; + } + private Agent CreateAgent(Ship ship) { var agent = new Minion(ship); @@ -231,7 +249,7 @@ public class Zone { Radius = Pack.Radius, Mass = Pack.Mass, - Entities = Entities.Select(EntitySerializer.Pack).ToList(), + Entities = _entities.Select(EntitySerializer.Pack).ToList(), Orbits = Orbits.Keys.Select(key => new CultRecordRef(key)).ToList(), Planets = Planets.Keys.Select(key => new CultRecordRef(key)).ToList(), Time = _time, @@ -315,14 +333,44 @@ public class Zone // The one commit of a pickup: the first of the taker's cargo bays that accepts the item stores it and the item // stops floating. Otherwise nothing changes. + // An item held by another entity is refused: one holder per item holds at the commit as well. public bool TryPickUp(Entity taker, FloatingBodyId id) { var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); - if (floating == null || !taker.CargoBays.Any(bay => bay.TryStore(floating.Item))) return false; + if (floating == null || (floating.Holder != null && !ReferenceEquals(floating.Holder, taker))) return false; + if (!taker.CargoBays.Any(bay => bay.TryStore(floating.Item))) return false; FloatingItems.Remove(floating); return true; } + // The tractor beam's claim on a floating item: false if it is absent or already held. The item stays where it is + // until its pull begins; StepFloatingItems places it from then on. + public bool TryHold(Entity holder, FloatingBodyId id) + { + var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); + if (floating == null || floating.Holder != null) return false; + floating.Holder = holder; + floating.HeldFrom = floating.Body.Position; + floating.HeldProgress = 0; + return true; + } + + // How far along its pull to the holder a held item is, 0..1. + public void SetHeldProgress(FloatingBodyId id, float progress) + { + var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); + if (floating?.Holder != null) floating.HeldProgress = saturate(progress); + } + + // Lets a held item go: it floats on from where it is with the Velocity and Drift it had. + public void Free(FloatingBodyId id) + { + var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); + if (floating == null) return; + floating.Holder = null; + floating.HeldProgress = 0; + } + // Called from the death subscription, before the entity leaves the zone: one generator per death. Each equipped // item but the hull drops on a LootDropProbability draw; every cargo stack drops whole. Each drop leaves from the // dead entity's position at a random heading. Draw order: Equipment order (the probability draw, then the heading @@ -344,12 +392,18 @@ public class Zone } // After StepClouds and before FireControl.Step: each floating item drifts as a KinematicBody on the zone's force - // and is removed once its lifetime has run out. + // and is removed once its lifetime has run out. A held item is neither integrated nor expired: it is placed + // between where it was claimed and its holder, now that the entities have moved. private void StepFloatingItems(float deltaTime) { var settings = _itemManager.GameplaySettings; foreach (var floating in FloatingItems.ToArray()) { + if (floating.Holder != null) + { + floating.Body.Position = lerp(floating.HeldFrom, floating.Holder.Position.xz, smoothstep(0f, 1f, floating.HeldProgress)); + continue; + } floating.Body.Step(deltaTime, GetForce(floating.Body.Position), settings); if (floating.ReleasedAt + floating.Lifetime <= _time) FloatingItems.Remove(floating); } diff --git a/Assets/Scripts/UI/FieldTester.cs b/Assets/Scripts/UI/FieldTester.cs index de18a33e5..a86e970c4 100644 --- a/Assets/Scripts/UI/FieldTester.cs +++ b/Assets/Scripts/UI/FieldTester.cs @@ -60,9 +60,9 @@ public class FieldTester : MonoBehaviour Debug.LogError("FieldTester expects a CapabilityPresentationBinder on TestField's GameObject; no capability events will reach the field."); else binder.Bind(_capabilityEvents, - grabTargetResolver: handle => handle >= 0 && handle < _pickups.Count ? _pickups[handle].transform : null, - grabInitialVelocityResolver: handle => Vector3.forward * - (PickupTravelDistance / lerp(PickupTravelTimeMin, PickupTravelTimeMax, _pickups[handle].lerp))); + grabTargetResolver: id => (int) id.Value >= 0 && (int) id.Value < _pickups.Count ? _pickups[(int) id.Value].transform : null, + grabInitialVelocityResolver: id => Vector3.forward * + (PickupTravelDistance / lerp(PickupTravelTimeMin, PickupTravelTimeMax, _pickups[(int) id.Value].lerp))); // Impact absorption (fork L): what used to be FieldDriver's own click handler is now this // scene's stand-in for whatever will eventually detect a hit and publish AbsorbEvent. @@ -102,7 +102,7 @@ public class FieldTester : MonoBehaviour if (!TestField.CanGrab) return; click.Clear(); var p = _pickups[i1]; - _capabilityEvents.PublishGrab(new GrabEvent(i1, GrabPhase.Started, p.transform.position.ToCultMath(), 0)); + _capabilityEvents.PublishGrab(new GrabEvent(new FloatingBodyId((uint) i1), GrabPhase.Started, p.transform.position.ToCultMath(), 0)); p.transform = null; _pickups[i1] = p; }; diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index d39e0d2ea..e24bd2eb2 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -40,10 +40,10 @@ public class ZoneRenderer : MonoBehaviour public LODHandler LODHandler; public Slime SlimeRenderer; public Camera SlimeGravityCamera; - public GridObject SimpleCommodityPickup; - public GridObject CompoundCommodityPickup; - public GridObject GearPickup; - public GridObject WeaponPickup; + public ItemPickup SimpleCommodityPickup; + public ItemPickup CompoundCommodityPickup; + public ItemPickup GearPickup; + public ItemPickup WeaponPickup; public MineInstance MinePrefab; public VapourCloudInstance CloudPrefab; public Material[] MapGravityMaterials; @@ -94,7 +94,7 @@ public class ZoneRenderer : MonoBehaviour private bool _showAsteroidUI; public Dictionary WormholeInstances = new Dictionary(); - private List _loot = new List(); + private Dictionary _floatingItems = new Dictionary(); private Dictionary _mines = new Dictionary(); private Dictionary _clouds = new Dictionary(); @@ -236,6 +236,15 @@ public class ZoneRenderer : MonoBehaviour instance.Blast(); })); + // Every floating item, whoever released it (a death drop, jettisoned cargo), is drawn through this one + // subscription. An item leaves Zone.FloatingItems by expiry or by pickup, and either way its pickup is gone. + foreach (var floating in zone.FloatingItems) LoadFloatingItem(zone, floating); + _zoneSubscriptions.Add(zone.FloatingItems.ObserveAdd().Subscribe(e => LoadFloatingItem(zone, e.Value))); + _zoneSubscriptions.Add(zone.FloatingItems.ObserveRemove().Subscribe(e => + { + if (_floatingItems.Remove(e.Value, out var pickup) && pickup) Destroy(pickup.gameObject); + })); + // Every vapour cloud, whoever vented it, is drawn through this one subscription. A cloud leaves Zone.Clouds // when its opacity has reached nothing, so a removal destroys its fog bank with nothing left to show. foreach (var cloud in zone.Clouds) LoadCloud(zone, cloud); @@ -274,6 +283,29 @@ public class ZoneRenderer : MonoBehaviour _mines.Add(mine, instance); } + private void LoadFloatingItem(Zone zone, FloatingItem floating) + { + var prefab = floating.Item switch + { + SimpleCommodity _ => SimpleCommodityPickup, + CompoundCommodity _ => CompoundCommodityPickup, + EquippableItem equippableItem when ItemManager.GetData(equippableItem) is WeaponItemData => WeaponPickup, + EquippableItem _ => GearPickup, + _ => throw new NotImplementedException() + }; + var pickup = Instantiate(prefab, ZoneRoot); + pickup.Bind(zone, floating); + pickup.ScanLabel.text = ItemManager.Title(floating.Item); + if (floating.Item is CraftedItemInstance craftedItemInstance) + { + var c = ItemManager.GetTier(craftedItemInstance).tier.Color.ToColor(); + c.a = 0; + pickup.ScanLabel.color = c; + } + else pickup.ScanLabel.color = new Color(.75f, .75f, .75f, 0); + _floatingItems.Add(floating, pickup); + } + private void LoadCloud(Zone zone, VapourCloud cloud) { var instance = Instantiate(CloudPrefab); @@ -312,9 +344,9 @@ public class ZoneRenderer : MonoBehaviour if (instance) Destroy(instance.gameObject); _clouds.Clear(); - foreach(var gridObject in _loot) - if(gridObject) Destroy(gridObject.gameObject); - _loot.Clear(); + foreach (var pickup in _floatingItems.Values) + if (pickup) Destroy(pickup.gameObject); + _floatingItems.Clear(); foreach (var entity in Zone.Entities) { @@ -487,7 +519,7 @@ public class ZoneRenderer : MonoBehaviour void Update() { var maxDepth = 0f; - foreach (var loot in _loot) + foreach (var loot in _floatingItems.Values) { loot.ViewOrigin = PerspectiveEntity.Position; loot.ViewDirection = PerspectiveEntity.Aim; @@ -603,41 +635,6 @@ public class ZoneRenderer : MonoBehaviour // MinimapGravityQuad.transform.position = gravPos; // MinimapTintQuad.transform.position = gravPos - Vector3.up*10; } - - public void DestroyLoot(ItemPickup loot) - { - _loot.Remove(loot); - } - - public void DropItem(Vector3 position, Vector3 velocity, ItemInstance item) - { - var gridObject = item switch - { - SimpleCommodity _ => Instantiate(SimpleCommodityPickup), - CompoundCommodity _ => Instantiate(CompoundCommodityPickup), - EquippableItem equippableItem when ItemManager.GetData(equippableItem) is WeaponItemData => Instantiate(WeaponPickup), - EquippableItem _ => Instantiate(GearPickup), - _ => throw new NotImplementedException() - }; - var t = gridObject.transform; - t.parent = ZoneRoot; - gridObject.Zone = Zone; - t.position = position; - gridObject.Velocity = velocity; - var itemPickup = gridObject.gameObject.GetComponent(); - itemPickup.Item = item; - itemPickup.ZoneRenderer = this; - itemPickup.ScanLabel.text = ItemManager.Title(item); - if (item is CraftedItemInstance craftedItemInstance) - { - var c = ItemManager.GetTier(craftedItemInstance).tier.Color.ToColor(); - c.a = 0; - itemPickup.ScanLabel.color = c; - } - else itemPickup.ScanLabel.color = new Color(.75f, .75f, .75f, 0); - gridObject.gameObject.AddComponent().Duration = Settings.PickupLifetime; - _loot.Add(itemPickup); - } } diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index b5167136e..f9ac53cf6 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce5c8be5194fcab8b1e477ef110db708143f572cbe61c8f0705708da7ae89ced -size 12729848 +oid sha256:1a2cb55b63f77a113dea0571716994ef8311d5da572a4fc41cbd3a7d2561fb68 +size 12730143 diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index a262d41e3..3fe4bfb51 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -113,11 +113,10 @@ public sealed class BrownoutTests : IDisposable // not flight, and a lone off-centre thruster could not fire without turning. Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")), new int2(2, 1))); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); // Ship.Turn stays 0, so Ship.Update commands no rotation: no incidental torque thrust from the // ship trying to turn. Aim steers nothing, so this one is only a plausible aim. ship.Aim = float3(0, 0, 1); - ship.Activate(); return ship; } diff --git a/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs b/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs index 9876e7ad8..60e1c6b85 100644 --- a/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs +++ b/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs @@ -26,7 +26,7 @@ public sealed class CapabilityEventsTests events.Thrust.Subscribe(thrust.Add); var absorbEvent = new AbsorbEvent(float3(1, 2, 3), float3(0, 0, 1), 5f, DamageType.Corrosive, AbsorbLayer.Hull); - var grabEvent = new GrabEvent(7, GrabPhase.Started, float3(1, 0, 0), 1.5f); + var grabEvent = new GrabEvent(new FloatingBodyId(7), GrabPhase.Started, float3(1, 0, 0), 1.5f); var meleeEvent = new MeleeEvent(float3(0, 0, 1), 10f, 90f, 0.5f); var thrustEvent = new ThrustEvent(float2(1, 0), 0.5f, float2(0.2f, -0.2f)); diff --git a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs index 83da4a488..e294b942f 100644 --- a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -96,9 +96,8 @@ public sealed class ConditionRatioTests : IDisposable // not flight, and a lone off-centre thruster could not fire without turning. Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")), new int2(2, 1))); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Aim = float3(0, 0, 1); // Turn stays 0, as in BrownoutTests - ship.Activate(); consumer = ship.Equipment.Single(e => e.Data.Name == "Consumer"); Assert.True(ship.GetBehavior().Torque == 0f, "the lone thruster turns nothing"); diff --git a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs index 3f3e75a85..cd968e885 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs @@ -83,7 +83,8 @@ public sealed class ConsumableSupplyTests : IDisposable var stranger = seed.Upsert(new Faction { Name = "Stranger", ShortName = "STR" }); maker = seed.Upsert(new Faction { Name = "Maker", ShortName = "MKR", Allegiance = { { ally, 1f } } }); - var skiff = seed.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(4, 3), Price = 100 }); + // Interior for the crate, the capacitor and the tractor beam every generated ship carries. + var skiff = seed.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 3), Price = 100 }); var platform = seed.Upsert(new HullData { Name = "Platform", HullType = HullType.Station, Shape = Solid(5, 5), Price = 100 }); // A hold with no cell cannot take a consumable. var crate = seed.Upsert(new CargoBayData @@ -93,6 +94,7 @@ public sealed class ConsumableSupplyTests : IDisposable }); var dock = seed.Upsert(new DockingBayData { Name = "Dock", Shape = new Shape(), Price = 5, InteriorShape = holds == Holds.DockRefuses ? new Shape(1, 1) : Solid(1, 1) }); var capacitor = seed.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); + var tractor = seed.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } }); var heater = seed.Upsert(new GearData { Name = "Heater", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, @@ -114,7 +116,7 @@ public sealed class ConsumableSupplyTests : IDisposable foreach (var (name, design) in new[] { ("Skiff", skiff.Key), ("Platform", platform.Key), ("Crate", crate.Key), ("Dock", dock.Key), - ("Cap", capacitor.Key), ("Heater", heater.Key), ("Orphan", orphan.Key) + ("Cap", capacitor.Key), ("Tractor", tractor.Key), ("Heater", heater.Key), ("Orphan", orphan.Key) }.Concat(sold)) Sell(name + " by Maker", design, maker); @@ -333,11 +335,13 @@ public sealed class ConsumableSupplyTests : IDisposable } // What the plain catalog leaves in both generators' Random, measured on the tree before consumables existed - // (origin/master d4a27def, the same fixture and seeds). The gated and plain catalogs agreeing is not enough: - // a step that drew unconditionally would draw in both. This pins the value itself. + // (origin/master d4a27def, the same fixture and seeds), plus the one product draw each of the three generated + // ships makes for its tractor beam (loot-2): with that draw taken out the tree leaves the d4a27def values + // 0.85624266, 0.037778974, 0.7931649, 0.3596493, 0.17511952, 0.6642336, 0.31897342, 0.5977364. The gated and + // plain catalogs agreeing is not enough: a step that drew unconditionally would draw in both. This pins the value itself. var beforeConsumables = new[] { - 0.85624266f, 0.037778974f, 0.7931649f, 0.3596493f, 0.17511952f, 0.6642336f, 0.31897342f, 0.5977364f + 0.3596493f, 0.03616327f, 0.8790466f, 0.16682059f, 0.5977364f, 0.08633745f, 0.2869571f, 0.6890472f }; var plainAfter = AfterGeneration(plain); Assert.Equal(beforeConsumables, plainAfter); diff --git a/tests/Aetheria.Shared.Tests/ConsumableTests.cs b/tests/Aetheria.Shared.Tests/ConsumableTests.cs index b5717e9d6..7ba593e6e 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableTests.cs @@ -83,8 +83,7 @@ public sealed class ConsumableTests : IDisposable var ship = new Ship(items, zone, Gear(cache.Get(hullRef)), new EntitySettings()); Assert.True(ship.TryEquip(Gear(cache.Get(engineRef)), EngineCell)); Assert.True(ship.TryEquip(Gear(cache.Get(holdRef)), HoldCell)); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); var data = cache.Get(consumableRef); return new Rig { diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index a3bc68be9..54616c93c 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -230,8 +230,7 @@ public sealed partial class ContactRevealTests : IDisposable Assert.True(ship.TryFindSpace(item, out var at)); Assert.True(ship.TryEquip(item, at)); } - _zone.Entities.Add(ship); - ship.Activate(); + _zone.Admit(ship, piloted: false); ship.Position = position; ship.Aim = float3(0, 0, 1); _zone.Update(0f); // brings the gear online @@ -603,8 +602,8 @@ public sealed partial class ContactRevealTests : IDisposable { switch (how) { - case "remove": zone.Entities.Remove(e); break; - case "warp": e.Deactivate(); zone.Entities.Remove(e); break; + case "remove": zone.Remove(e); break; + case "warp": e.Deactivate(); zone.Remove(e); break; case "death": e.Hull.Durability = 0f; e.HullDamage.OnNext(1f); break; default: throw new Exception("fixture: " + how); } @@ -643,7 +642,7 @@ public sealed partial class ContactRevealTests : IDisposable Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate"); Assert.Equal(2, times.Count); - _zone.Entities.Remove(e); + _zone.Remove(e); Assert.Equal(2, o.RevealsOf(e).Count()); var lapsed = false; @@ -729,7 +728,7 @@ public sealed partial class ContactRevealTests : IDisposable var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f); ShowOnly(e, gate * against, Guns(e)[0]); - _zone.Entities.Remove(e); + _zone.Remove(e); Tick(.1f); Assert.Equal(ended ? 0 : 1, o.RevealsOf(e).Count()); @@ -799,7 +798,7 @@ public sealed partial class ContactRevealTests : IDisposable ShowOnly(e, threshold * 8f, Guns(e)[0]); Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) == threshold, "fixture: the ship's light at removal is exactly the gate"); - _zone.Entities.Remove(e); + _zone.Remove(e); Tick(.1f); Assert.Single(o.RevealsOf(e)); @@ -816,8 +815,9 @@ public sealed partial class ContactRevealTests : IDisposable Tick(1f); // the gun cools down ShowOnly(e, 1000f, gun); // bright enough for any gate - _zone.Entities.Remove(e); - _zone.Entities.Add(e); + _zone.Remove(e); + e.Deactivate(); + _zone.Admit(e, piloted: false); Tick(.1f); Assert.Equal(at, Assert.Single(o.RevealsOf(e)).Time); @@ -932,7 +932,7 @@ public sealed partial class ContactRevealTests : IDisposable var first = Assert.Single(o.RevealsOf(locker)); // fixture: the lock paints every tick, and the first one is recorded Assert.True(locker.Visibility * o.PassiveInfo(locker.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its departure is below o's gate"); - _zone.Entities.Remove(locker); // dies, warps or docks while its latest paint is still in o's inbox + _zone.Remove(locker); // dies, warps or docks while its latest paint is still in o's inbox var inboxed = ((System.Collections.IList) typeof(Entity).GetField("_paintInbox", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(o)).Count; Assert.True(inboxed > 0 || o.IncomingPaints.Count > 0, "fixture: a paint is in flight"); Tick(.1f); @@ -984,7 +984,7 @@ public sealed partial class ContactRevealTests : IDisposable for (var tick = 0; tick < 100 && inbox.Count == 0; tick++) Tick(.1f); Assert.NotEmpty(inbox); // fixture: the ping crossed o and its paint waits to be published - _zone.Entities.Remove(p); + _zone.Remove(p); Tick(.1f); Assert.Single(o.IncomingPaints); @@ -1158,7 +1158,7 @@ public sealed partial class ContactRevealTests : IDisposable ShowOnly(e, 1f, gun); var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= threshold, "fixture: from where o is, the departure is below the gate"); - _zone.Entities.Remove(e); + _zone.Remove(e); Tick(.1f); Assert.Single(o.RevealsOf(e)); @@ -1188,8 +1188,8 @@ public sealed partial class ContactRevealTests : IDisposable var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; Assert.True(a.Visibility * o.PassiveInfo(a.Position.xz, 1f) > threshold && b.Visibility * o.PassiveInfo(b.Position.xz, 1f) > threshold, "fixture: both departures pass the gate"); - _zone.Entities.Remove(firstThenSecond ? a : b); - _zone.Entities.Remove(firstThenSecond ? b : a); + _zone.Remove(firstThenSecond ? a : b); + _zone.Remove(firstThenSecond ? b : a); Tick(.1f); Assert.Empty(o.RevealsOf(a)); @@ -1215,7 +1215,7 @@ public sealed partial class ContactRevealTests : IDisposable Tick(.01f); Assert.Same(guns[1], Assert.Single(o.RevealsOf(e)).Item); // fixture: one gun ended as destroyed, one waiting e.Deactivate(); // the warp path: the ship's own subscriptions to the observer go with it - _zone.Entities.Remove(e); + _zone.Remove(e); for (var i = 0; i < 40; i++) Tick(.1f); // past BearingMemory (2 s) Assert.Equal(0, RevealBookSubjects(o)); // fixture: pruned @@ -1307,7 +1307,7 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Contains(identified.VisibilitySources.Values, v => v.Item == gunItems[0] && v.Strength > 0f); var departed = _zone.Entities.Single(x => x.Equipment.Contains(gunItems[18])); foreach (var source in departed.VisibilitySources.Keys.ToArray()) departed.SetSignature(source, SignatureChannel.Reflection, 0f, gunItems[18]); - _zone.Entities.Remove(departed); + _zone.Remove(departed); Assert.Single((System.Collections.IList) typeof(Zone).GetProperty("Departures", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(_zone)); sense(); Assert.Single(o.RevealsOf(departed)); // fixture: awaiting its prune diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index 91803619a..eafb44cc6 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -64,8 +64,7 @@ public sealed class ContactTests : IDisposable { var hullRef = Items.ItemData.RefOf(Items.ItemData.GetByName(hull)); var ship = new Ship(Items, Zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = Lot++ }, new EntitySettings()); - Zone.Entities.Add(ship); - ship.Activate(); + Zone.Admit(ship, piloted: false); ship.Position = at; ship.Velocity = velocity; return ship; diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 3b558a90e..00996179c 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -306,10 +306,11 @@ public sealed partial class RunStartTests else { // The envelope sums the live columns, the drives' yaw among them (turn-authority-full), so its reach sits above - // the allocated one by the turn the drives add: 1.24 nose-on and 1.12 broadside at the last measurement. The - // ceiling is that, from the columns the allocator solves with, not an open bound: an envelope inflated by a - // factor (doubled: 2.5 and 2.2) breaks it. - Assert.InRange(reach.noseOn, measured.noseOn * .9f, allocated.noseOn * 1.3f); + // the allocated one by the turn the drives add: 1.37 nose-on and 1.17 broadside at the last measurement + // (1.24 and 1.12 before every generated ship drew a tractor beam: the extra draw per ship fits the Longinus + // with other rolls). The ceiling is that, from the columns + // the allocator solves with, not an open bound: an envelope inflated by a factor (doubled: 2.7 and 2.3) breaks it. + Assert.InRange(reach.noseOn, measured.noseOn * .9f, allocated.noseOn * 1.5f); Assert.InRange(reach.broadside, measured.broadside * .9f, allocated.broadside * 1.2f); Assert.InRange(measured.noseOn, allocated.noseOn * .9f, allocated.noseOn * 1.15f); Assert.InRange(measured.broadside, allocated.broadside * .85f, allocated.broadside * 1.15f); diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index c1777109a..6f1f3b7b4 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -176,10 +176,8 @@ public sealed class FireAuthorityTests : IDisposable // extra gear a test needs (a second aimable item, a shield, a reactor) must go on before Activate(). beforeActivate?.Invoke(items, shooter, target); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); @@ -327,7 +325,7 @@ public sealed class FireAuthorityTests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var before = e.Target.Hull.Durability; - e.Zone.Entities.Remove(e.Target); + e.Zone.Remove(e.Target); e.Zone.Update(10f); // well past arrival Assert.Equal(before, e.Target.Hull.Durability); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index 237c39851..04a3188ce 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -176,10 +176,8 @@ public sealed class FireControlCut11Tests : IDisposable Assert.True(target.TryEquip(shield, spos)); } - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3(37, 0, -11); target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter @@ -279,7 +277,7 @@ public sealed class FireControlCut11Tests : IDisposable // What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s. Assert.Equal(.3f, committed.ArrivalIn, 3); - e.Zone.Entities.Remove(e.Target); // the target is gone before the shot arrives + e.Zone.Remove(e.Target); // the target is gone before the shot arrives e.Zone.Update(1f); Assert.NotNull(resolved); @@ -306,7 +304,7 @@ public sealed class FireControlCut11Tests : IDisposable e.Zone.Update(1f); SafeAssert.Empty(committed); - e.Zone.Entities.Remove(e.Target); + e.Zone.Remove(e.Target); e.Zone.Update(1f); foreach (var outcome in new[] { SafeAssert.Only(committed), SafeAssert.Only(resolved) }) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index be01188b2..9f1fbdac5 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -223,10 +223,8 @@ public sealed class FireControlCut123Tests : IDisposable cockpit = target.Equipment.Single(x => x.EquippableItem == ci); } - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); // Cut 11.3: the shooter sits off the origin. shooter.Position = float3(37, 0, -11); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 880f34c77..15fca5f6b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -241,10 +241,8 @@ public sealed partial class FireControlCut124Tests : IDisposable Assert.True(target.TryEquip(shield, spos)); } - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); // Off-origin (the fixture rule): the shooter sits away from the world origin, and the target sits // `targetRange` ahead of it along the shooter's own +z, so a stationary target's TravelDirection is @@ -514,8 +512,7 @@ public sealed partial class FireControlCut124Tests : IDisposable Data = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Marker")), Durability = 1000000, Lot = lot + 1 }; Assert.True(ship.TryEquip(marker, new int2(1, 1))); - e.Zone.Entities.Add(ship); - ship.Activate(); + e.Zone.Admit(ship, piloted: false); ship.Position = position; return ship; } @@ -1464,7 +1461,7 @@ public sealed partial class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); Assert.True(SafeAssert.OnlyShot(e.Zone).Target == e.Target, "fixture: the pushed-out round keeps its target"); e.Zone.Update(.01f); - e.Zone.Entities.Remove(e.Target); + e.Zone.Remove(e.Target); e.Zone.Update(3f); SafeAssert.NoShots(e.Zone); @@ -1509,7 +1506,7 @@ public sealed partial class FireControlCut124Tests : IDisposable var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); using var c = e.Zone.ShotCommitted.Subscribe(_ => throw new InvalidOperationException("observer")); - e.Zone.Entities.Remove(e.Target); + e.Zone.Remove(e.Target); Assert.ThrowsAny(() => e.Zone.Update(.01f)); var second = Record.Exception(() => e.Zone.Update(.01f)); @@ -2003,7 +2000,7 @@ public sealed partial class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); for (var i = 0; i < updatesBeforeLeaving; i++) e.Zone.Update(.01f); - e.Zone.Entities.Remove(e.Shooter); + e.Zone.Remove(e.Shooter); e.Zone.Update(4f); SafeAssert.NoShots(e.Zone); @@ -2021,7 +2018,7 @@ public sealed partial class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); - e.Zone.Entities.Remove(e.Shooter); + e.Zone.Remove(e.Shooter); e.Zone.Update(6f); SafeAssert.NoShots(e.Zone); @@ -2043,7 +2040,7 @@ public sealed partial class FireControlCut124Tests : IDisposable for (var i = 0; i < 12; i++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); - e.Zone.Entities.Remove(e.Shooter); + e.Zone.Remove(e.Shooter); e.Zone.Update(6f); SafeAssert.NoShots(e.Zone); @@ -2273,7 +2270,7 @@ public sealed partial class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(committedFirst ? 4.6f : .01f); Assert.Equal(committedFirst, e.Zone.PendingShots[0].Committed); - e.Zone.Entities.Remove(e.Target); + e.Zone.Remove(e.Target); e.Zone.Update(6f); foreach (var s in subscriptions) s.Dispose(); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index fb1fa19fc..44aa2d90d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -268,10 +268,8 @@ public sealed class FireControlCut12Tests : IDisposable Assert.True(target.TryEquip(shield, spos)); } - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); // Cut 11.3: the shooter sits off the origin -- a shooter at float3.zero makes `target - source` and // `target + source` the same vector, hiding a whole class of sign mutants. diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index c73d486e3..8ff7eef2f 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -154,10 +154,8 @@ public sealed class FireControlCut4Tests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); @@ -233,8 +231,7 @@ public sealed class FireControlCut4Tests : IDisposable Assert.True(shooter.TryEquip(gun, new int2(0, 0))); var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.EquippableItem == gun).Behaviors.Single(b => b is Weapon); - zone.Entities.Add(shooter); - shooter.Activate(); + zone.Admit(shooter, piloted: false); zone.Update(0f); // warm up resolved stats // No target, no OnFire subscriber -- exactly a headless shooter with no target and no presentation. @@ -276,7 +273,7 @@ public sealed class FireControlCut4Tests : IDisposable var ship = new Ship(items, zone, hull, new EntitySettings()); ship.Position = position; ship.Direction = direction; - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); return ship; } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index a21e4bef3..8c18523fd 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -183,10 +183,8 @@ public sealed class FireControlCut5Tests : IDisposable beforeActivate?.Invoke(items, shooter, target); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); @@ -338,10 +336,8 @@ public sealed class FireControlCut5Tests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = targetPosition; @@ -426,10 +422,8 @@ public sealed class FireControlCut5Tests : IDisposable var targetGun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 4 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); return (items, shooter, target, weaponItem); } @@ -575,8 +569,7 @@ public sealed class FireControlCut5Tests : IDisposable Assert.True(target.TryEquip(medium, mediumPos)); var mediumItem = target.Equipment.Single(e => e.EquippableItem == medium); - zone.Entities.Add(target); - target.Activate(); + zone.Admit(target, piloted: false); return (items, target, bigItem, mediumItem); } @@ -591,8 +584,7 @@ public sealed class FireControlCut5Tests : IDisposable var (items, target, big, medium) = BuildRevealTarget(settings); var observerHullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); var observer = new Ship(items, target.Zone, new EquippableItem { Data = observerHullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); - target.Zone.Entities.Add(observer); - observer.Activate(); + target.Zone.Admit(observer, piloted: false); observer.SetTarget(target); observer.RecordReveal(target, big, SignatureChannel.LaunchFlash); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 888124dd2..832038882 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -155,10 +155,8 @@ public sealed class FireControlCut6Tests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs index c5f9584a7..b0b0ed373 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs @@ -137,10 +137,8 @@ public sealed class FireControlCut6cTests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); @@ -211,6 +209,7 @@ public sealed class FireControlCut6cTests : IDisposable var cargo = seedCache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 }); var capacitor = seedCache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); var targeting = seedCache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new TargetingSystemData() } }); + var tractor = seedCache.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } }); // Everything but the targeting system is sold by Maker, who IS in the galaxy. Targeting is sold // only by Targeting Maker, who is NOT -- the single point of failure 6c.2 fixes. @@ -218,6 +217,7 @@ public sealed class FireControlCut6cTests : IDisposable { ("Armed by Maker", hull.Key, maker), ("Gun by Maker", gun.Key, maker), ("Crate by Maker", cargo.Key, maker), ("Cap by Maker", capacitor.Key, maker), + ("Tractor by Maker", tractor.Key, maker), ("Targeting by Targeting Maker", targeting.Key, targetingMaker) }) seedCache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = factionMaker }); @@ -266,8 +266,7 @@ public sealed class FireControlCut6cTests : IDisposable var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var entity = EntitySerializer.Unpack(items, zone, pack); - zone.Entities.Add(entity); - entity.Activate(); + zone.Admit(entity, piloted: false); zone.Update(0f); Assert.Null(entity.GetBehavior()); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs index c9ae69e9c..b41ea8ad2 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs @@ -155,10 +155,8 @@ public sealed class FireControlCut6dTests : IDisposable Assert.True(target.TryEquip(marker, markerCell.Value)); } - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs index d08db8c65..953dd0a37 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -131,10 +131,8 @@ public sealed class FireControlCut7Tests : IDisposable beforeActivate?.Invoke(items, shooter, target); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, 100); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index 5a112de4e..f313f7909 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -137,10 +137,8 @@ public sealed class FireControlCut8Tests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 4 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, 100); @@ -216,10 +214,8 @@ public sealed class FireControlCut8Tests : IDisposable var bystanderGun = new EquippableItem { Data = bystanderGunRef, Durability = 1, Lot = 4 }; Assert.True(bystander.TryEquip(bystanderGun, new int2(0, 0))); // _orderedEquipment must not be empty/null for Update to run - zone.Entities.Add(locker); - zone.Entities.Add(bystander); - locker.Activate(); - bystander.Activate(); + zone.Admit(locker, piloted: false); + zone.Admit(bystander, piloted: false); locker.Position = float3.zero; bystander.Position = float3(0, 0, 100); @@ -297,41 +293,4 @@ public sealed class FireControlCut8Tests : IDisposable Assert.Empty(e.Target.EntityInfoGathered); Assert.True(e.Target.VisibleEntities.Count == 0, "the target's visible set must be empty"); } - - // ---- Ordering risk (8.1's own callout): EntityInstance's loot-drop subscription (EntityInstance.cs:292-325) - // reads Entity.Equipment on Entity.HullDamage, independent of Entity.Death/Deactivate. EntityInstance - // itself is a MonoBehaviour and cannot run headlessly (it lives in the Gameplay assembly, not - // Aetheria.Shared), so the actual drop call (ZoneRenderer.DropItem) is an operator check, not something - // this suite reaches. What this suite CAN confirm headlessly is the data precondition that call depends - // on: Deactivate() must not clear Equipment, regardless of subscription order relative to Zone's own - // Death-triggered removal. ---- - - // Mirrors EntityInstance's own subscription shape (HullDamage, re-checking Durability itself) established - // AFTER the target has already joined the zone -- so Zone's Death-derived Deactivate (subscribed at join - // time, per 8.1) fires first on the same HullDamage.OnNext call, and this handler observes Equipment - // strictly after that. If Deactivate ever starts clearing Equipment, this goes red. - [Fact] - public void LootStillDropsOnDeath_EquipmentSurvivesDeactivate() - { - var e = Build(TestSettings(), damage: 10000, hullDurability: 5); // one hit is lethal - - List equipmentAtDeath = null; - e.Target.HullDamage.Subscribe(_ => - { - if (e.Target.Hull.Durability < .01f) equipmentAtDeath = e.Target.Equipment.ToList(); - }); - - FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - e.Zone.Update(.1f); - - Assert.True(e.Target.Hull.Durability < .01f); - Assert.False(e.Target.Active); // Zone's Death subscription (subscribed before this test's own) already - // deactivated the target by the time this handler runs. - Assert.NotNull(equipmentAtDeath); - Assert.NotEmpty(equipmentAtDeath); // the target's own gun -- Deactivate must not have cleared Equipment - - // Operator check, not reachable headlessly: confirm in play that ZoneRenderer.DropItem still fires for - // each surviving equipment/cargo entry (EntityInstance.cs:292-325) after this cut, i.e. that loot - // actually drops on a live kill, not only that the data behind it survived Deactivate(). - } } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 50d17bf88..09e76e35b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -128,10 +128,8 @@ public sealed class FireControlCut9Tests : IDisposable Assert.True(target.TryFindSpace(targetMarker, out var targetMarkerPos)); Assert.True(target.TryEquip(targetMarker, targetMarkerPos)); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, 100); @@ -339,10 +337,8 @@ public sealed class FireControlCut9Tests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, 100); @@ -566,10 +562,8 @@ public sealed class FireControlCut9Tests : IDisposable var bystanderGun = new EquippableItem { Data = bystanderGunRef, Durability = 1, Lot = 4 }; Assert.True(bystander.TryEquip(bystanderGun, new int2(0, 0))); // _orderedEquipment must not be empty/null for Update to run - zone.Entities.Add(locker); - zone.Entities.Add(bystander); - locker.Activate(); - bystander.Activate(); + zone.Admit(locker, piloted: false); + zone.Admit(bystander, piloted: false); locker.Position = float3.zero; bystander.Position = float3(0, 0, 100); @@ -602,7 +596,7 @@ public sealed class FireControlCut9Tests : IDisposable // the target dies or leaves while the locker is inactive -- its own Zone.Entities.ObserveRemove // subscription (the only thing that would have nulled Target) was disposed by Deactivate above, so // nothing reacts to this removal yet. - s.Zone.Entities.Remove(s.Bystander); + s.Zone.Remove(s.Bystander); s.Locker.Activate(); // undock Assert.True(s.Locker.Active); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 488d2f58e..bb065db55 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -237,10 +237,8 @@ public sealed class FireControlPerWeaponTests : IDisposable var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3(-53, 0, -101); target.Position = shooter.Position + float3(0, 0, targetRange); diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 70421bcbf..749923547 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -95,8 +95,7 @@ public sealed class FireControlTests : IDisposable weapons[i] = ship.Equipment.Single(e => e.EquippableItem == gun); } - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return (items, ship, weapons); } @@ -195,8 +194,7 @@ public sealed class FireControlTests : IDisposable // gun so the target is a real, ordinarily-constructed ship rather than a workaround-shaped one. var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); Assert.True(target.TryEquip(new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 1 }, new int2(0, 0))); - zone.Entities.Add(target); - target.Activate(); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = targetPosition; diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index 77cd3b48e..5b98ba649 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -239,9 +239,8 @@ public sealed class FloatingBodyTests : IDisposable Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) }; var ship = new Ship(items, zone, hull, new EntitySettings()); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Aim = float3(0, 0, 1); - ship.Activate(); ship.Position = float3(x, 0, 0); return ship; } diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 3e1012889..7fe1d9127 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -82,8 +82,7 @@ public sealed class IffAndCombatTests : IDisposable Assert.True(ship.TryEquip(gun, HardpointCell)); } - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return ship; } @@ -132,8 +131,9 @@ public sealed class IffAndCombatTests : IDisposable a.SetIff(b, true); Assert.True(a.EntityHostility[b]); - zone.Entities.Remove(b); // b leaves the zone: a's override on b must be cleared - zone.Entities.Add(b); // b returns as a "new" contact from a's perspective + zone.Remove(b); // b leaves the zone: a's override on b must be cleared + b.Deactivate(); + zone.Admit(b, piloted: false); // b returns as a "new" contact from a's perspective Assert.False(a.EntityHostility[b]); // recomputed fresh from the derived (non-hostile) rule Assert.False(a.IsHostileTo(b)); diff --git a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs index 22ab58df9..a968cec7e 100644 --- a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs +++ b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs @@ -33,8 +33,7 @@ public sealed partial class FireControlCut124Tests { var hullRef = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Hull")); var ship = new Ship(e.Items, e.Zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = lot }, new EntitySettings()); - e.Zone.Entities.Add(ship); - ship.Activate(); + e.Zone.Admit(ship, piloted: false); ship.Position = position; return ship; } @@ -136,7 +135,7 @@ public sealed partial class FireControlCut124Tests var gone = Build(TestSettings(), SolidShape(5, 4), velocity: 50f, damage: 100f); var goneAbsorbs = new[] { gone.Target, gone.Shooter }.SelectMany(x => Collect(x, subs)).ToList(); FireControl.Fire(gone.Weapon, gone.WeaponItem, gone.Shooter); - gone.Zone.Entities.Remove(gone.Target); + gone.Zone.Remove(gone.Target); for (var i = 0; i < 40; i++) gone.Zone.Update(.1f); foreach (var s in subs) s.Dispose(); diff --git a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs index d72113458..3a7b9e2d5 100644 --- a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs +++ b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs @@ -97,8 +97,7 @@ public sealed class InputCapacitorTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return (ship, items, ship.GetBehavior()); } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 5d0d523c9..f95d67047 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -501,7 +501,8 @@ public sealed class LoadoutTests : IDisposable } }); var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); - foreach (var (name, design) in new[] { ("Twin by Maker", hull.Key), ("Cap by Maker", capacitor.Key) }) + var tractor = cache.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } }); + foreach (var (name, design) in new[] { ("Twin by Maker", hull.Key), ("Cap by Maker", capacitor.Key), ("Tractor by Maker", tractor.Key) }) cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); cache.FlushAsync().Wait(); diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index e876bbf15..1c23d15aa 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -104,8 +104,7 @@ public sealed class LockWarningTests : IDisposable var gearRef = _items.ItemData.RefOf(_items.ItemData.GetByName(launcher)); Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = _nextLot++ }, HardpointCell)); } - _zone.Entities.Add(ship); - ship.Activate(); + _zone.Admit(ship, piloted: false); ship.Position = position; _zone.Update(0f); // brings the gear online return ship; diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index 4c3d78689..e6f79fe93 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -133,8 +133,7 @@ public sealed class LootTests : IDisposable Assert.True(ship.CargoBays.Single().TryStore(lab.Ore(7))); Assert.True(ship.CargoBays.Single().TryStore(lab.Make("Spare"))); } - lab.Zone.Entities.Add(ship); - ship.Activate(); + lab.Zone.Admit(ship, piloted: false); ship.Position = at; return ship; } @@ -153,8 +152,7 @@ public sealed class LootTests : IDisposable Assert.True(station.TryEquip(spare, pos)); Assert.True(station.CargoBays.Single().TryStore(lab.Ore(3))); } - lab.Zone.Entities.Add(station); - station.Activate(); + lab.Zone.Admit(station, piloted: false); station.Position = at; return station; } @@ -393,10 +391,10 @@ public sealed class LootTests : IDisposable var lab = Build(probability: 1f); var ship = Spawn(lab, float3(3, 0, 4)); // What Entity.TryDock then Entity.TryUndock do to the zone: Entities.Remove + Deactivate, then Zone.Admit. - lab.Zone.Entities.Remove(ship); + lab.Zone.Remove(ship); ship.Deactivate(); lab.Zone.Admit(ship, piloted: false); - lab.Zone.Entities.Remove(ship); + lab.Zone.Remove(ship); lab.Zone.Admit(ship, piloted: false); Kill(ship); var dropped = lab.Zone.FloatingItems.Select(f => f.Item).ToList(); @@ -412,7 +410,7 @@ public sealed class LootTests : IDisposable var ship = Spawn(lab, float3(3, 0, 4)); // What ActionGameManager does on a jump. ship.Deactivate(); - lab.Zone.Entities.Remove(ship); + lab.Zone.Remove(ship); ship.Zone = other; other.Admit(ship, piloted: false); Kill(ship); @@ -509,9 +507,10 @@ public sealed class LootTests : IDisposable Assert.True(lab.Zone.FloatingItems.Select(f => Math.Round(Math.Atan2(f.Body.Velocity.y, f.Body.Velocity.x), 3)).Distinct().Count() > 1); } - // A member added twice holds one death subscription, so it drops each item once, as a single death would. + // Membership has one owner (aetheria-release zone-entities-encapsulation): a second admission of a member is + // refused, so it never holds a second death watch and the zone never lists it twice. [Fact] - public void AMemberAddedTwiceStillDropsEachItemOnce() + public void AMemberAdmittedTwiceIsRefusedAndStillDropsEachItemOnce() { var once = Build("Once", probability: 1f); Kill(Spawn(once, float3(3, 0, 4))); @@ -520,20 +519,45 @@ public sealed class LootTests : IDisposable var lab = Build("Twice", probability: 1f); var ship = Spawn(lab, float3(3, 0, 4)); - lab.Zone.Entities.Add(ship); + Assert.Throws(() => lab.Zone.Admit(ship, piloted: false)); + Assert.Equal(1, lab.Zone.Entities.Count(e => e == ship)); Kill(ship); Assert.Equal(baseline, lab.Zone.FloatingItems.Count); } - // Clearing Entities removes every member, so a death afterwards drops nothing in this zone. + // Remove is the one removal: a non-member is refused with nothing changed, and a member's removal takes its death + // watch with it, so a death signal afterwards drops nothing and a re-admission watches exactly once. [Fact] - public void AClearedZoneDropsNothingWhenAFormerMemberDies() + public void RemovingANonMemberChangesNothingAndARemovedMembersWatchGoesWithIt() { - var lab = Build("Cleared", probability: 1f); + var lab = Build(probability: 1f); var ship = Spawn(lab, float3(3, 0, 4)); - lab.Zone.Entities.Clear(); + var stranger = Spawn(Build("Elsewhere", probability: 1f), float3(1, 0, 1)); + var members = lab.Zone.Entities.Count; + Assert.False(lab.Zone.Remove(stranger)); + Assert.Equal(members, lab.Zone.Entities.Count); + + Assert.True(lab.Zone.Remove(ship)); + Assert.False(lab.Zone.Remove(ship)); Kill(ship); Assert.Empty(lab.Zone.FloatingItems); + + ship.Deactivate(); + lab.Zone.Admit(ship, piloted: false); + Kill(ship); + var dropped = lab.Zone.FloatingItems.Select(f => f.Item).ToList(); + Assert.NotEmpty(dropped); + Assert.Equal(dropped.Count, dropped.Distinct().Count()); + } + + // The membership view has no mutating member: a caller can read it and subscribe to it, and nothing else. + [Fact] + public void TheMembershipViewOffersNoWayToMutateMembership() + { + var lab = Build(); + Spawn(lab, float3(3, 0, 4)); + Assert.IsNotAssignableFrom>(lab.Zone.Entities); + Assert.IsNotAssignableFrom(lab.Zone.Entities); } // The pickup is whole or nothing at the arithmetic boundary: two stacks of 90 hold 20 more in all and no free diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 74541bdac..da225ce86 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -130,8 +130,7 @@ public sealed class MineTests : IDisposable var targeting = lab.Make("Targeting", 1); Assert.True(layer.TryFindSpace(targeting, out var tpos)); Assert.True(layer.TryEquip(targeting, tpos)); - zone.Entities.Add(layer); - layer.Activate(); + zone.Admit(layer, piloted: false); layer.Position = layerAt ?? float3(-53, 0, -101); lab.Layer = layer; zone.Update(1f); @@ -152,8 +151,7 @@ public sealed class MineTests : IDisposable Assert.True(ship.TryFindSpace(gear, out var spos)); Assert.True(ship.TryEquip(gear, spos)); } - lab.Zone.Entities.Add(ship); - ship.Activate(); + lab.Zone.Admit(ship, piloted: false); ship.Position = at; if (shield != null) ship.Shield.Item.Enabled.Value = true; return ship; diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index c3bad4824..8ad9d8fc3 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -220,10 +220,9 @@ public sealed partial class MiningCut3Tests : IDisposable s.Cache.Upsert(gun); Assert.True(ship.TryEquip(Mint(s, gun))); } - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, at); ship.Aim = float3(0, 0, 1); - ship.Activate(); return ship; } @@ -865,10 +864,9 @@ public sealed partial class MiningCut3Tests : IDisposable var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); Assert.True(ship.TryEquip(Mint(s, s.Eye))); foreach (var weapon in weapons) Assert.True(ship.TryEquip(Mint(s, weapon))); - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, eyeAt); ship.Aim = float3(0, 0, 1); - ship.Activate(); Tick((ship, eyeAt)); // weapons read their ranges when they run return ship; } diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs index fe36125bc..f1f8eed9d 100644 --- a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -53,10 +53,9 @@ public sealed partial class MiningCut3Tests var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); Assert.True(ship.TryEquip(Mint(s, eye))); Assert.True(ship.TryEquip(Mint(s, gun))); - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, at); ship.Aim = float3(0, 0, 1); - ship.Activate(); Tick((ship, at)); // equipment comes online and weapons read their ranges on the first tick return ship; } @@ -176,7 +175,7 @@ public sealed partial class MiningCut3Tests Assert.True(expected.ToHashSet().SetEquals(offered), $"sensitivity {sensitivity}: {expected.Count} visible, {offered.Count} offered"); visibleSeen += expected.Count; if (examined < inReach.Count) pruned++; - s.Zone.Entities.Remove(observer); + s.Zone.Remove(observer); } Assert.True(visibleSeen > 50 && pruned > 4, $"degenerate: {visibleSeen} visible, {pruned} queries pruned"); } @@ -421,7 +420,7 @@ public sealed partial class MiningCut3Tests var brightest = all.Max(c => s.Zone.ChunkVisibility(c)); var unit = Observer(s, float2(5000, 0), 350f, 1f, Falloff()); var sensitivity = 120f / (brightest * unit.DetectionReachPerVisibility()); - s.Zone.Entities.Remove(unit); + s.Zone.Remove(unit); var offered = new List(); long examined = 0, inReach = 0; @@ -436,7 +435,7 @@ public sealed partial class MiningCut3Tests observer.VisibleChunksInReach(offered); examined += s.Zone.Targets.Examined - before; inReach += all.Count(c => length(At(s, c) - at) <= 350f); - s.Zone.Entities.Remove(observer); + s.Zone.Remove(observer); } Assert.True(examined < .265 * inReach, $"examined {examined} of {inReach} rocks in reach: the visibility bound has loosened"); } diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs index 64304e066..374f9292e 100644 --- a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -33,7 +33,7 @@ public sealed partial class MiningCut3Tests { var observer = Observer(s, at, reach, sensitivity, Falloff()); if (inReach.Count(observer.ChunkVisible) >= share * inReach.Count) return (s, observer); - s.Zone.Entities.Remove(observer); + s.Zone.Remove(observer); } throw new InvalidOperationException("no sensitivity sees the rocks"); } @@ -42,7 +42,7 @@ public sealed partial class MiningCut3Tests private static Ship Sight(Scene s, Ship observer, float2 at, bool enemy = false) { var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, at); observer.VisibleEntities.Add(ship); if (enemy) observer.VisibleEnemies.Add(ship); @@ -265,7 +265,7 @@ public sealed partial class MiningCut3Tests observer.Aim = float3(cos(heading), 0, sin(heading)); Check($"the reticle at {heading}", Cost(observer.TargetUnderReticle, TargetRef.None), reticle); } - s.Zone.Entities.Remove(observer); + s.Zone.Remove(observer); } } Assert.True(failures.Count == 0, string.Join("; ", failures)); @@ -336,10 +336,9 @@ public sealed partial class MiningCut3Tests var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); Assert.True(ship.TryEquip(Mint(s, s.Eye))); Assert.True(ship.TryEquip(Mint(s, launcher))); - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, at); ship.Aim = float3(0, 0, 1); - ship.Activate(); Tick((ship, at)); Assert.Contains(ship.Weapons, w => w.Range == 400f); Assert.True(AllChunks(s).Any(c => length(At(s, c) - at) <= 400f && ship.ChunkVisible(c)), "there are rocks within the launcher's range"); @@ -375,7 +374,7 @@ public sealed partial class MiningCut3Tests private static Ship Unseen(Scene s, float2 at) { var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, at); return ship; } diff --git a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs index b49b10481..5f79e2664 100644 --- a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs +++ b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs @@ -266,8 +266,7 @@ public sealed class MunitionPartsTests : IDisposable Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf(gear), Durability = gear.Durability, Lot = 1 }, new int2(x, y)), name); } Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf(gt), Durability = gt.Durability, Lot = 2 }, hardpoint.Position)); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); // Settle at the item's optimal temperature, as ConditionRatioTests does, so heat and wear read their identity and only // the role's quality moves a stat. var item = ship.Equipment.Single(e => e.Data.Name == "GT 3K"); diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 7ab47da79..44af2dae0 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -124,8 +124,7 @@ public sealed class PaintModeTests : IDisposable var gearRef2 = _items.ItemData.RefOf(_items.ItemData.GetByName(sensor2)); Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef2, Durability = 1, Lot = _nextLot++ }, new int2(1, 0))); } - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); ship.Position = position; ship.SetSignature(new object(), SignatureChannel.Reflection, 5f, null); zone.Update(0f); // brings the gear online @@ -331,7 +330,7 @@ public sealed class PaintModeTests : IDisposable if (tick == 2 && illuminatorDies) { Assert.True(SensorOf(a).PingRadius < 100f, "fixture: A dies before its wavefront reaches T"); - _zone.Entities.Remove(a); + _zone.Remove(a); } track.Add(Track(c, t)); } @@ -422,7 +421,7 @@ public sealed class PaintModeTests : IDisposable for (var tick = 1; tick <= 24; tick++) { Tick(.1f); - if (tick == removePingerAfterTick) _zone.Entities.Remove(a); + if (tick == removePingerAfterTick) _zone.Remove(a); Near(tick <= 3 ? 0f : .21213f, Track(c, t)); Assert.Equal(tick == 4 ? 1 : 0, t.IncomingPaints.Count); } @@ -683,7 +682,7 @@ public sealed class PaintModeTests : IDisposable SensorOf(a).Ping(); for (var tick = 1; tick <= 3; tick++) Tick(.1f); Assert.True(SensorOf(a).PingRadius > 50f); // the front had passed 50 m by the move - a.Deactivate(); _zone.Entities.Remove(a); a.Zone = z2; z2.Admit(a, false); + a.Deactivate(); _zone.Remove(a); a.Zone = z2; z2.Admit(a, false); var paints = 0; for (var tick = 1; tick <= 24; tick++) { z2.Update(.1f); paints += near.IncomingPaints.Count + far.IncomingPaints.Count; } Assert.Equal(0, paints); @@ -703,8 +702,8 @@ public sealed class PaintModeTests : IDisposable var paints = 0; for (var tick = 1; tick <= 30; tick++) { - if (tick == 5) { _zone.Entities.Remove(t); t.Deactivate(); } - if (tick == 8) { _zone.Entities.Add(t); t.Activate(); } + if (tick == 5) { _zone.Remove(t); t.Deactivate(); } + if (tick == 8) { _zone.Admit(t, piloted: false); } Tick(.1f); paints += t.IncomingPaints.Count; } diff --git a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs new file mode 100644 index 000000000..fefdaeb81 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Linq; +using Xunit; + +// Pickup is a capability (aetheria-release cut loot-2), so a ship that can pick things up carries the gear that grants +// it. Generation is the one place a ship's gear is chosen, so this runs the real catalog through it. +public sealed partial class RunStartTests +{ + // The beams a pack carries fitted. A station may stock one in its hold to sell, which is the point of selling it. + private int PickupGear(EntityPack pack) => pack.Equipment.Select(fitted => fitted.item) + .Count(item => _items.GetData(item) is GearData gear && gear.Behaviors.Any(b => b is PickupData)); + + [Fact] + public void EveryGeneratedShipCarriesATractorBeam() + { + // Every ship hull a product sells. + var generator = PreludeGenerator(); + foreach (var hull in LoadoutGenerator.SoldHulls(_cache).Where(hull => hull.HullType == HullType.Ship)) + Assert.True(PickupGear(generator.GenerateShipLoadout(candidate => candidate == hull)) == 1, $"a {hull.Name} carries exactly one tractor beam"); + + // Ships generated for each faction of a main galaxy, and the starting ship the game stages. + var main = Launch(new MainGalaxy(), Inputs(() => GalaxySeed)); + Assert.Empty(main.failures); + var factions = main.galaxy.Factions.ToArray(); + for (var i = 0; i < 50; i++) + { + var faction = factions[i % factions.Length]; + var maker = new LoadoutGenerator(ref _items.Random, _items, main.galaxy, main.galaxy.Entrance, faction, .5f); + Assert.True(PickupGear(maker.GenerateShipLoadout()) == 1, $"ship {i} of {faction.Name} carries exactly one tractor beam"); + } + Assert.Equal(1, main.staged.Player.Equipment.Count(item => item.Behaviors.OfType().Any())); + Assert.All(main.arena.Pack.Entities.Where(pack => pack is ShipPack), pack => Assert.Equal(1, PickupGear(pack))); + + // Stations and turrets share the interior outfitting and get none. + for (var i = 0; i < 4; i++) + { + Assert.Equal(0, PickupGear(generator.GenerateStationLoadout())); + Assert.Equal(0, PickupGear(generator.GenerateTurretLoadout())); + } + Assert.All(main.arena.Pack.Entities.Where(pack => !(pack is ShipPack)), pack => Assert.Equal(0, PickupGear(pack))); + } +} diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs new file mode 100644 index 000000000..330864fc3 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -0,0 +1,706 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// The tractor beam in the sim (aetheria-release cut loot-2): a Pickup behaviour spins its beam up in sim time and runs +// one timed grab of a floating item, ending in Zone.TryPickUp. Rulings: jettison-shared-floating-items, +// unity-presents-only-r3, sim-speed-presentation; the question loot-grab-release-cancels, built hold-required. Every +// grab here is run by Zone.Update ticking a ship's own gear: nothing calls the behaviour directly. +public sealed class PickupTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-pickup-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public PickupTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var c in _caches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape Solid(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private static PickupData Beam(float extend = .3f, float envelop = .2f, float pull = .5f) => new PickupData + { + Range = Constant(75), BeamRadius = Constant(25), GrabPower = Constant(.5f), SpinRate = Constant(2), + ExtendTime = Constant(extend), EnvelopTime = Constant(envelop), PullTime = Constant(pull) + }; + + private sealed class Lab + { + public ItemManager Items; + public Zone Zone; + public int Lot = 1; + + public EquippableItem Make(string name) => new EquippableItem + { + Data = Items.ItemData.RefOf(Items.ItemData.GetByName(name)), Durability = 1, Lot = Lot++ + }; + + public SimpleCommodity Ore(int quantity) => new SimpleCommodity + { + Data = Items.ItemData.RefOf(Items.ItemData.GetByName("Ore")), Quantity = quantity + }; + } + + private Lab Build(PickupData beam, float lifetime = 30f) + { + var settings = new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f, + FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 1f, + UnaidedAccuracy = .05f, UnaidedTracking = 10f, UnaidedPrecision = 2f, AgentMinHitProbability = 0f, + LootDropProbability = 0, LootDropVelocity = 25, FloatingItemLifetime = lifetime + }; + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 5), Durability = 1000000, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = Solid(1, 1), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { beam } + }); + cache.Upsert(new GearData + { + Name = "Spare", Hardpoint = HardpointType.Tool, Shape = Solid(1, 1), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new GearData + { + Name = "Block", Hardpoint = HardpointType.Tool, Shape = Solid(2, 2), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = Solid(4, 4), Durability = 4 }); + cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = Solid(1, 1), MaxStack = 100 }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Pickup", Owner = null }, null); + return new Lab { Items = items, Zone = zone }; + } + + // A ship with a cargo bay, aimed along +x and (unless told otherwise) carrying the tractor beam. + private static Ship Spawn(Lab lab, float3 at, bool beam = true) + { + var hull = lab.Items.ItemData.RefOf(lab.Items.ItemData.GetByName("Skiff")); + var ship = new Ship(lab.Items, lab.Zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = lab.Lot++ }, new EntitySettings()); + var crate = lab.Make("Crate"); + Assert.True(ship.TryFindSpace(crate, out var cratePos)); + Assert.True(ship.TryEquip(crate, cratePos)); + if (beam) Equip(ship, lab.Make("Tractor")); + lab.Zone.Admit(ship, piloted: false); + ship.Position = at; + ship.Aim = float3(1, 0, 0); + return ship; + } + + private static void Equip(Ship ship, EquippableItem gear) + { + Assert.True(ship.TryFindSpace(gear, out var pos)); + Assert.True(ship.TryEquip(gear, pos)); + } + + private static FloatingItem Drift(Lab lab, float x, float y, float2 velocity = default, int ore = 5) => + lab.Zone.Release(lab.Ore(ore), float2(x, y), velocity); + + private static List Watch(Entity ship, CompositeDisposable bag) + { + var events = new List(); + ship.Capabilities.Grab.Subscribe(events.Add).AddTo(bag); + return events; + } + + private static void Tick(Lab lab, int ticks, float dt) + { + for (var i = 0; i < ticks; i++) lab.Zone.Update(dt); + } + + // Ticks until the condition holds, for at most twenty sim seconds; a grab that never comes fails here instead of hanging. + private static void Until(Lab lab, float dt, Func done) + { + for (var tick = 0; tick < 1200 && !done(); tick++) lab.Zone.Update(dt); + Assert.True(done(), "the expected moment never came"); + } + + private static void Kill(Entity e) => e.HeatstrokeDeath.OnNext(Unit.Default); + + [Fact] + public void NoPickupBehaviourNoGrab() + { + using var bag = new CompositeDisposable(); + + // The same ship and item, with and without the gear: only the gear grabs. + var bare = Build(Beam()); + var plain = Spawn(bare, float3(0, 0, 0), beam: false); + var left = Drift(bare, 40, 0); + var plainEvents = Watch(plain, bag); + plain.TractorCommand = 1; + Tick(bare, 240, 1 / 60f); + Assert.True(left.Holder == null, "left is held"); + Assert.True(bare.Zone.FloatingItems.Contains(left), "left left the zone"); + Assert.Equal(0f, plain.TractorPower); + Assert.Empty(plainEvents); + + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 60, 1 / 60f); + Assert.True(ReferenceEquals(ship, item.Holder), "item is not held by ship"); + Assert.Equal(GrabPhase.Started, events.First().Phase); + Assert.True(ship.TractorPower > 0f); + } + + [Theory] + [InlineData(60)] + [InlineData(600)] + public void TheBeamSpinsUpInSimTime(int ticksPerSecond) + { + var dt = 1f / ticksPerSecond; + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0); + + // Power rises two per sim second and not faster, at any tick rate; the claim comes on the tick it passes GrabPower. + ship.TractorCommand = 1; + int claim = -1, full = -1; + var before = 0f; + for (var tick = 1; tick <= ticksPerSecond; tick++) + { + lab.Zone.Update(dt); + if (claim < 0 && item.Holder != null) + { + claim = tick; + Assert.True(ship.TractorPower >= .5f); + Assert.True(before < .5f); + } + if (full < 0 && ship.TractorPower >= 1f) full = tick; + before = ship.TractorPower; + } + Assert.Equal(ticksPerSecond / 4, claim); + Assert.Equal(ticksPerSecond / 2, full); + + // Command 0 spins it down at the same rate. + ship.TractorCommand = 0; + var empty = -1; + for (var tick = 1; tick <= ticksPerSecond; tick++) + { + lab.Zone.Update(dt); + if (empty < 0 && ship.TractorPower <= 0f) empty = tick; + } + Assert.Equal(ticksPerSecond / 2, empty); + } + + [Theory] + [InlineData(.3f, .2f, .5f, 60)] + [InlineData(1f, 1f, 1f, 180)] + public void AGrabTakesExactlyItsPhasesThenCommits(float extend, float envelop, float pull, int ticksAfterClaim) + { + foreach (var rate in new[] { 60, 600 }) + { + var dt = 1f / rate; + var lab = Build(Beam(extend, envelop, pull)); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0); + ship.TractorCommand = 1; + int claim = -1, commit = -1; + for (var tick = 1; tick <= rate * 6 && commit < 0; tick++) + { + lab.Zone.Update(dt); + if (claim < 0 && item.Holder != null) claim = tick; + if (!lab.Zone.FloatingItems.Contains(item)) commit = tick; + } + Assert.True(claim > 0); + Assert.Equal(ticksAfterClaim * rate / 60, commit - claim); + Assert.Equal(5, ship.CargoBays.Single().Cargo.Keys.OfType().Single().Quantity); + } + } + + [Fact] + public void GrabEventsNameTheFloatingItem() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var other = Drift(lab, 40, 20); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + for (var tick = 1; tick <= 150; tick++) lab.Zone.Update(1 / 60f); + Assert.Equal(new[] { GrabPhase.Started, GrabPhase.Extend, GrabPhase.Envelop, GrabPhase.Pull, GrabPhase.Completed }, + events.Take(5).Select(e => e.Phase).ToArray()); + Assert.All(events.Take(5), e => Assert.Equal(item.Id, e.Target)); + Assert.NotEqual(other.Id, item.Id); + Assert.Equal(.3f, events[1].PhaseDuration, 4); + Assert.Equal(.2f, events[2].PhaseDuration, 4); + Assert.Equal(.5f, events[3].PhaseDuration, 4); + } + + // Each phase is published on the first tick at or after its boundary (.3, .5 and 1 s after the claim), at any + // tick rate or an uneven one. The clock is a float sum, so a boundary an exact number of ticks away can land one + // tick late (Envelop and Pull do at 1/600): the rule is "not before, at most one tick after", not a tick count. + [Theory] + [InlineData(1, 60, 60, 60, 60)] + [InlineData(1, 600, 600, 600, 600)] + [InlineData(4, 60, 24, 90, 33)] + public void EachPhaseFiresOnTheFirstTickAtOrAfterItsBoundary(int cycle, int a, int b, int c, int d) + { + using var bag = new CompositeDisposable(); + var rates = cycle == 1 ? new[] { a } : new[] { a, b, c, d }; + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + var since = -1.0; + var times = new List(); + var steps = new List(); + for (var tick = 0; tick < rates.Length * 3000 && events.Count(e => e.Phase == GrabPhase.Completed) == 0; tick++) + { + var dt = 1f / rates[tick % rates.Length]; + lab.Zone.Update(dt); + if (since >= 0) since += dt; + else if (events.Count > 0) since = 0; + while (times.Count < events.Count) { times.Add(since); steps.Add(dt); } + } + var phases = events.Select(e => e.Phase).ToList(); + Assert.Equal(GrabPhase.Completed, phases.Last()); + foreach (var (phase, boundary) in new[] { (GrabPhase.Envelop, .3), (GrabPhase.Pull, .5), (GrabPhase.Completed, 1.0) }) + { + var i = phases.IndexOf(phase); + Assert.True(i >= 0, "the phase never came"); + Assert.True(times[i] >= boundary - 1e-5, "the phase fired before its boundary"); + // Where no tick lands exactly on a boundary (the uneven cycle) the tick before must be strictly short of it; + // on a regular grid the float sum may leave that tick a hair short, so it may be equal. + var slack = cycle == 1 ? 1e-5 : -1e-5; + Assert.True(times[i] - steps[i] < boundary + slack, "the phase fired after the first tick at or past its boundary"); + } + } + + [Fact] + public void ReleasingTheBeamCancelsAndTheItemFloatsFree() + { + using var bag = new CompositeDisposable(); + const float dt = 1 / 60f; + var lab = Build(Beam(), lifetime: 2f); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0, float2(3, 1)); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + var tick = 0; + while (!events.Any(e => e.Phase == GrabPhase.Pull) && tick++ < 600) lab.Zone.Update(dt); + Assert.True(ReferenceEquals(ship, item.Holder), "item is not held by ship"); + var velocity = item.Body.Velocity; + var drift = item.Body.Drift; + + // The key comes up during the pull: Power falls below GrabPower and the grab is dropped. + ship.TractorCommand = 0; + while (!events.Any(e => e.Phase == GrabPhase.Cancelled) && tick++ < 600) lab.Zone.Update(dt); + Assert.True(tick < 110); + Assert.DoesNotContain(events, e => e.Phase == GrabPhase.Completed); + Assert.True(item.Holder == null, "item is held"); + Assert.True(lab.Zone.FloatingItems.Contains(item), "item left the zone"); + // (It has taken the one step of its freeing tick, so it is the held velocity less a tick's drag.) + Assert.Equal(velocity.x, item.Body.Velocity.x, velocity.x * .01f); + Assert.Equal(velocity.y, item.Body.Velocity.y, velocity.y * .01f); + Assert.True(velocity.x > 1f); + + // It drifts on with that velocity and expires on the clock it was released on, not from the cancel. + var at = item.Body.Position; + lab.Zone.Update(dt); + Assert.True(item.Body.Position.x > at.x); + while (lab.Zone.FloatingItems.Contains(item) && tick++ < 600) lab.Zone.Update(dt); + Assert.InRange(tick, 118, 122); + } + + [Fact] + public void AHoldThatFillsMidGrabCancelsAtTheEnd() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var bay = ship.CargoBays.Single(); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Until(lab, 1 / 60f, () => item.Holder != null); + + // The hold fills while the grab runs: the item slides in and then floats free. + for (var i = 0; i < 16; i++) Assert.True(bay.TryStore(lab.Make("Spare"))); + var tick = 0; + while (!events.Any(e => e.Phase == GrabPhase.Cancelled) && tick++ < 300) lab.Zone.Update(1 / 60f); + Assert.Equal(new[] { GrabPhase.Started, GrabPhase.Extend, GrabPhase.Envelop, GrabPhase.Pull, GrabPhase.Cancelled }, + events.Select(e => e.Phase).ToArray()); + Assert.True(item.Holder == null, "item is held"); + Assert.True(lab.Zone.FloatingItems.Contains(item), "item left the zone"); + Assert.Equal(16, bay.Cargo.Count); + } + + [Fact] + public void AFullHoldNeverClaimsWhatItCannotStore() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var bay = ship.CargoBays.Single(); + for (var i = 0; i < 16; i++) Assert.True(bay.TryStore(lab.Make("Spare"))); + var item = Drift(lab, 40, 0, float2(3, 1)); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 300, 1 / 60f); + Assert.Equal(1f, ship.TractorPower); + Assert.Empty(events); + Assert.True(item.Holder == null, "item is held"); + Assert.True(item.Body.Position.x > 40f, "item was pinned instead of drifting"); + } + + [Fact] + public void AnItemTheHoldCannotStoreDoesNotBlockOneItCan() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var bay = ship.CargoBays.Single(); + for (var i = 0; i < 15; i++) Assert.True(bay.TryStore(lab.Make("Spare"))); + // One free cell: the 2x2 block is nearest the axis and cannot be stored; the ore beside it can. + var block = lab.Zone.Release(lab.Make("Block"), float2(30, 0), default); + var ore = Drift(lab, 50, 10); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 150, 1 / 60f); + Assert.Contains(events, e => e.Phase == GrabPhase.Completed); + Assert.All(events, e => Assert.Equal(ore.Id, e.Target)); + Assert.False(lab.Zone.FloatingItems.Contains(ore), "ore is still in the zone"); + Assert.True(lab.Zone.FloatingItems.Contains(block), "block left the zone"); + Assert.True(block.Holder == null, "block is held"); + } + + [Fact] + public void AnItemInTheBeamsFarCornerIsCollected() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + // 74 along and 20 off the axis: inside the beam, though 76.7 from the ship, past Range as a straight distance. + var corner = Drift(lab, 74, 20); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 150, 1 / 60f); + Assert.DoesNotContain(events, e => e.Phase == GrabPhase.Cancelled); + Assert.Contains(events, e => e.Phase == GrabPhase.Completed); + Assert.False(lab.Zone.FloatingItems.Contains(corner), "corner is still in the zone"); + } + + [Fact] + public void AnItemLeavingTheBeamBeforeThePullIsCancelled() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Until(lab, 1 / 60f, () => item.Holder != null); + + // The pilot swings the beam away while the grab extends: the item is off the axis and the grab is dropped. + ship.Aim = float3(0, 0, 1); + Tick(lab, 3, 1 / 60f); + Assert.Equal(GrabPhase.Cancelled, events.Last().Phase); + Assert.True(item.Holder == null, "item is held"); + } + + [Fact] + public void OneHolderPerItem() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var a = Spawn(lab, float3(0, 0, 0)); + var b = Spawn(lab, float3(0, 0, 10)); + var eventsA = Watch(a, bag); + var eventsB = Watch(b, bag); + + // One item, two beams: exactly one claims it, the other never does, and the non-holder cannot take it. + var only = Drift(lab, 40, 5); + a.TractorCommand = 1; + b.TractorCommand = 1; + Tick(lab, 20, 1 / 60f); + Assert.Equal(1, new[] { eventsA, eventsB }.Count(e => e.Any(x => x.Phase == GrabPhase.Started))); + var holder = only.Holder; + var loser = ReferenceEquals(holder, a) ? b : a; + Assert.False(lab.Zone.TryPickUp(loser, only.Id)); + Assert.False(lab.Zone.TryHold(loser, only.Id)); + Assert.True(lab.Zone.FloatingItems.Contains(only), "only left the zone"); + Assert.Empty(loser.CargoBays.Single().Cargo); + Tick(lab, 120, 1 / 60f); + Assert.False(lab.Zone.FloatingItems.Contains(only), "only is still in the zone"); + Assert.Single(holder.CargoBays.Single().Cargo); + Assert.Empty(loser.CargoBays.Single().Cargo); + + // Two items, each nearest a different beam's axis: each ship holds its own. + var first = Drift(lab, 60, 0); + var second = Drift(lab, 60, 10); + Tick(lab, 20, 1 / 60f); + Assert.True(first.Holder != null, "first is free"); + Assert.True(second.Holder != null, "second is free"); + Assert.False(ReferenceEquals(first.Holder, second.Holder), "one holder for two items"); + + // Both ships' nearest item is the same one: the one that loses it takes the other. + var shared = Build(Beam()); + var p = Spawn(shared, float3(0, 0, 0)); + var q = Spawn(shared, float3(0, 0, 10)); + var near = Drift(shared, 40, 5); + var far = Drift(shared, 40, -14); + p.TractorCommand = 1; + q.TractorCommand = 1; + Tick(shared, 20, 1 / 60f); + Assert.True(near.Holder != null, "near is free"); + Assert.True(far.Holder != null, "far is free"); + Assert.False(ReferenceEquals(near.Holder, far.Holder), "one holder for two items"); + } + + [Fact] + public void AGrabIsDroppedWhenItsGearGoesInactiveOrTheItemFallsOutOfRangeBeforeThePull() + { + using var bag = new CompositeDisposable(); + const float dt = 1 / 60f; + + // Gear switched off mid-grab: cancelled, the item free. + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 30, dt); + Assert.True(ReferenceEquals(ship, item.Holder), "item is not held by ship"); + ship.Equipment.Single(e => e.Behaviors.OfType().Any()).Enabled.Value = false; + Tick(lab, 2, dt); + Assert.Equal(GrabPhase.Cancelled, events.Last().Phase); + Assert.True(item.Holder == null, "item is held"); + + // The ship leaves while the beam is still extending: cancelled. The same departure during the pull is not. + foreach (var departAtPull in new[] { false, true }) + { + var other = Build(Beam()); + var pilot = Spawn(other, float3(0, 0, 0)); + var target = Drift(other, 40, 0); + var seen = Watch(pilot, bag); + pilot.TractorCommand = 1; + Until(other, dt, () => seen.Any(e => departAtPull ? e.Phase == GrabPhase.Pull : e.Phase == GrabPhase.Extend)); + pilot.Position = float3(-200, 0, 0); + Tick(other, 120, dt); + if (departAtPull) + { + Assert.Equal(GrabPhase.Completed, seen.First(e => e.Phase == GrabPhase.Completed || e.Phase == GrabPhase.Cancelled).Phase); + Assert.False(other.Zone.FloatingItems.Contains(target), "target is still in the zone"); + } + else + { + Assert.Equal(GrabPhase.Cancelled, seen.First(e => e.Phase == GrabPhase.Completed || e.Phase == GrabPhase.Cancelled).Phase); + Assert.True(target.Holder == null, "target is held"); + Assert.True(other.Zone.FloatingItems.Contains(target), "target left the zone"); + } + } + } + + [Fact] + public void SelectionIsTheItemNearestTheBeamAxis() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + + // The nearer to the axis wins, though the other is closer to the ship. + var near = Drift(lab, 20, 12); + var onAxis = Drift(lab, 60, 3); + Tick(lab, 30, 1 / 60f); + Assert.True(ReferenceEquals(ship, onAxis.Holder), "onAxis is not held by ship"); + Assert.True(near.Holder == null, "near is held"); + Assert.Equal(onAxis.Id, events.First().Target); + + // Out of the beam, past its range or behind the ship, an item is never taken. + foreach (var (x, y) in new[] { (30f, 30f), (80f, 0f), (-10f, 0f) }) + { + var other = Build(Beam()); + var lone = Spawn(other, float3(0, 0, 0)); + var stray = Drift(other, x, y); + var attempts = Watch(lone, bag); + lone.TractorCommand = 1; + Tick(other, 240, 1 / 60f); + Assert.Empty(attempts); + Assert.True(stray.Holder == null, "stray is held"); + Assert.True(other.Zone.FloatingItems.Contains(stray), "stray left the zone"); + } + + // Equally far from the axis, the lower id. + var tie = Build(Beam()); + var pilot = Spawn(tie, float3(0, 0, 0)); + var lower = Drift(tie, 40, 5); + var higher = Drift(tie, 40, -5); + pilot.TractorCommand = 1; + Tick(tie, 30, 1 / 60f); + Assert.True(ReferenceEquals(pilot, lower.Holder), "lower is not held by pilot"); + Assert.True(higher.Holder == null, "higher is held"); + } + + [Fact] + public void AHeldItemIsPlacedNotIntegratedAndDoesNotExpire() + { + using var bag = new CompositeDisposable(); + const float dt = 1 / 60f; + var lab = Build(Beam(), lifetime: 1f); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0, float2(3, 0)); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Until(lab, dt, () => item.Holder != null); + var claimed = item.Body.Position; + Assert.Equal(claimed, item.HeldFrom); + + // Through Extend and Envelop the item stays where it was claimed, whatever velocity it had. + for (var guard = 0; guard < 1200 && !events.Any(e => e.Phase == GrabPhase.Pull); guard++) + { + lab.Zone.Update(dt); + if (events.Any(e => e.Phase == GrabPhase.Pull)) break; + Assert.Equal(claimed, item.Body.Position); + } + Assert.Contains(events, e => e.Phase == GrabPhase.Pull); + + // During the pull it moves toward the holder, and a held item outlives its lifetime. + lab.Zone.Update(dt * 10); + Assert.True(item.Body.Position.x < claimed.x); + Assert.True(item.Body.Position.x > 0); + var held = Build(Beam(), lifetime: 1f); + var carrier = Spawn(held, float3(5, 0, 7)); + var stuck = Drift(held, 40, 0); + Assert.True(held.Zone.TryHold(carrier, stuck.Id)); + Tick(held, 180, dt); + Assert.True(held.Zone.FloatingItems.Contains(stuck), "stuck left the zone"); + Assert.Equal(float2(40, 0), stuck.Body.Position); + + // The pose eases (smoothstep): a quarter of the progress is 15.625 per cent of the way, not 25. + held.Zone.SetHeldProgress(stuck.Id, .25f); + held.Zone.Update(dt); + Assert.Equal(40f - 35f * .15625f, stuck.Body.Position.x, 3); + Assert.Equal(7f * .15625f, stuck.Body.Position.y, 3); + + // Placed, not drifted: the same progress held for two steps puts it at the same halfway point. + held.Zone.SetHeldProgress(stuck.Id, .5f); + held.Zone.Update(dt); + held.Zone.Update(dt); + Assert.Equal(22.5f, stuck.Body.Position.x, 3); + Assert.Equal(3.5f, stuck.Body.Position.y, 3); + + // At progress 1 it is at the holder; freed, an item past its lifetime goes on the next step. + held.Zone.SetHeldProgress(stuck.Id, 1); + held.Zone.Update(dt); + Assert.Equal(5f, stuck.Body.Position.x, 3); + Assert.Equal(7f, stuck.Body.Position.y, 3); + held.Zone.Free(stuck.Id); + held.Zone.Update(dt); + Assert.False(held.Zone.FloatingItems.Contains(stuck), "stuck is still in the zone"); + } + + [Fact] + public void AnInactiveBeamSpinsDownAndMustSpinUpAgain() + { + using var bag = new CompositeDisposable(); + const float dt = 1 / 60f; + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var gear = ship.Equipment.Single(e => e.Behaviors.OfType().Any()); + ship.TractorCommand = 1; + Tick(lab, 60, dt); + Assert.Equal(1f, ship.TractorPower); + + // Switched off with the key still down, the beam spins down at the spin rate... + gear.Enabled.Value = false; + Tick(lab, 15, dt); + Assert.InRange(ship.TractorPower, .45f, .55f); + Tick(lab, 30, dt); + Assert.Equal(0f, ship.TractorPower); + + // ...and switched back on it spins up from nothing: an item in the beam is not claimed at once. + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + gear.Enabled.Value = true; + Tick(lab, 10, dt); + Assert.Empty(events); + Assert.True(item.Holder == null, "item is held"); + Tick(lab, 20, dt); + Assert.True(ReferenceEquals(ship, item.Holder), "item is not held by ship"); + } + + [Fact] + public void RemovingOneHolderFreesOnlyItsOwnItems() + { + var lab = Build(Beam()); + var a = Spawn(lab, float3(0, 0, 0)); + var b = Spawn(lab, float3(0, 0, 50)); + var mine = Drift(lab, 40, 0); + var theirs = Drift(lab, 40, 50); + a.TractorCommand = 1; + b.TractorCommand = 1; + Tick(lab, 30, 1 / 60f); + Assert.True(ReferenceEquals(a, mine.Holder), "mine is not held by a"); + Assert.True(ReferenceEquals(b, theirs.Holder), "theirs is not held by b"); + + Kill(a); + Assert.True(mine.Holder == null, "mine is held"); + Assert.True(ReferenceEquals(b, theirs.Holder), "theirs was freed with a"); + } + + [Fact] + public void TheHoldersLeavingFreesItsItems() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + Assert.True(ship.CargoBays.Single().TryStore(lab.Ore(7))); + var item = Drift(lab, 40, 0, ore: 3); + ship.TractorCommand = 1; + Tick(lab, 30, 1 / 60f); + Assert.True(ReferenceEquals(ship, item.Holder), "item is not held by ship"); + var before = lab.Zone.FloatingItems.Count; + + // Death goes through the zone's one removal: the held item is free, and the ship's own cargo drops as always. + Kill(ship); + Assert.True(item.Holder == null, "item is held"); + Assert.True(lab.Zone.FloatingItems.Contains(item), "item left the zone"); + Assert.Equal(before + 1, lab.Zone.FloatingItems.Count); + Assert.Equal(7, ((SimpleCommodity) lab.Zone.FloatingItems.Last().Item).Quantity); + Tick(lab, 60, 1 / 60f); + Assert.True(lab.Zone.FloatingItems.Contains(item), "item left the zone"); + Assert.True(item.Holder == null, "item is held"); + } +} diff --git a/tests/Aetheria.Shared.Tests/PowerBusTests.cs b/tests/Aetheria.Shared.Tests/PowerBusTests.cs index c0817c6a0..2ab76f599 100644 --- a/tests/Aetheria.Shared.Tests/PowerBusTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerBusTests.cs @@ -101,8 +101,7 @@ public sealed class PowerBusTests : IDisposable if (reactorFirst) { EquipReactor(); EquipCapacitor(); } else { EquipCapacitor(); EquipReactor(); } - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); var reactor = ship.GetBehavior(); var capacitor = ship.GetBehavior(); diff --git a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs index 183238e55..e0e03b4ea 100644 --- a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs @@ -103,8 +103,7 @@ public sealed class PowerCurveTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Engine")))); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return ship; } @@ -408,9 +407,8 @@ public sealed class PowerCurveTests : IDisposable var ship = new Ship(items, zone, hullItem, new EntitySettings()); Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Drain")), HardpointCell)); Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Booster")), SecondHardpointCell)); - zone.Entities.Add(ship); - var error = Assert.Throws(() => ship.Activate()); + var error = Assert.Throws(() => zone.Admit(ship, piloted: false)); Assert.Contains("Drain", error.Message); // the item whose request would be corrupted Assert.Contains("Booster", error.Message); // the item whose modifier would corrupt it } diff --git a/tests/Aetheria.Shared.Tests/PowerTierTests.cs b/tests/Aetheria.Shared.Tests/PowerTierTests.cs index 256b9bb26..ecdbd4b2d 100644 --- a/tests/Aetheria.Shared.Tests/PowerTierTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerTierTests.cs @@ -72,8 +72,7 @@ public sealed class PowerTierTests : IDisposable ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); var reactor = ship.GetBehavior(); ship.Update(0f); diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 4086e4b4a..7bca4194a 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -314,9 +314,8 @@ public sealed class RestoredHullsTests Assert.True(ship.TryEquip(fillerItem, filled.Position)); } - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Position = float3.zero; - ship.Activate(); zone.Update(0f); // warm-up: resolves equipment stats before the driven ticks read them return ship; } @@ -618,9 +617,8 @@ public sealed class RestoredHullsTests var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var ship = (Ship) EntitySerializer.Unpack(items, zone, pack); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Position = float3.zero; - ship.Activate(); zone.Update(0f); ship.MovementDirection = float2(0, 1); // full forward diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 20a7f2ba5..d4196b0cb 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -80,6 +80,52 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(8, RecordsIn(Run)); } + // Continue finds the player's entity by its place in the saved zone: the index Capture writes must be the entity's + // place in the live zone, and survive the round trip to the same entity's pack. + [Fact] + public void ThePlayersZoneIndexSurvivesSaveAndContinue() + { + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var shape = new Shape(4, 4); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + foreach (var name in new[] { "Skiff", "Barge" }) + cache.Upsert(new HullData { Name = name, HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 100 }); + cache.FlushAsync().Wait(); + } + + using (var cache = Open()) + RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 0), new ProvenanceLedger()); + + using (var cache = Open()) + { + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 2; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, TestSettings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); + galaxy.Zones[0].Contents = zone; + Ship Make(string hull, int lot) => new Ship(items, zone, new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(hull)), Durability = 100, Lot = lot + }, new EntitySettings()); + var other = Make("Skiff", 1); + var player = Make("Barge", 2); + zone.Admit(other, piloted: false); + zone.Admit(player, piloted: false); + var (game, zones) = RunSave.Capture(cache, galaxy, zone, player, new SavedActionBarBinding[0], 0); + RunSave.Commit(cache, game, zones, items.Lots); + } + + using (var cache = Open()) + { + var saved = cache.GetGlobal(); + Assert.Equal(1, saved.CurrentZoneEntity); + var packs = cache.Get(saved.Zones[saved.CurrentZone]).Contents.Entities; + Assert.Equal("Barge", cache.Get(packs[saved.CurrentZoneEntity].Hull.Data).Name); + } + } + [Fact] public void ResumeThenSaveKeepsUnloadedZoneContents() { @@ -307,7 +353,8 @@ public sealed class RunSaveTests : IDisposable var gear = cache.Upsert(new GearData { Name = "Scanner", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 5 }); var cargo = cache.Upsert(new CargoBayData { Name = "Hold", Shape = new Shape(), InteriorShape = new Shape(), Price = 3 }); var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); - foreach (var (name, design) in new[] { ("Runabout by Generator Maker", hull.Key), ("Scanner by Generator Maker", gear.Key), ("Hold by Generator Maker", cargo.Key), ("Cap by Generator Maker", capacitor.Key) }) + var tractor = cache.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } }); + foreach (var (name, design) in new[] { ("Runabout by Generator Maker", hull.Key), ("Scanner by Generator Maker", gear.Key), ("Hold by Generator Maker", cargo.Key), ("Cap by Generator Maker", capacitor.Key), ("Tractor by Generator Maker", tractor.Key) }) cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); cache.FlushAsync().Wait(); } diff --git a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs index 58f0654d9..d84519034 100644 --- a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs +++ b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs @@ -95,8 +95,7 @@ public sealed class ShieldReserveTests : IDisposable var shieldData = cache.GetByName("Shield"); Assert.True(ship.TryEquip(Mint(cache, items, shieldData))); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return (ship, ship.GetBehavior()); } diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 4f818e4a8..1c95dc38f 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -158,8 +158,7 @@ public sealed class SignatureTests : IDisposable Assert.True(ship.TryEquip(item, at)); } - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); ship.Aim = float3(0, 0, 1); zone.Update(1f); return rig; diff --git a/tests/Aetheria.Shared.Tests/SolutionTests.cs b/tests/Aetheria.Shared.Tests/SolutionTests.cs index d9e55cc9c..d3a18141c 100644 --- a/tests/Aetheria.Shared.Tests/SolutionTests.cs +++ b/tests/Aetheria.Shared.Tests/SolutionTests.cs @@ -189,8 +189,7 @@ public sealed partial class FireControlCut124Tests var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc, weaponRange: 200f); var hull = e.Items.ItemData.RefOf(e.HullData); var decoy = new Ship(e.Items, e.Zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = 400 }, new EntitySettings()); - e.Zone.Entities.Add(decoy); - decoy.Activate(); + e.Zone.Admit(decoy, piloted: false); var at = radians(40f); decoy.Position = e.Shooter.Position + float3(sin(at), 0, cos(at)) * 100f; e.Shooter.VisibleEntities.Add(decoy); diff --git a/tests/Aetheria.Shared.Tests/StatResolverTests.cs b/tests/Aetheria.Shared.Tests/StatResolverTests.cs index c7d32f337..aefb239c1 100644 --- a/tests/Aetheria.Shared.Tests/StatResolverTests.cs +++ b/tests/Aetheria.Shared.Tests/StatResolverTests.cs @@ -71,8 +71,7 @@ public sealed class StatResolverTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Battery")), HardpointCell)); if (withBooster) Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Booster")), SecondHardpointCell)); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return ship; } @@ -287,9 +286,8 @@ public sealed class StatResolverTests : IDisposable var hullItem = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hullItem, new EntitySettings()); Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Loop")), HardpointCell)); - zone.Entities.Add(ship); - var error = Assert.Throws(() => ship.Activate()); + var error = Assert.Throws(() => zone.Admit(ship, piloted: false)); Assert.Contains(ship.Name, error.Message); // the entity, by name ("Skiff", HullData.Name) Assert.Contains(nameof(CapacitorData), error.Message); Assert.Contains(nameof(CapacitorData.Capacity), error.Message); @@ -513,8 +511,7 @@ public sealed class StatResolverTests : IDisposable Assert.True(ship.TryEquip(boost, cells[i + 1])); } ship.OverrideShutdown = true; - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); Func mint = null; if (consumable != null) diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index a5120c646..603d5a138 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -138,8 +138,7 @@ public sealed class TargetingSystemTests : IDisposable targetingItem = target.Equipment.Single(e => e.EquippableItem == targeting); } - zone.Entities.Add(target); - target.Activate(); + zone.Admit(target, piloted: false); return (items, target, weapon, wideItem, narrowItem, targetingItem); } @@ -151,8 +150,7 @@ public sealed class TargetingSystemTests : IDisposable var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }; var observer = new Ship(items, zone, hull, new EntitySettings()); - zone.Entities.Add(observer); - observer.Activate(); + zone.Admit(observer, piloted: false); return observer; } @@ -388,9 +386,8 @@ public sealed class TargetingSystemTests : IDisposable targeting.Lot = items.Lots.Add(new Lot { Design = cache.RefOf(targetingData), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List() }); Assert.True(ship.TryEquip(targeting)); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Aim = float3(0, 0, 1); - ship.Activate(); return ship; } @@ -429,11 +426,12 @@ public sealed class TargetingSystemTests : IDisposable var cargo = cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 }); var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); var targeting = cache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new TargetingSystemData() } }); + var tractor = cache.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } }); foreach (var (name, design) in new[] { ("Armed by Maker", hull.Key), ("Gun by Maker", gun.Key), ("Crate by Maker", cargo.Key), - ("Cap by Maker", capacitor.Key), ("Targeting by Maker", targeting.Key) + ("Cap by Maker", capacitor.Key), ("Targeting by Maker", targeting.Key), ("Tractor by Maker", tractor.Key) }) cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); cache.FlushAsync().Wait(); diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index 58c1fe356..8511e371a 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -105,9 +105,8 @@ public sealed class ThrottleLockTests : IDisposable } Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.CounterClockwise), new int2(1, 3))); Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Clockwise), new int2(3, 3))); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Aim = float3(0, 0, 1); - ship.Activate(); // A thruster is live from its first performance update; the allocator reads that every tick. foreach (var item in ship.Equipment) item.UpdatePerformance(); diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs index 3bd0ec7d1..120f7764b 100644 --- a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -130,10 +130,8 @@ public sealed class VapourCloudTests : IDisposable // Held off until a test pulls the trigger: the dump vents on its first run. lab.Dump.Enabled.Value = false; - zone.Entities.Add(lab.Observer); - zone.Entities.Add(lab.Target); - lab.Observer.Activate(); - lab.Target.Activate(); + zone.Admit(lab.Observer, piloted: false); + zone.Admit(lab.Target, piloted: false); lab.Observer.Position = float3(0, 0, 0); lab.Observer.Aim = float3(0, 0, 1); lab.Target.Position = float3(0, 0, distance);