LANTERN purge B1: delete the automation chain (Harvester/Conveyor/Fabricator)

Deletes the M7 production systems, automation components/math, authoring, 3
machine prefabs, and 6 test files (-43 tests, 459 green). Trims the automation
paths out of BaseRestoreSystem/SaveStructureScan/BuildPlaceSystem/BuildSendSystem/
HudSystem/HudTheme/StructureCatalogAuthoring/Tuning. RuntimePlacedTag (save
marker, a keeper) re-homed into StructureComponents.cs. StructureType byte codes
stay reserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 14:32:11 -07:00
parent 511e78556b
commit a0f6d4a5c4
49 changed files with 39 additions and 2680 deletions
-147
View File
@@ -1,147 +0,0 @@
%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
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 91f9551ef855b3b4f98bcfcb7bf747e2
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
-151
View File
@@ -1,151 +0,0 @@
%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: 1, y: 1, z: 1}
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: -1810898345513765494, guid: c8c42eb35b8e2e649b185de6fd33e2e7, 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: 4cee7a5983b187943adc589b9cb1f084, 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: 1
OutResourceId: 4
OutAmount: 3
PeriodTicks: 30
InputFromLedger: 1
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 8dd9baab4cbf6c04f9320ed5ed764c65
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
-148
View File
@@ -1,148 +0,0 @@
%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
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 9e227d9b387139340933dcf55d1c3a87
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: c51a770922f68b046b12dc55a7f054c2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,35 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for a Conveyor belt ghost prefab. Bakes <see cref="PlacedStructure"/>{Type=Conveyor} +
/// <see cref="Conveyor"/> (default facing; BuildPlaceSystem overrides Direction per placement from the RPC) +
/// a DISABLED <see cref="ConveyorItem"/> (an empty belt). BuildPlaceSystem stamps the Cell; the transport
/// system initializes the period gate on first encounter.
/// </summary>
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<ConveyorAuthoring>
{
public override void Bake(ConveyorAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
this.AddPlacedStructure(entity, StructureType.Conveyor);
AddComponent(entity, new Conveyor
{
Direction = authoring.Direction,
PeriodTicks = authoring.PeriodTicks,
});
AddComponent(entity, new ConveyorItem { ResourceId = 0, Count = 0 });
SetComponentEnabled<ConveyorItem>(entity, false); // baked empty (disabled)
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 23131e6166dce204582bbedc8511658e
@@ -1,53 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for a Fabricator machine ghost prefab. Bakes <see cref="PlacedStructure"/>{Type=Fabricator} +
/// <see cref="Fabricator"/> recipe + a (kept) empty <see cref="MachineInput"/> buffer — the production query
/// needs the buffer as a column, but a ledger-fed Fabricator ignores it. It deposits its output directly into
/// the GLOBAL ledger, so it needs no output buffer. Default recipe (EB-2): 1 Ore -> 3 Charge, ledger-fed
/// (<see cref="Fabricator.InputFromLedger"/> != 0) — mints the turret-ammo Charge that <c>TurretFireSystem</c>
/// spends per shot.
/// </summary>
public class FabricatorAuthoring : MonoBehaviour
{
[Tooltip("Input resource id consumed per run (1=Aether, 2=Ore, 3=Biomass).")]
public byte InResourceId = ResourceId.Ore;
[Min(1)] public int InAmount = 1;
[Tooltip("Output resource id deposited to the global ledger (4 = EB-2 Charge / turret ammo).")]
public byte OutResourceId = ResourceId.Charge;
[Min(1)] public int OutAmount = 3;
[Min(1)] public int PeriodTicks = 30;
[Tooltip("EB-2: 1 = ledger-fed (consume the input from the shared ledger; no conveyor). 0 = M7 MachineInput chain.")]
public byte InputFromLedger = 1;
[Min(1f)] public float MaxHp = 120f;
private class FabricatorBaker : Baker<FabricatorAuthoring>
{
public override void Bake(FabricatorAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
this.AddPlacedStructure(entity, StructureType.Fabricator);
AddComponent(entity, new Fabricator
{
InResourceId = authoring.InResourceId,
InAmount = authoring.InAmount,
OutResourceId = authoring.OutResourceId,
OutAmount = authoring.OutAmount,
PeriodTicks = authoring.PeriodTicks,
InputFromLedger = authoring.InputFromLedger,
});
AddBuffer<MachineInput>(entity);
// EB-1 parity (DR-032): machines can die. Without Health this baker left the Fabricator OUT of
// the EnemyAISystem fortress-aggro snapshot entirely (its query keys on Health) — an invulnerable
// machine, diverging from DR-032. Now that it IS targetable, the DamageEvent buffer must exist
// (an AI strike appends into it; absent = ECB-playback throw) + Destructible lets
// HealthApplyDamageSystem destroy it at 0 (occupancy auto-frees).
this.AddDamageable(entity, authoring.MaxHp);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 014833c467fb1d6499f437b7bf76db80
@@ -1,36 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for a Harvester machine ghost prefab (duplicate a structure ghost so the ownerless interpolated
/// GhostAuthoringComponent comes free). Bakes <see cref="PlacedStructure"/>{Type=Harvester} + <see cref="Harvester"/>
/// stats + an empty <see cref="MachineOutput"/> buffer. BuildPlaceSystem stamps the Cell at placement; the
/// production system initializes the tick baseline on first encounter (NextTick/LastProcessedTick baked 0).
/// </summary>
public class HarvesterAuthoring : MonoBehaviour
{
[Tooltip("Resource id this generator produces (1=Aether, 2=Ore, 3=Biomass).")]
public byte OutputResourceId = ResourceId.Ore;
[Min(1)] public int Yield = 1;
[Min(1)] public int PeriodTicks = 60;
private class HarvesterBaker : Baker<HarvesterAuthoring>
{
public override void Bake(HarvesterAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
this.AddPlacedStructure(entity, StructureType.Harvester);
AddComponent(entity, new Harvester
{
ResourceId = authoring.OutputResourceId,
Yield = authoring.Yield,
PeriodTicks = authoring.PeriodTicks,
});
AddBuffer<MachineOutput>(entity);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d69d7296747332b4fbe7901ec5210149
@@ -6,12 +6,10 @@ using UnityEngine.Serialization;
namespace ProjectM.Authoring namespace ProjectM.Authoring
{ {
/// <summary> /// <summary>
/// Authoring for the baked <see cref="StructureCatalog"/> singleton (the build cost/prefab table). For the /// Authoring for the baked <see cref="StructureCatalog"/> singleton (the build cost/prefab table). Flat
/// M6 foundation there is one buildable type (Turret), so the entry is flat fields — only the prefab /// prefab + cost fields per buildable type (the type + cost-resource are byte consts in the baker —
/// object-ref + cost amount need inspector wiring; the type + cost-resource are byte consts in the baker /// enum-via-MCP is unreliable, and bytes dodge the cross-assembly enum-in-Burst hazard); the runtime
/// (enum-via-MCP is unreliable, and bytes dodge the cross-assembly enum-in-Burst hazard). M7 generalizes to /// <see cref="StructureCatalogEntry"/> buffer is the data-driven shape. Place once in the gameplay subscene.
/// an array; the runtime <see cref="StructureCatalogEntry"/> buffer is already the data-driven shape. Place
/// once in the gameplay subscene.
/// </summary> /// </summary>
public class StructureCatalogAuthoring : MonoBehaviour public class StructureCatalogAuthoring : MonoBehaviour
{ {
@@ -33,18 +31,6 @@ namespace ProjectM.Authoring
[Tooltip("Ore cost to build a pylon.")] [Tooltip("Ore cost to build a pylon.")]
[Min(0)] public int PylonCostOre = 2; [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<StructureCatalogAuthoring> private class StructureCatalogBaker : Baker<StructureCatalogAuthoring>
{ {
public override void Bake(StructureCatalogAuthoring authoring) public override void Bake(StructureCatalogAuthoring authoring)
@@ -84,40 +70,6 @@ namespace ProjectM.Authoring
CostAmount = authoring.PylonCostOre, 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,
});
}
} }
} }
} }
@@ -29,9 +29,8 @@ namespace ProjectM.Client
{ {
(UnityEngine.InputSystem.Key.B, StructureType.Turret), (UnityEngine.InputSystem.Key.B, StructureType.Turret),
(UnityEngine.InputSystem.Key.V, StructureType.Wall), (UnityEngine.InputSystem.Key.V, StructureType.Wall),
(UnityEngine.InputSystem.Key.F, StructureType.Fabricator), // LANTERN purge: the automation buildables (Fabricator/Harvester/Conveyor) are deleted; Pylon stays
// DR-042 C6d: Pylon/Harvester/Conveyor are dead (unwired automation) — dropped from the hotkey fallback // hidden from the hotkey fallback to match the build palette. PlaceStructure execute_code statics remain.
// to match the hidden build palette; their PlaceStructure execute_code statics remain for dev.
}; };
UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily) UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily)
@@ -61,9 +60,6 @@ namespace ProjectM.Client
/// <summary>EDITOR / execute_code hook: queue a wall placement at a specific cell.</summary> /// <summary>EDITOR / execute_code hook: queue a wall placement at a specific cell.</summary>
public static void PlaceWall(int cellX, int cellZ) => PlaceStructure(StructureType.Wall, cellX, cellZ); public static void PlaceWall(int cellX, int cellZ) => PlaceStructure(StructureType.Wall, cellX, cellZ);
/// <summary>EDITOR / execute_code hook: queue a fabricator placement at a specific cell.</summary>
public static void PlaceFabricator(int cellX, int cellZ) => PlaceStructure(StructureType.Fabricator, cellX, cellZ);
#endif #endif
protected override void OnCreate() protected override void OnCreate()
@@ -96,8 +96,8 @@ namespace ProjectM.Client
Label _aetherNum, _oreNum, _bioNum, _chargeNum; Label _aetherNum, _oreNum, _bioNum, _chargeNum;
// build palette + hints // build palette + hints
VisualElement _paletteRow, _hintBar, _facingArrow, _buildDiscoveryChip; VisualElement _paletteRow, _hintBar, _buildDiscoveryChip;
bool _paletteBuilt, _hintBuilt, _hintConveyor; bool _paletteBuilt, _hintBuilt;
byte _hintScheme = 255; byte _hintScheme = 255;
readonly Dictionary<byte, PaletteItem> _palette = new(); readonly Dictionary<byte, PaletteItem> _palette = new();
@@ -520,10 +520,7 @@ namespace ProjectM.Client
if (buildActive) if (buildActive)
{ {
byte scheme = AimPresentation.Scheme; byte scheme = AimPresentation.Scheme;
bool conv = BuildPaletteState.Selected == StructureType.Conveyor; if (!_hintBuilt || _hintScheme != scheme) RebuildHints(scheme);
if (!_hintBuilt || _hintScheme != scheme || _hintConveyor != conv) RebuildHints(scheme, conv);
if (conv && _facingArrow != null)
_facingArrow.style.rotate = new StyleRotate(new Rotate(new Angle(FacingDegrees(BuildPaletteState.Direction))));
_hintBar.style.display = DisplayStyle.Flex; _hintBar.style.display = DisplayStyle.Flex;
} }
else else
@@ -678,10 +675,8 @@ namespace ProjectM.Client
} }
} }
// DR-042 C6d: Harvester/Conveyor/Pylon are dead (unwired automation) -> hidden from the build palette // LANTERN purge: the automation buildables are deleted; Pylon stays hidden from the build palette (cosmetic-only).
// (catalog + prefabs stay baked, code-intact per DR-020). Only Turret/Wall/Fabricator are buildable in the UI. static bool IsPaletteType(byte type) => type != StructureType.Pylon;
static bool IsPaletteType(byte type) =>
type != StructureType.Pylon && type != StructureType.Harvester && type != StructureType.Conveyor;
void UpdatePalette(int aether, int ore, int bio, bool onExpedition) void UpdatePalette(int aether, int ore, int bio, bool onExpedition)
{ {
@@ -769,30 +764,15 @@ namespace ProjectM.Client
_palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost, CostRes = costRes, Glow = glow, Icon = iconEl }; _palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost, CostRes = costRes, Glow = glow, Icon = iconEl };
} }
void RebuildHints(byte scheme, bool conveyor) void RebuildHints(byte scheme)
{ {
_hintBar.Clear(); _hintBar.Clear();
_facingArrow = null;
var theme = HudTheme.Get(); var theme = HudTheme.Get();
bool pad = scheme == InputSchemeId.Gamepad; bool pad = scheme == InputSchemeId.Gamepad;
AddHint(pad ? theme?.PadPlace : theme?.KbmPlace, pad ? "A" : "LMB", "PLACE"); AddHint(pad ? theme?.PadPlace : theme?.KbmPlace, pad ? "A" : "LMB", "PLACE");
AddHint(pad ? theme?.PadCancel : theme?.KbmCancel, pad ? "B" : "RMB", "CANCEL"); AddHint(pad ? theme?.PadCancel : theme?.KbmCancel, pad ? "B" : "RMB", "CANCEL");
if (conveyor)
{
// Rotate hint + a LIVE facing arrow (resolves the DR-021 conveyor-facing indicator). Only conveyors
// rotate, so this chip is gated to them — the other buildables don't show a meaningless ROTATE.
var chip = MakeChip();
chip.Add(HudUi.Glyph(pad ? theme?.PadRotate : null, pad ? "LB" : "R", 26));
var lbl = HudUi.Text("FACING", 12, MenuUi.SubCol, TextAnchor.MiddleLeft);
lbl.style.marginLeft = 5; lbl.style.marginRight = 6;
chip.Add(lbl);
_facingArrow = HudUi.Icon(theme != null ? theme.ConveyorIcon : null, 24, AetherCyan);
chip.Add(_facingArrow);
_hintBar.Add(chip);
}
AddHint(pad ? theme?.PadExit : null, pad ? "MENU" : "ESC", "EXIT"); AddHint(pad ? theme?.PadExit : null, pad ? "MENU" : "ESC", "EXIT");
_hintScheme = scheme; _hintScheme = scheme;
_hintConveyor = conveyor;
_hintBuilt = true; _hintBuilt = true;
} }
@@ -1313,17 +1293,6 @@ namespace ProjectM.Client
return resId == ResourceId.Aether ? t.AetherIcon : resId == ResourceId.Biomass ? t.BioIcon : t.OreIcon; return resId == ResourceId.Aether ? t.AetherIcon : resId == ResourceId.Biomass ? t.BioIcon : t.OreIcon;
} }
// Conveyor facing (BuildPaletteState.Direction 0=+X,1=-X,2=+Z,3=-Z) → arrow rotation; the arrow art points up (+Z).
static float FacingDegrees(byte dir)
{
switch (dir)
{
case 0: return 90f; // +X
case 1: return 270f; // -X
case 3: return 180f; // -Z
default: return 0f; // +Z
}
}
static Color PhaseColor(byte phase) static Color PhaseColor(byte phase)
{ {
@@ -1352,9 +1321,6 @@ namespace ProjectM.Client
case StructureType.Turret: return "Turret"; case StructureType.Turret: return "Turret";
case StructureType.Wall: return "Wall"; case StructureType.Wall: return "Wall";
case StructureType.Pylon: return "Pylon"; case StructureType.Pylon: return "Pylon";
case StructureType.Harvester: return "Harvester";
case StructureType.Fabricator: return "Fabricator";
case StructureType.Conveyor: return "Conveyor";
default: return "?"; default: return "?";
} }
} }
@@ -51,14 +51,10 @@ namespace ProjectM.Client
public Sprite TurretIcon; public Sprite TurretIcon;
public Sprite WallIcon; public Sprite WallIcon;
public Sprite PylonIcon; public Sprite PylonIcon;
public Sprite HarvesterIcon;
public Sprite FabricatorIcon;
public Sprite ConveyorIcon;
[Header("Build-ghost preview meshes (authored real-size, ground pivot — BuildSendSystem)")] [Header("Build-ghost preview meshes (authored real-size, ground pivot — BuildSendSystem)")]
public Mesh TurretGhostMesh; public Mesh TurretGhostMesh;
public Mesh WallGhostMesh; public Mesh WallGhostMesh;
public Mesh FabricatorGhostMesh;
[Header("Build-mode control glyphs")] [Header("Build-mode control glyphs")]
public Sprite KbmPlace; // LMB public Sprite KbmPlace; // LMB
@@ -89,9 +85,6 @@ namespace ProjectM.Client
case StructureType.Turret: return TurretIcon; case StructureType.Turret: return TurretIcon;
case StructureType.Wall: return WallIcon; case StructureType.Wall: return WallIcon;
case StructureType.Pylon: return PylonIcon; case StructureType.Pylon: return PylonIcon;
case StructureType.Harvester: return HarvesterIcon;
case StructureType.Fabricator: return FabricatorIcon;
case StructureType.Conveyor: return ConveyorIcon;
default: return null; default: return null;
} }
} }
@@ -103,7 +96,6 @@ namespace ProjectM.Client
{ {
case StructureType.Turret: return TurretGhostMesh; case StructureType.Turret: return TurretGhostMesh;
case StructureType.Wall: return WallGhostMesh; case StructureType.Wall: return WallGhostMesh;
case StructureType.Fabricator: return FabricatorGhostMesh;
default: return null; default: return null;
} }
} }
@@ -10,13 +10,11 @@ namespace ProjectM.Server
{ {
/// <summary> /// <summary>
/// One-shot server restore of player-built structures for a "Continue" session. The menu (WorldLauncher) stages a /// One-shot server restore of player-built structures for a "Continue" session. The menu (WorldLauncher) stages a
/// <see cref="PendingStructure"/>/<see cref="PendingStructureIo"/> carrier in the fresh ServerWorld BEFORE the /// <see cref="PendingStructure"/> carrier in the fresh ServerWorld BEFORE the gameplay subscene streams; this
/// gameplay subscene streams; this system waits (RequireForUpdate) for the streamed <see cref="StructureCatalog"/> /// system waits (RequireForUpdate) for the streamed <see cref="StructureCatalog"/> + <see cref="BaseAnchor"/> +
/// + <see cref="BaseAnchor"/> + a valid NetworkTime, then replays each saved structure CHARGE-FREE: Instantiate the /// a valid NetworkTime, then replays each saved structure CHARGE-FREE: Instantiate the catalog prefab at the
/// catalog prefab at the saved cell (preserving the baked Scale), re-stamp the rebased tick fields /// saved cell (preserving the baked Scale), restore the wounded HP born-correct, re-tag RegionTag{Base} +
/// (<see cref="ProductionMath.RestoreNextTick"/>; LastProcessed = now so within-session catch-up resumes from now, /// RuntimePlacedTag, then DESTROY the carrier so it never runs again. The ledger restores separately +
/// 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). /// absolutely via CycleDirectorSpawnSystem's born-correct load (no double-spend, no Withdraw here).
/// </summary> /// </summary>
[BurstCompile] [BurstCompile]
@@ -24,14 +22,12 @@ namespace ProjectM.Server
public partial struct BaseRestoreSystem : ISystem public partial struct BaseRestoreSystem : ISystem
{ {
ComponentLookup<LocalTransform> m_TransformLookup; ComponentLookup<LocalTransform> m_TransformLookup;
ComponentLookup<Conveyor> m_ConveyorLookup;
ComponentLookup<Health> m_HealthLookup; ComponentLookup<Health> m_HealthLookup;
[BurstCompile] [BurstCompile]
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
{ {
m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true); m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
m_ConveyorLookup = state.GetComponentLookup<Conveyor>(isReadOnly: true);
m_HealthLookup = state.GetComponentLookup<Health>(isReadOnly: true); m_HealthLookup = state.GetComponentLookup<Health>(isReadOnly: true);
state.RequireForUpdate<StructureCatalog>(); state.RequireForUpdate<StructureCatalog>();
state.RequireForUpdate<BaseAnchor>(); state.RequireForUpdate<BaseAnchor>();
@@ -48,7 +44,6 @@ namespace ProjectM.Server
uint now = serverTick.TickIndexForValidTick; uint now = serverTick.TickIndexForValidTick;
m_TransformLookup.Update(ref state); m_TransformLookup.Update(ref state);
m_ConveyorLookup.Update(ref state);
m_HealthLookup.Update(ref state); m_HealthLookup.Update(ref state);
var anchor = SystemAPI.GetSingleton<BaseAnchor>(); var anchor = SystemAPI.GetSingleton<BaseAnchor>();
@@ -56,8 +51,8 @@ namespace ProjectM.Server
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (pending, ioBuf, carrier) in foreach (var (pending, carrier) in
SystemAPI.Query<DynamicBuffer<PendingStructure>, DynamicBuffer<PendingStructureIo>>().WithEntityAccess()) SystemAPI.Query<DynamicBuffer<PendingStructure>>().WithEntityAccess())
{ {
for (int s = 0; s < pending.Length; s++) for (int s = 0; s < pending.Length; s++)
{ {
@@ -81,12 +76,12 @@ namespace ProjectM.Server
{ {
Type = p.Type, Type = p.Type,
Cell = cell, Cell = cell,
NextTick = ProductionMath.RestoreNextTick(now, p.RemainingTicks), NextTick = 0u, // cooldown restore retired with the automation chain (LANTERN purge)
LastProcessedTick = TickUtil.NonZero(now), LastProcessedTick = TickUtil.NonZero(now),
}); });
// EB-1: restore the wounded HP born-correct in the SAME ecb as Instantiate (Health.Current is a // EB-1: restore the wounded HP born-correct in the SAME ecb as Instantiate (Health.Current is a
// [GhostField]; a deferred set would leak baked Max to clients for one snapshot). Max + the // [GhostField]; a deferred set would leak baked Max to clients for one snapshot). Max + the
// 0->full fallback come from the BAKED prefab, never the save. Automation machines lack Health. // 0->full fallback come from the BAKED prefab, never the save.
if (m_HealthLookup.HasComponent(prefab)) if (m_HealthLookup.HasComponent(prefab))
{ {
var hm = m_HealthLookup[prefab]; var hm = m_HealthLookup[prefab];
@@ -94,35 +89,6 @@ namespace ProjectM.Server
} }
ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base }); ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base });
ecb.AddComponent<RuntimePlacedTag>(structure); ecb.AddComponent<RuntimePlacedTag>(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<ConveyorItem>(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<MachineInput> inBuf = default;
DynamicBuffer<MachineOutput> 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<MachineInput>(structure); inInit = true; }
inBuf.Add(new MachineInput { ResourceId = ioBuf[r].ResourceId, Count = ioBuf[r].Count });
}
else
{
if (!outInit) { outBuf = ecb.SetBuffer<MachineOutput>(structure); outInit = true; }
outBuf.Add(new MachineOutput { ResourceId = ioBuf[r].ResourceId, Count = ioBuf[r].Count });
}
}
} }
ecb.DestroyEntity(carrier); ecb.DestroyEntity(carrier);
@@ -1,276 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// 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 <see cref="Conveyor"/> first PULLS one item off an adjacent
/// upstream <see cref="MachineOutput"/> (the cell at <c>myCell DirOffset(dir)</c> — i.e. the machine feeding
/// INTO this belt) onto its own <see cref="ConveyorItem"/>; then every loaded conveyor advances its item
/// EXACTLY one cell toward <c>myCell + DirOffset(dir)</c>. The move resolution is delegated to the pure,
/// unit-tested <see cref="ConveyorMath.ResolveMoves"/> so determinism is provable WITHOUT a world: sources are
/// processed sorted by <see cref="ConveyorMath.CellKey"/> (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
/// <see cref="MachineInput"/> never collides with belt occupancy.
/// <para>
/// Mirrors <c>TurretFireSystem</c>'s now-extraction (<c>NetworkTime.ServerTick.TickIndexForValidTick</c>) +
/// <see cref="PlacedStructure.NextTick"/> cooldown idiom (each conveyor is period-gated the same way), and
/// <c>ResourceHarvestSystem</c>'s Temp-collection foreach idiom. Runs in the plain server
/// <c>SimulationSystemGroup</c> <c>[UpdateAfter(HarvesterProductionSystem)]</c> (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.
/// </para>
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(HarvesterProductionSystem))]
public partial struct ConveyorTransportSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<Conveyor>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
// ---- Snapshot every conveyor once (entity, cell, direction, item, period-due) ----
var convEntity = new NativeList<Entity>(Allocator.Temp);
var convCell = new NativeList<int2>(Allocator.Temp);
var convDir = new NativeList<byte>(Allocator.Temp);
var convItemRes = new NativeList<int>(Allocator.Temp); // 0 = empty
var convItemCnt = new NativeList<int>(Allocator.Temp); // 0 = empty
var convDue = new NativeList<bool>(Allocator.Temp); // period-gate satisfied this tick
foreach (var (ps, conveyor, e) in
SystemAPI.Query<RefRW<PlacedStructure>, RefRO<Conveyor>>().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<ConveyorItem>(e))
{
var item = SystemAPI.GetComponent<ConveyorItem>(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<int2, int>(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<int2>(8, Allocator.Temp);
var sinkCellToEntity = new NativeHashMap<int2, Entity>(8, Allocator.Temp);
foreach (var (ps, _, e) in
SystemAPI.Query<RefRO<PlacedStructure>, DynamicBuffer<MachineInput>>().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<int2, Entity>(8, Allocator.Temp);
foreach (var (ps, _, e) in
SystemAPI.Query<RefRO<PlacedStructure>, DynamicBuffer<MachineOutput>>().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<MachineOutput>(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<int2>(n, Allocator.Temp);
var dirs = new NativeArray<byte>(n, Allocator.Temp);
var itemRes = new NativeArray<int>(n, Allocator.Temp);
var itemCnt = new NativeArray<int>(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<int2>(n, Allocator.Temp);
var outMoveSrcIdx = new NativeArray<int>(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<int>(n, Allocator.Temp);
var endCnt = new NativeArray<int>(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<MachineInput>(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<ConveyorItem>(e, true);
}
else
{
SystemAPI.SetComponent(e, new ConveyorItem { ResourceId = 0, Count = 0 });
SystemAPI.SetComponentEnabled<ConveyorItem>(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();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 002c28988137cb945b9ffaccbb6d645f
@@ -1,104 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only, deterministic fabricator production — the BACK of the M7 auto-gather chain
/// (Harvester → Conveyor → Fabricator). Each <see cref="Fabricator"/> consumes
/// <see cref="Fabricator.InAmount"/> of its (byte) input resource from its OWN server-only
/// <see cref="MachineInput"/> buffer (filled by an upstream conveyor) and, on a
/// <see cref="Fabricator.PeriodTicks"/> cadence, deposits <see cref="Fabricator.OutAmount"/> of its output
/// resource into the GLOBAL ledger — so a self-running base compounds its stockpile. Resolves the ledger via
/// <c>GetSingletonEntity&lt;ResourceLedger&gt;()</c> → <c>GetBuffer&lt;StorageEntry&gt;()</c> (NEVER
/// <c>GetSingleton&lt;StorageEntry&gt;</c> — a second StorageEntry buffer exists on the base container).
/// Mirrors <c>TurretFireSystem</c>'s now-extraction + cooldown idiom and <c>ResourceHarvestSystem</c>'s ledger
/// resolve; runs in the plain server <c>SimulationSystemGroup</c>
/// <c>[UpdateAfter(ConveyorTransportSystem)]</c> (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.
/// <para>
/// SINGLE GATED CATCH-UP PATH, INPUT-LIMITED (no mint-from-nothing): when due, the awarded
/// <see cref="ProductionMath.CyclesDue"/> cycles are further clamped to what the buffered input can afford
/// (<c>floor(TotalOf(input,InResourceId)/InAmount)</c>). The tick fields are re-stamped EVERY due period
/// regardless of <c>runs</c> (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.
/// </para>
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(ConveyorTransportSystem))]
public partial struct FabricatorProductionSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<ResourceLedger>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<Fabricator>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var ledgerEntity = SystemAPI.GetSingletonEntity<ResourceLedger>();
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
foreach (var (ps, fab, input) in
SystemAPI.Query<RefRW<PlacedStructure>, RefRO<Fabricator>, DynamicBuffer<MachineInput>>())
{
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 available input affords (no mint-from-nothing). EB-2:
// a ledger-fed Fabricator (InputFromLedger != 0) sources its input from the SHARED ledger (read LIVE
// here so a 2nd ledger-fed Fabricator sees the 1st's same-tick withdrawal) instead of MachineInput;
// both modes deposit the output to the ledger. A zero/negative input amount is unsatisfiable.
bool fromLedger = fab.ValueRO.InputFromLedger != 0;
int available = fromLedger ? StorageMath.TotalOf(ledger, inId) : MachineSlotMath.TotalOf(input, inId);
int affordable = inAmount > 0 ? available / inAmount : 0;
int runs = math.min(cycles, affordable);
if (runs > 0)
{
if (fromLedger)
StorageMath.Withdraw(ledger, inId, inAmount * runs);
else
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);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1d8dbf02b41c9a94ea57fd7ca00f266d
@@ -1,76 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only, deterministic harvester production — the FRONT of the M7 auto-gather chain
/// (Harvester → Conveyor → Fabricator). Each <see cref="Harvester"/> machine is a fixed-yield generator:
/// every <see cref="Harvester.PeriodTicks"/> server ticks it deposits <see cref="Harvester.Yield"/> of its
/// configured (byte) resource into its OWN server-only <see cref="MachineOutput"/> buffer (NOT the global
/// ledger — a conveyor pulls it onward, or it sits buffered). Mirrors <c>TurretFireSystem</c>'s exact
/// now-extraction (<c>NetworkTime.ServerTick.TickIndexForValidTick</c>) + <see cref="PlacedStructure.NextTick"/>
/// cooldown idiom, and runs in the plain server <c>SimulationSystemGroup</c>
/// <c>[UpdateAfter(PredictedSimulationSystemGroup)]</c> (the predicted group is OrderFirst → UpdateBefore is
/// ignored). Production mutates a DynamicBuffer in place (not a structural change) → no ECB needed.
/// <para>
/// 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
/// <see cref="ProductionMath.CyclesDue"/> awards <c>floor((now-LastProcessedTick)/period)</c> cycles, clamped
/// to <see cref="Tuning.MaxProductionCatchup"/>, 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.
/// </para>
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
public partial struct HarvesterProductionSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<Harvester>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
foreach (var (ps, harvester, output) in
SystemAPI.Query<RefRW<PlacedStructure>, RefRO<Harvester>, DynamicBuffer<MachineOutput>>())
{
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);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c484cb2331137134888f10eed7689140
@@ -23,13 +23,11 @@ namespace ProjectM.Server
public partial struct BuildPlaceSystem : ISystem public partial struct BuildPlaceSystem : ISystem
{ {
ComponentLookup<LocalTransform> m_TransformLookup; ComponentLookup<LocalTransform> m_TransformLookup;
ComponentLookup<Conveyor> m_ConveyorLookup;
[BurstCompile] [BurstCompile]
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
{ {
m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true); m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
m_ConveyorLookup = state.GetComponentLookup<Conveyor>(isReadOnly: true);
state.RequireForUpdate<StructureCatalog>(); state.RequireForUpdate<StructureCatalog>();
state.RequireForUpdate<BaseAnchor>(); state.RequireForUpdate<BaseAnchor>();
state.RequireForUpdate<ResourceLedger>(); state.RequireForUpdate<ResourceLedger>();
@@ -43,7 +41,6 @@ namespace ProjectM.Server
public void OnUpdate(ref SystemState state) public void OnUpdate(ref SystemState state)
{ {
m_TransformLookup.Update(ref state); m_TransformLookup.Update(ref state);
m_ConveyorLookup.Update(ref state);
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick; uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
var anchor = SystemAPI.GetSingleton<BaseAnchor>(); var anchor = SystemAPI.GetSingleton<BaseAnchor>();
@@ -102,12 +99,6 @@ namespace ProjectM.Server
}); });
ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base }); ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base });
ecb.AddComponent<RuntimePlacedTag>(structure); // player-built -> persisted by SaveStructureScan ecb.AddComponent<RuntimePlacedTag>(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);
}
} }
} }
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 4eba886d11c07eb4d97ca0d821a1560f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,91 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// A fixed-yield resource generator — the FRONT of the M7 auto-gather chain (Harvester -> Conveyor ->
/// Fabricator). Each period it deposits <see cref="Yield"/> of <see cref="ResourceId"/> into its OWN
/// server-only <see cref="MachineOutput"/> buffer (a conveyor pulls it onward). Server-only data (NO
/// [GhostField]); the client only ever sees <c>PlacedStructure.Type</c>. Reuses
/// <c>PlacedStructure.NextTick</c>/<c>LastProcessedTick</c> for the deterministic, within-session catch-up
/// cadence (see <c>HarvesterProductionSystem</c>).
/// </summary>
public struct Harvester : IComponentData
{
/// <summary>Resource id produced (a byte; see <see cref="ResourceId"/>).</summary>
public byte ResourceId;
/// <summary>Units produced per elapsed period.</summary>
public int Yield;
/// <summary>Server ticks between productions.</summary>
public int PeriodTicks;
}
/// <summary>
/// A recipe machine — the BACK of the M7 chain. Consumes <see cref="InAmount"/> of <see cref="InResourceId"/>
/// per run from its own <see cref="MachineInput"/> buffer (fed by a conveyor) and deposits <see cref="OutAmount"/>
/// of <see cref="OutResourceId"/> into the GLOBAL ledger. Strictly input-limited (never mints from an empty
/// slot). Server-only data.
/// </summary>
public struct Fabricator : IComponentData
{
public byte InResourceId;
public int InAmount;
public byte OutResourceId;
public int OutAmount;
public int PeriodTicks;
/// <summary>EB-2: 0 = consume the input from the MachineInput buffer (the M7 conveyor chain); !=0 = consume
/// the input from the SHARED ledger (a base-loop ledger-fed Fabricator, e.g. Ore -> Charge). Server-only, NO [GhostField].</summary>
public byte InputFromLedger;
}
/// <summary>
/// A directional transport belt — the MIDDLE of the M7 chain. Each period it pulls one item off an adjacent
/// upstream <see cref="MachineOutput"/> (when empty) and advances a held <see cref="ConveyorItem"/> exactly one
/// cell toward <see cref="Direction"/>. <see cref="Direction"/> is a byte (0=+X,1=-X,2=+Z,3=-Z) — never an enum
/// (the cross-assembly enum-in-Burst hazard). Server-only data.
/// </summary>
public struct Conveyor : IComponentData
{
/// <summary>Belt facing: 0=+X, 1=-X, 2=+Z, 3=-Z (see <c>ConveyorMath.DirOffset</c>).</summary>
public byte Direction;
public int PeriodTicks;
}
/// <summary>
/// A machine's INPUT staging buffer (server-only, NO [GhostField] -> never replicated). A DISTINCT element type
/// from the global ledger's <see cref="StorageEntry"/> (so <c>GetSingleton&lt;StorageEntry&gt;</c> stays
/// unambiguous) and from <see cref="MachineOutput"/> (so a machine can carry both without a buffer-type clash).
/// </summary>
public struct MachineInput : IBufferElementData
{
public byte ResourceId;
public int Count;
}
/// <summary>A machine's OUTPUT staging buffer (server-only, NO [GhostField]). See <see cref="MachineInput"/>.</summary>
public struct MachineOutput : IBufferElementData
{
public byte ResourceId;
public int Count;
}
/// <summary>
/// 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.
/// </summary>
public struct ConveyorItem : IComponentData, IEnableableComponent
{
public byte ResourceId;
public int Count;
}
/// <summary>
/// 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).
/// </summary>
public struct RuntimePlacedTag : IComponentData { }
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6eeef378186b39d41a2db7adcc620dd9
@@ -1,97 +0,0 @@
using Unity.Collections;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic, ORDER-INDEPENDENT conveyor move resolver (the server <c>ConveyorTransportSystem</c>
/// applies the result). Determinism: sources are processed sorted by <see cref="CellKey"/> (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.
/// </summary>
public static class ConveyorMath
{
/// <summary>Cardinal grid step for a belt direction byte (0=+X,1=-X,2=+Z,3=-Z).</summary>
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
}
}
/// <summary>A stable, collision-free total order over grid cells (the deterministic tie-break key).</summary>
public static long CellKey(int2 cell) => ((long)cell.x << 32) | (uint)cell.y;
/// <summary>
/// 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 (<paramref name="outMoveSrcIdx"/> ->
/// <paramref name="outMoveDst"/>), length <paramref name="moveCount"/>. 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.
/// </summary>
public static void ResolveMoves(
NativeArray<int2> srcCells, NativeArray<byte> dirs,
NativeArray<int> itemRes, NativeArray<int> itemCnt,
NativeHashMap<int2, int> cellToIndex, NativeHashSet<int2> sinkCells,
NativeArray<int2> outMoveDst, NativeArray<int> 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<int>(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<int2>(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();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 251361cf456e888459d473b5fedf7c4a
@@ -1,85 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic deposit/withdraw/total helpers for a machine's server-only <see cref="MachineInput"/> /
/// <see cref="MachineOutput"/> staging buffers — the byte-id, non-replicated twin of <see cref="StorageMath"/>
/// (which serves the [GhostField] global <see cref="StorageEntry"/> 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 &lt;= 0 or resource id 0; Withdraw clamps to available and drops a row at zero.
/// </summary>
public static class MachineSlotMath
{
// ---- MachineOutput ----
public static void Deposit(DynamicBuffer<MachineOutput> 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<MachineOutput> 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<MachineOutput> 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<MachineInput> 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<MachineInput> 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<MachineInput> 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;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 16b6aef96031f54469f05044a2c18e66
@@ -1,51 +0,0 @@
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// 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 (<see cref="NeedsInit"/>) 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.
/// </summary>
public static class ProductionMath
{
/// <summary>True for a never-processed machine (baked/just-placed) — initialize the baseline before producing.</summary>
public static bool NeedsInit(uint lastProcessedTick) => lastProcessedTick == 0u;
/// <summary>
/// Cycles to award THIS process. 0 if cooling (<paramref name="nextTick"/> newer than <paramref name="now"/>)
/// or nothing elapsed; otherwise floor(elapsed/period) clamped to [0, <paramref name="maxCatchup"/>].
/// <paramref name="nextTick"/>==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&gt;=period, so a sub-period
/// edge (e.g. a freshly restored remaining==0 machine) floors to 0 rather than minting prematurely.
/// <paramref name="period"/> is guarded by max(1,...) so a 0 never divides.
/// </summary>
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);
}
/// <summary>Remaining cooldown ticks to PERSIST (epoch-independent): 0 if inactive or already due, else nextTick-now.</summary>
public static uint RemainingTicks(uint nextTick, uint nowTick) =>
nextTick == 0u ? 0u : (nextTick > nowTick ? nextTick - nowTick : 0u);
/// <summary>Re-anchor a persisted remaining cooldown to the current tick origin on restore (NonZero-guarded).</summary>
public static uint RestoreNextTick(uint nowTick, uint remaining) => TickUtil.NonZero(nowTick + remaining);
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6d461ab50604ea642b26586bffeed41e
@@ -76,4 +76,12 @@ namespace ProjectM.Simulation
/// <summary>Tag on the baked singleton carrying the <see cref="StructureCatalogEntry"/> buffer (the build cost/prefab table).</summary> /// <summary>Tag on the baked singleton carrying the <see cref="StructureCatalogEntry"/> buffer (the build cost/prefab table).</summary>
public struct StructureCatalog : IComponentData { } public struct StructureCatalog : IComponentData { }
/// <summary>
/// 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). (Re-homed here from the retired automation components — LANTERN purge.)
/// </summary>
public struct RuntimePlacedTag : IComponentData { }
} }
@@ -6,17 +6,16 @@ namespace ProjectM.Simulation
{ {
/// <summary> /// <summary>
/// Scans a server world for PLAYER-built structures (<see cref="PlacedStructure"/> + <see cref="RuntimePlacedTag"/>) /// Scans a server world for PLAYER-built structures (<see cref="PlacedStructure"/> + <see cref="RuntimePlacedTag"/>)
/// into the flat SaveData v2 arrays — the SINGLE shared scan used by BOTH the autosave (SaveWriteSystem) and the /// into the flat SaveData arrays — the SINGLE shared scan used by BOTH the autosave (SaveWriteSystem) and the
/// quit-to-menu save (WorldLauncher), so the two paths can never drift (only RuntimePlacedTag structures are saved; /// quit-to-menu save (WorldLauncher), so the two paths can never drift (only RuntimePlacedTag structures are saved;
/// anything baked into the subscene is the subscene's source of truth, not the save's). Cooldowns are stored as /// anything baked into the subscene is the subscene's source of truth, not the save's). Managed (List/array) —
/// REMAINING ticks (epoch-independent). Managed (List/array) — runs only on a save, never in the hot loop. /// runs only on a save, never in the hot loop.
/// </summary> /// </summary>
public static class SaveStructureScan public static class SaveStructureScan
{ {
public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures, out StructureIoRow[] io) public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures, out StructureIoRow[] io)
{ {
var structs = new List<StructureSave>(); var structs = new List<StructureSave>();
var ioRows = new List<StructureIoRow>();
using var q = em.CreateEntityQuery( using var q = em.CreateEntityQuery(
ComponentType.ReadOnly<PlacedStructure>(), ComponentType.ReadOnly<PlacedStructure>(),
@@ -27,46 +26,19 @@ namespace ProjectM.Simulation
{ {
var e = entities[k]; var e = entities[k];
var ps = em.GetComponentData<PlacedStructure>(e); var ps = em.GetComponentData<PlacedStructure>(e);
int idx = structs.Count;
var row = new StructureSave structs.Add(new StructureSave
{ {
Type = ps.Type, Type = ps.Type,
CellX = ps.Cell.x, CellX = ps.Cell.x,
CellZ = ps.Cell.y, CellZ = ps.Cell.y,
RemainingTicks = ProductionMath.RemainingTicks(ps.NextTick, nowTick), // EB-1: guarded so structures without Health don't crash the autosave path (no try/catch).
// EB-1: guarded so automation machines (no Health) don't crash the autosave path (no try/catch).
HP = em.HasComponent<Health>(e) ? em.GetComponentData<Health>(e).Current : 0f, HP = em.HasComponent<Health>(e) ? em.GetComponentData<Health>(e).Current : 0f,
}; });
if (em.HasComponent<Conveyor>(e))
row.Direction = em.GetComponentData<Conveyor>(e).Direction;
if (em.HasComponent<ConveyorItem>(e) && em.IsComponentEnabled<ConveyorItem>(e))
{
var item = em.GetComponentData<ConveyorItem>(e);
row.ConveyorResId = item.ResourceId;
row.ConveyorCount = item.Count;
}
structs.Add(row);
if (em.HasBuffer<MachineInput>(e))
{
var buf = em.GetBuffer<MachineInput>(e, true);
for (int i = 0; i < buf.Length; i++)
ioRows.Add(new StructureIoRow { StructureIndex = idx, Slot = 0, ResourceId = buf[i].ResourceId, Count = buf[i].Count });
}
if (em.HasBuffer<MachineOutput>(e))
{
var buf = em.GetBuffer<MachineOutput>(e, true);
for (int i = 0; i < buf.Length; i++)
ioRows.Add(new StructureIoRow { StructureIndex = idx, Slot = 1, ResourceId = buf[i].ResourceId, Count = buf[i].Count });
}
} }
structures = structs.ToArray(); structures = structs.ToArray();
io = ioRows.ToArray(); io = System.Array.Empty<StructureIoRow>(); // machine I/O retired with the automation chain (row type dies at save v7)
} }
} }
} }
+3 -7
View File
@@ -56,11 +56,8 @@ namespace ProjectM.Simulation
/// baked telegraph can't drift from the server windup.</summary> /// baked telegraph can't drift from the server windup.</summary>
public const int ChargerWindupTicks = 30; public const int ChargerWindupTicks = 30;
// ---- Production / automation (M7: Harvester/Conveyor/Fabricator) ---- // ---- Base defense (EB-2) ----
/// <summary>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).</summary>
public const int MaxProductionCatchup = 600;
/// <summary>EB-2: Charge (turret munition) consumed per turret shot, withdrawn from the global ledger. A /// <summary>EB-2: Charge (turret munition) consumed per turret shot, withdrawn from the global ledger. A
/// turret with 0 Charge SOFT-FAILS (no shot, no cooldown advance). A ledger-fed Fabricator mints Charge from /// turret with 0 Charge SOFT-FAILS (no shot, no cooldown advance). A ledger-fed Fabricator mints Charge from
@@ -73,9 +70,8 @@ namespace ProjectM.Simulation
// ---- Cold start (CycleDirectorSpawnSystem seeds the shared ledger on a NEW game) ---- // ---- Cold start (CycleDirectorSpawnSystem seeds the shared ledger on a NEW game) ----
/// <summary>DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save keeps /// <summary>DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save
/// its persisted ledger). Bootstraps the Fabricator(30)->Charge->Turret(10) chain so a turret placed before any /// keeps its persisted ledger) — seed capital for the build loop.</summary>
/// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives.</summary>
public const int StartingOre = 90; public const int StartingOre = 90;
// ---- Expedition run economy (RoomFieldSystem / RunDirectorSystem) ---- // ---- Expedition run economy (RoomFieldSystem / RunDirectorSystem) ----
@@ -1,71 +0,0 @@
using NUnit.Framework;
using ProjectM.Simulation;
using UnityEngine;
namespace ProjectM.Tests
{
/// <summary>
/// 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.
/// </summary>
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<SaveData>(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<SaveData>("{\"Version\":2,\"GoalCharge\":1,\"GoalTarget\":10}"));
Assert.IsNotNull(back);
Assert.IsTrue(back.Structures == null || back.Structures.Length == 0);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ad495310b2721f147925ae48deb41bb4
@@ -1,256 +0,0 @@
using System.Collections.Generic;
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Mathematics;
namespace ProjectM.Tests
{
/// <summary>
/// Pure, world-free tests for <see cref="ConveyorMath"/> — the DETERMINISTIC, ORDER-INDEPENDENT belt resolver
/// that the server <c>ConveyorTransportSystem</c> applies. Drives the array-based <c>ResolveMoves</c> 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. <c>DirOffset</c>/<c>CellKey</c> are pinned too.
/// </summary>
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 ------------------------------------------------------------------------------
/// <summary>One conveyor cell in a scenario: its grid cell, belt direction, and the item it currently holds.</summary>
struct Belt
{
public int2 Cell;
public byte Dir;
public int Res; // 0 = empty
public int Cnt; // 0 = empty
}
/// <summary>
/// Drives <see cref="ConveyorMath.ResolveMoves"/> over a set of belts (+ optional sink cells), returning the
/// resolved (srcIndex -&gt; destCell) move list as a dictionary keyed by source index for easy assertion.
/// All native containers are Temp + disposed before returning.
/// </summary>
static Dictionary<int, int2> Resolve(Belt[] belts, int2[] sinks)
{
int n = belts.Length;
var srcCells = new NativeArray<int2>(n, Allocator.Temp);
var dirs = new NativeArray<byte>(n, Allocator.Temp);
var itemRes = new NativeArray<int>(n, Allocator.Temp);
var itemCnt = new NativeArray<int>(n, Allocator.Temp);
var cellToIndex = new NativeHashMap<int2, int>(n, Allocator.Temp);
var sinkCells = new NativeHashSet<int2>(math.max(1, sinks?.Length ?? 0), Allocator.Temp);
var outMoveDst = new NativeArray<int2>(n, Allocator.Temp);
var outMoveSrcIdx = new NativeArray<int>(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<int, int2>();
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<int2, int2> ReKeyBySourceCell(Belt[] belts, Dictionary<int, int2> movesBySrcIdx)
{
var byCell = new Dictionary<int2, int2>();
foreach (var kv in movesBySrcIdx)
byCell[belts[kv.Key].Cell] = kv.Value;
return byCell;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e7846ef025344bf428c68924d389d1b8
@@ -1,187 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="ConveyorTransportSystem"/> — the transport step
/// that PULLS resources off an adjacent upstream <see cref="MachineOutput"/> onto an empty belt, advances a held
/// <see cref="ConveyorItem"/> exactly one cell per tick along its <see cref="Conveyor.Direction"/>, and DEPOSITS
/// into a downstream machine's <see cref="MachineInput"/> sink. Determinism (Y-junction tie-break, stall-no-loss,
/// shuffle-invariance) is exhaustively proven world-free in <see cref="ConveyorMathTests"/>; 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).
/// </summary>
public class ConveyorTransportSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ConveyorTransportSystem>());
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<ConveyorItem>(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<MachineOutput>(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<MachineInput>(e);
return e;
}
static int OutputOf(EntityManager em, Entity machine, byte resourceId)
{
var buf = em.GetBuffer<MachineOutput>(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<MachineInput>(machine);
int total = 0;
for (int i = 0; i < buf.Length; i++)
if (buf[i].ResourceId == resourceId) total += buf[i].Count;
return total;
}
/// <summary>Ticks the system past its per-conveyor init gate so subsequent updates actually transport.</summary>
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<ConveyorItem>(belt), "An empty belt adjacent to an upstream output pulls an item (ConveyorItem enabled).");
var item = em.GetComponentData<ConveyorItem>(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<ConveyorItem>(src), "The item leaves the source belt (now empty).");
Assert.IsTrue(em.IsComponentEnabled<ConveyorItem>(dst), "The item arrives on the next belt (exactly one cell along +X).");
Assert.AreEqual(ResourceId.Biomass, em.GetComponentData<ConveyorItem>(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<ConveyorItem>(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<ConveyorItem>(src), "The item stays put while the belt is cooling down.");
Assert.IsFalse(em.IsComponentEnabled<ConveyorItem>(dst), "Nothing arrives before the belt's period elapses.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 5342d4ffd0bafeb418f5c2f9b3e3ee4e
@@ -1,260 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="FabricatorProductionSystem"/> — the recipe machine
/// that consumes <c>InAmount</c> of its input resource per run and deposits <c>OutAmount * runs</c> into the GLOBAL
/// resource ledger (resolved via <see cref="ResourceLedger"/>, never GetSingleton&lt;StorageEntry&gt;). Pins: it
/// INITIALIZES on first touch without producing; it is strictly INPUT-LIMITED (runs = min(cycles, affordable) — no
/// mint-from-nothing); it consumes exactly InAmount*runs; and catch-up after skipped ticks awards the exact capped
/// amount. EB-2 adds the LEDGER-FED mode (<c>InputFromLedger != 0</c>): input is withdrawn from the shared ledger
/// (read LIVE inside the loop so two ledger-fed machines split a finite pool correctly) instead of MachineInput —
/// this is how mined Ore becomes turret-ammo Charge.
/// </summary>
public class FabricatorProductionSystemTests
{
static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<FabricatorProductionSystem>());
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<StorageEntry>(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, byte fromLedger = 0)
{
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,
InputFromLedger = fromLedger,
});
var input = em.AddBuffer<MachineInput>(e);
if (seedInput > 0)
input.Add(new MachineInput { ResourceId = inId, Count = seedInput });
em.AddBuffer<MachineOutput>(e);
return e;
}
static void SeedLedger(EntityManager em, Entity ledger, ushort itemId, int count)
{
var buf = em.GetBuffer<StorageEntry>(ledger);
buf.Add(new StorageEntry { ItemId = itemId, Count = count });
}
static int LedgerCount(EntityManager em, Entity ledger, ushort itemId)
{
var buf = em.GetBuffer<StorageEntry>(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<MachineInput>(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<PlacedStructure>(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).");
}
}
// ---- EB-2: ledger-fed Fabricator (mined Ore -> turret-ammo Charge) ----
[Test]
public void LedgerFed_Withdraws_Input_From_Ledger_And_Deposits_Output()
{
var (world, group, ledger) = MakeWorld("FabLedgerFed", serverTick: 100);
using (world)
{
var em = world.EntityManager;
SeedLedger(em, ledger, ResourceId.Ore, 10);
// InputFromLedger=1, MachineInput intentionally EMPTY (seedInput:0) — input must come from the ledger.
var f = MakeFabricator(em, ResourceId.Ore, inAmt: 1, outId: ResourceId.Charge, outAmt: 3, periodTicks: 30, seedInput: 0, fromLedger: 1);
group.Update(); // init (NextTick -> 130)
SetServerTick(world, 130);
group.Update(); // one period: withdraw 1 Ore FROM the ledger, deposit 3 Charge
Assert.AreEqual(3, LedgerCount(em, ledger, ResourceId.Charge), "One run deposits OutAmount Charge into the ledger.");
Assert.AreEqual(9, LedgerCount(em, ledger, ResourceId.Ore), "One run withdraws InAmount Ore FROM the ledger (10 - 1).");
Assert.AreEqual(0, InputOf(em, f, ResourceId.Ore), "A ledger-fed Fabricator never touches its MachineInput buffer.");
}
}
[Test]
public void Two_LedgerFed_Fabricators_Split_A_Finite_Ledger_Via_The_Live_Read()
{
// 3 Ore in the ledger, two ledger-fed Fabricators each needing 2 Ore/run. The system reads the ledger
// LIVE inside the loop, so the first withdraws 2 (Ore 3->1) and the second sees only 1 (floor(1/2)=0) and
// starves. A HOISTED/stale read would let both see 3, both run, and drive the ledger negative.
var (world, group, ledger) = MakeWorld("FabLedgerSplit", serverTick: 100);
using (world)
{
var em = world.EntityManager;
SeedLedger(em, ledger, ResourceId.Ore, 3);
MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Charge, outAmt: 1, periodTicks: 30, seedInput: 0, fromLedger: 1);
MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Charge, outAmt: 1, periodTicks: 30, seedInput: 0, fromLedger: 1);
group.Update(); // init both (NextTick -> 130)
SetServerTick(world, 130);
group.Update(); // one period: only ONE fab can afford 2 Ore from the shared pool
Assert.AreEqual(1, LedgerCount(em, ledger, ResourceId.Charge),
"Only one of the two ledger-fed Fabricators affords a run from the shared 3 Ore — the live read prevents a double-spend.");
Assert.AreEqual(1, LedgerCount(em, ledger, ResourceId.Ore), "2 of 3 Ore withdrawn; the ledger never goes negative.");
}
}
[Test]
public void LedgerFed_Runs_Are_Clamped_To_Affordable_Ledger_Under_CatchUp()
{
// 5 periods become due (150 ticks / 30), but the shared ledger only affords 3 runs (7 Ore / 2 per run).
// Pins the LEDGER branch of runs = min(cyclesDue, affordable) as the strict binding minimum under catch-up.
var (world, group, ledger) = MakeWorld("FabLedgerCatchUp", serverTick: 100);
using (world)
{
var em = world.EntityManager;
SeedLedger(em, ledger, ResourceId.Ore, 7);
var f = MakeFabricator(em, ResourceId.Ore, inAmt: 2, outId: ResourceId.Charge, outAmt: 1, periodTicks: 30, seedInput: 0, fromLedger: 1);
group.Update(); // init at 100
SetServerTick(world, 250); // floor(150/30) = 5 cycles due
group.Update();
Assert.AreEqual(3, LedgerCount(em, ledger, ResourceId.Charge),
"runs = min(cyclesDue=5, ledgerAffordable=floor(7/2)=3) = 3 — clamped by the LEDGER, not the cycle count.");
Assert.AreEqual(1, LedgerCount(em, ledger, ResourceId.Ore), "3 runs withdraw 6 of 7 Ore from the ledger, leaving 1.");
Assert.AreEqual(0, InputOf(em, f, ResourceId.Ore), "Ledger-fed mode never touches MachineInput.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: aeaf3925e6ddb6e4faeac39f32f98e5d
@@ -1,153 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="HarvesterProductionSystem"/> — the fixed-yield
/// generator that deposits <c>Yield * cycles</c> of its resource into its OWN <see cref="MachineOutput"/> 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.
/// </summary>
public class HarvesterProductionSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<HarvesterProductionSystem>());
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<MachineOutput>(e);
return e;
}
static int OutputOf(EntityManager em, Entity machine, byte resourceId)
{
var buf = em.GetBuffer<MachineOutput>(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<PlacedStructure>(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).");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a5de181e5c7cfe54a803d176411f8944
@@ -1,134 +0,0 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Pure tests for <see cref="ProductionMath"/> — 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 <c>maxCatchup</c> (no wall-clock mint), period=0 is guarded by <c>max(1,...)</c>, and the
/// RemainingTicks/RestoreNextTick pair round-trips epoch-independently for save/restore.
/// </summary>
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).");
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1c51d5b61b932d04c9273a3d674752a3