diff --git a/Assets/_Project/Prefabs/Conveyor.prefab b/Assets/_Project/Prefabs/Conveyor.prefab new file mode 100644 index 000000000..c5a9149c0 --- /dev/null +++ b/Assets/_Project/Prefabs/Conveyor.prefab @@ -0,0 +1,147 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &3885353946372160549 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3572766465862231365} + - component: {fileID: 3909651526955663392} + - component: {fileID: 3320445911748035220} + - component: {fileID: 9053853372340598254} + - component: {fileID: 6834786618115927220} + - component: {fileID: 2432281660239002494} + m_Layer: 0 + m_Name: Conveyor + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &3572766465862231365 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 2.5, y: 2.5, z: 2.5} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &3909651526955663392 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Mesh: {fileID: 4300000, guid: abc00000000010690097314383055197, type: 3} +--- !u!23 &3320445911748035220 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + 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: + - {fileID: 2100000, guid: c01dbe8a4e818c5469eceae8b286bf4d, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + 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!114 &9053853372340598254 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring +--- !u!114 &6834786618115927220 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent + HasOwner: 0 + SupportAutoCommandTarget: 1 + TrackInterpolationDelay: 0 + GhostGroup: 0 + UsePreSerialization: 0 + UseSingleBaseline: 0 + RollbackPredictedSpawnedGhostState: 0 + RollbackPredictionOnStructuralChanges: 1 + DefaultGhostMode: 0 + SupportedGhostModes: 3 + OptimizationMode: 0 + Importance: 1 + MaxSendRate: 0 + prefabId: +--- !u!114 &2432281660239002494 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 23131e6166dce204582bbedc8511658e, type: 3} + m_Name: + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ConveyorAuthoring + Direction: 0 + PeriodTicks: 20 diff --git a/Assets/_Project/Prefabs/Conveyor.prefab.meta b/Assets/_Project/Prefabs/Conveyor.prefab.meta new file mode 100644 index 000000000..c0eb1ab59 --- /dev/null +++ b/Assets/_Project/Prefabs/Conveyor.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 91f9551ef855b3b4f98bcfcb7bf747e2 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Prefabs/Fabricator.prefab b/Assets/_Project/Prefabs/Fabricator.prefab new file mode 100644 index 000000000..45b83cc45 --- /dev/null +++ b/Assets/_Project/Prefabs/Fabricator.prefab @@ -0,0 +1,150 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &3885353946372160549 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3572766465862231365} + - component: {fileID: 3909651526955663392} + - component: {fileID: 3320445911748035220} + - component: {fileID: 9053853372340598254} + - component: {fileID: 6834786618115927220} + - component: {fileID: 320054987393778360} + m_Layer: 0 + m_Name: Fabricator + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &3572766465862231365 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 2.5, y: 2.5, z: 2.5} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &3909651526955663392 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Mesh: {fileID: 4300000, guid: abc00000000010690097314383055197, type: 3} +--- !u!23 &3320445911748035220 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + 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: + - {fileID: 2100000, guid: c01dbe8a4e818c5469eceae8b286bf4d, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + 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!114 &9053853372340598254 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring +--- !u!114 &6834786618115927220 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent + HasOwner: 0 + SupportAutoCommandTarget: 1 + TrackInterpolationDelay: 0 + GhostGroup: 0 + UsePreSerialization: 0 + UseSingleBaseline: 0 + RollbackPredictedSpawnedGhostState: 0 + RollbackPredictionOnStructuralChanges: 1 + DefaultGhostMode: 0 + SupportedGhostModes: 3 + OptimizationMode: 0 + Importance: 1 + MaxSendRate: 0 + prefabId: +--- !u!114 &320054987393778360 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 014833c467fb1d6499f437b7bf76db80, type: 3} + m_Name: + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.FabricatorAuthoring + InResourceId: 2 + InAmount: 2 + OutResourceId: 1 + OutAmount: 1 + PeriodTicks: 90 diff --git a/Assets/_Project/Prefabs/Fabricator.prefab.meta b/Assets/_Project/Prefabs/Fabricator.prefab.meta new file mode 100644 index 000000000..2087e87bd --- /dev/null +++ b/Assets/_Project/Prefabs/Fabricator.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8dd9baab4cbf6c04f9320ed5ed764c65 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Prefabs/Harvester.prefab b/Assets/_Project/Prefabs/Harvester.prefab new file mode 100644 index 000000000..76d9785f4 --- /dev/null +++ b/Assets/_Project/Prefabs/Harvester.prefab @@ -0,0 +1,148 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &3885353946372160549 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3572766465862231365} + - component: {fileID: 3909651526955663392} + - component: {fileID: 3320445911748035220} + - component: {fileID: 9053853372340598254} + - component: {fileID: 6834786618115927220} + - component: {fileID: 1647940186412478499} + m_Layer: 0 + m_Name: Harvester + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &3572766465862231365 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 2.5, y: 2.5, z: 2.5} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &3909651526955663392 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Mesh: {fileID: 4300000, guid: abc00000000010690097314383055197, type: 3} +--- !u!23 &3320445911748035220 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + 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: + - {fileID: 2100000, guid: c01dbe8a4e818c5469eceae8b286bf4d, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + 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!114 &9053853372340598254 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring +--- !u!114 &6834786618115927220 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent + HasOwner: 0 + SupportAutoCommandTarget: 1 + TrackInterpolationDelay: 0 + GhostGroup: 0 + UsePreSerialization: 0 + UseSingleBaseline: 0 + RollbackPredictedSpawnedGhostState: 0 + RollbackPredictionOnStructuralChanges: 1 + DefaultGhostMode: 0 + SupportedGhostModes: 3 + OptimizationMode: 0 + Importance: 1 + MaxSendRate: 0 + prefabId: +--- !u!114 &1647940186412478499 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3885353946372160549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d69d7296747332b4fbe7901ec5210149, type: 3} + m_Name: + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.HarvesterAuthoring + OutputResourceId: 2 + Yield: 1 + PeriodTicks: 60 diff --git a/Assets/_Project/Prefabs/Harvester.prefab.meta b/Assets/_Project/Prefabs/Harvester.prefab.meta new file mode 100644 index 000000000..0245ba1a0 --- /dev/null +++ b/Assets/_Project/Prefabs/Harvester.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9e227d9b387139340933dcf55d1c3a87 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Authoring/Automation.meta b/Assets/_Project/Scripts/Authoring/Automation.meta new file mode 100644 index 000000000..3ecd05c4a --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Automation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c51a770922f68b046b12dc55a7f054c2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Authoring/Automation/ConveyorAuthoring.cs b/Assets/_Project/Scripts/Authoring/Automation/ConveyorAuthoring.cs new file mode 100644 index 000000000..eb640d074 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Automation/ConveyorAuthoring.cs @@ -0,0 +1,41 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// Authoring for a Conveyor belt ghost prefab. Bakes {Type=Conveyor} + + /// (default facing; BuildPlaceSystem overrides Direction per placement from the RPC) + + /// a DISABLED (an empty belt). BuildPlaceSystem stamps the Cell; the transport + /// system initializes the period gate on first encounter. + /// + public class ConveyorAuthoring : MonoBehaviour + { + [Tooltip("Default belt facing (0=+X, 1=-X, 2=+Z, 3=-Z); the build RPC overrides this per placement.")] + public byte Direction = 0; + [Min(1)] public int PeriodTicks = 20; + + private class ConveyorBaker : Baker + { + public override void Bake(ConveyorAuthoring authoring) + { + var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); + AddComponent(entity, new PlacedStructure + { + Type = StructureType.Conveyor, + Cell = default, + NextTick = 0u, + LastProcessedTick = 0u, + }); + AddComponent(entity, new Conveyor + { + Direction = authoring.Direction, + PeriodTicks = authoring.PeriodTicks, + }); + AddComponent(entity, new ConveyorItem { ResourceId = 0, Count = 0 }); + SetComponentEnabled(entity, false); // baked empty (disabled) + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Automation/ConveyorAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Automation/ConveyorAuthoring.cs.meta new file mode 100644 index 000000000..eda769d31 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Automation/ConveyorAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 23131e6166dce204582bbedc8511658e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Automation/FabricatorAuthoring.cs b/Assets/_Project/Scripts/Authoring/Automation/FabricatorAuthoring.cs new file mode 100644 index 000000000..f80e9faa3 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Automation/FabricatorAuthoring.cs @@ -0,0 +1,47 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// Authoring for a Fabricator machine ghost prefab. Bakes {Type=Fabricator} + + /// recipe + an empty buffer (a conveyor fills it; the + /// fabricator deposits its output directly into the GLOBAL ledger, so it needs no output buffer). Default + /// recipe: 2 Ore -> 1 Aether (both existing resources — the "auto-gather existing resources" terminal). + /// + public class FabricatorAuthoring : MonoBehaviour + { + [Tooltip("Input resource id consumed per run (1=Aether, 2=Ore, 3=Biomass).")] + public byte InResourceId = 2; // Ore + [Min(1)] public int InAmount = 2; + [Tooltip("Output resource id deposited to the global ledger.")] + public byte OutResourceId = 1; // Aether + [Min(1)] public int OutAmount = 1; + [Min(1)] public int PeriodTicks = 90; + + private class FabricatorBaker : Baker + { + public override void Bake(FabricatorAuthoring authoring) + { + var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); + AddComponent(entity, new PlacedStructure + { + Type = StructureType.Fabricator, + Cell = default, + NextTick = 0u, + LastProcessedTick = 0u, + }); + AddComponent(entity, new Fabricator + { + InResourceId = authoring.InResourceId, + InAmount = authoring.InAmount, + OutResourceId = authoring.OutResourceId, + OutAmount = authoring.OutAmount, + PeriodTicks = authoring.PeriodTicks, + }); + AddBuffer(entity); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Automation/FabricatorAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Automation/FabricatorAuthoring.cs.meta new file mode 100644 index 000000000..0391c7660 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Automation/FabricatorAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 014833c467fb1d6499f437b7bf76db80 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Automation/HarvesterAuthoring.cs b/Assets/_Project/Scripts/Authoring/Automation/HarvesterAuthoring.cs new file mode 100644 index 000000000..df21fd65e --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Automation/HarvesterAuthoring.cs @@ -0,0 +1,42 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// Authoring for a Harvester machine ghost prefab (duplicate a structure ghost so the ownerless interpolated + /// GhostAuthoringComponent comes free). Bakes {Type=Harvester} + + /// stats + an empty buffer. BuildPlaceSystem stamps the Cell at placement; the + /// production system initializes the tick baseline on first encounter (NextTick/LastProcessedTick baked 0). + /// + public class HarvesterAuthoring : MonoBehaviour + { + [Tooltip("Resource id this generator produces (1=Aether, 2=Ore, 3=Biomass).")] + public byte OutputResourceId = 2; // Ore + [Min(1)] public int Yield = 1; + [Min(1)] public int PeriodTicks = 60; + + private class HarvesterBaker : Baker + { + public override void Bake(HarvesterAuthoring authoring) + { + var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); + AddComponent(entity, new PlacedStructure + { + Type = StructureType.Harvester, + Cell = default, + NextTick = 0u, + LastProcessedTick = 0u, + }); + AddComponent(entity, new Harvester + { + ResourceId = authoring.OutputResourceId, + Yield = authoring.Yield, + PeriodTicks = authoring.PeriodTicks, + }); + AddBuffer(entity); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Automation/HarvesterAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Automation/HarvesterAuthoring.cs.meta new file mode 100644 index 000000000..d01c64a0e --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Automation/HarvesterAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d69d7296747332b4fbe7901ec5210149 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs b/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs index 61eb006bb..af4f90197 100644 --- a/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs @@ -32,6 +32,18 @@ namespace ProjectM.Authoring [Tooltip("Ore cost to build a pylon.")] [Min(0)] public int PylonCostOre = 2; + [Tooltip("Harvester machine ghost prefab (HarvesterAuthoring + GhostAuthoring).")] + public GameObject HarvesterPrefab; + [Min(0)] public int HarvesterCostOre = 20; + + [Tooltip("Fabricator machine ghost prefab (FabricatorAuthoring + GhostAuthoring).")] + public GameObject FabricatorPrefab; + [Min(0)] public int FabricatorCostOre = 30; + + [Tooltip("Conveyor belt ghost prefab (ConveyorAuthoring + GhostAuthoring).")] + public GameObject ConveyorPrefab; + [Min(0)] public int ConveyorCostOre = 2; + private class StructureCatalogBaker : Baker { public override void Bake(StructureCatalogAuthoring authoring) @@ -71,6 +83,39 @@ namespace ProjectM.Authoring CostAmount = authoring.PylonCostOre, }); } + if (authoring.HarvesterPrefab != null) + { + buf.Add(new StructureCatalogEntry + { + Type = StructureType.Harvester, + Prefab = GetEntity(authoring.HarvesterPrefab, TransformUsageFlags.Dynamic), + CostResourceId = ResourceId.Ore, + CostAmount = authoring.HarvesterCostOre, + }); + } + + if (authoring.FabricatorPrefab != null) + { + buf.Add(new StructureCatalogEntry + { + Type = StructureType.Fabricator, + Prefab = GetEntity(authoring.FabricatorPrefab, TransformUsageFlags.Dynamic), + CostResourceId = ResourceId.Ore, + CostAmount = authoring.FabricatorCostOre, + }); + } + + if (authoring.ConveyorPrefab != null) + { + buf.Add(new StructureCatalogEntry + { + Type = StructureType.Conveyor, + Prefab = GetEntity(authoring.ConveyorPrefab, TransformUsageFlags.Dynamic), + CostResourceId = ResourceId.Ore, + CostAmount = authoring.ConveyorCostOre, + }); + } + } } diff --git a/Assets/_Project/Scripts/Server/Automation.meta b/Assets/_Project/Scripts/Server/Automation.meta new file mode 100644 index 000000000..ad9257478 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Automation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d8478382df1bb34498b308c63531da1d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs b/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs new file mode 100644 index 000000000..4c911d0a6 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs @@ -0,0 +1,123 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Server +{ + /// + /// One-shot server restore of player-built structures for a "Continue" session. The menu (WorldLauncher) stages a + /// / carrier in the fresh ServerWorld BEFORE the + /// gameplay subscene streams; this system waits (RequireForUpdate) for the streamed + /// + + a valid NetworkTime, then replays each saved structure CHARGE-FREE: Instantiate the + /// catalog prefab at the saved cell (preserving the baked Scale), re-stamp the rebased tick fields + /// (; LastProcessed = now so within-session catch-up resumes from now, + /// never a wall-clock mint), re-tag RegionTag{Base} + RuntimePlacedTag, refill the in-flight conveyor item + the + /// machine I/O buffers, then DESTROY the carrier so it never runs again. The ledger/goal restore separately + + /// absolutely via CycleDirectorSpawnSystem's born-correct load (no double-spend, no Withdraw here). + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + public partial struct BaseRestoreSystem : ISystem + { + ComponentLookup m_TransformLookup; + ComponentLookup m_ConveyorLookup; + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + m_TransformLookup = state.GetComponentLookup(isReadOnly: true); + m_ConveyorLookup = state.GetComponentLookup(isReadOnly: true); + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (!serverTick.IsValid) + return; + uint now = serverTick.TickIndexForValidTick; + + m_TransformLookup.Update(ref state); + m_ConveyorLookup.Update(ref state); + + var anchor = SystemAPI.GetSingleton(); + var catalog = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); + + var ecb = new EntityCommandBuffer(Allocator.Temp); + + foreach (var (pending, ioBuf, carrier) in + SystemAPI.Query, DynamicBuffer>().WithEntityAccess()) + { + for (int s = 0; s < pending.Length; s++) + { + var p = pending[s]; + + int entryIdx = -1; + for (int i = 0; i < catalog.Length; i++) + if (catalog[i].Type == p.Type) { entryIdx = i; break; } + if (entryIdx < 0 || catalog[entryIdx].Prefab == Entity.Null) + continue; // type not in the catalog (e.g. a save from a newer build) -> skip, don't crash + + var prefab = catalog[entryIdx].Prefab; + var structure = ecb.Instantiate(prefab); + + int2 cell = new int2(p.CellX, p.CellZ); + var xform = m_TransformLookup[prefab]; + xform.Position = BaseGridMath.CellToWorld(anchor, cell); // preserve baked Scale (FromPosition would reset it) + ecb.SetComponent(structure, xform); + + ecb.SetComponent(structure, new PlacedStructure + { + Type = p.Type, + Cell = cell, + NextTick = ProductionMath.RestoreNextTick(now, p.RemainingTicks), + LastProcessedTick = TickUtil.NonZero(now), + }); + ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base }); + ecb.AddComponent(structure); + + if (p.Type == StructureType.Conveyor && m_ConveyorLookup.HasComponent(prefab)) + { + var conv = m_ConveyorLookup[prefab]; + conv.Direction = p.Direction; + ecb.SetComponent(structure, conv); + ecb.SetComponent(structure, new ConveyorItem { ResourceId = p.ConveyorResId, Count = p.ConveyorCount }); + ecb.SetComponentEnabled(structure, p.ConveyorCount > 0); + } + + // Refill machine I/O buffers from the flat io table (only slots with saved rows -> the prefab has them). + bool inInit = false, outInit = false; + DynamicBuffer inBuf = default; + DynamicBuffer outBuf = default; + for (int r = 0; r < ioBuf.Length; r++) + { + if (ioBuf[r].StructureIndex != s) + continue; + if (ioBuf[r].Slot == 0) + { + if (!inInit) { inBuf = ecb.SetBuffer(structure); inInit = true; } + inBuf.Add(new MachineInput { ResourceId = ioBuf[r].ResourceId, Count = ioBuf[r].Count }); + } + else + { + if (!outInit) { outBuf = ecb.SetBuffer(structure); outInit = true; } + outBuf.Add(new MachineOutput { ResourceId = ioBuf[r].ResourceId, Count = ioBuf[r].Count }); + } + } + } + + ecb.DestroyEntity(carrier); + } + + ecb.Playback(state.EntityManager); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs.meta b/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs.meta new file mode 100644 index 000000000..cfc8e2198 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Automation/BaseRestoreSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4003027ade5ccd5418e300d87e5c5e14 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Automation/ConveyorTransportSystem.cs b/Assets/_Project/Scripts/Server/Automation/ConveyorTransportSystem.cs new file mode 100644 index 000000000..090caa33c --- /dev/null +++ b/Assets/_Project/Scripts/Server/Automation/ConveyorTransportSystem.cs @@ -0,0 +1,276 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server-only, deterministic conveyor transport — the MIDDLE of the M7 auto-gather chain + /// (Harvester → Conveyor → Fabricator). Unlike the per-machine catch-up production systems, a conveyor is a + /// single TRANSPORT STEP: each period-due, empty first PULLS one item off an adjacent + /// upstream (the cell at myCell − DirOffset(dir) — i.e. the machine feeding + /// INTO this belt) onto its own ; then every loaded conveyor advances its item + /// EXACTLY one cell toward myCell + DirOffset(dir). The move resolution is delegated to the pure, + /// unit-tested so determinism is provable WITHOUT a world: sources are + /// processed sorted by (NOT hashmap order), occupancy is read from a + /// pre-move double-buffer snapshot, a destination conveyor cell accepts at most one item (only if it was empty + /// in the snapshot; ties → lowest CellKey wins, losers stall with no silent loss), and machine-input sink + /// cells always accept (deposit). Sinks are a separate set so an item leaving the belt into a fabricator's + /// never collides with belt occupancy. + /// + /// Mirrors TurretFireSystem's now-extraction (NetworkTime.ServerTick.TickIndexForValidTick) + + /// cooldown idiom (each conveyor is period-gated the same way), and + /// ResourceHarvestSystem's Temp-collection foreach idiom. Runs in the plain server + /// SimulationSystemGroup [UpdateAfter(HarvesterProductionSystem)] (after harvesters deposit, so + /// fresh output is pull-eligible this tick; before the fabricator consumes). All buffer/enableable mutation is + /// in place (toggling an enableable bit is NOT a structural change) → no ECB. + /// + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateAfter(typeof(HarvesterProductionSystem))] + public partial struct ConveyorTransportSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (!serverTick.IsValid) + return; + uint now = serverTick.TickIndexForValidTick; + + // ---- Snapshot every conveyor once (entity, cell, direction, item, period-due) ---- + var convEntity = new NativeList(Allocator.Temp); + var convCell = new NativeList(Allocator.Temp); + var convDir = new NativeList(Allocator.Temp); + var convItemRes = new NativeList(Allocator.Temp); // 0 = empty + var convItemCnt = new NativeList(Allocator.Temp); // 0 = empty + var convDue = new NativeList(Allocator.Temp); // period-gate satisfied this tick + + foreach (var (ps, conveyor, e) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + int period = math.max(1, conveyor.ValueRO.PeriodTicks); + + // Period-gate each conveyor through NextTick exactly like the production systems. A never-processed + // belt initialises its baseline this tick and is NOT due (mirrors NeedsInit on the machines). + bool due; + if (ProductionMath.NeedsInit(ps.ValueRO.LastProcessedTick)) + { + ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now); + ps.ValueRW.NextTick = TickUtil.NonZero(now + (uint)period); + due = false; + } + else + { + int cycles = ProductionMath.CyclesDue( + serverTick, ps.ValueRO.NextTick, ps.ValueRO.LastProcessedTick, period, Tuning.MaxProductionCatchup); + due = cycles > 0; + if (due) + { + // A belt moves at most one cell per period; collapse any catch-up to a single step but keep + // the baseline advancing so it re-evaluates next period. + ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now); + ps.ValueRW.NextTick = TickUtil.NonZero(now + (uint)period); + } + } + + int res = 0, cnt = 0; + if (SystemAPI.IsComponentEnabled(e)) + { + var item = SystemAPI.GetComponent(e); + if (item.Count > 0) + { + res = item.ResourceId; + cnt = item.Count; + } + } + + convEntity.Add(e); + convCell.Add(ps.ValueRO.Cell); + convDir.Add(conveyor.ValueRO.Direction); + convItemRes.Add(res); + convItemCnt.Add(cnt); + convDue.Add(due); + } + + int n = convEntity.Length; + + // Cell → conveyor snapshot index (belt occupancy map for ResolveMoves + the pull lookup). + var cellToIndex = new NativeHashMap(n, Allocator.Temp); + for (int i = 0; i < n; i++) + cellToIndex.TryAdd(convCell[i], i); // duplicate cells can't occur (one structure per cell) + + // Sink cells = cells hosting a machine-input buffer (fabricators); these always accept a deposit. Map + // each sink cell to its owning entity so an arriving item can be deposited into its MachineInput. + var sinkCells = new NativeHashSet(8, Allocator.Temp); + var sinkCellToEntity = new NativeHashMap(8, Allocator.Temp); + foreach (var (ps, _, e) in + SystemAPI.Query, DynamicBuffer>().WithEntityAccess()) + { + sinkCells.Add(ps.ValueRO.Cell); + sinkCellToEntity.TryAdd(ps.ValueRO.Cell, e); + } + + // Source cells = cells hosting a machine-OUTPUT buffer (harvesters/fabricators) a belt can pull from. + // Built once so the pull phase is a single hash lookup per belt (no nested per-belt query). + var outputCellToEntity = new NativeHashMap(8, Allocator.Temp); + foreach (var (ps, _, e) in + SystemAPI.Query, DynamicBuffer>().WithEntityAccess()) + { + outputCellToEntity.TryAdd(ps.ValueRO.Cell, e); + } + + // ---- PULL: each empty, due belt draws one item off an adjacent UPSTREAM MachineOutput ---- + // Upstream cell = myCell − DirOffset(dir): the machine feeding INTO this belt sits there. We pull a + // single unit so a harvester's buffered output flows onto the belt one item per period. + for (int i = 0; i < n; i++) + { + if (!convDue[i] || convItemCnt[i] > 0) + continue; + + int2 srcCell = convCell[i] - ConveyorMath.DirOffset(convDir[i]); + + // The feeder must be a machine with a MachineOutput buffer (harvester or fabricator), NOT another + // conveyor (belts hand off in the move phase, not via pull). Single hash lookup on the prebuilt map. + if (!outputCellToEntity.TryGetValue(srcCell, out var feeder)) + continue; + + var output = SystemAPI.GetBuffer(feeder); + // Pull the first available resource row off the feeder (deterministic: first non-empty row order). + byte pulledId = 0; + for (int r = 0; r < output.Length; r++) + { + if (output[r].ResourceId != 0 && output[r].Count > 0) + { + pulledId = output[r].ResourceId; + break; + } + } + if (pulledId == 0) + continue; + + int taken = MachineSlotMath.Withdraw(output, pulledId, 1); + if (taken <= 0) + continue; + + // Load the belt in the snapshot so it participates in THIS tick's move resolution. + convItemRes[i] = pulledId; + convItemCnt[i] = taken; + } + + // ---- MOVE: resolve all belt advances from the pre-move (post-pull) snapshot, then apply ---- + var srcCells = new NativeArray(n, Allocator.Temp); + var dirs = new NativeArray(n, Allocator.Temp); + var itemRes = new NativeArray(n, Allocator.Temp); + var itemCnt = new NativeArray(n, Allocator.Temp); + for (int i = 0; i < n; i++) + { + srcCells[i] = convCell[i]; + dirs[i] = convDir[i]; + // Pass the FULL post-pull occupancy (due AND non-due) so the resolver blocks a due belt from moving + // INTO an occupied non-due cell. Non-due belts must still not ADVANCE themselves — that is enforced + // after resolution by skipping any returned move whose source belt is not due this tick. + itemRes[i] = convItemRes[i]; + itemCnt[i] = convItemCnt[i]; + } + + var outMoveDst = new NativeArray(n, Allocator.Temp); + var outMoveSrcIdx = new NativeArray(n, Allocator.Temp); + ConveyorMath.ResolveMoves( + srcCells, dirs, itemRes, itemCnt, + cellToIndex, sinkCells, + outMoveDst, outMoveSrcIdx, out int moveCount); + + // Track which belts END this tick holding an item so we can settle enableable bits exactly once. + var endRes = new NativeArray(n, Allocator.Temp); + var endCnt = new NativeArray(n, Allocator.Temp); + for (int i = 0; i < n; i++) + { + // Default: every snapshot item stays put (stalls / non-due / no valid move). Moves below override. + endRes[i] = convItemRes[i]; + endCnt[i] = convItemCnt[i]; + } + + for (int m = 0; m < moveCount; m++) + { + int srcIdx = outMoveSrcIdx[m]; + + // A non-due belt contributes its occupancy to the snapshot (so due belts can't overrun it) but must + // NOT advance its own item — skip its move and leave its item parked (the endRes/endCnt defaults). + if (!convDue[srcIdx]) + continue; + + int2 dst = outMoveDst[m]; + int movRes = convItemRes[srcIdx]; + int movCnt = convItemCnt[srcIdx]; + + // The source belt empties (its item left this cell). + endRes[srcIdx] = 0; + endCnt[srcIdx] = 0; + + if (sinkCells.Contains(dst)) + { + // Item leaves the belt network into a machine input slot. + if (sinkCellToEntity.TryGetValue(dst, out var sinkEntity)) + { + var input = SystemAPI.GetBuffer(sinkEntity); + MachineSlotMath.Deposit(input, (byte)movRes, movCnt); + } + } + else if (cellToIndex.TryGetValue(dst, out int dstIdx)) + { + // Item advances onto the next belt cell (resolver guaranteed it was empty in the snapshot). + endRes[dstIdx] = movRes; + endCnt[dstIdx] = movCnt; + } + } + + // ---- Settle each conveyor's ConveyorItem to its end-of-tick state (single write per belt) ---- + for (int i = 0; i < n; i++) + { + var e = convEntity[i]; + if (endCnt[i] > 0) + { + SystemAPI.SetComponent(e, new ConveyorItem { ResourceId = (byte)endRes[i], Count = endCnt[i] }); + SystemAPI.SetComponentEnabled(e, true); + } + else + { + SystemAPI.SetComponent(e, new ConveyorItem { ResourceId = 0, Count = 0 }); + SystemAPI.SetComponentEnabled(e, false); + } + } + + convEntity.Dispose(); + convCell.Dispose(); + convDir.Dispose(); + convItemRes.Dispose(); + convItemCnt.Dispose(); + convDue.Dispose(); + cellToIndex.Dispose(); + sinkCells.Dispose(); + sinkCellToEntity.Dispose(); + outputCellToEntity.Dispose(); + srcCells.Dispose(); + dirs.Dispose(); + itemRes.Dispose(); + itemCnt.Dispose(); + outMoveDst.Dispose(); + outMoveSrcIdx.Dispose(); + endRes.Dispose(); + endCnt.Dispose(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Automation/ConveyorTransportSystem.cs.meta b/Assets/_Project/Scripts/Server/Automation/ConveyorTransportSystem.cs.meta new file mode 100644 index 000000000..9e22417cb --- /dev/null +++ b/Assets/_Project/Scripts/Server/Automation/ConveyorTransportSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 002c28988137cb945b9ffaccbb6d645f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Automation/FabricatorProductionSystem.cs b/Assets/_Project/Scripts/Server/Automation/FabricatorProductionSystem.cs new file mode 100644 index 000000000..8cb103f61 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Automation/FabricatorProductionSystem.cs @@ -0,0 +1,99 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server-only, deterministic fabricator production — the BACK of the M7 auto-gather chain + /// (Harvester → Conveyor → Fabricator). Each consumes + /// of its (byte) input resource from its OWN server-only + /// buffer (filled by an upstream conveyor) and, on a + /// cadence, deposits of its output + /// resource into the GLOBAL ledger — so a self-running base compounds its stockpile. Resolves the ledger via + /// GetSingletonEntity<ResourceLedger>()GetBuffer<StorageEntry>() (NEVER + /// GetSingleton<StorageEntry> — a second StorageEntry buffer exists on the base container). + /// Mirrors TurretFireSystem's now-extraction + cooldown idiom and ResourceHarvestSystem's ledger + /// resolve; runs in the plain server SimulationSystemGroup + /// [UpdateAfter(ConveyorTransportSystem)] (which itself is after the harvester + the predicted group), + /// so a single tick can harvest → transport → fabricate in chain order. In-place buffer/ledger mutation + /// (not structural) → no ECB. + /// + /// SINGLE GATED CATCH-UP PATH, INPUT-LIMITED (no mint-from-nothing): when due, the awarded + /// cycles are further clamped to what the buffered input can afford + /// (floor(TotalOf(input,InResourceId)/InAmount)). The tick fields are re-stamped EVERY due period + /// regardless of runs (even a starved fabricator advances its baseline so it re-evaluates next period, + /// not on the next tick — preventing a busy retry storm). Offline catch-up is within-session tick math; never + /// wall-clock. + /// + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateAfter(typeof(ConveyorTransportSystem))] + public partial struct FabricatorProductionSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (!serverTick.IsValid) + return; + uint now = serverTick.TickIndexForValidTick; + + var ledgerEntity = SystemAPI.GetSingletonEntity(); + var ledger = SystemAPI.GetBuffer(ledgerEntity); + + foreach (var (ps, fab, input) in + SystemAPI.Query, RefRO, DynamicBuffer>()) + { + int period = fab.ValueRO.PeriodTicks; // CyclesDue clamps to max(1, period) + + // Never-processed (baked/just-placed) machine: initialise the catch-up baseline, produce nothing. + if (ProductionMath.NeedsInit(ps.ValueRO.LastProcessedTick)) + { + ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now); + ps.ValueRW.NextTick = TickUtil.NonZero(now + (uint)System.Math.Max(1, period)); + continue; + } + + int cycles = ProductionMath.CyclesDue( + serverTick, ps.ValueRO.NextTick, ps.ValueRO.LastProcessedTick, period, Tuning.MaxProductionCatchup); + if (cycles <= 0) + continue; // still cooling down / nothing due + + byte inId = fab.ValueRO.InResourceId; + int inAmount = fab.ValueRO.InAmount; + + // Input-limited: never produce more than the buffered input affords (no mint-from-nothing). A + // zero/negative recipe input amount is treated as unsatisfiable rather than dividing by zero. + int affordable = inAmount > 0 + ? MachineSlotMath.TotalOf(input, inId) / inAmount + : 0; + int runs = math.min(cycles, affordable); + + if (runs > 0) + { + MachineSlotMath.Withdraw(input, inId, inAmount * runs); + StorageMath.Deposit(ledger, (ushort)fab.ValueRO.OutResourceId, fab.ValueRO.OutAmount * runs); + } + + // Re-stamp every due period regardless of runs (starved fabricators re-evaluate next period, not + // every tick) so the catch-up baseline never silently rewinds. + uint p = (uint)System.Math.Max(1, period); + ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now); + ps.ValueRW.NextTick = TickUtil.NonZero(now + p); + } + } + } +} diff --git a/Assets/_Project/Scripts/Server/Automation/FabricatorProductionSystem.cs.meta b/Assets/_Project/Scripts/Server/Automation/FabricatorProductionSystem.cs.meta new file mode 100644 index 000000000..da75b3c33 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Automation/FabricatorProductionSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1d8dbf02b41c9a94ea57fd7ca00f266d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Automation/HarvesterProductionSystem.cs b/Assets/_Project/Scripts/Server/Automation/HarvesterProductionSystem.cs new file mode 100644 index 000000000..b83fc1194 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Automation/HarvesterProductionSystem.cs @@ -0,0 +1,76 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server-only, deterministic harvester production — the FRONT of the M7 auto-gather chain + /// (Harvester → Conveyor → Fabricator). Each machine is a fixed-yield generator: + /// every server ticks it deposits of its + /// configured (byte) resource into its OWN server-only buffer (NOT the global + /// ledger — a conveyor pulls it onward, or it sits buffered). Mirrors TurretFireSystem's exact + /// now-extraction (NetworkTime.ServerTick.TickIndexForValidTick) + + /// cooldown idiom, and runs in the plain server SimulationSystemGroup + /// [UpdateAfter(PredictedSimulationSystemGroup)] (the predicted group is OrderFirst → UpdateBefore is + /// ignored). Production mutates a DynamicBuffer in place (not a structural change) → no ECB needed. + /// + /// SINGLE GATED CATCH-UP PATH (offline-quit safe, NO wall-clock minting): a never-processed machine + /// (LastProcessedTick==0) is initialised this tick and produces nothing; otherwise + /// awards floor((now-LastProcessedTick)/period) cycles, clamped + /// to , and the tick fields are re-stamped. All catch-up is + /// WITHIN-SESSION tick math; the stockpile is preserved across quit by the persistence layer, never re-minted + /// from a saved wall-clock. + /// + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateAfter(typeof(PredictedSimulationSystemGroup))] + public partial struct HarvesterProductionSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (!serverTick.IsValid) + return; + uint now = serverTick.TickIndexForValidTick; + + foreach (var (ps, harvester, output) in + SystemAPI.Query, RefRO, DynamicBuffer>()) + { + int period = harvester.ValueRO.PeriodTicks; // CyclesDue clamps to max(1, period) + + // Never-processed (baked/just-placed) machine: initialise the catch-up baseline, produce nothing. + if (ProductionMath.NeedsInit(ps.ValueRO.LastProcessedTick)) + { + ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now); + ps.ValueRW.NextTick = TickUtil.NonZero(now + (uint)System.Math.Max(1, period)); + continue; + } + + int cycles = ProductionMath.CyclesDue( + serverTick, ps.ValueRO.NextTick, ps.ValueRO.LastProcessedTick, period, Tuning.MaxProductionCatchup); + if (cycles <= 0) + continue; // still cooling down / nothing due + + // Fixed-yield generation into the machine's own output slot (byte id; ledger conversion happens + // only at the global-ledger boundary, which this machine never crosses directly). + MachineSlotMath.Deposit(output, harvester.ValueRO.ResourceId, harvester.ValueRO.Yield * cycles); + + uint p = (uint)System.Math.Max(1, period); + ps.ValueRW.LastProcessedTick = TickUtil.NonZero(now); + ps.ValueRW.NextTick = TickUtil.NonZero(now + p); + } + } + } +} diff --git a/Assets/_Project/Scripts/Server/Automation/HarvesterProductionSystem.cs.meta b/Assets/_Project/Scripts/Server/Automation/HarvesterProductionSystem.cs.meta new file mode 100644 index 000000000..31bd916ef --- /dev/null +++ b/Assets/_Project/Scripts/Server/Automation/HarvesterProductionSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c484cb2331137134888f10eed7689140 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Building/BuildPlaceSystem.cs b/Assets/_Project/Scripts/Server/Building/BuildPlaceSystem.cs index e59264bd7..cccea02ff 100644 --- a/Assets/_Project/Scripts/Server/Building/BuildPlaceSystem.cs +++ b/Assets/_Project/Scripts/Server/Building/BuildPlaceSystem.cs @@ -23,11 +23,13 @@ namespace ProjectM.Server public partial struct BuildPlaceSystem : ISystem { ComponentLookup m_TransformLookup; + ComponentLookup m_ConveyorLookup; [BurstCompile] public void OnCreate(ref SystemState state) { m_TransformLookup = state.GetComponentLookup(isReadOnly: true); + m_ConveyorLookup = state.GetComponentLookup(isReadOnly: true); state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); @@ -41,6 +43,7 @@ namespace ProjectM.Server public void OnUpdate(ref SystemState state) { m_TransformLookup.Update(ref state); + m_ConveyorLookup.Update(ref state); uint now = SystemAPI.GetSingleton().ServerTick.TickIndexForValidTick; var anchor = SystemAPI.GetSingleton(); @@ -88,9 +91,16 @@ namespace ProjectM.Server Type = req.StructureType, Cell = cell, NextTick = 0u, - LastProcessedTick = TickUtil.NonZero(now), + LastProcessedTick = 0u, // 0 = uninitialized; the production systems set the baseline on first encounter (turret ignores it) }); ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base }); + ecb.AddComponent(structure); // player-built -> persisted by SaveStructureScan + if (req.StructureType == StructureType.Conveyor && m_ConveyorLookup.HasComponent(entry.Prefab)) + { + var conv = m_ConveyorLookup[entry.Prefab]; + conv.Direction = req.Direction; + ecb.SetComponent(structure, conv); + } } } diff --git a/Assets/_Project/Scripts/Simulation/Automation.meta b/Assets/_Project/Scripts/Simulation/Automation.meta new file mode 100644 index 000000000..ef17aa019 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Automation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4eba886d11c07eb4d97ca0d821a1560f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Simulation/Automation/AutomationComponents.cs b/Assets/_Project/Scripts/Simulation/Automation/AutomationComponents.cs new file mode 100644 index 000000000..f8f54f48c --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Automation/AutomationComponents.cs @@ -0,0 +1,87 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// A fixed-yield resource generator — the FRONT of the M7 auto-gather chain (Harvester -> Conveyor -> + /// Fabricator). Each period it deposits of into its OWN + /// server-only buffer (a conveyor pulls it onward). Server-only data (NO + /// [GhostField]); the client only ever sees PlacedStructure.Type. Reuses + /// PlacedStructure.NextTick/LastProcessedTick for the deterministic, within-session catch-up + /// cadence (see HarvesterProductionSystem). + /// + public struct Harvester : IComponentData + { + /// Resource id produced (a byte; see ). + public byte ResourceId; + /// Units produced per elapsed period. + public int Yield; + /// Server ticks between productions. + public int PeriodTicks; + } + + /// + /// A recipe machine — the BACK of the M7 chain. Consumes of + /// per run from its own buffer (fed by a conveyor) and deposits + /// of into the GLOBAL ledger. Strictly input-limited (never mints from an empty + /// slot). Server-only data. + /// + public struct Fabricator : IComponentData + { + public byte InResourceId; + public int InAmount; + public byte OutResourceId; + public int OutAmount; + public int PeriodTicks; + } + + /// + /// A directional transport belt — the MIDDLE of the M7 chain. Each period it pulls one item off an adjacent + /// upstream (when empty) and advances a held exactly one + /// cell toward . is a byte (0=+X,1=-X,2=+Z,3=-Z) — never an enum + /// (the cross-assembly enum-in-Burst hazard). Server-only data. + /// + public struct Conveyor : IComponentData + { + /// Belt facing: 0=+X, 1=-X, 2=+Z, 3=-Z (see ConveyorMath.DirOffset). + public byte Direction; + public int PeriodTicks; + } + + /// + /// A machine's INPUT staging buffer (server-only, NO [GhostField] -> never replicated). A DISTINCT element type + /// from the global ledger's (so GetSingleton<StorageEntry> stays + /// unambiguous) and from (so a machine can carry both without a buffer-type clash). + /// + public struct MachineInput : IBufferElementData + { + public byte ResourceId; + public int Count; + } + + /// A machine's OUTPUT staging buffer (server-only, NO [GhostField]). See . + public struct MachineOutput : IBufferElementData + { + public byte ResourceId; + public int Count; + } + + /// + /// The single in-flight item a conveyor carries. An ENABLEABLE component (enabled = the belt is occupied) so a + /// transport step is a bit-flip + field copy, never a structural change. Baked DISABLED (an empty belt). + /// Server-only. + /// + public struct ConveyorItem : IComponentData, IEnableableComponent + { + public byte ResourceId; + public int Count; + } + + /// + /// Marks a structure PLACED by a player at runtime (BuildPlaceSystem) or restored from a save — i.e. the + /// persistable set, as opposed to anything baked into the subscene. SaveWriteSystem scans only these and + /// BaseRestoreSystem re-adds the tag, so save/restore is the single source of truth for player builds. + /// Server-only (not replicated). + /// + public struct RuntimePlacedTag : IComponentData { } +} diff --git a/Assets/_Project/Scripts/Simulation/Automation/AutomationComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Automation/AutomationComponents.cs.meta new file mode 100644 index 000000000..2d881c927 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Automation/AutomationComponents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6eeef378186b39d41a2db7adcc620dd9 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Automation/ConveyorMath.cs b/Assets/_Project/Scripts/Simulation/Automation/ConveyorMath.cs new file mode 100644 index 000000000..0617cc1fe --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Automation/ConveyorMath.cs @@ -0,0 +1,97 @@ +using Unity.Collections; +using Unity.Mathematics; + +namespace ProjectM.Simulation +{ + /// + /// Pure, deterministic, ORDER-INDEPENDENT conveyor move resolver (the server ConveyorTransportSystem + /// applies the result). Determinism: sources are processed sorted by (NEVER hashmap + /// order); a destination belt cell accepts AT MOST ONE item and only if it was EMPTY in the pre-move snapshot + /// (double-buffering -> exactly one cell/tick); ties break to the lowest-CellKey source and losers STALL with no + /// loss; machine-input SINK cells always accept (a merge). World-free so it is exhaustively unit-tested. + /// + public static class ConveyorMath + { + /// Cardinal grid step for a belt direction byte (0=+X,1=-X,2=+Z,3=-Z). + public static int2 DirOffset(byte dir) + { + switch (dir) + { + case 1: return new int2(-1, 0); + case 2: return new int2(0, 1); + case 3: return new int2(0, -1); + default: return new int2(1, 0); // 0 = +X + } + } + + /// A stable, collision-free total order over grid cells (the deterministic tie-break key). + public static long CellKey(int2 cell) => ((long)cell.x << 32) | (uint)cell.y; + + /// + /// Resolve, for each belt holding an item, whether it advances one cell toward its direction this tick. + /// Inputs are read-only snapshots; outputs are the accepted moves ( -> + /// ), length . A move is accepted when the + /// destination is a SINK cell (always, a merge) or an EMPTY, unclaimed belt cell. Sources are iterated in + /// CellKey order so the result is identical regardless of input array order. Scratch is Temp + disposed. + /// + public static void ResolveMoves( + NativeArray srcCells, NativeArray dirs, + NativeArray itemRes, NativeArray itemCnt, + NativeHashMap cellToIndex, NativeHashSet sinkCells, + NativeArray outMoveDst, NativeArray outMoveSrcIdx, out int moveCount) + { + int n = srcCells.Length; + moveCount = 0; + + // Stable iteration order = sources sorted by CellKey (insertion sort; n is small). + var order = new NativeArray(n, Allocator.Temp); + for (int i = 0; i < n; i++) order[i] = i; + for (int i = 1; i < n; i++) + { + int cur = order[i]; + long curKey = CellKey(srcCells[cur]); + int j = i - 1; + while (j >= 0 && CellKey(srcCells[order[j]]) > curKey) + { + order[j + 1] = order[j]; + j--; + } + order[j + 1] = cur; + } + + var claimed = new NativeHashSet(n, Allocator.Temp); + for (int oi = 0; oi < n; oi++) + { + int i = order[oi]; + if (itemCnt[i] <= 0) continue; // nothing to move + + int2 dst = srcCells[i] + DirOffset(dirs[i]); + + bool accept = false; + bool isSink = sinkCells.Contains(dst); + if (isSink) + { + accept = true; // sinks merge -> unlimited acceptors, never claimed + } + else if (cellToIndex.TryGetValue(dst, out int dstIdx)) + { + // dst is a belt cell: accept only if EMPTY in the snapshot AND not already claimed this tick. + if (itemCnt[dstIdx] == 0 && !claimed.Contains(dst)) + accept = true; + } + // else: dst is neither a belt nor a sink -> dead end -> stall. + + if (accept) + { + if (!isSink) claimed.Add(dst); + outMoveDst[moveCount] = dst; + outMoveSrcIdx[moveCount] = i; + moveCount++; + } + } + + order.Dispose(); + claimed.Dispose(); + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Automation/ConveyorMath.cs.meta b/Assets/_Project/Scripts/Simulation/Automation/ConveyorMath.cs.meta new file mode 100644 index 000000000..8612aad89 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Automation/ConveyorMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 251361cf456e888459d473b5fedf7c4a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Automation/MachineSlotMath.cs b/Assets/_Project/Scripts/Simulation/Automation/MachineSlotMath.cs new file mode 100644 index 000000000..332a5cfbd --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Automation/MachineSlotMath.cs @@ -0,0 +1,85 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Pure, deterministic deposit/withdraw/total helpers for a machine's server-only / + /// staging buffers — the byte-id, non-replicated twin of + /// (which serves the [GhostField] global ledger). No RNG/wall-clock. DynamicBuffer is + /// a handle, so mutations apply to the underlying entity buffer. Overloaded per buffer type because the two + /// element types are deliberately distinct (a machine can carry both without a singleton-buffer clash). Deposit + /// is a no-op for count <= 0 or resource id 0; Withdraw clamps to available and drops a row at zero. + /// + public static class MachineSlotMath + { + // ---- MachineOutput ---- + public static void Deposit(DynamicBuffer buffer, byte resourceId, int count) + { + if (count <= 0 || resourceId == 0) return; + for (int i = 0; i < buffer.Length; i++) + if (buffer[i].ResourceId == resourceId) + { + var e = buffer[i]; e.Count += count; buffer[i] = e; return; + } + buffer.Add(new MachineOutput { ResourceId = resourceId, Count = count }); + } + + public static int Withdraw(DynamicBuffer buffer, byte resourceId, int count) + { + if (count <= 0 || resourceId == 0) return 0; + for (int i = 0; i < buffer.Length; i++) + if (buffer[i].ResourceId == resourceId) + { + var e = buffer[i]; + int taken = e.Count < count ? e.Count : count; + e.Count -= taken; + if (e.Count <= 0) buffer.RemoveAt(i); else buffer[i] = e; + return taken; + } + return 0; + } + + public static int TotalOf(DynamicBuffer buffer, byte resourceId) + { + int total = 0; + for (int i = 0; i < buffer.Length; i++) + if (buffer[i].ResourceId == resourceId) total += buffer[i].Count; + return total; + } + + // ---- MachineInput ---- + public static void Deposit(DynamicBuffer buffer, byte resourceId, int count) + { + if (count <= 0 || resourceId == 0) return; + for (int i = 0; i < buffer.Length; i++) + if (buffer[i].ResourceId == resourceId) + { + var e = buffer[i]; e.Count += count; buffer[i] = e; return; + } + buffer.Add(new MachineInput { ResourceId = resourceId, Count = count }); + } + + public static int Withdraw(DynamicBuffer buffer, byte resourceId, int count) + { + if (count <= 0 || resourceId == 0) return 0; + for (int i = 0; i < buffer.Length; i++) + if (buffer[i].ResourceId == resourceId) + { + var e = buffer[i]; + int taken = e.Count < count ? e.Count : count; + e.Count -= taken; + if (e.Count <= 0) buffer.RemoveAt(i); else buffer[i] = e; + return taken; + } + return 0; + } + + public static int TotalOf(DynamicBuffer buffer, byte resourceId) + { + int total = 0; + for (int i = 0; i < buffer.Length; i++) + if (buffer[i].ResourceId == resourceId) total += buffer[i].Count; + return total; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Automation/MachineSlotMath.cs.meta b/Assets/_Project/Scripts/Simulation/Automation/MachineSlotMath.cs.meta new file mode 100644 index 000000000..d1926ae5a --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Automation/MachineSlotMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 16b6aef96031f54469f05044a2c18e66 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Automation/ProductionMath.cs b/Assets/_Project/Scripts/Simulation/Automation/ProductionMath.cs new file mode 100644 index 000000000..d956055be --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Automation/ProductionMath.cs @@ -0,0 +1,51 @@ +using Unity.Mathematics; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Pure, deterministic catch-up + cooldown math shared by the M7 production systems (Harvester/Conveyor/ + /// Fabricator). No RNG/wall-clock -> server-authoritative. The single GATED catch-up path: a never-processed + /// machine () initializes first; a cooling machine yields 0; a due machine yields + /// floor(elapsed/period) clamped to [0, maxCatchup]; period is guarded by max(1,...). Cooldown is persisted as + /// REMAINING ticks (epoch-independent) so a save survives the server-tick origin reset on a fresh session. + /// + public static class ProductionMath + { + /// True for a never-processed machine (baked/just-placed) — initialize the baseline before producing. + public static bool NeedsInit(uint lastProcessedTick) => lastProcessedTick == 0u; + + /// + /// Cycles to award THIS process. 0 if cooling ( newer than ) + /// or nothing elapsed; otherwise floor(elapsed/period) clamped to [0, ]. + /// ==0 is the inactive sentinel (never read as a future cooling tick). The lower + /// bound is 0 (not 1): when genuinely due the NextTick gate guarantees elapsed>=period, so a sub-period + /// edge (e.g. a freshly restored remaining==0 machine) floors to 0 rather than minting prematurely. + /// is guarded by max(1,...) so a 0 never divides. + /// + public static int CyclesDue(NetworkTick now, uint nextTick, uint lastProcessedTick, int period, int maxCatchup) + { + int p = math.max(1, period); + + if (nextTick != 0u) + { + var next = new NetworkTick(nextTick); + if (next.IsValid && next.IsNewerThan(now)) + return 0; // still cooling down + } + + int since = now.TicksSince(new NetworkTick(TickUtil.NonZero(lastProcessedTick))); + if (since <= 0) + return 0; + + return math.clamp(since / p, 0, maxCatchup); + } + + /// Remaining cooldown ticks to PERSIST (epoch-independent): 0 if inactive or already due, else nextTick-now. + public static uint RemainingTicks(uint nextTick, uint nowTick) => + nextTick == 0u ? 0u : (nextTick > nowTick ? nextTick - nowTick : 0u); + + /// Re-anchor a persisted remaining cooldown to the current tick origin on restore (NonZero-guarded). + public static uint RestoreNextTick(uint nowTick, uint remaining) => TickUtil.NonZero(nowTick + remaining); + } +} diff --git a/Assets/_Project/Scripts/Simulation/Automation/ProductionMath.cs.meta b/Assets/_Project/Scripts/Simulation/Automation/ProductionMath.cs.meta new file mode 100644 index 000000000..b90f0117e --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Automation/ProductionMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6d461ab50604ea642b26586bffeed41e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Building/BuildPlaceRequest.cs b/Assets/_Project/Scripts/Simulation/Building/BuildPlaceRequest.cs index f327d4e2f..86749a5e4 100644 --- a/Assets/_Project/Scripts/Simulation/Building/BuildPlaceRequest.cs +++ b/Assets/_Project/Scripts/Simulation/Building/BuildPlaceRequest.cs @@ -14,5 +14,6 @@ namespace ProjectM.Simulation public byte StructureType; public int CellX; public int CellZ; + public byte Direction; } } diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 340f5161b..a62be4e5d 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -50,5 +50,11 @@ namespace ProjectM.Simulation /// Wind-up ticks before a Husk strike lands (~0.3s @ 60 ticks/sec). 0/1 = near-instant (legacy behaviour). public const int AttackWindupTicks = 18; + + // ---- Production / automation (M7: Harvester/Conveyor/Fabricator) ---- + + /// Max production cycles a single machine awards in one process (bounds within-session + /// catch-up after any skipped ticks; restore re-seats the baseline so this never reflects wall-clock). + public const int MaxProductionCatchup = 600; } } diff --git a/Assets/_Project/Subscenes/Gameplay.unity b/Assets/_Project/Subscenes/Gameplay.unity index 3603998ae..045020ed1 100644 --- a/Assets/_Project/Subscenes/Gameplay.unity +++ b/Assets/_Project/Subscenes/Gameplay.unity @@ -324,6 +324,12 @@ MonoBehaviour: WallCostOre: 4 PylonPrefab: {fileID: 3885353946372160549, guid: 7d0637ef90f120a4c9e2ba637dfc00af, type: 3} PylonCostOre: 2 + HarvesterPrefab: {fileID: 3885353946372160549, guid: 9e227d9b387139340933dcf55d1c3a87, type: 3} + HarvesterCostOre: 20 + FabricatorPrefab: {fileID: 3885353946372160549, guid: 8dd9baab4cbf6c04f9320ed5ed764c65, type: 3} + FabricatorCostOre: 30 + ConveyorPrefab: {fileID: 3885353946372160549, guid: 91f9551ef855b3b4f98bcfcb7bf747e2, type: 3} + ConveyorCostOre: 2 --- !u!4 &380046995 Transform: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Tests/EditMode/AutomationSaveRoundTripTests.cs b/Assets/_Project/Tests/EditMode/AutomationSaveRoundTripTests.cs new file mode 100644 index 000000000..d8c929522 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/AutomationSaveRoundTripTests.cs @@ -0,0 +1,71 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using UnityEngine; + +namespace ProjectM.Tests +{ + /// + /// Tests the SaveData v2 structure-persistence schema (M7): a player-built structure set + the flat machine-I/O + /// table round-trip through JsonUtility with fields intact; the REMAINING-ticks cooldown survives an epoch reset + /// (RemainingTicks/RestoreNextTick); and a save JSON lacking the v2 arrays deserializes without throwing (degrades + /// to no structures). The deeper instantiate-from-catalog restore is covered by the Play-mode validation pass. + /// + public class AutomationSaveRoundTripTests + { + [Test] + public void StructuresAndIo_RoundTrip_PreservesFields() + { + var data = new SaveData + { + GoalCharge = 3, + GoalTarget = 10, + Structures = new[] + { + new StructureSave { Type = StructureType.Harvester, CellX = 4, CellZ = -2, RemainingTicks = 12 }, + new StructureSave { Type = StructureType.Conveyor, CellX = 5, CellZ = -2, Direction = 2, RemainingTicks = 3, ConveyorResId = ResourceId.Ore, ConveyorCount = 1 }, + new StructureSave { Type = StructureType.Fabricator, CellX = 6, CellZ = -2, RemainingTicks = 40 }, + }, + StructureIo = new[] + { + new StructureIoRow { StructureIndex = 0, Slot = 1, ResourceId = ResourceId.Ore, Count = 7 }, + new StructureIoRow { StructureIndex = 2, Slot = 0, ResourceId = ResourceId.Ore, Count = 5 }, + }, + }; + + var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); + + Assert.AreEqual(SaveData.CurrentVersion, back.Version); + Assert.AreEqual(3, back.Structures.Length); + Assert.AreEqual(StructureType.Conveyor, back.Structures[1].Type); + Assert.AreEqual(2, back.Structures[1].Direction); + Assert.AreEqual(ResourceId.Ore, back.Structures[1].ConveyorResId); + Assert.AreEqual(1, back.Structures[1].ConveyorCount); + Assert.AreEqual(12u, back.Structures[0].RemainingTicks); + Assert.AreEqual(2, back.StructureIo.Length); + Assert.AreEqual(2, back.StructureIo[1].StructureIndex); + Assert.AreEqual(0, back.StructureIo[1].Slot); + Assert.AreEqual(5, back.StructureIo[1].Count); + } + + [Test] + public void RemainingTicks_RestoreNextTick_PreservesCooldownGap_AcrossEpochs() + { + uint saveNow = 5000u, savedNext = 5037u; + uint remaining = ProductionMath.RemainingTicks(savedNext, saveNow); + Assert.AreEqual(37u, remaining); + + uint restoreNow = 11u; + uint restoredNext = ProductionMath.RestoreNextTick(restoreNow, remaining); + Assert.AreEqual(48u, restoredNext); + } + + [Test] + public void Save_Lacking_V2_Arrays_DeserializesWithoutThrowing() + { + SaveData back = null; + Assert.DoesNotThrow(() => back = JsonUtility.FromJson("{\"Version\":2,\"GoalCharge\":1,\"GoalTarget\":10}")); + Assert.IsNotNull(back); + Assert.IsTrue(back.Structures == null || back.Structures.Length == 0); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/AutomationSaveRoundTripTests.cs.meta b/Assets/_Project/Tests/EditMode/AutomationSaveRoundTripTests.cs.meta new file mode 100644 index 000000000..264b506c9 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/AutomationSaveRoundTripTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ad495310b2721f147925ae48deb41bb4 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ConveyorMathTests.cs b/Assets/_Project/Tests/EditMode/ConveyorMathTests.cs new file mode 100644 index 000000000..dbc61781a --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ConveyorMathTests.cs @@ -0,0 +1,256 @@ +using System.Collections.Generic; +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Mathematics; + +namespace ProjectM.Tests +{ + /// + /// Pure, world-free tests for — the DETERMINISTIC, ORDER-INDEPENDENT belt resolver + /// that the server ConveyorTransportSystem applies. Drives the array-based ResolveMoves directly + /// (no ECS world) so determinism is provable: a 4-cell line advances exactly one cell/tick, a Y-junction lets a + /// single deterministic winner (lowest CellKey source) into a shared destination while the loser STALLS with no + /// loss, a blocked (already-occupied) destination stalls its source, and the END-STATE is IDENTICAL under two + /// shuffled input orders. Sink (machine-input) cells always accept. DirOffset/CellKey are pinned too. + /// + public class ConveyorMathTests + { + const byte DirPosX = 0; + const byte DirNegX = 1; + const byte DirPosZ = 2; + const byte DirNegZ = 3; + + [Test] + public void DirOffset_Maps_All_Four_Directions() + { + Assert.AreEqual(new int2(1, 0), ConveyorMath.DirOffset(DirPosX), "0 = +X"); + Assert.AreEqual(new int2(-1, 0), ConveyorMath.DirOffset(DirNegX), "1 = -X"); + Assert.AreEqual(new int2(0, 1), ConveyorMath.DirOffset(DirPosZ), "2 = +Z"); + Assert.AreEqual(new int2(0, -1), ConveyorMath.DirOffset(DirNegZ), "3 = -Z"); + } + + [Test] + public void CellKey_Is_Stable_And_Order_Defining() + { + // The key must be a stable total order used to break move ties deterministically. + long a = ConveyorMath.CellKey(new int2(0, 0)); + long b = ConveyorMath.CellKey(new int2(1, 0)); + long c = ConveyorMath.CellKey(new int2(0, 1)); + Assert.AreEqual(a, ConveyorMath.CellKey(new int2(0, 0)), "CellKey is deterministic for a given cell."); + Assert.AreNotEqual(a, b); + Assert.AreNotEqual(a, c); + Assert.AreNotEqual(b, c, "Distinct cells map to distinct keys (no collision in-range)."); + } + + // ---- ResolveMoves harness ------------------------------------------------------------------------------ + + /// One conveyor cell in a scenario: its grid cell, belt direction, and the item it currently holds. + struct Belt + { + public int2 Cell; + public byte Dir; + public int Res; // 0 = empty + public int Cnt; // 0 = empty + } + + /// + /// Drives over a set of belts (+ optional sink cells), returning the + /// resolved (srcIndex -> destCell) move list as a dictionary keyed by source index for easy assertion. + /// All native containers are Temp + disposed before returning. + /// + static Dictionary Resolve(Belt[] belts, int2[] sinks) + { + int n = belts.Length; + var srcCells = new NativeArray(n, Allocator.Temp); + var dirs = new NativeArray(n, Allocator.Temp); + var itemRes = new NativeArray(n, Allocator.Temp); + var itemCnt = new NativeArray(n, Allocator.Temp); + var cellToIndex = new NativeHashMap(n, Allocator.Temp); + var sinkCells = new NativeHashSet(math.max(1, sinks?.Length ?? 0), Allocator.Temp); + var outMoveDst = new NativeArray(n, Allocator.Temp); + var outMoveSrcIdx = new NativeArray(n, Allocator.Temp); + + for (int i = 0; i < n; i++) + { + srcCells[i] = belts[i].Cell; + dirs[i] = belts[i].Dir; + itemRes[i] = belts[i].Res; + itemCnt[i] = belts[i].Cnt; + cellToIndex[belts[i].Cell] = i; + } + if (sinks != null) + foreach (var s in sinks) + sinkCells.Add(s); + + ConveyorMath.ResolveMoves(srcCells, dirs, itemRes, itemCnt, cellToIndex, sinkCells, + outMoveDst, outMoveSrcIdx, out int moveCount); + + var result = new Dictionary(); + for (int m = 0; m < moveCount; m++) + result[outMoveSrcIdx[m]] = outMoveDst[m]; + + srcCells.Dispose(); dirs.Dispose(); itemRes.Dispose(); itemCnt.Dispose(); + cellToIndex.Dispose(); sinkCells.Dispose(); outMoveDst.Dispose(); outMoveSrcIdx.Dispose(); + return result; + } + + [Test] + public void Line_Item_Advances_Exactly_One_Cell() + { + // 4-cell +X line at (0,0),(1,0),(2,0),(3,0). Only the head (index 0) holds an item; the cell ahead (1,0) + // is EMPTY in the snapshot, so the item moves exactly one cell. No other belt holds an item -> no other move. + var belts = new[] + { + new Belt { Cell = new int2(0, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, + new Belt { Cell = new int2(1, 0), Dir = DirPosX, Res = 0, Cnt = 0 }, + new Belt { Cell = new int2(2, 0), Dir = DirPosX, Res = 0, Cnt = 0 }, + new Belt { Cell = new int2(3, 0), Dir = DirPosX, Res = 0, Cnt = 0 }, + }; + + var moves = Resolve(belts, sinks: null); + + Assert.AreEqual(1, moves.Count, "Exactly one item moves this tick."); + Assert.IsTrue(moves.ContainsKey(0), "The head belt's item is the one that moves."); + Assert.AreEqual(new int2(1, 0), moves[0], "The item advances exactly one cell along +X."); + } + + [Test] + public void Full_Line_All_Advance_One_Cell_From_PreMove_Snapshot() + { + // Every belt in a 4-cell line holds an item, EXCEPT the head cell ahead of the line is open. Because + // occupancy is read from the PRE-MOVE snapshot (double-buffered), each item shifts forward one cell — + // the cell ahead was occupied in the snapshot for the tail belts, so ONLY the lead item (whose target is + // empty in the snapshot) may advance. This pins the snapshot (not live) occupancy rule. + var belts = new[] + { + new Belt { Cell = new int2(0, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // -> (1,0) occupied in snapshot -> stall + new Belt { Cell = new int2(1, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // -> (2,0) occupied in snapshot -> stall + new Belt { Cell = new int2(2, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // -> (3,0) occupied in snapshot -> stall + new Belt { Cell = new int2(3, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // -> (4,0) not a belt, not a sink -> no move + }; + + var moves = Resolve(belts, sinks: null); + + Assert.AreEqual(0, moves.Count, + "A fully-packed belt line with a dead end produces no moves (snapshot occupancy blocks every step)."); + } + + [Test] + public void Sink_Cell_Always_Accepts_The_Head_Item() + { + // A 2-cell +X line whose head's target cell is a SINK (machine input). Sinks always accept (deposit), + // even though they are not conveyor cells in cellToIndex. + var belts = new[] + { + new Belt { Cell = new int2(0, 0), Dir = DirPosX, Res = ResourceId.Biomass, Cnt = 2 }, + }; + var sinks = new[] { new int2(1, 0) }; + + var moves = Resolve(belts, sinks); + + Assert.AreEqual(1, moves.Count, "The item moves into the adjacent sink."); + Assert.AreEqual(new int2(1, 0), moves[0], "The destination is the sink cell."); + } + + [Test] + public void Y_Junction_Deterministic_Winner_By_Lowest_CellKey_Loser_Stalls() + { + // Two source belts both feed the SAME destination (1,1): + // A at (0,1) facing +X -> (1,1) + // B at (1,0) facing +Z -> (1,1) + // The destination is an EMPTY conveyor cell -> it accepts AT MOST ONE; the tie breaks to the lowest + // CellKey source. The loser STALLS (no move) with no item loss. + var dst = new int2(1, 1); + var a = new int2(0, 1); + var b = new int2(1, 0); + + var belts = new[] + { + new Belt { Cell = a, Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // index 0 + new Belt { Cell = b, Dir = DirPosZ, Res = ResourceId.Ore, Cnt = 1 }, // index 1 + new Belt { Cell = dst, Dir = DirPosX, Res = 0, Cnt = 0 }, // index 2 (empty target) + }; + + var moves = Resolve(belts, sinks: null); + + // Exactly one of the two contenders wins; it targets dst. The winner is the lower-CellKey source. + int winnerIdx = ConveyorMath.CellKey(a) < ConveyorMath.CellKey(b) ? 0 : 1; + int loserIdx = winnerIdx == 0 ? 1 : 0; + + Assert.AreEqual(1, moves.Count, "Only one item may enter the shared (empty) destination this tick."); + Assert.IsTrue(moves.ContainsKey(winnerIdx), "The lowest-CellKey source wins the contended cell."); + Assert.AreEqual(dst, moves[winnerIdx], "The winner moves into the shared destination."); + Assert.IsFalse(moves.ContainsKey(loserIdx), "The loser stalls in place (no item loss)."); + } + + [Test] + public void Blocked_Cell_Stalls_The_Source() + { + // Head item at (0,0) facing +X; (1,0) is OCCUPIED in the snapshot (and its own item can't move because + // (2,0) is not a belt/sink). The head item must STALL, not overwrite or destroy the blocker. + var belts = new[] + { + new Belt { Cell = new int2(0, 0), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, + new Belt { Cell = new int2(1, 0), Dir = DirPosX, Res = ResourceId.Biomass, Cnt = 1 }, // blocker; -> (2,0) dead end + }; + + var moves = Resolve(belts, sinks: null); + + Assert.IsFalse(moves.ContainsKey(0), "A source whose destination is occupied in the snapshot stalls (no loss)."); + Assert.IsFalse(moves.ContainsKey(1), "The blocker itself has a dead-end target and also stalls."); + Assert.AreEqual(0, moves.Count, "Nothing moves when the only path is blocked."); + } + + [Test] + public void EndState_Is_Identical_Under_Two_Shuffled_Input_Orders() + { + // A Y-junction plus a 4-cell line, fed in two DIFFERENT array orders. Because ResolveMoves iterates + // sources SORTED by CellKey (not hashmap/array order), the resolved set of (srcCell -> destCell) moves + // must be byte-for-byte identical regardless of input ordering. We compare keyed by CELL (order-stable), + // not by array index (which differs between the two orderings). + var dst = new int2(1, 1); + var aCell = new int2(0, 1); + var bCell = new int2(1, 0); + + var orderOne = new[] + { + new Belt { Cell = aCell, Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, + new Belt { Cell = bCell, Dir = DirPosZ, Res = ResourceId.Ore, Cnt = 1 }, + new Belt { Cell = dst, Dir = DirPosX, Res = 0, Cnt = 0 }, + new Belt { Cell = new int2(5, 5), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, // lone item -> (6,5) sink + }; + // Same scenario, shuffled: reverse + interleave the array order. + var orderTwo = new[] + { + new Belt { Cell = new int2(5, 5), Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, + new Belt { Cell = dst, Dir = DirPosX, Res = 0, Cnt = 0 }, + new Belt { Cell = bCell, Dir = DirPosZ, Res = ResourceId.Ore, Cnt = 1 }, + new Belt { Cell = aCell, Dir = DirPosX, Res = ResourceId.Ore, Cnt = 1 }, + }; + var sinks = new[] { new int2(6, 5) }; + + var movesOne = Resolve(orderOne, sinks); + var movesTwo = Resolve(orderTwo, sinks); + + // Re-key both result sets by the SOURCE CELL (stable across orderings) -> destination cell. + var byCellOne = ReKeyBySourceCell(orderOne, movesOne); + var byCellTwo = ReKeyBySourceCell(orderTwo, movesTwo); + + Assert.AreEqual(byCellOne.Count, byCellTwo.Count, "Both orderings resolve the same number of moves."); + foreach (var kv in byCellOne) + { + Assert.IsTrue(byCellTwo.ContainsKey(kv.Key), $"Source cell {kv.Key} moved in order-1 but not order-2."); + Assert.AreEqual(kv.Value, byCellTwo[kv.Key], $"Source cell {kv.Key} resolved to a different destination across orderings (NON-deterministic)."); + } + } + + static Dictionary ReKeyBySourceCell(Belt[] belts, Dictionary movesBySrcIdx) + { + var byCell = new Dictionary(); + foreach (var kv in movesBySrcIdx) + byCell[belts[kv.Key].Cell] = kv.Value; + return byCell; + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ConveyorMathTests.cs.meta b/Assets/_Project/Tests/EditMode/ConveyorMathTests.cs.meta new file mode 100644 index 000000000..ce478dcdb --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ConveyorMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e7846ef025344bf428c68924d389d1b8 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ConveyorTransportSystemTests.cs b/Assets/_Project/Tests/EditMode/ConveyorTransportSystemTests.cs new file mode 100644 index 000000000..af4baaed2 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ConveyorTransportSystemTests.cs @@ -0,0 +1,187 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Core; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for the server-only — the transport step + /// that PULLS resources off an adjacent upstream onto an empty belt, advances a held + /// exactly one cell per tick along its , and DEPOSITS + /// into a downstream machine's sink. Determinism (Y-junction tie-break, stall-no-loss, + /// shuffle-invariance) is exhaustively proven world-free in ; these tests pin the + /// SYSTEM wiring: snapshot -> ResolveMoves -> apply (ConveyorItem enable/disable bit + sink deposit + machine pull), + /// plus the per-conveyor period gate (init on first touch, then move on the production cadence). + /// + public class ConveyorTransportSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + SetServerTick(world, serverTick); + return (world, group); + } + + static void SetServerTick(World world, uint tick) + { + var em = world.EntityManager; + using var q = em.CreateEntityQuery(typeof(NetworkTime)); + Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); + em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); + } + + static Entity MakeConveyor(EntityManager em, int2 cell, byte dir, int periodTicks, byte itemRes, int itemCnt) + { + var e = em.CreateEntity(); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); + em.AddComponentData(e, new PlacedStructure + { + Type = StructureType.Conveyor, + Cell = cell, + NextTick = 0u, + LastProcessedTick = 0u, + }); + em.AddComponentData(e, new Conveyor { Direction = dir, PeriodTicks = periodTicks }); + em.AddComponentData(e, new ConveyorItem { ResourceId = itemRes, Count = itemCnt }); + em.SetComponentEnabled(e, itemCnt > 0); // baked DISABLED unless carrying an item + return e; + } + + static Entity MakeHarvesterOutput(EntityManager em, int2 cell, byte resourceId, int count) + { + // A minimal upstream producer: a PlacedStructure at a cell with a populated MachineOutput buffer. + var e = em.CreateEntity(); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); + em.AddComponentData(e, new PlacedStructure { Type = StructureType.Harvester, Cell = cell, NextTick = 0u, LastProcessedTick = 1u }); + var output = em.AddBuffer(e); + if (count > 0) + output.Add(new MachineOutput { ResourceId = resourceId, Count = count }); + return e; + } + + static Entity MakeFabricatorInput(EntityManager em, int2 cell) + { + // A minimal downstream sink: a PlacedStructure at a cell with an (empty) MachineInput buffer. + var e = em.CreateEntity(); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); + em.AddComponentData(e, new PlacedStructure { Type = StructureType.Fabricator, Cell = cell, NextTick = 0u, LastProcessedTick = 1u }); + em.AddBuffer(e); + return e; + } + + static int OutputOf(EntityManager em, Entity machine, byte resourceId) + { + var buf = em.GetBuffer(machine); + int total = 0; + for (int i = 0; i < buf.Length; i++) + if (buf[i].ResourceId == resourceId) total += buf[i].Count; + return total; + } + + static int InputOf(EntityManager em, Entity machine, byte resourceId) + { + var buf = em.GetBuffer(machine); + int total = 0; + for (int i = 0; i < buf.Length; i++) + if (buf[i].ResourceId == resourceId) total += buf[i].Count; + return total; + } + + /// Ticks the system past its per-conveyor init gate so subsequent updates actually transport. + static void InitThenAdvance(World world, SimulationSystemGroup group, uint initTick, uint moveTick) + { + SetServerTick(world, initTick); + group.Update(); // first touch: init the conveyor period gate (no move) + SetServerTick(world, moveTick); + group.Update(); // a period later: the transport step runs + } + + [Test] + public void Conveyor_Pulls_From_Adjacent_Upstream_MachineOutput_When_Empty() + { + var (world, group) = MakeWorld("ConveyorPull", serverTick: 100); + using (world) + { + var em = world.EntityManager; + // Harvester output at (0,0); an EMPTY belt at (1,0) facing +X. The belt should pull one unit from the + // upstream output (the harvester's cell (0,0) is belt.cell - DirOffset = (1,0)-(1,0) = (0,0)). + var harvester = MakeHarvesterOutput(em, new int2(0, 0), ResourceId.Ore, count: 4); + var belt = MakeConveyor(em, new int2(1, 0), dir: 0 /*+X*/, periodTicks: 30, itemRes: 0, itemCnt: 0); + + InitThenAdvance(world, group, initTick: 100, moveTick: 130); + + Assert.IsTrue(em.IsComponentEnabled(belt), "An empty belt adjacent to an upstream output pulls an item (ConveyorItem enabled)."); + var item = em.GetComponentData(belt); + Assert.AreEqual(ResourceId.Ore, item.ResourceId, "The pulled item carries the upstream resource id."); + Assert.AreEqual(3, OutputOf(em, harvester, ResourceId.Ore), "Exactly one unit is pulled off the upstream output (4 -> 3)."); + } + } + + [Test] + public void Item_Advances_Exactly_One_Cell_Along_Direction() + { + var (world, group) = MakeWorld("ConveyorAdvance", serverTick: 100); + using (world) + { + var em = world.EntityManager; + // Two +X belts (0,0)->(1,0). The source carries an item; the destination belt is empty. After a tick + // the item should be on the destination belt and the source belt empty. + var src = MakeConveyor(em, new int2(0, 0), dir: 0, periodTicks: 30, itemRes: ResourceId.Biomass, itemCnt: 1); + var dst = MakeConveyor(em, new int2(1, 0), dir: 0, periodTicks: 30, itemRes: 0, itemCnt: 0); + + InitThenAdvance(world, group, initTick: 100, moveTick: 130); + + Assert.IsFalse(em.IsComponentEnabled(src), "The item leaves the source belt (now empty)."); + Assert.IsTrue(em.IsComponentEnabled(dst), "The item arrives on the next belt (exactly one cell along +X)."); + Assert.AreEqual(ResourceId.Biomass, em.GetComponentData(dst).ResourceId, "The carried resource id is preserved across the move."); + } + } + + [Test] + public void Item_Deposits_Into_Downstream_Machine_Input_Sink() + { + var (world, group) = MakeWorld("ConveyorSink", serverTick: 100); + using (world) + { + var em = world.EntityManager; + // A +X belt at (0,0) carrying an item; a fabricator input at (1,0) is the sink. The item should be + // deposited into the machine's MachineInput and removed from the belt. + var belt = MakeConveyor(em, new int2(0, 0), dir: 0, periodTicks: 30, itemRes: ResourceId.Ore, itemCnt: 1); + var fab = MakeFabricatorInput(em, new int2(1, 0)); + + InitThenAdvance(world, group, initTick: 100, moveTick: 130); + + Assert.AreEqual(1, InputOf(em, fab, ResourceId.Ore), "The item is deposited into the downstream machine's input."); + Assert.IsFalse(em.IsComponentEnabled(belt), "The belt is empty after handing its item to the sink."); + } + } + + [Test] + public void Does_Not_Transport_While_Cooling_Down() + { + var (world, group) = MakeWorld("ConveyorCooling", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var src = MakeConveyor(em, new int2(0, 0), dir: 0, periodTicks: 30, itemRes: ResourceId.Ore, itemCnt: 1); + var dst = MakeConveyor(em, new int2(1, 0), dir: 0, periodTicks: 30, itemRes: 0, itemCnt: 0); + + SetServerTick(world, 100); + group.Update(); // init (NextTick -> 130) + SetServerTick(world, 115); + group.Update(); // 115 < 130 -> belt is still cooling, no move + + Assert.IsTrue(em.IsComponentEnabled(src), "The item stays put while the belt is cooling down."); + Assert.IsFalse(em.IsComponentEnabled(dst), "Nothing arrives before the belt's period elapses."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ConveyorTransportSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/ConveyorTransportSystemTests.cs.meta new file mode 100644 index 000000000..241ef0c3d --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ConveyorTransportSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5342d4ffd0bafeb418f5c2f9b3e3ee4e \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs b/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs new file mode 100644 index 000000000..d55434cac --- /dev/null +++ b/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs @@ -0,0 +1,180 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Core; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for the server-only — the recipe machine + /// that consumes InAmount of its input resource per run from its OWN buffer and + /// deposits OutAmount * runs into the GLOBAL resource ledger (resolved via , + /// never GetSingleton<StorageEntry>). Pins: it INITIALIZES on first touch without producing; it is strictly + /// INPUT-LIMITED (runs = min(cycles, affordable) — no mint-from-nothing when the input slot is empty); it consumes + /// exactly InAmount*runs from the input buffer; and catch-up after skipped ticks awards the exact capped amount. + /// + public class FabricatorProductionSystemTests + { + static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name, uint serverTick) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + SetServerTick(world, serverTick); + var em = world.EntityManager; + var ledger = em.CreateEntity(typeof(ResourceLedger)); + em.AddBuffer(ledger); + return (world, group, ledger); + } + + static void SetServerTick(World world, uint tick) + { + var em = world.EntityManager; + using var q = em.CreateEntityQuery(typeof(NetworkTime)); + Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); + em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); + } + + static Entity MakeFabricator(EntityManager em, byte inId, int inAmt, byte outId, int outAmt, int periodTicks, int seedInput) + { + var e = em.CreateEntity(); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); + em.AddComponentData(e, new PlacedStructure + { + Type = StructureType.Fabricator, + NextTick = 0u, + LastProcessedTick = 0u, + }); + em.AddComponentData(e, new Fabricator + { + InResourceId = inId, + InAmount = inAmt, + OutResourceId = outId, + OutAmount = outAmt, + PeriodTicks = periodTicks, + }); + var input = em.AddBuffer(e); + if (seedInput > 0) + input.Add(new MachineInput { ResourceId = inId, Count = seedInput }); + em.AddBuffer(e); + return e; + } + + static int LedgerCount(EntityManager em, Entity ledger, ushort itemId) + { + var buf = em.GetBuffer(ledger); + for (int i = 0; i < buf.Length; i++) + if (buf[i].ItemId == itemId) return buf[i].Count; + return 0; + } + + static int InputOf(EntityManager em, Entity machine, byte resourceId) + { + var buf = em.GetBuffer(machine); + int total = 0; + for (int i = 0; i < buf.Length; i++) + if (buf[i].ResourceId == resourceId) + total += buf[i].Count; + return total; + } + + [Test] + public void First_Update_Initializes_Without_Producing() + { + var (world, group, ledger) = MakeWorld("FabInit", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 1, periodTicks: 30, seedInput: 10); + + group.Update(); + + Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "First touch only initializes (no production)."); + Assert.AreEqual(10, InputOf(em, f, ResourceId.Ore), "No input is consumed during init."); + var ps = em.GetComponentData(f); + Assert.AreNotEqual(0u, ps.LastProcessedTick); + Assert.AreNotEqual(0u, ps.NextTick); + } + } + + [Test] + public void Produces_One_Run_Per_Period_When_Input_Is_Available() + { + var (world, group, ledger) = MakeWorld("FabRun", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 3, periodTicks: 30, seedInput: 10); + + group.Update(); // init (NextTick -> 130) + SetServerTick(world, 130); + group.Update(); // one period elapsed, input affords it -> 1 run + + Assert.AreEqual(3, LedgerCount(em, ledger, ResourceId.Aether), "One run deposits OutAmount into the ledger."); + Assert.AreEqual(8, InputOf(em, f, ResourceId.Ore), "One run consumes InAmount from the input buffer (10 - 2)."); + } + } + + [Test] + public void Is_Input_Limited_No_Mint_From_Empty_Slot() + { + var (world, group, ledger) = MakeWorld("FabStarved", serverTick: 100); + using (world) + { + var em = world.EntityManager; + // Empty input slot: even with periods elapsed, affordable == 0 -> runs == 0 -> nothing minted. + var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 3, periodTicks: 30, seedInput: 0); + + group.Update(); // init + SetServerTick(world, 250); // plenty of periods elapsed + group.Update(); + + Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), + "A starved fabricator mints nothing — production is strictly input-limited."); + Assert.AreEqual(0, InputOf(em, f, ResourceId.Ore), "No phantom input appears."); + } + } + + [Test] + public void Runs_Are_Clamped_To_Affordable_Input() + { + var (world, group, ledger) = MakeWorld("FabAfford", serverTick: 100); + using (world) + { + var em = world.EntityManager; + // 5 periods become due (150 ticks / 30), but only 3 runs are affordable (7 input / 2 per run = 3). + var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Aether, outAmt: 1, periodTicks: 30, seedInput: 7); + + group.Update(); // init at 100 + SetServerTick(world, 250); // floor(150/30) = 5 cycles due + group.Update(); + + Assert.AreEqual(3, LedgerCount(em, ledger, ResourceId.Aether), + "runs = min(cyclesDue=5, affordable=3) = 3 — output is clamped to available input."); + Assert.AreEqual(1, InputOf(em, f, ResourceId.Ore), "3 runs consume 6 of 7 input, leaving 1."); + } + } + + [Test] + public void CatchUp_Awards_Exact_Multiple_When_Input_Allows() + { + var (world, group, ledger) = MakeWorld("FabCatchUp", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var f = MakeFabricator(em, ResourceId.Ore, inAmt: 1, outId: ResourceId.Biomass, outAmt: 2, periodTicks: 30, seedInput: 1000); + + group.Update(); // init at 100 + SetServerTick(world, 250); // floor(150/30) = 5 cycles, all affordable + group.Update(); + + Assert.AreEqual(10, LedgerCount(em, ledger, ResourceId.Biomass), "5 runs * OutAmount(2) = 10 deposited."); + Assert.AreEqual(995, InputOf(em, f, ResourceId.Ore), "5 runs * InAmount(1) = 5 consumed (1000 - 5)."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs.meta new file mode 100644 index 000000000..1c4c58592 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/FabricatorProductionSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aeaf3925e6ddb6e4faeac39f32f98e5d \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/HarvesterProductionSystemTests.cs b/Assets/_Project/Tests/EditMode/HarvesterProductionSystemTests.cs new file mode 100644 index 000000000..849a08a86 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/HarvesterProductionSystemTests.cs @@ -0,0 +1,153 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Core; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for the server-only — the fixed-yield + /// generator that deposits Yield * cycles of its resource into its OWN buffer + /// on a deterministic period. Pins the SINGLE GATED catch-up path from the M7 contract: a never-processed machine + /// (LastProcessedTick==0) only INITIALIZES on its first touch (no production), then produces exactly one yield per + /// elapsed period, catch-up after skipped ticks awards the exact (capped) amount, and a cooling machine produces + /// nothing. Output stays in the machine's local buffer (server-only, no GhostField) — the conveyor pulls it later. + /// + public class HarvesterProductionSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + SetServerTick(world, serverTick); + return (world, group); + } + + static void SetServerTick(World world, uint tick) + { + var em = world.EntityManager; + using var q = em.CreateEntityQuery(typeof(NetworkTime)); + Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); + em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); + } + + static Entity MakeHarvester(EntityManager em, byte resourceId, int yield, int periodTicks) + { + var e = em.CreateEntity(); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); + em.AddComponentData(e, new PlacedStructure + { + Type = StructureType.Harvester, + NextTick = 0u, + LastProcessedTick = 0u, // never processed -> first update only initializes + }); + em.AddComponentData(e, new Harvester { ResourceId = resourceId, Yield = yield, PeriodTicks = periodTicks }); + em.AddBuffer(e); + return e; + } + + static int OutputOf(EntityManager em, Entity machine, byte resourceId) + { + var buf = em.GetBuffer(machine); + int total = 0; + for (int i = 0; i < buf.Length; i++) + if (buf[i].ResourceId == resourceId) + total += buf[i].Count; + return total; + } + + [Test] + public void First_Update_Initializes_Without_Producing() + { + var (world, group) = MakeWorld("HarvesterInit", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var h = MakeHarvester(em, ResourceId.Aether, yield: 5, periodTicks: 30); + + group.Update(); + + Assert.AreEqual(0, OutputOf(em, h, ResourceId.Aether), + "A never-processed machine only initializes on its first touch (no production)."); + var ps = em.GetComponentData(h); + Assert.AreNotEqual(0u, ps.LastProcessedTick, "Init stamps LastProcessedTick to 'now'."); + Assert.AreNotEqual(0u, ps.NextTick, "Init stamps the next production tick (now + period)."); + } + } + + [Test] + public void Produces_One_Yield_After_Exactly_One_Period() + { + var (world, group) = MakeWorld("HarvesterOnePeriod", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var h = MakeHarvester(em, ResourceId.Aether, yield: 5, periodTicks: 30); + + group.Update(); // tick 100: init (NextTick -> 130) + SetServerTick(world, 130); + group.Update(); // tick 130: one period elapsed -> +5 + + Assert.AreEqual(5, OutputOf(em, h, ResourceId.Aether), "One elapsed period yields exactly Yield."); + } + } + + [Test] + public void Does_Not_Produce_While_Cooling_Down() + { + var (world, group) = MakeWorld("HarvesterCooling", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var h = MakeHarvester(em, ResourceId.Ore, yield: 5, periodTicks: 30); + + group.Update(); // init (NextTick -> 130) + SetServerTick(world, 115); + group.Update(); // 115 < 130 -> still cooling + + Assert.AreEqual(0, OutputOf(em, h, ResourceId.Ore), "No production before the period elapses."); + } + } + + [Test] + public void CatchUp_Awards_Exact_Multiple_For_Skipped_Periods() + { + var (world, group) = MakeWorld("HarvesterCatchUp", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var h = MakeHarvester(em, ResourceId.Biomass, yield: 5, periodTicks: 30); + + group.Update(); // init at 100 (LastProcessedTick -> 100) + SetServerTick(world, 250); // 150 ticks elapsed -> floor(150/30) = 5 cycles + group.Update(); + + Assert.AreEqual(25, OutputOf(em, h, ResourceId.Biomass), + "Catch-up awards Yield * floor(elapsed/period) = 5 * 5 = 25 (skipped ticks are not lost)."); + } + } + + [Test] + public void CatchUp_Is_Capped_At_MaxProductionCatchup() + { + var (world, group) = MakeWorld("HarvesterCap", serverTick: 100); + using (world) + { + var em = world.EntityManager; + var h = MakeHarvester(em, ResourceId.Aether, yield: 1, periodTicks: 1); + + group.Update(); // init at 100 + SetServerTick(world, 100u + 5_000_000u); // an absurd gap + group.Update(); + + Assert.AreEqual(Tuning.MaxProductionCatchup, OutputOf(em, h, ResourceId.Aether), + "A long gap is capped at MaxProductionCatchup cycles (no unbounded mint)."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/HarvesterProductionSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/HarvesterProductionSystemTests.cs.meta new file mode 100644 index 000000000..2063052ac --- /dev/null +++ b/Assets/_Project/Tests/EditMode/HarvesterProductionSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a5de181e5c7cfe54a803d176411f8944 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ProductionMathTests.cs b/Assets/_Project/Tests/EditMode/ProductionMathTests.cs new file mode 100644 index 000000000..1c8d59d80 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ProductionMathTests.cs @@ -0,0 +1,134 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.NetCode; + +namespace ProjectM.Tests +{ + /// + /// Pure tests for — the deterministic, world-free catch-up math the M7 automation + /// systems (Harvester/Conveyor/Fabricator) share. Pins the SINGLE GATED catch-up path: a never-processed machine + /// needs init (no production), a cooling machine yields 0 cycles, a due machine yields at least 1, a long-skipped + /// machine is CAPPED at maxCatchup (no wall-clock mint), period=0 is guarded by max(1,...), and the + /// RemainingTicks/RestoreNextTick pair round-trips epoch-independently for save/restore. + /// + public class ProductionMathTests + { + [Test] + public void NeedsInit_True_Only_For_Zero_LastProcessedTick() + { + Assert.IsTrue(ProductionMath.NeedsInit(0u), "A 0 LastProcessedTick is a never-processed (baked/uninit) machine."); + Assert.IsFalse(ProductionMath.NeedsInit(1u), "Any non-zero tick has been initialized."); + Assert.IsFalse(ProductionMath.NeedsInit(12345u)); + } + + [Test] + public void CyclesDue_Cooling_Returns_Zero() + { + // NextTick is in the future relative to now -> still cooling, no production. + var now = new NetworkTick(100u); + int cycles = ProductionMath.CyclesDue(now, nextTick: 130u, lastProcessedTick: 100u, period: 30, maxCatchup: 600); + Assert.AreEqual(0, cycles, "A machine whose NextTick is newer than now is cooling down (0 cycles)."); + } + + [Test] + public void CyclesDue_Exactly_One_Period_Elapsed_Returns_One() + { + // now == nextTick (not newer than) and one full period has elapsed since lastProcessed. + var now = new NetworkTick(130u); + int cycles = ProductionMath.CyclesDue(now, nextTick: 130u, lastProcessedTick: 100u, period: 30, maxCatchup: 600); + Assert.AreEqual(1, cycles, "One elapsed period at the ready tick produces exactly one cycle."); + } + + [Test] + public void CyclesDue_Multiple_Periods_Awards_Floor_Division() + { + // 100 ticks elapsed at period 30 -> floor(100/30) = 3. + var now = new NetworkTick(200u); + int cycles = ProductionMath.CyclesDue(now, nextTick: 130u, lastProcessedTick: 100u, period: 30, maxCatchup: 600); + Assert.AreEqual(3, cycles, "Catch-up awards floor(elapsed/period) cycles."); + } + + [Test] + public void CyclesDue_FarPast_Is_Capped_At_MaxCatchup() + { + // Huge elapsed gap must not mint unbounded production — clamp to maxCatchup. + var now = new NetworkTick(1_000_000u); + int cycles = ProductionMath.CyclesDue(now, nextTick: 31u, lastProcessedTick: 1u, period: 1, maxCatchup: 600); + Assert.AreEqual(600, cycles, "A long-skipped machine is capped at maxCatchup (no wall-clock mint)."); + } + + [Test] + public void CyclesDue_Period_Zero_Is_Guarded_By_Max_One() + { + // period 0 must not divide-by-zero; max(1,period) means every elapsed tick is one cycle (then capped). + var now = new NetworkTick(110u); + int cycles = ProductionMath.CyclesDue(now, nextTick: 0u, lastProcessedTick: 100u, period: 0, maxCatchup: 600); + Assert.AreEqual(10, cycles, "period=0 is treated as 1 (floor(10/1) = 10), never a divide-by-zero."); + } + + [Test] + public void CyclesDue_NonPositive_Elapsed_Returns_Zero() + { + // now == lastProcessed -> since == 0 -> 0 cycles (nothing due yet). NextTick=0 means "ready/inactive". + var now = new NetworkTick(100u); + int cycles = ProductionMath.CyclesDue(now, nextTick: 0u, lastProcessedTick: 100u, period: 30, maxCatchup: 600); + Assert.AreEqual(0, cycles, "Zero elapsed ticks since last process yields no cycles."); + } + + [Test] + public void CyclesDue_Inactive_NextTick_Zero_Does_Not_Cool_Block() + { + // NextTick==0 is the "inactive/uninitialized" sentinel — it must NOT be read as a future cooling tick. + // With a full period elapsed, the machine is due despite NextTick==0. + var now = new NetworkTick(140u); + int cycles = ProductionMath.CyclesDue(now, nextTick: 0u, lastProcessedTick: 100u, period: 30, maxCatchup: 600); + Assert.AreEqual(1, cycles, "NextTick==0 is the inactive sentinel, never a cooling gate."); + } + + [Test] + public void RemainingTicks_Zero_NextTick_Is_Inactive() + { + Assert.AreEqual(0u, ProductionMath.RemainingTicks(nextTick: 0u, nowTick: 100u), + "An inactive (NextTick==0) machine has no remaining cooldown to persist."); + } + + [Test] + public void RemainingTicks_Future_NextTick_Returns_Gap() + { + Assert.AreEqual(25u, ProductionMath.RemainingTicks(nextTick: 125u, nowTick: 100u), + "Remaining = nextTick - now when the next action is still in the future."); + } + + [Test] + public void RemainingTicks_Past_NextTick_Returns_Zero() + { + Assert.AreEqual(0u, ProductionMath.RemainingTicks(nextTick: 90u, nowTick: 100u), + "A machine already past its NextTick has 0 remaining (it is due, not cooling)."); + } + + [Test] + public void RemainingTicks_RestoreNextTick_RoundTrip_Is_EpochIndependent() + { + // Save at one epoch (saveNow), restore at an unrelated epoch (restoreNow): the COOLDOWN GAP is preserved + // even though the absolute tick differs. This is why we persist remaining-ticks, not an absolute tick. + uint saveNow = 1000u; + uint savedNext = 1040u; // 40 ticks of cooldown remaining at save time + uint remaining = ProductionMath.RemainingTicks(savedNext, saveNow); + Assert.AreEqual(40u, remaining); + + uint restoreNow = 7u; // a brand-new session, ticks start near 0 + uint restoredNext = ProductionMath.RestoreNextTick(restoreNow, remaining); + Assert.AreEqual(47u, restoredNext, "Restore re-stamps now + remaining so the cooldown gap survives across sessions."); + // And the gap measured from the restore epoch matches the original remaining. + Assert.AreEqual(40u, ProductionMath.RemainingTicks(restoredNext, restoreNow)); + } + + [Test] + public void RestoreNextTick_Coerces_Zero_Sum_Away_From_The_Inactive_Sentinel() + { + // now+remaining == 0 (both zero) must not collapse to the "inactive" sentinel; TickUtil.NonZero coerces to 1. + uint restoredNext = ProductionMath.RestoreNextTick(nowTick: 0u, remaining: 0u); + Assert.AreEqual(1u, restoredNext, "A 0 sum is coerced to 1 (the 0 = inactive sentinel is reserved)."); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ProductionMathTests.cs.meta b/Assets/_Project/Tests/EditMode/ProductionMathTests.cs.meta new file mode 100644 index 000000000..3d44f20a2 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ProductionMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1c51d5b61b932d04c9273a3d674752a3 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef b/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef index ae90e455c..34e52c2a8 100644 --- a/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef +++ b/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef @@ -4,6 +4,7 @@ "references": [ "ProjectM.Simulation", "ProjectM.Server", + "ProjectM.Client", "Unity.Entities", "Unity.Transforms", "Unity.Collections",