diff --git a/.serena/project.yml b/.serena/project.yml index 9095265ac..2d83ffcdb 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -55,8 +55,8 @@ ignore_all_files_in_gitignore: true # advanced configuration option allowing to configure language server-specific options. # Maps the language key to the options. -# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. -# No documentation on options means no options are available. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} # list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). @@ -131,3 +131,16 @@ read_only_memory_patterns: [] # Extends the list from the global configuration, merging the two lists. # Example: ["_archive/.*", "_episodes/.*"] ignored_memory_patterns: [] + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 diff --git a/Assets/Scenes/Game.unity b/Assets/Scenes/Game.unity index f821303d9..767ea0179 100644 --- a/Assets/Scenes/Game.unity +++ b/Assets/Scenes/Game.unity @@ -7910,6 +7910,96 @@ Transform: m_CorrespondingSourceObject: {fileID: 2973149261809905399, guid: f5ba9e2973c6d8d4cad295b79e2a7f45, type: 3} m_PrefabInstance: {fileID: 275699048} m_PrefabAsset: {fileID: 0} +--- !u!1 &276249889 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 276249890} + - component: {fileID: 276249892} + - component: {fileID: 276249891} + m_Layer: 0 + m_Name: CoreCrystals + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 4294967295 + m_IsActive: 1 +--- !u!4 &276249890 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 276249889} + 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: 2002840223} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &276249891 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 276249889} + 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: bc3bde0b4f16ba74aa27458eeac7042f, 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: 0 + 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!33 &276249892 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 276249889} + m_Mesh: {fileID: 1214762786157944795, guid: e6f6a00e68d9de4489304ee8097054c3, type: 3} --- !u!1001 &276342955 PrefabInstance: m_ObjectHideFlags: 0 @@ -12307,8 +12397,8 @@ Transform: - {fileID: 519877589} - {fileID: 1646293828} - {fileID: 1833012037} - - {fileID: 1911983842} - {fileID: 1282400926} + - {fileID: 2002840223} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1001 &426724431 @@ -51293,88 +51383,6 @@ LODGroup: - renderer: {fileID: 1078374115} m_Enabled: 1 m_GlobalIlluminationLOD: 0 ---- !u!1001 &1911983841 -PrefabInstance: - m_ObjectHideFlags: 0 - serializedVersion: 2 - m_Modification: - serializedVersion: 3 - m_TransformParent: {fileID: 423312854} - m_Modifications: - - target: {fileID: 100000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_Name - value: EngineCore - objectReference: {fileID: 0} - - target: {fileID: 100000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_StaticEditorFlags - value: 4294967295 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalScale.x - value: 0.58309853 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalScale.y - value: 0.61549294 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalScale.z - value: 0.58309853 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalPosition.x - value: 0 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalPosition.y - value: 0 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalPosition.z - value: 0 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalRotation.w - value: 1 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalRotation.x - value: -0 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalRotation.y - value: -0 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalRotation.z - value: -0 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalEulerAnglesHint.x - value: 0 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalEulerAnglesHint.y - value: 0 - objectReference: {fileID: 0} - - target: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: m_LocalEulerAnglesHint.z - value: 0 - objectReference: {fileID: 0} - - target: {fileID: 2300000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - propertyPath: 'm_Materials.Array.data[0]' - value: - objectReference: {fileID: 2100000, guid: bc3bde0b4f16ba74aa27458eeac7042f, type: 2} - m_RemovedComponents: [] - m_RemovedGameObjects: [] - m_AddedGameObjects: [] - m_AddedComponents: [] - m_SourcePrefab: {fileID: 100100000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} ---- !u!4 &1911983842 stripped -Transform: - m_CorrespondingSourceObject: {fileID: 400000, guid: d47dfef90b5bed241aa0ce186e8e5231, type: 3} - m_PrefabInstance: {fileID: 1911983841} - m_PrefabAsset: {fileID: 0} --- !u!1001 &1912606105 PrefabInstance: m_ObjectHideFlags: 0 @@ -52357,6 +52365,96 @@ Transform: m_CorrespondingSourceObject: {fileID: 6509791800259593245, guid: e6680d59b8f4ae845ae51557281e8e53, type: 3} m_PrefabInstance: {fileID: 1951217784} m_PrefabAsset: {fileID: 0} +--- !u!1 &1953443706 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1953443707} + - component: {fileID: 1953443709} + - component: {fileID: 1953443708} + m_Layer: 0 + m_Name: CoreMachine + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 4294967295 + m_IsActive: 1 +--- !u!4 &1953443707 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1953443706} + 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: 2002840223} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!23 &1953443708 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1953443706} + 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: 0 + 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!33 &1953443709 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1953443706} + m_Mesh: {fileID: -1751949791255435831, guid: e6f6a00e68d9de4489304ee8097054c3, type: 3} --- !u!1001 &1965792741 PrefabInstance: m_ObjectHideFlags: 0 @@ -53654,6 +53752,39 @@ Transform: m_CorrespondingSourceObject: {fileID: 591573043354113928, guid: bafd1387c024a3a459afa35dd24cec25, type: 3} m_PrefabInstance: {fileID: 1999454281} m_PrefabAsset: {fileID: 0} +--- !u!1 &2002840222 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2002840223} + m_Layer: 0 + m_Name: AwakeningEngineCore + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 4294967295 + m_IsActive: 1 +--- !u!4 &2002840223 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2002840222} + 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: + - {fileID: 1953443707} + - {fileID: 276249890} + m_Father: {fileID: 423312854} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1001 &2003593929 PrefabInstance: m_ObjectHideFlags: 0 diff --git a/Assets/Screenshots/step14_boon_modal.png b/Assets/Screenshots/step14_boon_modal.png new file mode 100644 index 000000000..07f2b1ce6 --- /dev/null +++ b/Assets/Screenshots/step14_boon_modal.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8aa22cdad645652f719e8efff1f804589a50e8c2380ae986d41205a26ca6ecca +size 162953 diff --git a/Assets/Screenshots/step14_boon_modal.png.meta b/Assets/Screenshots/step14_boon_modal.png.meta new file mode 100644 index 000000000..3b9411472 --- /dev/null +++ b/Assets/Screenshots/step14_boon_modal.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 2baac2a0d1e30d741883fde616060cea +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Screenshots/step14_route_panel.png b/Assets/Screenshots/step14_route_panel.png new file mode 100644 index 000000000..e171f0314 --- /dev/null +++ b/Assets/Screenshots/step14_route_panel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d487c28b55cf05d9c19dacee12c9fb17a52d5b51c9221e44e46b50e592481f56 +size 162894 diff --git a/Assets/Screenshots/step14_route_panel.png.meta b/Assets/Screenshots/step14_route_panel.png.meta new file mode 100644 index 000000000..ba46f72bd --- /dev/null +++ b/Assets/Screenshots/step14_route_panel.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 7efb157dfef14154684f62d0ea47deb0 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Screenshots/step14_route_panel2.png b/Assets/Screenshots/step14_route_panel2.png new file mode 100644 index 000000000..bfd1cfb37 --- /dev/null +++ b/Assets/Screenshots/step14_route_panel2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef54b33c70ad0e7d6aefdcaa21b025470f7dfb85b0138536be06bddf28239f44 +size 162922 diff --git a/Assets/Screenshots/step14_route_panel2.png.meta b/Assets/Screenshots/step14_route_panel2.png.meta new file mode 100644 index 000000000..2253cce8e --- /dev/null +++ b/Assets/Screenshots/step14_route_panel2.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 94383215ba0e27c4193027b9ca5a0801 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Screenshots/step14_route_panel_final.png b/Assets/Screenshots/step14_route_panel_final.png new file mode 100644 index 000000000..9a98361a9 --- /dev/null +++ b/Assets/Screenshots/step14_route_panel_final.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfedb54dae7a13ad7025328a2d8a869bc67e8fe8c161bae17e69cf04521ff860 +size 280820 diff --git a/Assets/Screenshots/step14_route_panel_final.png.meta b/Assets/Screenshots/step14_route_panel_final.png.meta new file mode 100644 index 000000000..68d4ac292 --- /dev/null +++ b/Assets/Screenshots/step14_route_panel_final.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: a3d5062ca2d4c074688459d79f43f5ac +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Screenshots/step14_route_panel_live.png b/Assets/Screenshots/step14_route_panel_live.png new file mode 100644 index 000000000..f4c4b875b --- /dev/null +++ b/Assets/Screenshots/step14_route_panel_live.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2210ab810dfa0b1449b032699e204d101b1703f6ef399fb26ed8840dce65c18f +size 151833 diff --git a/Assets/Screenshots/step14_route_panel_live.png.meta b/Assets/Screenshots/step14_route_panel_live.png.meta new file mode 100644 index 000000000..8e6fa7400 --- /dev/null +++ b/Assets/Screenshots/step14_route_panel_live.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: f95ae7500f290a449b0c5f61af5527a6 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Screenshots/step14_route_panel_live2.png b/Assets/Screenshots/step14_route_panel_live2.png new file mode 100644 index 000000000..6782d2a91 --- /dev/null +++ b/Assets/Screenshots/step14_route_panel_live2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8acab117db696f27bf5e68e49170083be885f3eb961e58496b0cef92fdaf7a15 +size 153910 diff --git a/Assets/Screenshots/step14_route_panel_live2.png.meta b/Assets/Screenshots/step14_route_panel_live2.png.meta new file mode 100644 index 000000000..7f3116eea --- /dev/null +++ b/Assets/Screenshots/step14_route_panel_live2.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: c129c83b6ee3a80429dde83b9bed1290 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Screenshots/step14_staging_ready.png b/Assets/Screenshots/step14_staging_ready.png new file mode 100644 index 000000000..6e9e371bc --- /dev/null +++ b/Assets/Screenshots/step14_staging_ready.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e082a8b74ecb2e41c90cd6e7d3531a7ab0cf77e7c4307019cc3ff37c62f72024 +size 83587 diff --git a/Assets/Screenshots/step14_staging_ready.png.meta b/Assets/Screenshots/step14_staging_ready.png.meta new file mode 100644 index 000000000..ece27df4f --- /dev/null +++ b/Assets/Screenshots/step14_staging_ready.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 586c9080e97d9ba4c8d2606eae86cd69 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art.meta b/Assets/_Project/Art.meta new file mode 100644 index 000000000..c49c22a27 --- /dev/null +++ b/Assets/_Project/Art.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5532ca12b65632c4583c1be034e2393b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Experiments.meta b/Assets/_Project/Art/Experiments.meta new file mode 100644 index 000000000..ced7cf4b5 --- /dev/null +++ b/Assets/_Project/Art/Experiments.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aa9ffc37c66896e43ba6cd690104c528 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Experiments/GattlingGun_Twin.prefab b/Assets/_Project/Art/Experiments/GattlingGun_Twin.prefab new file mode 100644 index 000000000..32ae89509 --- /dev/null +++ b/Assets/_Project/Art/Experiments/GattlingGun_Twin.prefab @@ -0,0 +1,87 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1001 &4916349407969455839 +PrefabInstance: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_Modification: + serializedVersion: 3 + m_TransformParent: {fileID: 0} + m_Modifications: + - target: {fileID: -8679921383154817045, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_LocalPosition.x + value: -0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_LocalPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_LocalPosition.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_LocalRotation.w + value: 0.7071067 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_LocalRotation.x + value: 0.7071068 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_LocalRotation.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_LocalRotation.z + value: -0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_LocalEulerAnglesHint.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_LocalEulerAnglesHint.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_LocalEulerAnglesHint.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -7511558181221131132, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: 'm_Materials.Array.data[0]' + value: + objectReference: {fileID: 2100000, guid: e600b338198602a449203a6e59c0e794, type: 2} + - target: {fileID: -5594369587634721024, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: 'm_Materials.Array.data[0]' + value: + objectReference: {fileID: 2100000, guid: e600b338198602a449203a6e59c0e794, type: 2} + - target: {fileID: -2416533905622979561, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: 'm_Materials.Array.data[0]' + value: + objectReference: {fileID: 2100000, guid: e600b338198602a449203a6e59c0e794, type: 2} + - target: {fileID: -2244577078095019901, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: 'm_Materials.Array.data[0]' + value: + objectReference: {fileID: 2100000, guid: e600b338198602a449203a6e59c0e794, type: 2} + - target: {fileID: -914913065822880002, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: 'm_Materials.Array.data[0]' + value: + objectReference: {fileID: 2100000, guid: e600b338198602a449203a6e59c0e794, type: 2} + - target: {fileID: 919132149155446097, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: m_Name + value: GattlingGun_Twin + objectReference: {fileID: 0} + - target: {fileID: 3903384658590035005, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: 'm_Materials.Array.data[0]' + value: + objectReference: {fileID: 2100000, guid: e600b338198602a449203a6e59c0e794, type: 2} + - target: {fileID: 6038234858806578497, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} + propertyPath: 'm_Materials.Array.data[0]' + value: + objectReference: {fileID: 2100000, guid: e600b338198602a449203a6e59c0e794, type: 2} + m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] + m_SourcePrefab: {fileID: 100100000, guid: 663f66b08f62e514fbb8ea9fdf609c1f, type: 3} diff --git a/Assets/_Project/Art/Experiments/GattlingGun_Twin.prefab.meta b/Assets/_Project/Art/Experiments/GattlingGun_Twin.prefab.meta new file mode 100644 index 000000000..0469e66de --- /dev/null +++ b/Assets/_Project/Art/Experiments/GattlingGun_Twin.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0eb166271cf8ef747ba1236e70a8e46e +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Experiments/SM_Wep_GattlingGun_01_Twin.fbx b/Assets/_Project/Art/Experiments/SM_Wep_GattlingGun_01_Twin.fbx new file mode 100644 index 000000000..8fc383e71 --- /dev/null +++ b/Assets/_Project/Art/Experiments/SM_Wep_GattlingGun_01_Twin.fbx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5d97d411f199ca5f29faa00a60139ecacc4fe422964ceb755e5e521aae6c0ac +size 289212 diff --git a/Assets/_Project/Art/Experiments/SM_Wep_GattlingGun_01_Twin.fbx.meta b/Assets/_Project/Art/Experiments/SM_Wep_GattlingGun_01_Twin.fbx.meta new file mode 100644 index 000000000..a5ad65544 --- /dev/null +++ b/Assets/_Project/Art/Experiments/SM_Wep_GattlingGun_01_Twin.fbx.meta @@ -0,0 +1,114 @@ +fileFormatVersion: 2 +guid: 663f66b08f62e514fbb8ea9fdf609c1f +ModelImporter: + serializedVersion: 24501 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + searchTexturesGlobally: 0 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 0 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + importUVs: -1 + importVertexColors: 1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + calculateBlendshapeNormalsDeltaFromImportedNormals: 0 + referencedClips: [] + importAnimation: 1 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 2 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Structures.meta b/Assets/_Project/Art/Structures.meta new file mode 100644 index 000000000..e26706dae --- /dev/null +++ b/Assets/_Project/Art/Structures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9e5f10ea2cf310a46956ebeb646c41e8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Structures/SM_AwakeningCore_01.fbx b/Assets/_Project/Art/Structures/SM_AwakeningCore_01.fbx new file mode 100644 index 000000000..a314c277c --- /dev/null +++ b/Assets/_Project/Art/Structures/SM_AwakeningCore_01.fbx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6dcd8ae03e028e98b6564d82dc03ec0b601ddb912a7b2bde9f3fccae75c7fa70 +size 185228 diff --git a/Assets/_Project/Art/Structures/SM_AwakeningCore_01.fbx.meta b/Assets/_Project/Art/Structures/SM_AwakeningCore_01.fbx.meta new file mode 100644 index 000000000..27d01b303 --- /dev/null +++ b/Assets/_Project/Art/Structures/SM_AwakeningCore_01.fbx.meta @@ -0,0 +1,114 @@ +fileFormatVersion: 2 +guid: e6f6a00e68d9de4489304ee8097054c3 +ModelImporter: + serializedVersion: 24501 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + searchTexturesGlobally: 0 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 0 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + importUVs: -1 + importVertexColors: 1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + calculateBlendshapeNormalsDeltaFromImportedNormals: 0 + referencedClips: [] + importAnimation: 1 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 2 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Structures/SM_Fabricator_01.fbx b/Assets/_Project/Art/Structures/SM_Fabricator_01.fbx new file mode 100644 index 000000000..99ba5be70 --- /dev/null +++ b/Assets/_Project/Art/Structures/SM_Fabricator_01.fbx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12fa65b703e529c2bf39846fd415e3b4ac8e845dec93eb6921492f86923512f6 +size 75036 diff --git a/Assets/_Project/Art/Structures/SM_Fabricator_01.fbx.meta b/Assets/_Project/Art/Structures/SM_Fabricator_01.fbx.meta new file mode 100644 index 000000000..a62d41a80 --- /dev/null +++ b/Assets/_Project/Art/Structures/SM_Fabricator_01.fbx.meta @@ -0,0 +1,114 @@ +fileFormatVersion: 2 +guid: c8c42eb35b8e2e649b185de6fd33e2e7 +ModelImporter: + serializedVersion: 24501 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + searchTexturesGlobally: 0 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 0 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + importUVs: -1 + importVertexColors: 1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + calculateBlendshapeNormalsDeltaFromImportedNormals: 0 + referencedClips: [] + importAnimation: 1 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 2 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Structures/SM_Turret_01.fbx b/Assets/_Project/Art/Structures/SM_Turret_01.fbx new file mode 100644 index 000000000..0e5e8bdd7 --- /dev/null +++ b/Assets/_Project/Art/Structures/SM_Turret_01.fbx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38caba977587668a21c06d130b4891bd8bb74726e7be552646895eb0bbddbc2e +size 236124 diff --git a/Assets/_Project/Art/Structures/SM_Turret_01.fbx.meta b/Assets/_Project/Art/Structures/SM_Turret_01.fbx.meta new file mode 100644 index 000000000..14f863eae --- /dev/null +++ b/Assets/_Project/Art/Structures/SM_Turret_01.fbx.meta @@ -0,0 +1,114 @@ +fileFormatVersion: 2 +guid: 472a8342ece31c14f9d53ff7119b7857 +ModelImporter: + serializedVersion: 24501 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + searchTexturesGlobally: 0 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 0 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + importUVs: -1 + importVertexColors: 1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + calculateBlendshapeNormalsDeltaFromImportedNormals: 0 + referencedClips: [] + importAnimation: 1 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 2 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Structures/SM_Wall_01.fbx b/Assets/_Project/Art/Structures/SM_Wall_01.fbx new file mode 100644 index 000000000..3738756ca --- /dev/null +++ b/Assets/_Project/Art/Structures/SM_Wall_01.fbx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f74e76e7f2755e32eac27a682b009467795cf28aad86a55b4b7107835609c0a +size 20684 diff --git a/Assets/_Project/Art/Structures/SM_Wall_01.fbx.meta b/Assets/_Project/Art/Structures/SM_Wall_01.fbx.meta new file mode 100644 index 000000000..88f664bbf --- /dev/null +++ b/Assets/_Project/Art/Structures/SM_Wall_01.fbx.meta @@ -0,0 +1,114 @@ +fileFormatVersion: 2 +guid: d5f5c8f8a3300c249a861a444f85e05d +ModelImporter: + serializedVersion: 24501 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + searchTexturesGlobally: 0 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 0 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + importUVs: -1 + importVertexColors: 1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + calculateBlendshapeNormalsDeltaFromImportedNormals: 0 + referencedClips: [] + importAnimation: 1 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 2 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Prefabs/Fabricator.prefab b/Assets/_Project/Prefabs/Fabricator.prefab index 8f6e4c349..f01aeb50c 100644 --- a/Assets/_Project/Prefabs/Fabricator.prefab +++ b/Assets/_Project/Prefabs/Fabricator.prefab @@ -31,7 +31,7 @@ Transform: 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_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} @@ -43,7 +43,7 @@ MeshFilter: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 3885353946372160549} - m_Mesh: {fileID: 4300000, guid: abc00000000010690097314383055197, type: 3} + m_Mesh: {fileID: -1810898345513765494, guid: c8c42eb35b8e2e649b185de6fd33e2e7, type: 3} --- !u!23 &3320445911748035220 MeshRenderer: m_ObjectHideFlags: 0 @@ -69,7 +69,7 @@ MeshRenderer: m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: - - {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2} + - {fileID: 2100000, guid: 4cee7a5983b187943adc589b9cb1f084, type: 2} m_StaticBatchInfo: firstSubMesh: 0 subMeshCount: 0 diff --git a/Assets/_Project/Prefabs/Turret.prefab b/Assets/_Project/Prefabs/Turret.prefab index a301cd4e2..2428fd310 100644 --- a/Assets/_Project/Prefabs/Turret.prefab +++ b/Assets/_Project/Prefabs/Turret.prefab @@ -26,12 +26,11 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 2057690259992313649} serializedVersion: 2 - m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} - m_LocalScale: {x: 0.8, y: 0.8, z: 0.8} + m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 8624793677999475166} + m_Children: [] m_Father: {fileID: 3572766465862231365} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!33 &3818890329194429404 @@ -41,7 +40,7 @@ MeshFilter: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 2057690259992313649} - m_Mesh: {fileID: 4300000, guid: b9f5d4937fcf552448a1757f00aec25a, type: 3} + m_Mesh: {fileID: -8131118695690612189, guid: 472a8342ece31c14f9d53ff7119b7857, type: 3} --- !u!23 &6553709043537316589 MeshRenderer: m_ObjectHideFlags: 0 @@ -67,282 +66,8 @@ MeshRenderer: m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: - - {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, 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: 0 - 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!1 &2187311308710316335 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1661254142448502089} - - component: {fileID: 826915993840495878} - - component: {fileID: 8045752627718943123} - m_Layer: 0 - m_Name: SM_Wep_Ballista_Mounted_Bolt_01 - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!4 &1661254142448502089 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2187311308710316335} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: 0.000120390985, y: 0.07090128, z: 1.1123258} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 4587729640460227676} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!33 &826915993840495878 -MeshFilter: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2187311308710316335} - m_Mesh: {fileID: 4300010, guid: b9f5d4937fcf552448a1757f00aec25a, type: 3} ---- !u!23 &8045752627718943123 -MeshRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2187311308710316335} - 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: 71e41e43b459a244abaf5acca76b89ee, 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: 0 - 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!1 &2457356283659831041 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 8624793677999475166} - - component: {fileID: 2206687939742515296} - - component: {fileID: 2797104103422709025} - m_Layer: 0 - m_Name: SM_Wep_Ballista_Mounted_Horizontal_01 - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!4 &8624793677999475166 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2457356283659831041} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: 0.00000019073485, y: 1.0745214, z: -0.0000007629394} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 4587729640460227676} - m_Father: {fileID: 4449724123933675757} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!33 &2206687939742515296 -MeshFilter: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2457356283659831041} - m_Mesh: {fileID: 4300002, guid: b9f5d4937fcf552448a1757f00aec25a, type: 3} ---- !u!23 &2797104103422709025 -MeshRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2457356283659831041} - 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: 71e41e43b459a244abaf5acca76b89ee, 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: 0 - 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!1 &2839324981893915979 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 4587729640460227676} - - component: {fileID: 336643015455069546} - - component: {fileID: 1156568749056329743} - m_Layer: 0 - m_Name: SM_Wep_Ballista_Mounted_Vertical_01 - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!4 &4587729640460227676 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2839324981893915979} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: -0.000000055864938, y: 0.29948944, z: -0.0005771637} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 1661254142448502089} - - {fileID: 2244160433493401752} - - {fileID: 1648902957520227441} - - {fileID: 4099830417809752389} - m_Father: {fileID: 8624793677999475166} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!33 &336643015455069546 -MeshFilter: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2839324981893915979} - m_Mesh: {fileID: 4300004, guid: b9f5d4937fcf552448a1757f00aec25a, type: 3} ---- !u!23 &1156568749056329743 -MeshRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2839324981893915979} - 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: 71e41e43b459a244abaf5acca76b89ee, type: 2} + - {fileID: 2100000, guid: 4cee7a5983b187943adc589b9cb1f084, type: 2} + - {fileID: 2100000, guid: e600b338198602a449203a6e59c0e794, type: 2} m_StaticBatchInfo: firstSubMesh: 0 subMeshCount: 0 @@ -477,273 +202,3 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 0.8, y: 1.2, z: 0.8} m_Center: {x: 0, y: 0.6, z: 0} ---- !u!1 &4051895978514069616 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 2244160433493401752} - - component: {fileID: 5785378423824416967} - - component: {fileID: 774247800957589290} - m_Layer: 0 - m_Name: SM_Wep_Ballista_Mounted_Handle_01 - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!4 &2244160433493401752 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 4051895978514069616} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: 0.000000055864938, y: -0.011389092, z: -1.9009238} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 4587729640460227676} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!33 &5785378423824416967 -MeshFilter: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 4051895978514069616} - m_Mesh: {fileID: 4300008, guid: b9f5d4937fcf552448a1757f00aec25a, type: 3} ---- !u!23 &774247800957589290 -MeshRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 4051895978514069616} - 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: 71e41e43b459a244abaf5acca76b89ee, 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: 0 - 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!1 &4368797453099486757 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 4099830417809752389} - - component: {fileID: 4089364109599762086} - - component: {fileID: 5885630555293055191} - m_Layer: 0 - m_Name: SM_Wep_Ballista_Mounted_String_01 - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!4 &4099830417809752389 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 4368797453099486757} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: -0.00000013486994, y: 0.0375115, z: -0.07168248} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 4587729640460227676} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!33 &4089364109599762086 -MeshFilter: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 4368797453099486757} - m_Mesh: {fileID: 4300012, guid: b9f5d4937fcf552448a1757f00aec25a, type: 3} ---- !u!23 &5885630555293055191 -MeshRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 4368797453099486757} - 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: 71e41e43b459a244abaf5acca76b89ee, 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: 0 - 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!1 &5096850821653549945 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1648902957520227441} - - component: {fileID: 8575753145058782478} - - component: {fileID: 1327063321531964727} - m_Layer: 0 - m_Name: SM_Wep_Ballista_Mounted_Loader_01 - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!4 &1648902957520227441 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 5096850821653549945} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: 0.000000055864938, y: 0.08274717, z: -1.2890174} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 4587729640460227676} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!33 &8575753145058782478 -MeshFilter: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 5096850821653549945} - m_Mesh: {fileID: 4300006, guid: b9f5d4937fcf552448a1757f00aec25a, type: 3} ---- !u!23 &1327063321531964727 -MeshRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 5096850821653549945} - 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: 71e41e43b459a244abaf5acca76b89ee, 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: 0 - 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} diff --git a/Assets/_Project/Prefabs/Wall.prefab b/Assets/_Project/Prefabs/Wall.prefab index fae933c61..292654088 100644 --- a/Assets/_Project/Prefabs/Wall.prefab +++ b/Assets/_Project/Prefabs/Wall.prefab @@ -26,9 +26,9 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 551144932704427983} serializedVersion: 2 - m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} - m_LocalScale: {x: 1.2, y: 1.2, z: 1.2} + m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 3572766465862231365} @@ -40,7 +40,7 @@ MeshFilter: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 551144932704427983} - m_Mesh: {fileID: 4300000, guid: d948b51d152cd2348b756856f220cc7e, type: 3} + m_Mesh: {fileID: 4754861833707048916, guid: d5f5c8f8a3300c249a861a444f85e05d, type: 3} --- !u!23 &4489297219945009455 MeshRenderer: m_ObjectHideFlags: 0 @@ -66,6 +66,7 @@ MeshRenderer: m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: + - {fileID: 2100000, guid: 2a95f9ff80948c643a57b9e94a98eb3f, type: 2} - {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2} m_StaticBatchInfo: firstSubMesh: 0 diff --git a/Assets/_Project/Resources/HudTheme.asset b/Assets/_Project/Resources/HudTheme.asset index 379f13ada..57cc0e65b 100644 --- a/Assets/_Project/Resources/HudTheme.asset +++ b/Assets/_Project/Resources/HudTheme.asset @@ -37,6 +37,9 @@ MonoBehaviour: HarvesterIcon: {fileID: 21300000, guid: 5a8c6e8575552cb4d96a8fe09227c6e2, type: 3} FabricatorIcon: {fileID: 21300000, guid: ca43072da0d43f44fbdbf57c997414ba, type: 3} ConveyorIcon: {fileID: 21300000, guid: ba4939cd85537454a93777a4cc9e8b38, type: 3} + TurretGhostMesh: {fileID: -8131118695690612189, guid: 472a8342ece31c14f9d53ff7119b7857, type: 3} + WallGhostMesh: {fileID: 4754861833707048916, guid: d5f5c8f8a3300c249a861a444f85e05d, type: 3} + FabricatorGhostMesh: {fileID: -1810898345513765494, guid: c8c42eb35b8e2e649b185de6fd33e2e7, type: 3} KbmPlace: {fileID: 21300000, guid: 3fc063021eaa5eb4cb710e15a38ceb6c, type: 3} KbmCancel: {fileID: 21300000, guid: 78d618b10af7f9b4db2600f77fdce06c, type: 3} PadPlace: {fileID: 21300000, guid: 15a4f1d900c35fd4091f6970c5250a44, type: 3} diff --git a/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs new file mode 100644 index 000000000..9b1ae3dc6 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// Authoring for the boon-catalog config singleton (place ONE in the gameplay subscene). Designers can author + /// rows in the inspector; an EMPTY list bakes the code-default v1 table () + /// verbatim — so the subscene object needs zero property assignment through the tooling (the + /// component_properties enum/array-drop hazard). Ids are APPEND-ONLY (they ride the replicated BoonOffer bytes + /// and, later, per-run analytics). + /// + public class BoonCatalogAuthoring : MonoBehaviour + { + [Serializable] + public struct BoonRow + { + public byte Id; + public StatTarget Target; + public ModOp Op; + public float Value; + [Tooltip("Rarity draw weight: common 100 / rare 30 / epic 10. 0 removes the boon from the pool.")] + public byte Weight; + [Tooltip("bit0 = Warrior, bit1 = Ranger, 3 = both.")] + public byte ClassMask; + public string Name; + public string Desc; + } + + [Tooltip("Leave EMPTY to bake the code-default v1 table; fill to fully replace it.")] + public List Rows = new List(); + + private class BoonCatalogBaker : Baker + { + public override void Bake(BoonCatalogAuthoring authoring) + { + var entity = GetEntity(authoring, TransformUsageFlags.None); + + BlobAssetReference blob; + if (authoring.Rows == null || authoring.Rows.Count == 0) + { + blob = BoonCatalogData.BuildDefault(); + } + else + { + var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var defs = builder.Allocate(ref root.Defs, authoring.Rows.Count); + for (int i = 0; i < authoring.Rows.Count; i++) + { + var r = authoring.Rows[i]; + defs[i] = new BoonDefBlob + { + Id = r.Id, + Target = (byte)r.Target, + Op = (byte)r.Op, + Value = r.Value, + Weight = r.Weight, + ClassMask = r.ClassMask, + Name = new FixedString64Bytes(r.Name ?? string.Empty), + Desc = new FixedString128Bytes(r.Desc ?? string.Empty), + }; + } + blob = builder.CreateBlobAssetReference(Allocator.Persistent); + builder.Dispose(); + } + + AddBlobAsset(ref blob, out _); + AddComponent(entity, new BoonCatalog { Value = blob }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs.meta new file mode 100644 index 000000000..53d5ebbd4 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/BoonCatalogAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 075a570afb8ef9541b6307280b53f4e7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs deleted file mode 100644 index bd4eb792d..000000000 --- a/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs +++ /dev/null @@ -1,51 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// Authoring for the home-base mining field (). Place ONE in the gameplay - /// subscene. = the SAME ResourceNode ghost prefab the expedition uses; the server - /// system overrides each instance to RegionTag{Base} + ResourceId.Ore and scatters them in the - /// [, ] annulus around the base plot center. Defaults are - /// sized to the baked 32x32 plot (square corner reach ~22.6) inside the ~28.7 boundary ring, so nodes form a - /// reachable perimeter ring that never sits on a build cell. - /// - public class BaseFieldSpawnerAuthoring : MonoBehaviour - { - [Tooltip("Resource-node ghost prefab (ResourceNodeAuthoring + GhostAuthoring). Reuse the expedition node prefab.")] - public GameObject NodePrefab; - - [Tooltip("Live base-node target; the field refills toward this each respawn pass.")] - [Min(1)] public int TargetCount = 10; - - [Tooltip("Inner scatter radius — clears the build plot corner reach (~22.6) + spawn ring.")] - [Min(0f)] public float InnerRadius = 23.5f; - - [Tooltip("Outer scatter radius — stays inside the walkable boundary ring (~28.7).")] - [Min(0f)] public float OuterRadius = 27f; - - [Tooltip("Server ticks (@60) between top-up passes.")] - [Min(1)] public int RespawnIntervalTicks = 600; - - private class BaseFieldSpawnerBaker : Baker - { - public override void Bake(BaseFieldSpawnerAuthoring authoring) - { - var entity = GetEntity(authoring, TransformUsageFlags.None); - AddComponent(entity, new BaseFieldSpawner - { - Prefab = authoring.NodePrefab != null - ? GetEntity(authoring.NodePrefab, TransformUsageFlags.Dynamic) - : Entity.Null, - TargetCount = authoring.TargetCount, - InnerRadius = authoring.InnerRadius, - OuterRadius = authoring.OuterRadius, - RespawnIntervalTicks = authoring.RespawnIntervalTicks, - }); - AddComponent(entity, new BaseFieldRuntime { Epoch = 0, NextSpawnTick = 0u }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs.meta deleted file mode 100644 index adfe96de1..000000000 --- a/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c4055a6a779d06949ae23b16334b810e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Meta.meta b/Assets/_Project/Scripts/Authoring/Meta.meta new file mode 100644 index 000000000..5f8fb91b2 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Meta.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d0503ff85f3b39f49af750a451d44e00 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs b/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs new file mode 100644 index 000000000..5b6ba8c8a --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// Authoring for the PERMANENT meta-upgrade catalog singleton (place ONE in the gameplay subscene — the + /// BoonCatalogAuthoring pattern). An EMPTY row list bakes the code-default v1 table + /// () verbatim, so the subscene object needs zero property assignment + /// through the tooling. Ids are APPEND-ONLY (persisted in SaveData v6; a removed id's saved rows are preserved + /// and skipped, never crashed on). + /// + public class MetaCatalogAuthoring : MonoBehaviour + { + [Serializable] + public struct MetaRow + { + public byte Id; + [Tooltip("bit0 = Warrior, bit1 = Ranger, 3 = both.")] + public byte ClassMask; + public StatTarget Target; + public ModOp Op; + public byte MaxTier; + public float ValuePerTier; + public int BaseCost; + public int CostGrowth; + [Tooltip("0xFF (255) = no prerequisite.")] + public byte PrereqId; + public byte PrereqTier; + public string Name; + public string Desc; + } + + [Tooltip("Leave EMPTY to bake the code-default v1 table; fill to fully replace it.")] + public List Rows = new List(); + + private class MetaCatalogBaker : Baker + { + public override void Bake(MetaCatalogAuthoring authoring) + { + var entity = GetEntity(authoring, TransformUsageFlags.None); + + BlobAssetReference blob; + if (authoring.Rows == null || authoring.Rows.Count == 0) + { + blob = MetaCatalogData.BuildDefault(); + } + else + { + var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var defs = builder.Allocate(ref root.Defs, authoring.Rows.Count); + for (int i = 0; i < authoring.Rows.Count; i++) + { + var r = authoring.Rows[i]; + defs[i] = new MetaUpgradeDefBlob + { + Id = r.Id, + ClassMask = r.ClassMask, + Target = (byte)r.Target, + Op = (byte)r.Op, + MaxTier = r.MaxTier, + ValuePerTier = r.ValuePerTier, + BaseCost = r.BaseCost, + CostGrowth = r.CostGrowth, + PrereqId = r.PrereqId, + PrereqTier = r.PrereqTier, + Name = new FixedString64Bytes(r.Name ?? string.Empty), + Desc = new FixedString128Bytes(r.Desc ?? string.Empty), + }; + } + blob = builder.CreateBlobAssetReference(Allocator.Persistent); + builder.Dispose(); + } + + AddBlobAsset(ref blob, out _); + AddComponent(entity, new MetaUpgradeCatalog { Value = blob }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs.meta new file mode 100644 index 000000000..38bc113ff --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Meta/MetaCatalogAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f007f7870c0afd4e93ea5b86fd21c8b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs index a0ee277f9..d9095496b 100644 --- a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs @@ -100,6 +100,11 @@ namespace ProjectM.Authoring AddComponent(entity, new RespawnState { RespawnTick = 0, DelayTicks = authoring.RespawnDelayTicks, InvulnTicks = authoring.RespawnInvulnTicks }); AddComponent(entity, new RespawnInvuln { UntilTick = 0 }); + + // Expedition redesign (the ONE player-ghost re-bake, front-loaded): the send-to-all ready-check + // flag + the owner-only choice-of-3 boon offer (inert until Step 9's BoonOfferSystem lights it up). + AddComponent(entity); + AddComponent(entity); } } } diff --git a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs index bd06f5b8a..7e8ecad8a 100644 --- a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs @@ -78,6 +78,13 @@ namespace ProjectM.Authoring // runtime-spawned director ghost (server + client bake the same prefab -> hash matches), like CoreIntegrity. AddComponent(entity, new ExpeditionObjective { State = ExpeditionObjectiveState.Idle, Remaining = 0 }); + // Expedition redesign: the replicated run-lifecycle FSM (RunInfo, 17 [GhostField]s) + the per-class + // permanent-meta tier buffer (MetaTierState) BOTH land in this ONE coordinated re-bake (front-loaded + // ghost layout — the writer systems arrive across Steps 2–13 while the state sits inert/default). + // Born Staging/empty; server RunDirectorSystem / MetaSpendSystem are the sole writers. + AddComponent(entity, new RunInfo { Lifecycle = RunLifecycle.Staging }); + AddBuffer(entity); + AddComponent(entity, new ThreatConfig { diff --git a/Assets/_Project/Scripts/Authoring/World/ExpeditionGateAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/ExpeditionGateAuthoring.cs deleted file mode 100644 index 327a6678d..000000000 --- a/Assets/_Project/Scripts/Authoring/World/ExpeditionGateAuthoring.cs +++ /dev/null @@ -1,45 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.Mathematics; -using UnityEngine; - -namespace ProjectM.Authoring -{ - /// - /// Authoring for a walk-in . Place on a visible gate object in the gameplay - /// subscene; baked into both worlds at the gate's position (the server reads its LocalTransform for the - /// overlap test, the client renders the mesh). Set From/To regions + the arrival point in the destination - /// region (offset from that region's gate so the player doesn't immediately re-trigger). - /// - public class ExpeditionGateAuthoring : MonoBehaviour - { - public enum Region : byte { Base = 0, Expedition = 1 } - - [Tooltip("Region a player must be in for this gate to act on them.")] - public Region From = Region.Base; - - [Tooltip("Region the player is transited to.")] - public Region To = Region.Expedition; - - [Min(0.5f)] public float Radius = 2.5f; - - [Tooltip("Where the player arrives in the destination region (offset from that region's gate).")] - public Vector3 ArrivalPos = new Vector3(1000f, 1f, 0f); - - private class ExpeditionGateBaker : Baker - { - public override void Bake(ExpeditionGateAuthoring authoring) - { - // Dynamic so the baked entity carries a LocalTransform the server can read for the overlap test. - var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); - AddComponent(entity, new ExpeditionGate - { - FromRegion = (byte)authoring.From, - ToRegion = (byte)authoring.To, - Radius = authoring.Radius, - ArrivalPos = new float3(authoring.ArrivalPos.x, authoring.ArrivalPos.y, authoring.ArrivalPos.z), - }); - } - } - } -} diff --git a/Assets/_Project/Scripts/Authoring/World/ExpeditionGateAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/World/ExpeditionGateAuthoring.cs.meta deleted file mode 100644 index f52499f9a..000000000 --- a/Assets/_Project/Scripts/Authoring/World/ExpeditionGateAuthoring.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 22f744b59ad23834abe28fc09b661005 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs index 9d91c5c54..bcfad3d92 100644 --- a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs +++ b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs @@ -38,13 +38,14 @@ namespace ProjectM.Client UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily) GameObject _ghost; // translucent ground preview cube Material _ghostMat; + MeshFilter _ghostMf; // ghost mesh swaps to the selected structure's preview mesh (HudTheme) + MeshRenderer _ghostMr; + Mesh _cubeMesh; // procedural fallback (the original preview cube) + byte _ghostType = 255; // last-applied preview type (255 = unset) byte _lastSelected; // skip placing on the frame a palette click changes the selection - // DR-042 C6a: the ability-upgrade send is RUNTIME (the HUD Aether button calls UpgradeAbility); only the - // execute_code PLACE statics stay editor-gated. Mirrors EquipSendSystem's unconditional queue + drain. - static int s_PendingUpgrades = 0; - /// Runtime hook (HUD Aether button) + execute_code: queue an ability-damage upgrade. - public static void UpgradeAbility() => s_PendingUpgrades++; + // (Step 11: UpgradeAbility/s_PendingUpgrades RETIRED with the AbilityUpgradeRequest wire — in-run boons + + // the base meta-shop replaced the Aether damage upgrade.)des++; #if UNITY_EDITOR struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; } @@ -113,17 +114,8 @@ namespace ProjectM.Client foreach (var (key, type) in s_BuildHotkeys) if (keyboard[key].wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell)) SendBuild(connection, type, cell.x, cell.y, type == StructureType.Conveyor ? s_ConveyorDir : (byte)0); - if (keyboard.uKey.wasPressedThisFrame) - SendUpgrade(connection); } - // DR-042 C6a: the ability-upgrade drain runs at RUNTIME (the HUD Aether button enqueues via UpgradeAbility); - // only the execute_code PLACE drain stays editor-gated. - while (s_PendingUpgrades > 0) - { - s_PendingUpgrades--; - SendUpgrade(connection); - } #if UNITY_EDITOR while (s_PendingBuild.Count > 0) { @@ -185,7 +177,7 @@ namespace ProjectM.Client byte reason = BuildPreviewMath.Evaluate(anchor, targetCell, occupied, LedgerOre(), cost); bool valid = reason == BuildPreviewMath.Valid; - ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid); + ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid, sel);; // Place on a left-click (valid, not the selecting click). if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame) @@ -208,16 +200,44 @@ namespace ProjectM.Client return int.MaxValue; } - // ---- Ground ghost preview (procedural translucent cube, like AimReticleSystem's reticle) ---- - void ShowGhost(float3 center, float cellSize, bool valid) + // ---- Ground ghost preview: the selected structure's REAL mesh (HudTheme, build-safe serialized refs) + // tinted translucent green/red; falls back to the original procedural cube when no mesh is authored. ---- + void ShowGhost(float3 center, float cellSize, bool valid, byte type) { EnsureGhost(); - _ghost.transform.position = (Vector3)center + Vector3.up * 0.5f; - _ghost.transform.localScale = new Vector3(cellSize * 0.9f, 1f, cellSize * 0.9f); + ApplyGhostMesh(type); + if (_ghostMf.sharedMesh == _cubeMesh) + { + _ghost.transform.position = (Vector3)center + Vector3.up * 0.5f; + _ghost.transform.localScale = new Vector3(cellSize * 0.9f, 1f, cellSize * 0.9f); + } + else + { + // preview meshes are authored real-size with a ground pivot (SM_Turret_01 / SM_Wall_01 / SM_Fabricator_01) + _ghost.transform.position = (Vector3)center; + _ghost.transform.localScale = Vector3.one; + } _ghostMat.color = valid ? new Color(0.3f, 1f, 0.45f, 0.35f) : new Color(1f, 0.32f, 0.26f, 0.35f); if (!_ghost.activeSelf) _ghost.SetActive(true); } + // Swap the ghost's mesh when the palette selection changes (255 = unset sentinel; no selection is 0 -> cube). + void ApplyGhostMesh(byte type) + { + if (type == _ghostType) return; + _ghostType = type; + var theme = HudTheme.Get(); + Mesh mesh = theme != null ? theme.StructureGhostMesh(type) : null; + if (mesh == null) mesh = _cubeMesh; + _ghostMf.sharedMesh = mesh; + if (_ghostMr.sharedMaterials.Length != mesh.subMeshCount) + { + var mats = new Material[mesh.subMeshCount]; // one translucent mat per submesh so the whole preview tints + for (int i = 0; i < mats.Length; i++) mats[i] = _ghostMat; + _ghostMr.sharedMaterials = mats; + } + } + void HideGhost() { if (_ghost != null && _ghost.activeSelf) _ghost.SetActive(false); @@ -233,10 +253,13 @@ namespace ProjectM.Client _ghost.name = "~BuildGhost"; var col = _ghost.GetComponent(); if (col != null) Object.Destroy(col); - var mr = _ghost.GetComponent(); - mr.sharedMaterial = _ghostMat; - mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; - mr.receiveShadows = false; + _ghostMf = _ghost.GetComponent(); + _cubeMesh = _ghostMf.sharedMesh; + _ghostType = 255; + _ghostMr = _ghost.GetComponent(); + _ghostMr.sharedMaterial = _ghostMat; + _ghostMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + _ghostMr.receiveShadows = false; _ghost.SetActive(false); } @@ -270,12 +293,6 @@ namespace ProjectM.Client EntityManager.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ, Direction = direction }); EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection }); } - - void SendUpgrade(Entity connection) - { - var e = EntityManager.CreateEntity(); - EntityManager.AddComponentData(e, new AbilityUpgradeRequest()); - EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection }); - } + // (Step 11: the Aether ability-upgrade sender was RETIRED with AbilityUpgradeRequest — boons replaced it.) } } diff --git a/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs b/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs new file mode 100644 index 000000000..f92747cdd --- /dev/null +++ b/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs @@ -0,0 +1,49 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-side boon-pick sender: a static enqueue (the Step-14 3-card modal / execute_code) drained into + /// RPCs. Carries only the option INDEX — the server resolves it against the + /// sender's own authoritative BoonOffer and validates lifecycle/pending, so a stale or forged pick is + /// simply dropped. Statics reset on play-enter (the stale-bridge hazard). + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + public partial class BoonSendSystem : SystemBase + { + static int s_Pending; + static byte s_PendingIndex; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() + { + s_Pending = 0; + s_PendingIndex = 0; + } + + /// Queue a boon pick (0/1/2). The HUD card click + execute_code drive this. + public static void PickBoon(byte optionIndex) + { + s_PendingIndex = optionIndex; + s_Pending++; + } + + protected override void OnCreate() + { + RequireForUpdate(); + } + + protected override void OnUpdate() + { + while (s_Pending > 0) + { + s_Pending--; + var req = EntityManager.CreateEntity(typeof(BoonPickRequest), typeof(SendRpcCommandRequest)); + EntityManager.SetComponentData(req, new BoonPickRequest { Index = s_PendingIndex }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs.meta b/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs.meta new file mode 100644 index 000000000..ad7427a4f --- /dev/null +++ b/Assets/_Project/Scripts/Client/Combat/BoonSendSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d7e90bc7230614845817b81f0f7b70f0 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Meta.meta b/Assets/_Project/Scripts/Client/Meta.meta new file mode 100644 index 000000000..2e19226be --- /dev/null +++ b/Assets/_Project/Scripts/Client/Meta.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 60aece22e94371c468102dbf35c3fe47 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs b/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs new file mode 100644 index 000000000..37262116f --- /dev/null +++ b/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-side meta-purchase sender: a static enqueue (the Step-14 base meta-shop panel / execute_code) drained + /// into RPCs. Carries only the upgrade ID — the tier is server-computed and the + /// server re-validates everything (Staging gate, class mask, MaxTier, prereq, Aether affordability), so a stale + /// or forged request is simply dropped. A real QUEUE (unlike the single-slot boon pick) — two rapid clicks on + /// two different shop rows must both arrive. Statics reset on play-enter (the stale-bridge hazard). + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + public partial class MetaSpendSendSystem : SystemBase + { + static readonly Queue s_Queue = new(); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() => s_Queue.Clear(); + + /// Queue a permanent-upgrade purchase by catalog id. The shop row click + execute_code drive this. + public static void RequestPurchase(byte upgradeId) => s_Queue.Enqueue(upgradeId); + + protected override void OnCreate() + { + RequireForUpdate(); + } + + protected override void OnUpdate() + { + while (s_Queue.Count > 0) + { + var req = EntityManager.CreateEntity(typeof(MetaSpendRequest), typeof(SendRpcCommandRequest)); + EntityManager.SetComponentData(req, new MetaSpendRequest { UpgradeId = s_Queue.Dequeue() }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs.meta b/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs.meta new file mode 100644 index 000000000..06dc91328 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Meta/MetaSpendSendSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f3197b4ede6dea41a6c4c93ffa51b42 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs index 494d744ff..3623b2a22 100644 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs +++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs @@ -102,9 +102,9 @@ namespace ProjectM.Client case Mine: return attack + " — Attack the glowing Ore to mine it"; case Build: return build + " — open Build, then place a Turret by your Core"; case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)"; - case Gate: return "Reach the Expedition Gate — clearing it charges the Engine"; - case Clear: return "Clear the zone — defeat every enemy"; - case Return: return "Return through the gate — bank your clear (+1 Engine charge)"; + case Gate: return "Press T to READY UP — when everyone is ready, the run launches"; + case Clear: return "Clear each room — pick a boon, choose your path, reach the boss"; + case Return: return "Fell the boss — you return home and the Engine charges (+1)";; case Defend: return "Defend the Core! — hold the line through the siege"; case Done: return "You've got it. Clear expeditions to fill the Engine and win."; default: return ""; diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs index 2dc64f077..a89e6a0f0 100644 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs +++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs @@ -215,13 +215,9 @@ namespace ProjectM.Client } return found; } - // base gate (go) lives in the base region; expedition gate (return) lives past the region split. - bool wantBase = kind == OnboardingStepMath.PointerBaseGate; - foreach (var lt in SystemAPI.Query>().WithAll()) - { - var p = lt.ValueRO.Position; - if ((p.x < ExpeditionRegionXMin) == wantBase) { target = p; return true; } - } + // Step 11: the walk-in ExpeditionGate was RETIRED (the ready-check launches runs), so gate pointers + // have no world target — hide the arrow; the step text still teaches. Step 14's HUD pass re-points + // onboarding at the READY panel instead. return false; } diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 5850181d3..9e0b1564f 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -68,8 +68,20 @@ namespace ProjectM.Client // END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side). VisualElement _runBanner; Label _runBannerText, _runBannerSub; - // DR-042 C6a: the Aether ability-upgrade button (was U-key only) + its live affordability tint. - Button _upgradeBtn; + // Step 14 (expedition redesign): the choice-of-3 boon modal + the route-choice panel. Both are + // observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks + // enqueue through the client send-systems' statics. Built lazily on first show. + VisualElement _boonModal, _boonCardRow; + VisualElement _routePanel, _routeBtnRow; + Label _routeTitle; + byte _boonShownFor; // last (Option0^Option1^Option2 ^ room) signature the modal was built for + bool _boonModalBuilt, _routePanelBuilt; + // Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether; + // row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything). + VisualElement _metaPanel, _metaRowsHost; + Label _metaShopTitle; + bool _metaShopBuilt; + int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for readonly List _pips = new(); @@ -171,41 +183,97 @@ namespace ProjectM.Client _cycleText.text = ""; } - // ---- Location + gate hint (banner sub-line) ---- - var cam = Camera.main; + // ---- Location line (banner sub-line) — Step 14: driven by the replicated RunInfo lifecycle FSM ---- + // (the old camera-X + walk-in-gate copy died with the gate; siege/final overrides below still win). + var cam = Camera.main; // camera-X region signal still feeds downstream panels (atmosphere/threat) bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin; - _locationText.text = onExpedition - ? "ON EXPEDITION - carve the frontier, then return" - : finalSiege - ? "FINAL SIEGE - hold the Engine, this is the last stand" - : siege - ? "DEFEND THE BASE - hold the line" - : "MINE THE CRYSTALS - any attack harvests Ore, then BUILD"; - _locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) - : finalSiege ? new Color(1f, 0.3f, 0.25f) - : siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f); - // DR-042 C7 (gate prompt) + C7b (objective readout): the expedition is the win-driver, so signpost it. - // Reads the REPLICATED ExpeditionObjective summary (cross-region safe). Lower priority than the siege / - // cold-turret / overrun overrides below, which still win. - if (SystemAPI.TryGetSingleton(out var obj)) + bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); + SystemAPI.TryGetSingleton(out var obj); + if (haveRun && !siege && !finalSiege) { - if (onExpedition) + switch (runInfo.Lifecycle) { - if (obj.State == ExpeditionObjectiveState.Cleared) - { _locationText.text = "ZONE CLEARED - return to base to claim"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); } - else if (obj.State == ExpeditionObjectiveState.Active) - { _locationText.text = "CLEAR THE ZONE - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); } - } - else if (!siege) - { - if (obj.State == ExpeditionObjectiveState.Cleared) - { _locationText.text = "EXPEDITION CLEARED - return to claim your reward"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); } - else if (obj.State == ExpeditionObjectiveState.Active) - { _locationText.text = "EXPEDITION IN PROGRESS - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); } - else - { _locationText.text = "GO TO THE EXPEDITION GATE - clear a sortie to advance the Engine"; _locationText.style.color = new Color(0.55f, 0.85f, 1f); } + case RunLifecycle.Staging: + { + // Client counts the party's replicated ready flags (send-to-all) for the N/M readout. + int total = 0, readyCount = 0; + foreach (var pr in SystemAPI.Query>().WithAll()) + { + total++; + if (pr.ValueRO.Value != 0) readyCount++; + } + _locationText.text = "READY UP [T] - " + readyCount + "/" + Mathf.Max(total, 1) + + " ready - launch a run to advance the Engine"; + _locationText.style.color = new Color(0.55f, 0.85f, 1f); + break; + } + case RunLifecycle.Launching: + { + uint nowTick = SystemAPI.TryGetSingleton(out var ntime) && ntime.ServerTick.IsValid + ? ntime.ServerTick.TickIndexForValidTick : 0u; + int secs = runInfo.LaunchTick != 0 && nowTick != 0 + ? Mathf.Max(0, (int)((runInfo.LaunchTick - nowTick) / 60u) + 1) : 0; + _locationText.text = "LAUNCHING IN " + secs + " - un-ready [T] to abort"; + _locationText.style.color = new Color(1f, 0.9f, 0.4f); + break; + } + case RunLifecycle.InRoom: + { + string room = "ROOM " + (runInfo.CurrentRoom + 1) + "/" + runInfo.RoomCount + + " " + RoomTypeLabel(runInfo.CurrentRoomType); + _locationText.text = obj.State == ExpeditionObjectiveState.Active + ? room + " - " + obj.Remaining + " enemies remaining" + : room + " - clear it to advance"; + _locationText.style.color = new Color(1f, 0.8f, 0.4f); + break; + } + case RunLifecycle.RoomReward: + _locationText.text = "ROOM CLEARED - choose your boon"; + _locationText.style.color = new Color(0.5f, 1f, 0.6f); + break; + case RunLifecycle.RouteSelect: + _locationText.text = "CHOOSE YOUR PATH"; + _locationText.style.color = new Color(0.55f, 0.85f, 1f); + break; + case RunLifecycle.Returning: + _locationText.text = "RETURNING HOME..."; + _locationText.style.color = new Color(0.7f, 0.9f, 1f); + break; } } + else if (!haveRun) + { + _locationText.text = finalSiege + ? "FINAL SIEGE - hold the Engine, this is the last stand" + : siege ? "DEFEND THE BASE - hold the line" + : "MINE THE CRYSTALS - any attack harvests Ore, then BUILD"; + _locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f) + : siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f); + } + else + { + _locationText.text = finalSiege + ? "FINAL SIEGE - hold the Engine, this is the last stand" + : "DEFEND THE BASE - hold the line"; + _locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f) : new Color(1f, 0.55f, 0.4f); + } + + + // ---- Step 14: the choice-of-3 boon modal + the route-choice panel (observe replicated state; the + // card/button clicks enqueue through the client send-systems' statics) ---- + BoonOffer localOffer = default; + bool hasOffer = false; + foreach (var off in SystemAPI.Query>().WithAll()) + { + localOffer = off.ValueRO; + hasOffer = true; + break; + } + BlobAssetReference boonPool = default; + if (SystemAPI.TryGetSingleton(out var bcat)) + boonPool = bcat.Value; + UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1, boonPool); + UpdateRoutePanel(haveRun ? runInfo : default); // ---- Goal (hex-pip meter, or a continuous bar for large targets) ---- if (SystemAPI.TryGetSingleton(out var goal)) @@ -255,9 +323,31 @@ namespace ProjectM.Client _oreNum.text = ore.ToString(); _bioNum.text = bio.ToString(); _chargeNum.text = charge.ToString(); + + // ---- Step 14 (meta shop): Staging-only permanent-upgrade shop for the LOCAL class. Class derives from + // the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only); tiers from the + // replicated MetaTierState record on the director ghost; Aether from the ledger read above. ---- + byte localClass = ClassTraits.WarriorClass; + bool haveLocalPlayer = false; + foreach (var ar in SystemAPI.Query>().WithAll()) + { + localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id); + haveLocalPlayer = true; + break; + } + bool metaShow = false; + BlobAssetReference metaPool = default; + DynamicBuffer metaRecord = default; + if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege + && SystemAPI.TryGetSingleton(out var metaCat) && metaCat.Value.IsCreated + && SystemAPI.TryGetSingletonBuffer(out metaRecord, true)) + { + metaPool = metaCat.Value; + metaShow = true; + } + UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord); // DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const). - if (_upgradeBtn != null) - _upgradeBtn.style.opacity = aether >= Tuning.AbilityUpgradeCostAmount ? 1f : 0.5f; + // (Step 11: upgrade-button affordability tint retired with the button.) // EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one // broken turret): a dry base during a siege tells the player to build a Fabricator. if (siege && charge == 0 && !onExpedition) @@ -848,9 +938,8 @@ namespace ProjectM.Client strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon) // DR-042 C6a: the only Aether sink (ability-damage upgrade) gets a visible, clickable button (was U-key // only). The Button element handles its own picking even though the HUD root Ignores clicks. - _upgradeBtn = MenuUi.Button("UPGRADE DMG (" + Tuning.AbilityUpgradeCostAmount + " AETHER)", BuildSendSystem.UpgradeAbility); - _upgradeBtn.style.marginLeft = 18; - strip.Add(_upgradeBtn); + // (Step 11: the Aether UPGRADE-DMG button was RETIRED with AbilityUpgradeRequest — the choice-of-3 + // boon modal + the base meta-shop (Step 14) replace it.) root.Add(strip); } @@ -1138,5 +1227,243 @@ namespace ProjectM.Client default: return "?"; } } + + // ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ==== + + byte _routeShownFor; // last (room ^ options) signature the route buttons were built for + + static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]" + : roomType == RoomTypeId.Elite ? "[ELITE]" + : roomType == RoomTypeId.Reward ? "[REWARD]" : "[COMBAT]"; + + void UpdateBoonModal(BoonOffer offer, bool show, BlobAssetReference pool) + { + if (!show || !pool.IsCreated) + { + if (_boonModal != null) _boonModal.style.display = DisplayStyle.None; + _boonShownFor = 0; + return; + } + var root = _doc != null ? _doc.rootVisualElement : null; + if (root == null) return; + if (!_boonModalBuilt) + { + BuildBoonModal(root); + _boonModalBuilt = true; + } + + // Rebuild the three cards only when the offer actually changes (a new room's deal). + byte sig = (byte)(offer.Option0 ^ (offer.Option1 * 3) ^ (offer.Option2 * 7)); + if (sig == 0) sig = 1; + if (_boonShownFor != sig) + { + _boonCardRow.Clear(); + ref var defs = ref pool.Value; + for (byte k = 0; k < 3; k++) + { + byte id = k == 2 ? offer.Option2 : k == 1 ? offer.Option1 : offer.Option0; + int idx = BoonMath.FindDef(ref defs, id); + string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id); + string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : ""; + byte pick = k; // capture a COPY into the closure, never the loop variable + var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick)); + card.style.width = 200; + card.style.height = 96; + card.style.marginLeft = 8; + card.style.marginRight = 8; + card.style.whiteSpace = WhiteSpace.Normal; + _boonCardRow.Add(card); + } + _boonShownFor = sig; + } + _boonModal.style.display = DisplayStyle.Flex; + } + + void BuildBoonModal(VisualElement root) + { + _boonModal = new VisualElement { pickingMode = PickingMode.Ignore }; + _boonModal.style.position = Position.Absolute; + _boonModal.style.left = 0; _boonModal.style.right = 0; + _boonModal.style.top = 0; _boonModal.style.bottom = 0; + _boonModal.style.alignItems = Align.Center; + _boonModal.style.justifyContent = Justify.Center; + _boonModal.style.display = DisplayStyle.None; + + var box = new VisualElement(); + box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.96f); + box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; + box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; + box.style.paddingLeft = 18; box.style.paddingRight = 18; + box.style.paddingTop = 14; box.style.paddingBottom = 16; + box.style.alignItems = Align.Center; + + var title = new Label("ROOM CLEARED — CHOOSE A BOON"); + title.style.color = new Color(0.6f, 1f, 0.7f); + title.style.fontSize = 18; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + title.style.marginBottom = 12; + box.Add(title); + + _boonCardRow = new VisualElement(); + _boonCardRow.style.flexDirection = FlexDirection.Row; + box.Add(_boonCardRow); + + _boonModal.Add(box); + root.Add(_boonModal); + } + + void UpdateRoutePanel(RunInfo runInfo) + { + // Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion). + bool show = runInfo.Lifecycle == RunLifecycle.RouteSelect && runInfo.RouteOptionCount > 0; + if (!show) + { + if (_routePanel != null) _routePanel.style.display = DisplayStyle.None; + _routeShownFor = 0; + return; + } + var root = _doc != null ? _doc.rootVisualElement : null; + if (root == null) return; + if (!_routePanelBuilt) + { + BuildRoutePanel(root); + _routePanelBuilt = true; + } + + byte sig = (byte)((runInfo.CurrentRoom + 1) ^ (runInfo.RouteOpt0Type * 3) + ^ (runInfo.RouteOpt1Type * 5) ^ (runInfo.RouteOpt2Type * 7) ^ runInfo.RouteOptionCount); + if (sig == 0) sig = 1; + if (_routeShownFor != sig) + { + _routeBtnRow.Clear(); + for (byte k = 0; k < runInfo.RouteOptionCount && k < 3; k++) + { + byte type = k == 2 ? runInfo.RouteOpt2Type : k == 1 ? runInfo.RouteOpt1Type : runInfo.RouteOpt0Type; + byte pick = k; // closure copy + var b = MenuUi.Button("→ " + RoomTypeLabel(type), () => RouteSendSystem.PickRoute(pick)); + b.style.marginLeft = 6; + b.style.marginRight = 6; + _routeBtnRow.Add(b); + } + _routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount; + _routeShownFor = sig; + } + _routePanel.style.display = DisplayStyle.Flex; + } + + void BuildRoutePanel(VisualElement root) + { + _routePanel = new VisualElement { pickingMode = PickingMode.Ignore }; + _routePanel.style.position = Position.Absolute; + _routePanel.style.left = 0; _routePanel.style.right = 0; + _routePanel.style.bottom = 120; + _routePanel.style.alignItems = Align.Center; + _routePanel.style.display = DisplayStyle.None; + + var box = new VisualElement(); + box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.94f); + box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; + box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; + box.style.paddingLeft = 16; box.style.paddingRight = 16; + box.style.paddingTop = 10; box.style.paddingBottom = 12; + box.style.alignItems = Align.Center; + + _routeTitle = new Label("CHOOSE YOUR PATH"); + _routeTitle.style.color = new Color(0.55f, 0.85f, 1f); + _routeTitle.style.fontSize = 15; + _routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold; + _routeTitle.style.marginBottom = 8; + box.Add(_routeTitle); + + _routeBtnRow = new VisualElement(); + _routeBtnRow.style.flexDirection = FlexDirection.Row; + box.Add(_routeBtnRow); + + _routePanel.Add(box); + root.Add(_routePanel); + } + + void UpdateMetaShop(bool show, byte classId, int aether, + BlobAssetReference pool, DynamicBuffer record) + { + if (!show) + { + if (_metaPanel != null) _metaPanel.style.display = DisplayStyle.None; + _metaShownFor = 0; + return; + } + var root = _doc != null ? _doc.rootVisualElement : null; + if (root == null) return; + if (!_metaShopBuilt) + { + BuildMetaShop(root); + _metaShopBuilt = true; + } + + // Rebuild the rows only when class / owned tiers / affordability actually change (Staging-only, <=8 rows). + int sig = classId * 131 ^ aether * 31; + for (int i = 0; i < record.Length; i++) + sig ^= (record[i].ClassId * 7 + record[i].UpgradeId * 13 + record[i].Tier) * (i + 3); + if (sig == 0) sig = 1; + if (_metaShownFor != sig) + { + _metaRowsHost.Clear(); + _metaShopTitle.text = (classId == ClassTraits.RangerClass ? "RANGER" : "WARRIOR") + + " PERMANENT UPGRADES - AETHER " + aether; + ref var defs = ref pool.Value; + byte classBit = BoonMath.MaskFor(classId); + for (int d = 0; d < defs.Defs.Length; d++) + { + if ((defs.Defs[d].ClassMask & classBit) == 0) continue; + byte id = defs.Defs[d].Id; + byte owned = MetaMath.TierOf(record, classId, id); + bool maxed = owned >= defs.Defs[d].MaxTier; + int cost = MetaMath.CostForTier(in defs.Defs[d], owned); + string label = defs.Defs[d].Name.ToString() + " [" + owned + "/" + defs.Defs[d].MaxTier + "]" + + (maxed ? " MAXED" : " - " + cost + " Aether") + + "\n" + defs.Defs[d].Desc.ToString(); + byte buyId = id; // closure copy, never the loop variable + var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId)); + row.style.width = 290; + row.style.marginBottom = 4; + row.style.whiteSpace = WhiteSpace.Normal; + row.style.unityTextAlign = TextAnchor.MiddleLeft; + row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway + _metaRowsHost.Add(row); + } + _metaShownFor = sig; + } + _metaPanel.style.display = DisplayStyle.Flex; + } + + void BuildMetaShop(VisualElement root) + { + _metaPanel = new VisualElement { pickingMode = PickingMode.Ignore }; + _metaPanel.style.position = Position.Absolute; + _metaPanel.style.right = 12; + _metaPanel.style.top = Length.Percent(22); + _metaPanel.style.alignItems = Align.FlexEnd; + _metaPanel.style.display = DisplayStyle.None; + + var box = new VisualElement(); + box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); + box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; + box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; + box.style.paddingLeft = 12; box.style.paddingRight = 12; + box.style.paddingTop = 10; box.style.paddingBottom = 10; + + _metaShopTitle = new Label("PERMANENT UPGRADES"); + _metaShopTitle.style.color = AetherCyan; + _metaShopTitle.style.fontSize = 14; + _metaShopTitle.style.unityFontStyleAndWeight = FontStyle.Bold; + _metaShopTitle.style.marginBottom = 8; + box.Add(_metaShopTitle); + + _metaRowsHost = new VisualElement(); + box.Add(_metaRowsHost); + + _metaPanel.Add(box); + root.Add(_metaPanel); + } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/WorldAtmosphereSystem.cs b/Assets/_Project/Scripts/Client/Presentation/WorldAtmosphereSystem.cs index daa0f3214..a69deb415 100644 --- a/Assets/_Project/Scripts/Client/Presentation/WorldAtmosphereSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/WorldAtmosphereSystem.cs @@ -37,6 +37,30 @@ namespace ProjectM.Client Color expFog = cfg != null ? cfg.ExpeditionFogColor : new Color(0.851f, 0.549f, 0.227f, 1f); float expDen = cfg != null ? cfg.ExpeditionFogDensity : 0.010f; Color expAmb = cfg != null ? cfg.ExpeditionAmbientSky : new Color(0.910f, 0.769f, 0.604f, 1f); + // Step 14 (expedition redesign): the EXPEDITION-side palette keys on the replicated per-room biome + // (RunInfo.CurrentBiome) so every room re-themes — the camera-X blend still owns the base↔run fade. + // Palettes are code consts (cosmetic; per-biome config knobs can layer on later without churn). + if (SystemAPI.TryGetSingleton(out var runInfo) + && runInfo.Lifecycle != ProjectM.Simulation.RunLifecycle.Staging) + { + switch (runInfo.CurrentBiome) + { + case ProjectM.Simulation.RoomBiomeId.Meadow: + expFog = new Color(0.62f, 0.82f, 0.62f, 1f); expDen = 0.008f; + expAmb = new Color(0.68f, 0.88f, 0.70f, 1f); + break; + case ProjectM.Simulation.RoomBiomeId.Cavern: + expFog = new Color(0.42f, 0.48f, 0.60f, 1f); expDen = 0.014f; + expAmb = new Color(0.50f, 0.56f, 0.72f, 1f); + break; + case ProjectM.Simulation.RoomBiomeId.Blight: + expFog = new Color(0.55f, 0.42f, 0.62f, 1f); expDen = 0.016f; + expAmb = new Color(0.62f, 0.50f, 0.70f, 1f); + break; + // Arid keeps the config/default orange above. + } + } + float x = _cam.transform.position.x; float t = Mathf.Clamp01((x - (boundary - half)) / (2f * half)); diff --git a/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs b/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs index c3bbaf37a..23f0b2372 100644 --- a/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs +++ b/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs @@ -94,7 +94,7 @@ namespace ProjectM.Client Body(c, "Turret (40 Ore) — auto-fires at enemies. Needs Charge as ammo."); Body(c, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing."); Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies."); - Body(c, "Aether — spend it on UPGRADE DMG to boost your damage."); + Body(c, "Aether — rare; fuels PERMANENT class upgrades at the base between runs."); Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it."); break; case 3: // Threats diff --git a/Assets/_Project/Scripts/Client/UI/HudTheme.cs b/Assets/_Project/Scripts/Client/UI/HudTheme.cs index cafb16892..12b54c93e 100644 --- a/Assets/_Project/Scripts/Client/UI/HudTheme.cs +++ b/Assets/_Project/Scripts/Client/UI/HudTheme.cs @@ -55,6 +55,11 @@ namespace ProjectM.Client public Sprite FabricatorIcon; public Sprite ConveyorIcon; + [Header("Build-ghost preview meshes (authored real-size, ground pivot — BuildSendSystem)")] + public Mesh TurretGhostMesh; + public Mesh WallGhostMesh; + public Mesh FabricatorGhostMesh; + [Header("Build-mode control glyphs")] public Sprite KbmPlace; // LMB public Sprite KbmCancel; // RMB @@ -91,6 +96,18 @@ namespace ProjectM.Client } } + /// Placement-ghost preview mesh for a byte (null → the cube fallback). + public Mesh StructureGhostMesh(byte type) + { + switch (type) + { + case StructureType.Turret: return TurretGhostMesh; + case StructureType.Wall: return WallGhostMesh; + case StructureType.Fabricator: return FabricatorGhostMesh; + default: return null; + } + } + // ---- cached SDF font definitions (one FontAsset per font, built once, reset per play session) ---- static FontAsset _displayFa, _bodyFa, _bodyLightFa; static bool _displayTried, _bodyTried, _bodyLightTried; diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs index 042261add..5f17104f5 100644 --- a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs +++ b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs @@ -120,7 +120,16 @@ namespace ProjectM.Client if (data == null) return; var em = server.EntityManager; var e = em.CreateEntity(); - em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, RunOutcome = (byte)data.RunOutcome, HasData = 1 }); + // v5->v6 migration (operator-approved): an old save's Charge counted boss-cleared runs (DR-042), so a + // missing RunsCompleted floors to it — the HUD never shows "Charge 3/4" beside "Runs completed: 0". + int runsCompleted = data.RunsCompleted > data.GoalCharge ? data.RunsCompleted : data.GoalCharge; + em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, RunOutcome = (byte)data.RunOutcome, RunsCompleted = runsCompleted, MaxDepthReached = data.MaxDepthReached, HasData = 1 }); + // v6: stage the meta tiers UNCONDITIONALLY (empty OK — the Bursted spawn system GetBuffers it in the + // HasData block; a conditional buffer would throw on any v<=5 Continue). Rows verbatim, no clamping. + var mbuf = em.AddBuffer(e); + if (data.MetaUpgrades != null) + foreach (var mrow in data.MetaUpgrades) + mbuf.Add(new PendingMetaRow { ClassId = mrow.ClassId, UpgradeId = mrow.UpgradeId, Tier = mrow.Tier }); var buf = em.AddBuffer(e); if (data.Ledger != null) foreach (var row in data.Ledger) @@ -167,6 +176,9 @@ namespace ProjectM.Client var st = tq.GetSingleton().ServerTick; if (st.IsValid) nowTick = st.TickIndexForValidTick; } + // v6: the permanent-meta slice via the ONE shared collector — omitting it HERE (the most common + // exit path) would silently WIPE all meta progression on quit (the meta review's top blocker). + MetaSaveScan.Collect(em, dir, out var metaRows, out var runsCompleted, out var maxDepth); SaveStructureScan.Collect(em, nowTick, out var structures, out var structureIo); SaveService.Save(new SaveData @@ -174,6 +186,9 @@ namespace ProjectM.Client GoalCharge = goal.Charge, GoalTarget = goal.Target, CoreCurrent = core.Current, + RunsCompleted = runsCompleted, + MaxDepthReached = maxDepth, + MetaUpgrades = metaRows, RunOutcome = outcome.Value, Ledger = rows, diff --git a/Assets/_Project/Scripts/Client/World.meta b/Assets/_Project/Scripts/Client/World.meta new file mode 100644 index 000000000..deeb6a039 --- /dev/null +++ b/Assets/_Project/Scripts/Client/World.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 23831c9502abad541a3b2a3dc65502c2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs b/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs new file mode 100644 index 000000000..b37857ecd --- /dev/null +++ b/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs @@ -0,0 +1,59 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-side ready-toggle sender: a static enqueue (HUD button at Step 14 / the T dev key / execute_code) + /// drained into RPC entities — the BuildSendSystem queue+drain idiom. The local + /// bool tracks only the toggle DIRECTION; the server-replicated is the truth the HUD + /// renders. Statics reset on play-enter (statics survive fast-enter-playmode reloads — the stale-bridge hazard). + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + public partial class ReadySendSystem : SystemBase + { + static int s_Pending; // queued explicit sets + static byte s_PendingValue; + static bool s_LocalReady; // last requested state (toggle direction only, not authority) + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() + { + s_Pending = 0; + s_PendingValue = 0; + s_LocalReady = false; + } + + /// Queue an explicit ready set (HUD button / execute_code). + public static void SetReady(bool ready) + { + s_PendingValue = (byte)(ready ? 1 : 0); + s_Pending++; + s_LocalReady = ready; + } + + /// Queue a toggle of the last requested state (the T dev key; HUD replaces this at Step 14). + public static void ToggleReady() => SetReady(!s_LocalReady); + + protected override void OnCreate() + { + RequireForUpdate(); + } + + protected override void OnUpdate() + { + var keyboard = UnityEngine.InputSystem.Keyboard.current; + if (keyboard != null && keyboard.tKey.wasPressedThisFrame && !PauseMenuController.Open) + ToggleReady(); + + while (s_Pending > 0) + { + s_Pending--; + var req = EntityManager.CreateEntity(typeof(ReadyToggleRequest), typeof(SendRpcCommandRequest)); + EntityManager.SetComponentData(req, new ReadyToggleRequest { Ready = s_PendingValue }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs.meta b/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs.meta new file mode 100644 index 000000000..47b3260f2 --- /dev/null +++ b/Assets/_Project/Scripts/Client/World/ReadySendSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7047cb8f6861ba8498f33698c9948dc8 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs b/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs new file mode 100644 index 000000000..3bcf2ae35 --- /dev/null +++ b/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs @@ -0,0 +1,60 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-side route-pick sender: a static enqueue (the Step-14 map panel's option buttons / execute_code) + /// drained into RPCs. The request is stamped from the CLIENT's replicated + /// : ForRunEpoch = (int)RunSeed (the re-meaned run-identity token — the server-only + /// RunEpoch is not client-knowable) and ForLayer = CurrentRoom VERBATIM (during a gate that is still the + /// just-cleared layer — never +1). The server re-validates everything; a stale/possessed pick is simply dropped. + /// Statics reset on play-enter (the stale-bridge hazard). + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + public partial class RouteSendSystem : SystemBase + { + static int s_Pending; + static byte s_PendingIndex; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() + { + s_Pending = 0; + s_PendingIndex = 0; + } + + /// Queue a route pick (0..RouteOptionCount-1). HUD map panel + execute_code drive this. + public static void PickRoute(byte optionIndex) + { + s_PendingIndex = optionIndex; + s_Pending++; + } + + protected override void OnCreate() + { + RequireForUpdate(); + RequireForUpdate(); + } + + protected override void OnUpdate() + { + if (s_Pending == 0) + return; + var runInfo = SystemAPI.GetSingleton(); + while (s_Pending > 0) + { + s_Pending--; + var req = EntityManager.CreateEntity(typeof(RouteSelectRequest), typeof(SendRpcCommandRequest)); + EntityManager.SetComponentData(req, new RouteSelectRequest + { + OptionIndex = s_PendingIndex, + ForRunEpoch = (int)runInfo.RunSeed, // the re-meaned replicated run token + ForLayer = runInfo.CurrentRoom, // the gate's un-incremented cleared layer + }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs.meta b/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs.meta new file mode 100644 index 000000000..f0b761e07 --- /dev/null +++ b/Assets/_Project/Scripts/Client/World/RouteSendSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c22921cccedc3564b86d12491d88488e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Building/AbilityUpgradeSystem.cs b/Assets/_Project/Scripts/Server/Building/AbilityUpgradeSystem.cs deleted file mode 100644 index 72a116c33..000000000 --- a/Assets/_Project/Scripts/Server/Building/AbilityUpgradeSystem.cs +++ /dev/null @@ -1,92 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server-authoritative ability-damage upgrade (handles RPCs). Resolves - /// the sender's player (SourceConnection -> NetworkId -> GhostOwner) and, if the global ledger affords the - /// Aether cost, withdraws it IN-PLACE and grows a single damage on the player - /// (replace-by-SourceId so the [InternalBufferCapacity(8)] buffer stays bounded — repeated upgrades grow one - /// row's percent rather than appending). StatRecomputeSystem folds it into EffectiveAbilityStats.Damage on - /// both worlds. Plain server SimulationSystemGroup (not predicted → applied once). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - public partial struct AbilityUpgradeSystem : ISystem - { - const uint UpgradeSourceId = Tuning.AbilityUpgradeSourceId; // distinct sentinel so the upgrade modifier is found + grown - const float TierStep = Tuning.AbilityUpgradeTierStep; // +25% damage per tier - const int CostAmount = Tuning.AbilityUpgradeCostAmount; // Aether per tier - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll(); - state.RequireForUpdate(state.GetEntityQuery(builder)); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var ledger = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); - - var playerByConn = new NativeHashMap(8, Allocator.Temp); - foreach (var (owner, entity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - playerByConn[owner.ValueRO.NetworkId] = entity; - - var ecb = new EntityCommandBuffer(Allocator.Temp); - - foreach (var (receive, requestEntity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - { - var conn = receive.ValueRO.SourceConnection; - if (SystemAPI.HasComponent(conn) - && playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var player)) - { - int have = 0; - for (int i = 0; i < ledger.Length; i++) - if (ledger[i].ItemId == ResourceId.Aether) { have = ledger[i].Count; break; } - - if (have >= CostAmount) - { - StorageMath.Withdraw(ledger, ResourceId.Aether, CostAmount); - - var mods = SystemAPI.GetBuffer(player); - bool grown = false; - for (int i = 0; i < mods.Length; i++) - { - if (mods[i].SourceId == UpgradeSourceId && mods[i].Target == (byte)StatTarget.Damage) - { - var m = mods[i]; - m.Value += TierStep; - mods[i] = m; - grown = true; - break; - } - } - if (!grown) - mods.Add(new StatModifier - { - Target = (byte)StatTarget.Damage, - Op = (byte)ModOp.PercentAdd, - Value = TierStep, - SourceId = UpgradeSourceId, - }); - } - } - - ecb.DestroyEntity(requestEntity); - } - - ecb.Playback(state.EntityManager); - playerByConn.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Building/AbilityUpgradeSystem.cs.meta b/Assets/_Project/Scripts/Server/Building/AbilityUpgradeSystem.cs.meta deleted file mode 100644 index 90855d758..000000000 --- a/Assets/_Project/Scripts/Server/Building/AbilityUpgradeSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ff2ed6b5fa37a174aa7413f4d2f5d6b3 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs b/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs new file mode 100644 index 000000000..adaba71d6 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs @@ -0,0 +1,133 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server receiver for + the reward-grace AUTO-PICK backstop. A valid pick + /// (sender resolved, RunInfo.Lifecycle == RoomReward — the D-F4 gate — Pending == 1, index in + /// range, option id known to the catalog) appends ONE in the run-scoped BOON band + /// (Tuning.BoonSourceIdBase + BoonPickCounter++ — distinct rows, one range-strip clears the run) and + /// clears Pending; the buffer mutation is non-structural and folds through the unchanged + /// StatRecomputeSystem on both worlds (rollback-correct). When the reward grace elapses, every still-pending + /// EXPEDITION player is auto-dealt Option0 (the operator's default un-picked policy — a player always + /// gets something) so the run never stalls on an AFK picker. + /// + /// Ordering: [UpdateBefore(RunDirectorSystem)] — ALL RPC receivers sit before the director (the + /// ReadyToggle/RouteSelect symmetry). This closes the D-F4 straggler race STRUCTURALLY: on the tick the + /// director strips (Returning), a straggler pick is rejected here FIRST (lifecycle is already past RoomReward), + /// so nothing can append after the strip; and the auto-pick lands before the director's exit gate reads + /// Pending. Requests are ALWAYS destroyed. No CyclePhase edge (the room-chain hard rule). + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateBefore(typeof(RunDirectorSystem))] + public partial struct BoonApplySystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var dirEntity = SystemAPI.GetSingletonEntity(); + var info = SystemAPI.GetComponent(dirEntity); + var run = SystemAPI.GetComponent(dirEntity); + bool rewarding = info.Lifecycle == RunLifecycle.RoomReward; + + var catalog = SystemAPI.GetComponent(SystemAPI.GetSingletonEntity()); + if (!catalog.Value.IsCreated) + return; + ref var pool = ref catalog.Value.Value; + + bool runDirty = false; + + // ---- explicit picks (drained every tick so stale requests die even outside RoomReward) ---- + var playerByConn = new NativeHashMap(8, Allocator.Temp); + foreach (var (owner, entity) in + SystemAPI.Query>().WithAll().WithEntityAccess()) + playerByConn[owner.ValueRO.NetworkId] = entity; + + var ecb = new EntityCommandBuffer(Allocator.Temp); + foreach (var (receive, req, requestEntity) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + var conn = receive.ValueRO.SourceConnection; + if (rewarding + && req.ValueRO.Index < 3 + && SystemAPI.HasComponent(conn) + && playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var player)) + { + var offer = SystemAPI.GetComponent(player); + if (offer.Pending == 1) + { + byte id = req.ValueRO.Index == 2 ? offer.Option2 + : req.ValueRO.Index == 1 ? offer.Option1 : offer.Option0; + if (Apply(ref state, player, id, ref pool, ref run)) + { + offer.Pending = 0; + SystemAPI.SetComponent(player, offer); + runDirty = true; + } + } + } + ecb.DestroyEntity(requestEntity); + } + ecb.Playback(state.EntityManager); + playerByConn.Dispose(); + + // ---- reward-grace auto-pick backstop (Option0 — the player always gets something) ---- + if (rewarding && run.RewardGraceTick != 0u) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (serverTick.IsValid && !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick)) + { + foreach (var (offer, region, entity) in + SystemAPI.Query, RefRO>() + .WithAll().WithEntityAccess()) + { + if (offer.ValueRO.Pending != 1 || region.ValueRO.Region != RegionId.Expedition) + continue; + if (Apply(ref state, entity, offer.ValueRO.Option0, ref pool, ref run)) + runDirty = true; + offer.ValueRW.Pending = 0; // cleared even if the id was unknown — never wedge the gate + } + } + } + + if (runDirty) + SystemAPI.SetComponent(dirEntity, run); // the documented BoonPickCounter co-write (band provenance) + } + + /// Append the boon's StatModifier in the run-scoped band. False iff the id is unknown/zero. + static bool Apply(ref SystemState state, Entity player, byte boonId, ref BoonCatalogBlob pool, ref RunRuntime run) + { + if (boonId == 0) + return false; + int idx = BoonMath.FindDef(ref pool, boonId); + if (idx < 0) + return false; // unknown id (catalog drift) — preserve-and-skip, never throw + + var mods = state.EntityManager.GetBuffer(player); + mods.Add(new StatModifier + { + Target = pool.Defs[idx].Target, + Op = pool.Defs[idx].Op, + Value = pool.Defs[idx].Value, + SourceId = Tuning.BoonSourceIdBase + (run.BoonPickCounter % Tuning.BoonSourceIdSpan), + }); + run.BoonPickCounter += 1; + return true; + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs.meta new file mode 100644 index 000000000..80a1a84dc --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5749745bedc86ca4396b9a3911ef8773 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs b/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs new file mode 100644 index 000000000..e5c56791b --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs @@ -0,0 +1,78 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server-only choice-of-3 boon dealer: once per (int-equality latch on + /// , attached beside the catalog singleton), when the run FSM enters RoomReward it + /// draws each EXPEDITION player's 3 distinct, rarity-weighted, class-filtered options via + /// — deterministically seeded from Hash(RunSeed, room, NetworkId) — and writes + /// the player's owner-only replicated (Pending=1). A base-region player (dead-respawned, + /// late joiner) gets NO offer and never holds the gate (RunDirector counts only Pending!=0). BoonApplySystem + /// (Step 10) consumes picks; the Returning-edge strip zeroes stragglers. + /// + /// Ordering: [UpdateAfter(RunDirectorSystem)] — on the RoomReward ENTRY tick this runs after the + /// transition, so offers exist BEFORE RunDirector's exit gate first evaluates (next tick). No CyclePhase edge. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateAfter(typeof(RunDirectorSystem))] + public partial struct BoonOfferSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var catalogEntity = SystemAPI.GetSingletonEntity(); + + // One-shot: attach this system's latch beside the catalog singleton (the RoomFieldState idiom). + if (!SystemAPI.HasComponent(catalogEntity)) + { + state.EntityManager.AddComponentData(catalogEntity, new BoonOfferState()); + return; // structural change — clean re-read next tick + } + + var dirEntity = SystemAPI.GetSingletonEntity(); + var info = SystemAPI.GetComponent(dirEntity); + if (info.Lifecycle != RunLifecycle.RoomReward) + return; + + var run = SystemAPI.GetComponent(dirEntity); + var offered = SystemAPI.GetComponent(catalogEntity); + if (offered.OfferedRoomEpoch == run.RoomEpoch) + return; // this room's offers are already dealt + + var catalog = SystemAPI.GetComponent(catalogEntity); + if (!catalog.Value.IsCreated) + return; + ref var pool = ref catalog.Value.Value; + + foreach (var (offer, owner, region, cls) in + SystemAPI.Query, RefRO, RefRO, RefRO>() + .WithAll()) + { + if (region.ValueRO.Region != RegionId.Expedition) + continue; // home-bound players (dead-respawned, joiners) are dealt nothing + + // Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room). + uint offerSeed = RunMapMath.Hash(run.RunSeed, (uint)info.CurrentRoom, (uint)owner.ValueRO.NetworkId) | 1u; + BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, ref pool, out byte o0, out byte o1, out byte o2); + offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 }; + } + + offered.OfferedRoomEpoch = run.RoomEpoch; + SystemAPI.SetComponent(catalogEntity, offered); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs.meta new file mode 100644 index 000000000..aa8806809 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d3715c60d2cc2348ac4ff7600006d23 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs new file mode 100644 index 000000000..016bf1755 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs @@ -0,0 +1,192 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Server +{ + /// + /// Server-only per-ROOM enemy director — the Step-6 successor of the presence-keyed ZoneEnemyDirectorSystem. + /// While the run FSM has a room active ( == InRoom) it seeds ONE wave per + /// (int-equality reseed) sized by indexed + /// on the room's (deeper rooms + Elite/Boss types skew heavier — the + /// grounded MC-2 mix bands are reused verbatim), drip-spawned one SLOT per cadence at the deterministic ring + /// around (base, ActiveSubSlot), under the same + /// "spawn-the-pack-only-if-it-fits-else-wait" relevancy guard. A + /// room spawns ONE beefed boss instead (health × , + /// scale × — v1's boss is a scaled Charger). Every spawn keeps the full + /// stack — EnemyTag + RegionTag{Expedition} + — PLUS {room} (the + /// teardown contract). Scale preserved via baked.WithPosition. + /// + /// The room CLEAR edge surfaces ONLY through the replicated .State == Cleared + /// (wave fully spawned AND zero alive, latched per seeded epoch) — written FIRST, ABOVE every early-return + /// (snapshot-above-early-return) so the HUD never freezes; RunDirectorSystem consumes it one-tick-late (Step 7). + /// The old CycleRuntime.ClearedThisEpoch write is gone (the C4 collapse), and the old base-siege Calm gate is + /// deliberately DROPPED — a home retaliation siege no longer freezes a live sortie (the DR-042 latent gap). + /// + /// Ordering: [UpdateAfter(RunDirectorSystem)] ONLY — reads the freshly-advanced room state same-tick. + /// NO CyclePhase edge may ever return to the room chain (Play-only sort-cycle, invisible to EditMode). + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateAfter(typeof(RunDirectorSystem))] + public partial struct RoomEnemyDirectorSystem : ISystem + { + EntityQuery m_ZoneEnemies; + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly()); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (!serverTick.IsValid) + return; + uint now = serverTick.TickIndexForValidTick; + + var runEntity = SystemAPI.GetSingletonEntity(); + var info = SystemAPI.GetComponent(runEntity); + var run = SystemAPI.GetComponent(runEntity); + bool roomActive = info.Lifecycle == RunLifecycle.InRoom; + + var directorEntity = SystemAPI.GetSingletonEntity(); + var dir = SystemAPI.GetComponent(directorEntity); + var zs = SystemAPI.GetComponent(directorEntity); + + int aliveZone = m_ZoneEnemies.CalculateEntityCount(); + + // REPLICATED objective summary FIRST, above every early-return (snapshot-above-early-return): the HUD + // readout must never freeze stale. Cleared latches only for a wave seeded FOR THIS RoomEpoch. + if (SystemAPI.HasComponent(runEntity)) + { + byte objState; + short objRemaining; + if (roomActive && (aliveZone > 0 || zs.RemainingToSpawn > 0)) + { + objState = ExpeditionObjectiveState.Active; + objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue); + } + else if (roomActive && zs.SeededEpoch == run.RoomEpoch && zs.RemainingToSpawn == 0 && aliveZone == 0) + { + objState = ExpeditionObjectiveState.Cleared; // fully spawned + fully dead -> advance-ready + objRemaining = 0; + } + else + { + objState = ExpeditionObjectiveState.Idle; + objRemaining = 0; + } + SystemAPI.SetComponent(runEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining }); + } + + if (!roomActive) + return; + + var prefabs = SystemAPI.GetBuffer(directorEntity); + if (prefabs.Length == 0) + return; + + // Single plan authority: the node RunDirector published — never re-derived here. + var map = RunMapMath.Generate(run.RunSeed); + var node = map.NodeAt(run.CurrentNodeId); + var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount); + byte room = (byte)(info.CurrentRoom & 0xFF); + bool bossRoom = plan.RoomType == RoomTypeId.Boss; + + var bands = new MixBands + { + GruntBase = dir.GruntsPerWave, + ChargerBase = dir.ChargersPerWave, + SpitterBase = dir.SpitterBase, + SwarmerSlotBase = dir.SwarmerSlotBase, + ChargerPerEpoch = dir.ChargerPerEpoch, + SpitterPerEpoch = dir.SpitterPerEpoch, + SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch, + SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch, + }; + + // (Re)seed once per ROOM (its OWN counter, in SLOTS; a swarmer slot is one pack; a boss room is 1 slot). + if (zs.SeededEpoch != run.RoomEpoch) + { + zs.SeededEpoch = run.RoomEpoch; + zs.SpawnCounter = 0; + zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands); + zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick + } + + if (zs.RemainingToSpawn > 0) + { + bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick); + if (dueNow) + { + int slot = (int)zs.SpawnCounter; + byte kind = bossRoom ? ZoneEnemyMath.KindCharger + : ZoneEnemyMath.KindForSlot(plan.DifficultyEpoch, slot, bands); + int packSize = !bossRoom && kind == ZoneEnemyMath.KindSwarmer + ? ZoneEnemyMath.PackSizeForSlot(plan.DifficultyEpoch, slot, bands, dir.SwarmerPackSize) : 1; + + // MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — keep the slot). + if (aliveZone + packSize <= math.max(1, dir.MaxAlive)) + { + float3 baseCenter = new float3(0f, 1f, 0f); + if (SystemAPI.TryGetSingleton(out var anchor)) + baseCenter = BaseGridMath.PlotCenter(anchor); + float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); + float3 center = bossRoom + ? origin // the boss anchors the room center + : EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius); + center.y = origin.y; + + int prefabIdx = kind; + if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively + var prefab = prefabs[prefabIdx].Prefab; + var baked = state.EntityManager.GetComponentData(prefab); + + var ecb = new EntityCommandBuffer(Allocator.Temp); + for (int k = 0; k < packSize; k++) + { + float3 pos = packSize > 1 + ? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center; + pos.y = origin.y; + var enemy = ecb.Instantiate(prefab); + var xform = baked.WithPosition(pos); // preserve the baked [GhostField] Scale + if (bossRoom) + xform.Scale = baked.Scale * Tuning.BossScaleMultiplier; + ecb.SetComponent(enemy, xform); + ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition }); + ecb.AddComponent(enemy); + ecb.AddComponent(enemy, new RoomTag { Room = room }); + if (bossRoom && SystemAPI.HasComponent(prefab)) + { + var hp = SystemAPI.GetComponent(prefab); + hp.Current *= Tuning.BossHealthMultiplier; + hp.Max *= Tuning.BossHealthMultiplier; + ecb.SetComponent(enemy, hp); + } + } + ecb.Playback(state.EntityManager); + ecb.Dispose(); + + zs.SpawnCounter += 1; // ONE slot consumed even for a pack + zs.RemainingToSpawn -= 1; + zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks)); + } + } + } + + SystemAPI.SetComponent(directorEntity, zs); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs.meta new file mode 100644 index 000000000..263acac0f --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3204b510b450f384a93bd49902c65721 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs b/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs deleted file mode 100644 index 9e51a31a3..000000000 --- a/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs +++ /dev/null @@ -1,189 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// Server-only expedition zone-enemy director: while a player is OUT in the expedition region and the base is in - /// , it seeds and drip-spawns one epoch-seeded combat wave around the expedition - /// origin. The wave size + grunt/charger composition is pure of the - /// (grunt-heavy -> charger-heavy as the epoch climbs), spawned one - /// every at a deterministic ring, capped at - /// concurrent (the v1 ghost-relevancy budget). Each enemy is the - /// existing Husk ghost prefab + RegionTag{Expedition} + , so it reuses the whole - /// combat/readability/AI stack (the per-region AI filter keeps it seeking the expedition player only). When the - /// wave is fully spawned and every zone enemy is dead, it marks once — - /// the gate's once-per-epoch Ore reward reads that on the player's return. - /// - /// DR-042 C7b: it ALSO writes the replicated summary every tick, ABOVE the - /// presence early-return (snapshot-above-early-return), so the client HUD's "enemies remaining / cleared" readout - /// never freezes stale even when nobody is out. - /// - /// Ordering: [UpdateAfter(ExpeditionFieldSystem)] ONLY. ExpeditionFieldSystem is itself - /// [UpdateAfter(CyclePhaseSystem)], so ALSO declaring [UpdateBefore(CyclePhaseSystem)] here (as the - /// v1 plan first sketched) would close a CyclePhase->Field->Zone->CyclePhase sort cycle that throws at Play - /// world creation and is invisible to EditMode. Running after the field manager also reads the freshly-bumped - /// epoch + current phase. Zone enemies are interpolated ghosts, moved server-only — no prediction. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(ExpeditionFieldSystem))] - public partial struct ZoneEnemyDirectorSystem : ISystem - { - EntityQuery m_ZoneEnemies; - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly()); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) - return; - uint now = serverTick.TickIndexForValidTick; - - // Per-player presence: the SPAWNER only runs while someone is OUT in the expedition (mirrors - // ExpeditionFieldSystem). The objective readout below is written FIRST, every tick, even when nobody's out. - int expeditionPlayers = 0; - foreach (var region in SystemAPI.Query>().WithAll()) - if (region.ValueRO.Region == RegionId.Expedition) - expeditionPlayers++; - - var directorEntity = SystemAPI.GetSingletonEntity(); - var dir = SystemAPI.GetComponent(directorEntity); - var zs = SystemAPI.GetComponent(directorEntity); - - var cycleEntity = SystemAPI.GetSingletonEntity(); - var cycle = SystemAPI.GetComponent(cycleEntity); - var runtime = SystemAPI.GetComponent(cycleEntity); - int epoch = runtime.ExpeditionEpoch; - - int aliveZone = m_ZoneEnemies.CalculateEntityCount(); - - // DR-042 C7b: write the REPLICATED objective summary FIRST, above the early-returns (snapshot-above- - // early-return) so the HUD never freezes stale. Rides the untagged CycleDirector ghost (cross-region safe). - if (SystemAPI.HasComponent(cycleEntity)) - { - byte objState; - short objRemaining; - if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch) - { - objState = ExpeditionObjectiveState.Cleared; // cleared but not yet claimed -> "return to claim" - objRemaining = 0; - } - else if (expeditionPlayers > 0 && (aliveZone > 0 || zs.RemainingToSpawn > 0)) - { - objState = ExpeditionObjectiveState.Active; - objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue); - } - else - { - objState = ExpeditionObjectiveState.Idle; - objRemaining = 0; - } - SystemAPI.SetComponent(cycleEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining }); - } - - if (expeditionPlayers == 0) - return; // nobody out there: the field manager owns teardown, the spawner does nothing - - var prefabs = SystemAPI.GetBuffer(directorEntity); - if (prefabs.Length == 0) - return; - - // MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base - // siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts. - var bands = new MixBands - { - GruntBase = dir.GruntsPerWave, - ChargerBase = dir.ChargersPerWave, - SpitterBase = dir.SpitterBase, - SwarmerSlotBase = dir.SwarmerSlotBase, - ChargerPerEpoch = dir.ChargerPerEpoch, - SpitterPerEpoch = dir.SpitterPerEpoch, - SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch, - SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch, - }; - - // (Re)seed this epoch's wave once — its OWN counter (in SLOTS; a swarmer slot is one pack). - if (zs.SeededEpoch != epoch) - { - zs.SeededEpoch = epoch; - zs.SpawnCounter = 0; - zs.RemainingToSpawn = ZoneEnemyMath.WaveSlots(epoch, bands); - zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick - } - - if (zs.RemainingToSpawn > 0) - { - // Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap. - bool calm = cycle.Phase == CyclePhase.Calm; - bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick); - if (calm && dueNow) - { - int slot = (int)zs.SpawnCounter; - byte kind = ZoneEnemyMath.KindForSlot(epoch, slot, bands); - int packSize = kind == ZoneEnemyMath.KindSwarmer - ? ZoneEnemyMath.PackSizeForSlot(epoch, slot, bands, dir.SwarmerPackSize) : 1; - - // MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot). - if (aliveZone + packSize <= math.max(1, dir.MaxAlive)) - { - float3 baseCenter = new float3(0f, 1f, 0f); - if (SystemAPI.TryGetSingleton(out var anchor)) - baseCenter = BaseGridMath.PlotCenter(anchor); - float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter); - float3 center = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius); - center.y = origin.y; - - int prefabIdx = kind; - if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively - var prefab = prefabs[prefabIdx].Prefab; - // Preserve the prefab's baked Scale ([GhostField]) — FromPosition would reset Scale->1. - var baked = state.EntityManager.GetComponentData(prefab); - - var ecb = new EntityCommandBuffer(Allocator.Temp); - for (int k = 0; k < packSize; k++) - { - float3 pos = packSize > 1 - ? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center; - pos.y = origin.y; - var enemy = ecb.Instantiate(prefab); - ecb.SetComponent(enemy, baked.WithPosition(pos)); - ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition }); - ecb.AddComponent(enemy); - } - ecb.Playback(state.EntityManager); - ecb.Dispose(); - - zs.SpawnCounter += 1; // ONE slot consumed even for a pack - zs.RemainingToSpawn -= 1; - zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks)); - } - } - } - else if (aliveZone == 0 && runtime.ClearedThisEpoch == 0) - { - // Wave fully spawned AND every zone enemy dead -> a REAL clear. Mark once; the gate pays the - // once-per-epoch Ore reward on the player's return to base. - runtime.ClearedThisEpoch = 1; - SystemAPI.SetComponent(cycleEntity, runtime); - } - - SystemAPI.SetComponent(directorEntity, zs); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs.meta deleted file mode 100644 index 8b29587c0..000000000 --- a/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4dc121e99d0e53640b5e6815640a0bc6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs b/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs index fd6d554eb..55ff6c8aa 100644 --- a/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs +++ b/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs @@ -20,6 +20,8 @@ namespace ProjectM.Server [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] public partial struct GoInGameServerSystem : ISystem { + bool _warnedMetaBlocked; // one-shot: a mis-authored subscene must not silently block spawns forever + [BurstCompile] public void OnCreate(ref SystemState state) { @@ -33,6 +35,22 @@ namespace ProjectM.Server [BurstCompile] public void OnUpdate(ref SystemState state) { + // Step 12a availability guard (review N2/L1-6): the born-correct meta seeding below needs the baked + // catalog + the live director's tier record. Both are per-tick-uniform, so guard ONCE at the TOP, + // BEFORE the ECB exists — a per-request continue would already have marked the connection in-game. + // Nothing is consumed; RequireForUpdate re-passes and the request retries next tick (a ≤1-tick window + // in practice — the director spawns at subscene-stream, before any GoInGame round-trip). + if (!SystemAPI.TryGetSingleton(out var metaCatalog) || !metaCatalog.Value.IsCreated + || !SystemAPI.TryGetSingletonBuffer(out var metaRecord, true)) + { + if (!_warnedMetaBlocked) + { + UnityEngine.Debug.LogWarning("GoInGameServerSystem: player spawn waiting on the meta catalog/director (a mis-authored subscene would block spawns forever)."); + _warnedMetaBlocked = true; + } + return; + } + var spawner = SystemAPI.GetSingleton(); // M5 home base: re-root the spawn ring on the baked BaseAnchor when present; fall back @@ -61,6 +79,34 @@ namespace ProjectM.Server byte classId = ClassTraits.Normalize(goReq.ValueRO.ClassId); ecb.SetComponent(player, new AbilityRef { Id = ClassTraits.AbilityFor(classId) }); ClassTraits.AppendSeeds(classId, player, ecb); + // Expedition redesign: the server-only class anchor the meta systems key on (born-correct meta + // seeding at Step 12a + per-class spend at Step 13 resolve the tier record through this). + ecb.AddComponent(player, new PlayerClass { ClassId = classId }); + // Step 12a: born-correct PERMANENT meta seeding — replay this class's persisted tiers as + // meta-band StatModifiers on the just-instantiated player (same ECB as Instantiate, the + // ClassTraits idiom). Skip tier 0 / unknown ids (preserve-don't-crash); CLAMP a saved tier above a + // rebalanced MaxTier (D-F5). Class gate via BoonMath.MaskFor (ClassId is the normalized + // CharacterId 2/3 — a raw 1<(out var metaCat) && metaCat.Value.IsCreated + && SystemAPI.TryGetSingletonBuffer(out var metaRecord, true)) + { + ref var metaPool = ref metaCat.Value.Value; + byte metaBit = BoonMath.MaskFor(newClass); + for (int mi = 0; mi < metaRecord.Length; mi++) + { + if (metaRecord[mi].ClassId != newClass || metaRecord[mi].Tier == 0) continue; + int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[mi].UpgradeId); + if (defIdx < 0) continue; + if ((metaPool.Defs[defIdx].ClassMask & metaBit) == 0) continue; + byte metaTier = metaRecord[mi].Tier < metaPool.Defs[defIdx].MaxTier + ? metaRecord[mi].Tier : metaPool.Defs[defIdx].MaxTier; + classMods.Add(new StatModifier + { + Target = metaPool.Defs[defIdx].Target, + Op = metaPool.Defs[defIdx].Op, + Value = metaPool.Defs[defIdx].ValuePerTier * metaTier, + SourceId = Tuning.MetaSourceIdBase + metaRecord[mi].UpgradeId, + }); + } + } + if (SystemAPI.HasComponent(sender)) + SystemAPI.SetComponent(sender, new PlayerClass { ClassId = newClass }); + // Let the swapped Fire ability fire immediately (both abilities share one cooldown gate). if (SystemAPI.HasComponent(sender)) SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 }); // 0 = ready diff --git a/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs b/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs deleted file mode 100644 index d5d5f775d..000000000 --- a/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs +++ /dev/null @@ -1,101 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// Server-only home-base mining-field manager. Keeps the live RegionTag{Base} ResourceNode count topped up to - /// so the gather -> build -> survive loop lives AT the base (no - /// expedition trip). Unlike (edge-triggered on player presence) this is a - /// TICK-CADENCED top-up: every it counts live base nodes - /// and instantiates (TargetCount - liveCount) more, scattered UNIFORMLY-IN-RADIUS (rad = inner + r*(outer-inner), - /// NOT the area-weighted sqrt that piles nodes on the outer wall) in the [Inner,Outer] annulus around - /// BaseGridMath.PlotCenter, each overridden via SetComponent (NOT Add — the prefab already bakes - /// RegionTag{Expedition}) to RegionTag{Base} + ResourceId.Ore. The FIRST pass fires immediately - /// (NextSpawnTick seeded 0) so the field seeds without waiting. Deterministic: the scatter RNG is seeded from - /// a monotonic Epoch (never the tick); the cadence gate is wrap-safe NetworkTick math (TickUtil.NonZero + - /// IsNewerThan), never raw uint. Runtime-spawned ghosts dodge the prespawn handshake. Plain server - /// SimulationSystemGroup; server-only, never predicted. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - public partial struct BaseFieldSpawnSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) - return; - uint now = serverTick.TickIndexForValidTick; - - var spawnerEntity = SystemAPI.GetSingletonEntity(); - var spawner = SystemAPI.GetComponent(spawnerEntity); - var runtime = SystemAPI.GetComponent(spawnerEntity); - if (spawner.Prefab == Entity.Null) - return; - - // Cadence gate: first pass (NextSpawnTick == 0) fires immediately; thereafter every RespawnIntervalTicks. - if (runtime.NextSpawnTick != 0u && new NetworkTick(runtime.NextSpawnTick).IsNewerThan(serverTick)) - return; - - // Count LIVE base-region nodes only (expedition nodes share the ResourceNode type; exclude by region). - int liveBase = 0; - foreach (var region in SystemAPI.Query>().WithAll()) - if (region.ValueRO.Region == RegionId.Base) - liveBase++; - - int deficit = spawner.TargetCount - liveBase; - if (deficit > 0) - { - var anchor = SystemAPI.GetSingleton(); - float3 center = BaseGridMath.PlotCenter(anchor); - var baseXform = SystemAPI.GetComponent(spawner.Prefab); - var prefabNode = SystemAPI.GetComponent(spawner.Prefab); - - runtime.Epoch += 1; - var rng = new Random(((uint)runtime.Epoch * 747796405u) | 1u); // epoch-seeded (never the tick), nonzero - float inner = math.max(0f, spawner.InnerRadius); - float outer = math.max(inner + 0.01f, spawner.OuterRadius); - - var ecb = new EntityCommandBuffer(Allocator.Temp); - for (int i = 0; i < deficit; i++) - { - var node = ecb.Instantiate(spawner.Prefab); - - float ang = rng.NextFloat(0f, math.PI * 2f); - float rad = inner + rng.NextFloat(0f, 1f) * (outer - inner); // UNIFORM in radius - var xform = baseXform; - xform.Position = center + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad); - ecb.SetComponent(node, xform); - - // Override the baked RegionTag{Expedition} -> Base (else RegionRelevancy hides it from base - // players) and force the resource to Ore (the build currency; base field stays Ore-only). - ecb.SetComponent(node, new RegionTag { Region = RegionId.Base }); - var rn = prefabNode; - rn.ResourceId = ResourceId.Ore; - ecb.SetComponent(node, rn); - } - ecb.Playback(state.EntityManager); - ecb.Dispose(); - } - - runtime.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, spawner.RespawnIntervalTicks)); - SystemAPI.SetComponent(spawnerEntity, runtime); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs.meta b/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs.meta deleted file mode 100644 index 7e6c28cf4..000000000 --- a/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 346e9c0fb92e7b94fa3761222fc2ff1e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs b/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs deleted file mode 100644 index 272f5a4a8..000000000 --- a/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs +++ /dev/null @@ -1,145 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// Server-only procedural expedition-field manager. Re-keyed off PER-PLAYER PRESENCE (no global phase): it - /// counts players whose server-only is the Expedition region, and on the - /// empty->occupied edge (a new sortie) bumps and scatters - /// resource-node ghosts (seeded by the epoch) around the expedition - /// origin — PLUS, if a singleton is present, - /// Blight-clutter ghosts (seeded DISTINCTLY so clutter and nodes don't - /// co-locate, Variant round-robined), each RegionTag{Expedition}; on the occupied->empty edge (the LAST - /// player left) it destroys every node AND every clutter piece. So the field lives as long as anyone is out - /// there, not on a global timer. Plain server SimulationSystemGroup. Server-authoritative; clients despawn - /// ghosts via GhostDespawnSystem. Per-epoch reproducible (the seed is the monotonic int epoch, compared by - /// equality — never tick math, never 0). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(CyclePhaseSystem))] - public partial struct ExpeditionFieldSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var cycleEntity = SystemAPI.GetSingletonEntity(); - var runtime = SystemAPI.GetComponent(cycleEntity); - var spawner = SystemAPI.GetSingleton(); - - // Per-player presence: is anyone currently out in the expedition region? - int expeditionPlayers = 0; - foreach (var region in SystemAPI.Query>().WithAll()) - if (region.ValueRO.Region == RegionId.Expedition) - expeditionPlayers++; - bool occupied = expeditionPlayers > 0; - bool wasOccupied = runtime.PrevExpeditionOccupied != 0; - - // empty -> occupied: a new sortie begins; bump the epoch so the field reseeds fresh. - if (occupied && !wasOccupied) - { - runtime.ExpeditionEpoch += 1; - runtime.ClearedThisEpoch = 0; // a fresh sortie has not been cleared yet (gates the once-per-epoch reward) - } - - float3 baseCenter = new float3(0f, 1f, 0f); - if (SystemAPI.TryGetSingleton(out var anchor)) - baseCenter = BaseGridMath.PlotCenter(anchor); - float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter); - - var ecb = new EntityCommandBuffer(Allocator.Temp); - - // SPAWN: a player is out and this epoch has not been seeded yet. - if (occupied - && runtime.LastSpawnedEpoch != runtime.ExpeditionEpoch - && spawner.Prefab != Entity.Null) - { - var baseXform = SystemAPI.GetComponent(spawner.Prefab); - var prefabNode = SystemAPI.GetComponent(spawner.Prefab); - var rng = new Random((uint)math.max(1, runtime.ExpeditionEpoch)); - int count = math.max(1, spawner.Count); - for (int i = 0; i < count; i++) - { - var node = ecb.Instantiate(spawner.Prefab); - - float ang = rng.NextFloat(0f, math.PI * 2f); - float rad = spawner.Radius * math.sqrt(rng.NextFloat(0f, 1f)); - var xform = baseXform; - xform.Position = origin + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad); - ecb.SetComponent(node, xform); - - // Round-robin the resource type (Aether / Ore / Biomass) over the prefab's baked node. - var rn = prefabNode; - rn.ResourceId = (byte)(ResourceId.Aether + (byte)(i % 3)); - ecb.SetComponent(node, rn); - } - - // Blight clutter (OPTIONAL singleton): scatter alongside the nodes with a DISTINCT seed so the - // two fields don't co-locate. Round-robin Variant for client visual variety. - if (SystemAPI.TryGetSingleton(out var clutterSpawner) - && clutterSpawner.Prefab != Entity.Null) - { - var clutterXform = SystemAPI.GetComponent(clutterSpawner.Prefab); - var prefabClutter = SystemAPI.GetComponent(clutterSpawner.Prefab); - var crng = new Random((uint)math.max(1, runtime.ExpeditionEpoch * 2 + 1)); - int ccount = math.max(1, clutterSpawner.Count); - for (int i = 0; i < ccount; i++) - { - var piece = ecb.Instantiate(clutterSpawner.Prefab); - - float ang = crng.NextFloat(0f, math.PI * 2f); - float rad = clutterSpawner.Radius * math.sqrt(crng.NextFloat(0f, 1f)); - var xform = clutterXform; - xform.Position = origin + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad); - ecb.SetComponent(piece, xform); - - var bc = prefabClutter; - bc.Variant = (byte)(i % 3); - ecb.SetComponent(piece, bc); - } - } - - runtime.LastSpawnedEpoch = runtime.ExpeditionEpoch; - } - - // DESTROY: the last player left the expedition — clear the whole field (nodes + clutter + zone enemies). - if (wasOccupied && !occupied) - { - // Only EXPEDITION nodes — the base field is permanent RegionTag{Base} and must NOT be torn down here. - foreach (var (rn, region, e) in - SystemAPI.Query, RefRO>().WithEntityAccess()) - if (region.ValueRO.Region == RegionId.Expedition) - ecb.DestroyEntity(e); - // Blight clutter is Expedition-only today; guard by region defensively (DR-040 MINOR 1). - foreach (var (region, e) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - if (region.ValueRO.Region == RegionId.Expedition) - ecb.DestroyEntity(e); - // Zone combat enemies share this single lifetime point (DR-040). EnemyTag + ZoneEnemyTag + - // RegionTag{Expedition}; disjoint from the node/clutter queries, so no double-destroy. - foreach (var (region, e) in - SystemAPI.Query>().WithAll().WithEntityAccess()) - if (region.ValueRO.Region == RegionId.Expedition) - ecb.DestroyEntity(e); - } - - runtime.PrevExpeditionOccupied = (byte)(occupied ? 1 : 0); - SystemAPI.SetComponent(cycleEntity, runtime); - - ecb.Playback(state.EntityManager); - } - } -} diff --git a/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs.meta b/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs.meta deleted file mode 100644 index cbf23f296..000000000 --- a/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9267d7809e68ea54caa55378f33e67f6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs new file mode 100644 index 000000000..80dda33cd --- /dev/null +++ b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs @@ -0,0 +1,146 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.Transforms; + +namespace ProjectM.Server +{ + /// + /// Server-only per-ROOM field seeder — the Step-5 successor of the presence-keyed ExpeditionFieldSystem. + /// When the run FSM has a room active ( == InRoom) and + /// has advanced past the epoch this system last seeded (int equality, never + /// tick math), it resolves the active room's from the map node RunDirectorSystem published + /// ( — the single plan authority; NEVER re-derived here) and scatters + /// plan.NodeCount resource nodes — FLOORED by the run-wide scarcity budget + /// , which this system spends down (documented co-write: RunDirector + /// STAGES the budget at launch; this system only decrements it) — plus a light Blight-clutter dressing, all + /// inside the room's shape at (base, ActiveSubSlot). Every spawn is + /// stamped {room} (the teardown contract) on top of the prefab-baked RegionTag{Expedition}; + /// Scale is preserved via baked.WithPosition (never FromPosition). + /// + /// Teardown: room-advance/return teardown belongs to RunDirectorSystem (RoomTeardown, Step 7). This system keeps + /// ONE defensive sweep — Staging with any alive → destroy them all (idempotent; covers + /// abort/disconnect edges). Untagged ghosts (base field, structures) are structurally untouchable. + /// + /// Ordering: [UpdateAfter(RunDirectorSystem)] so it reads the freshly-advanced room state same-tick. + /// The old inherited [UpdateAfter(CyclePhaseSystem)] is deliberately DROPPED and NO CyclePhase edge may + /// ever return to the room chain (the Play-only sort-cycle rule — invisible to EditMode). + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateAfter(typeof(RunDirectorSystem))] + public partial struct RoomFieldSystem : ISystem + { + /// Max clutter pieces per room — cosmetic ghosts still cost relevancy, keep the dressing light. + const int MaxClutterPerRoom = 6; + + EntityQuery m_RoomTagged; + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly()); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var dirEntity = SystemAPI.GetSingletonEntity(); + var info = SystemAPI.GetComponent(dirEntity); + var run = SystemAPI.GetComponent(dirEntity); + + var spawnerEntity = SystemAPI.GetSingletonEntity(); + var spawner = SystemAPI.GetComponent(spawnerEntity); + + // One-shot: attach this system's server-only bookkeeping beside the baked spawner singleton. + if (!SystemAPI.HasComponent(spawnerEntity)) + { + state.EntityManager.AddComponentData(spawnerEntity, new RoomFieldState()); + return; // structural change — clean re-read next tick + } + var rf = SystemAPI.GetComponent(spawnerEntity); + + var ecb = new EntityCommandBuffer(Allocator.Temp); + + if (info.Lifecycle == RunLifecycle.InRoom) + { + if (rf.LastSpawnedRoomEpoch != run.RoomEpoch && spawner.Prefab != Entity.Null) + { + float3 baseCenter = new float3(0f, 1f, 0f); + if (SystemAPI.TryGetSingleton(out var anchor)) + baseCenter = BaseGridMath.PlotCenter(anchor); + float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); + + // Single plan authority: the node RunDirector published — never re-derived from the col/path. + var map = RunMapMath.Generate(run.RunSeed); + var node = map.NodeAt(run.CurrentNodeId); + var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount); + byte room = (byte)(info.CurrentRoom & 0xFF); + + // Scarcity: the run-wide budget floors this room's count and is spent down (never negative). + int count = math.min(plan.NodeCount, math.max(0, run.NodeBudgetRemaining)); + if (count > 0) + { + var baked = SystemAPI.GetComponent(spawner.Prefab); + var prefabNode = SystemAPI.GetComponent(spawner.Prefab); + var rng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x0DEu) | 1u); + for (int i = 0; i < count; i++) + { + var e = ecb.Instantiate(spawner.Prefab); + float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng); + ecb.SetComponent(e, baked.WithPosition(pos)); + // Rarity-weighted resource type (Step 11): Ore 45% (building) / Biomass 40% (walls, + // fabricator) / AETHER 15% — the scarce permanent-meta currency, felt when it drops. + var rn = prefabNode; + int roll = rng.NextInt(0, 100); + rn.ResourceId = roll < 15 ? ResourceId.Aether : roll < 60 ? ResourceId.Ore : ResourceId.Biomass; + ecb.SetComponent(e, rn); + ecb.AddComponent(e, new RoomTag { Room = room }); + } + run.NodeBudgetRemaining -= count; + SystemAPI.SetComponent(dirEntity, run); // the documented budget co-write (spend only) + } + + // Clutter dressing (OPTIONAL singleton) — a DISTINCT seed so it never co-locates with nodes. + if (SystemAPI.TryGetSingleton(out var clutter) + && clutter.Prefab != Entity.Null) + { + var cBaked = SystemAPI.GetComponent(clutter.Prefab); + var cProto = SystemAPI.GetComponent(clutter.Prefab); + var crng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC17u) | 1u); + int cCount = math.min(math.max(1, clutter.Count), MaxClutterPerRoom); + for (int i = 0; i < cCount; i++) + { + var e = ecb.Instantiate(clutter.Prefab); + float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, cCount, ref crng); + ecb.SetComponent(e, cBaked.WithPosition(pos)); + var bc = cProto; + bc.Variant = (byte)(i % 3); + ecb.SetComponent(e, bc); + ecb.AddComponent(e, new RoomTag { Room = room }); + } + } + + rf.LastSpawnedRoomEpoch = run.RoomEpoch; + SystemAPI.SetComponent(spawnerEntity, rf); + } + } + else if (info.Lifecycle == RunLifecycle.Staging && !m_RoomTagged.IsEmpty) + { + // Defensive sweep: no run active but room ghosts linger (abort/disconnect edge) — clear every room. + var ents = m_RoomTagged.ToEntityArray(Allocator.Temp); + for (int i = 0; i < ents.Length; i++) + ecb.DestroyEntity(ents[i]); + ents.Dispose(); + } + + ecb.Playback(state.EntityManager); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs.meta b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs.meta new file mode 100644 index 000000000..efb11a6da --- /dev/null +++ b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b2ba012b5e31bcc48b50dd14220c9fc5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Meta.meta b/Assets/_Project/Scripts/Server/Meta.meta new file mode 100644 index 000000000..ab6d0f3d2 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Meta.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 032a0f4a47c8f1d459bd341f50d42054 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs b/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs new file mode 100644 index 000000000..66438bf07 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs @@ -0,0 +1,147 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server receiver for — the PERMANENT meta-upgrade purchase (Aether → tier). + /// Honored ONLY in Staging (N4: the base shop is a between-runs surface; mid-run Aether belongs to the run). + /// Per request, IN-LOOP against the live director buffers (the DR-014 placement idiom — two same-tick purchases + /// on barely-enough Aether cannot both pass): resolve sender → , validate catalog id / + /// class mask (, never raw 1<<ClassId) / MaxTier / prereq, price the NEXT tier + /// ( — tier is server-computed, never on the wire), then + /// pre-check BEFORE (Withdraw CLAMPS, it + /// never rejects), bump-or-append the row, and upsert the ABSOLUTE-value meta + /// StatModifier (R-F1: Value = ValuePerTier * newTier, keyed Tuning.MetaSourceIdBase + id) on every + /// pre-collected live player of that class (R-F2 — offline classmates get theirs born-correct at next spawn via + /// GoInGameServerSystem). Success raises so the tier is on disk before a crash. + /// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateBefore(typeof(RunDirectorSystem))] + public partial struct MetaSpendSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + var builder = new EntityQueryBuilder(Allocator.Temp) + .WithAll(); + state.RequireForUpdate(state.GetEntityQuery(builder)); + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + // N4 phase gate — hoisted (per-tick-uniform, like the ReadyToggle accept flag). + bool accept = SystemAPI.GetSingleton().Lifecycle == RunLifecycle.Staging; + + var catalog = SystemAPI.GetSingleton(); + var director = SystemAPI.GetSingletonEntity(); + if (!catalog.Value.IsCreated || !SystemAPI.HasBuffer(director)) + accept = false; // authoring hole: drop the requests below (no withdraw happened; nothing to roll back) + + // Sender resolution (SourceConnection → NetworkId → GhostOwner → player, the ReadyToggle idiom). + var playerByConn = new NativeHashMap(8, Allocator.Temp); + // R-F2: pre-collect the live (player, class) pairs ONCE — a successful purchase upserts the modifier on + // every live member of the class, not just the buyer (shared per-class pool, operator default). + var classMembers = new NativeList(8, Allocator.Temp); + var classIds = new NativeList(8, Allocator.Temp); + foreach (var (owner, playerClass, entity) in + SystemAPI.Query, RefRO>() + .WithAll().WithEntityAccess()) + { + playerByConn[owner.ValueRO.NetworkId] = entity; + classMembers.Add(entity); + classIds.Add(playerClass.ValueRO.ClassId); + } + + var ecb = new EntityCommandBuffer(Allocator.Temp); + foreach (var (receive, req, requestEntity) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + ecb.DestroyEntity(requestEntity); // ALWAYS consumed, accepted or not + if (!accept) continue; + + var conn = receive.ValueRO.SourceConnection; + if (!SystemAPI.HasComponent(conn) + || !playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var buyer)) + continue; + byte classId = SystemAPI.GetComponent(buyer).ClassId; + + ref var pool = ref catalog.Value.Value; + int defIdx = MetaMath.FindDef(ref pool, req.ValueRO.UpgradeId); + if (defIdx < 0) continue; // unknown id — dropped (a forged/stale request, not a crash) + ref var def = ref pool.Defs[defIdx]; + if ((def.ClassMask & BoonMath.MaskFor(classId)) == 0) continue; + + // LIVE in-loop reads (no hoist — the previous request this tick may have bumped the tier or + // drained the ledger; hoisted copies would let both pass). + var record = SystemAPI.GetBuffer(director); + byte owned = MetaMath.TierOf(record, classId, req.ValueRO.UpgradeId); + if (owned >= def.MaxTier) continue; + if (def.PrereqId != 0xFF && MetaMath.TierOf(record, classId, def.PrereqId) < def.PrereqTier) + continue; + + int cost = MetaMath.CostForTier(in def, owned); + var ledger = SystemAPI.GetBuffer(director); + if (StorageMath.TotalOf(ledger, ResourceId.Aether) < cost) continue; // pre-check: Withdraw CLAMPS + StorageMath.Withdraw(ledger, ResourceId.Aether, cost); // atomic commit (DR-014) + + byte newTier = (byte)(owned + 1); + bool bumped = false; + for (int i = 0; i < record.Length; i++) + if (record[i].ClassId == classId && record[i].UpgradeId == req.ValueRO.UpgradeId) + { + record[i] = new MetaTierState { ClassId = classId, UpgradeId = req.ValueRO.UpgradeId, Tier = newTier }; + bumped = true; + break; + } + if (!bumped) + record.Add(new MetaTierState { ClassId = classId, UpgradeId = req.ValueRO.UpgradeId, Tier = newTier }); + + // R-F1: ABSOLUTE-value upsert (Value = ValuePerTier * newTier) — never an incremental append; a + // second append would double-count in StatRecomputeSystem's sum. + uint sourceId = Tuning.MetaSourceIdBase + req.ValueRO.UpgradeId; + for (int p = 0; p < classMembers.Length; p++) + { + if (classIds[p] != classId) continue; + var mods = SystemAPI.GetBuffer(classMembers[p]); + bool upserted = false; + for (int m = 0; m < mods.Length; m++) + if (mods[m].SourceId == sourceId) + { + var row = mods[m]; + row.Value = def.ValuePerTier * newTier; + mods[m] = row; + upserted = true; + break; + } + if (!upserted) + mods.Add(new StatModifier + { + Target = def.Target, + Op = def.Op, + Value = def.ValuePerTier * newTier, + SourceId = sourceId, + }); + } + + // Persist immediately — the tier is real money (Aether); a crash must not eat it. + if (SystemAPI.HasComponent(director)) + SystemAPI.SetComponent(director, new SaveRequest { Pending = 1 }); + } + ecb.Playback(state.EntityManager); + playerByConn.Dispose(); + classMembers.Dispose(); + classIds.Dispose(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs.meta b/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs.meta new file mode 100644 index 000000000..69badb00a --- /dev/null +++ b/Assets/_Project/Scripts/Server/Meta/MetaSpendSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9b277eb9da63a054db9f9b3e041d582b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs index 838e20372..8794e30e6 100644 --- a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs +++ b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs @@ -55,6 +55,9 @@ namespace ProjectM.Server // M7: also persist player-built structures + their production tick-state / inventory (single shared scan). uint nowTick = SystemAPI.GetSingleton().ServerTick.TickIndexForValidTick; SaveStructureScan.Collect(EntityManager, nowTick, out var structures, out var structureIo); + // v6: the permanent-meta slice via the ONE shared collector (drift-proof vs the quit-to-menu writer). + MetaSaveScan.Collect(EntityManager, dir, out var metaRows, out var runsCompleted, out var maxDepth); + SaveService.Save(new SaveData { @@ -62,6 +65,9 @@ namespace ProjectM.Server GoalTarget = goal.Target, CoreCurrent = core.Current, RunOutcome = outcome.Value, + RunsCompleted = runsCompleted, + MaxDepthReached = maxDepth, + MetaUpgrades = metaRows, Ledger = rows, Structures = structures, diff --git a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs index 34404a460..581c2ed41 100644 --- a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs @@ -62,6 +62,15 @@ namespace ProjectM.Server // spawn like CycleRuntime/ThreatState (never on the ghost serializer). RunOutcome is baked on the prefab. ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal }); + // Expedition redesign: run-FSM working state + the co-op route first-commit latch + the persisted + // meta counters — ALL added UNCONDITIONALLY at spawn (the CycleRuntime/ThreatState/RunPhase idiom; + // D-F2: a New-Game boot must have the components the bank block reads; Continue restores VALUES only, + // inside the HasData block — Step 12b). HostSalt starts a fixed non-tick seed lineage (bumped per + // launch); SaveData v6 folds persisted RunsCompleted in at restore so cross-session runs diverge. + ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u }); + ecb.AddComponent(director, default(RouteCommand)); + ecb.AddComponent(director, default(MetaCounters)); + // Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director // DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger. bool restoredLedger = false; @@ -98,6 +107,33 @@ namespace ProjectM.Server // save / New Game = 0 -> InProgress). Independent of the Core -> NOT nested in the CoreIntegrity guard. ecb.SetComponent(director, new RunOutcome { Value = pending.RunOutcome }); + // v6: restore the permanent meta — counters (VALUES only; the component was added + // unconditionally above, D-F2), the tier record (SetBuffer replaces the baked-empty + // [GhostField] buffer pre-Playback — the StorageEntry idiom — rows VERBATIM incl. unknown + // ids), the born-correct RunInfo HUD mirror (the spawn-time exception to RunDirector's + // sole-writer rule, like CycleState/RunOutcome above), and the HostSalt fold (cross-session + // first-run maps diverge once you've banked clears — the promise at the RunRuntime add). + ecb.SetComponent(director, new MetaCounters + { + RunsCompleted = pending.RunsCompleted, + MaxDepthReached = pending.MaxDepthReached, + }); + var metaSrc = SystemAPI.GetBuffer(pendingEntity); + var metaDst = ecb.SetBuffer(director); + for (int mi = 0; mi < metaSrc.Length; mi++) + metaDst.Add(new MetaTierState { ClassId = metaSrc[mi].ClassId, UpgradeId = metaSrc[mi].UpgradeId, Tier = metaSrc[mi].Tier }); + if (SystemAPI.HasComponent(spawner.Prefab)) + { + var runInfo = SystemAPI.GetComponent(spawner.Prefab); // baked Lifecycle=Staging + runInfo.RunsCompleted = pending.RunsCompleted; + runInfo.MaxDepthReached = pending.MaxDepthReached; + ecb.SetComponent(director, runInfo); + } + ecb.SetComponent(director, new RunRuntime + { + HostSalt = RunMapMath.Hash(0x5EED0001u, (uint)pending.RunsCompleted + 1u), + }); + } ecb.DestroyEntity(pendingEntity); } diff --git a/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs b/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs deleted file mode 100644 index fcd31221a..000000000 --- a/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs +++ /dev/null @@ -1,122 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// Server-only walk-in gate transit: a player who walks within a gate's radius (and whose region matches the - /// gate's ) is transited to the gate's ToRegion at its ArrivalPos - /// (RegionTag flipped + LocalTransform teleported — GhostRelevancy re-scopes their ghosts, as in - /// RegionTransitSystem). Returning to BASE signals the ThreatDirector (a completed expedition can draw a - /// retaliation siege) by incrementing . Plain server - /// SimulationSystemGroup, ordered BEFORE CyclePhaseSystem (Gate -> ThreatDirector -> RunState) so the return is - /// consumed the same tick. Arrival points are offset from the destination gate so a transited player does not - /// immediately re-trigger. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateBefore(typeof(CyclePhaseSystem))] - public partial struct ExpeditionGateSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - // Snapshot gates once. - var gateFrom = new NativeList(Allocator.Temp); - var gateTo = new NativeList(Allocator.Temp); - var gateRadiusSq = new NativeList(Allocator.Temp); - var gatePos = new NativeList(Allocator.Temp); - var gateArrival = new NativeList(Allocator.Temp); - foreach (var (gate, xform) in SystemAPI.Query, RefRO>()) - { - gateFrom.Add(gate.ValueRO.FromRegion); - gateTo.Add(gate.ValueRO.ToRegion); - gateRadiusSq.Add(gate.ValueRO.Radius * gate.ValueRO.Radius); - gatePos.Add(xform.ValueRO.Position.xz); - gateArrival.Add(gate.ValueRO.ArrivalPos); - } - - bool returnedToBase = false; - foreach (var (region, xform) in - SystemAPI.Query, RefRW>().WithAll()) - { - byte r = region.ValueRO.Region; - float2 pp = xform.ValueRO.Position.xz; - for (int i = 0; i < gateFrom.Length; i++) - { - if (gateFrom[i] != r) continue; - if (math.distancesq(pp, gatePos[i]) > gateRadiusSq[i]) continue; - region.ValueRW.Region = gateTo[i]; - xform.ValueRW.Position = gateArrival[i]; - if (gateTo[i] == RegionId.Base) - returnedToBase = true; - break; - } - } - - gateFrom.Dispose(); - gateTo.Dispose(); - gateRadiusSq.Dispose(); - gatePos.Dispose(); - gateArrival.Dispose(); - - // A player returned to base from an expedition -> signal the ThreatDirector (it sizes/arms any - // retaliation siege). The gate teleports the returner out of its radius, so this fires once per return. - if (returnedToBase) - { - if (SystemAPI.TryGetSingletonEntity(out var threatEntity)) - { - var threat = SystemAPI.GetComponent(threatEntity); - threat.PendingReturns += 1; - threat.ExpeditionsCompleted += 1; - SystemAPI.SetComponent(threatEntity, threat); - } - - // Once-per-epoch zone-clear reward: a returner BANKS flat Ore to the shared ledger AND advances the - // long-arc win meter (DR-042 — EXPEDITION CLEARS, not survived base sieges, are the win-driver: - // CyclePhaseSystem no longer credits Charge, so this is the sole PRODUCTION writer of GoalProgress.Charge). - // Resolved ONCE here (not per-returner) so two same-tick co-op returns pay exactly once (DR-040 BLOCKER 4) - // and gate re-entry before a clear can't farm (MINOR 2). Ore + Charge share the SAME LastRewardedEpoch - // latch so they always share fate (never one without the other). The Charge credit is guarded - // independently of the ledger so it still lands in ledger-less worlds. - if (SystemAPI.HasSingleton()) - { - var cycleEntity = SystemAPI.GetSingletonEntity(); - var runtime = SystemAPI.GetComponent(cycleEntity); - if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch) - { - if (SystemAPI.TryGetSingleton(out var zoneDir) - && SystemAPI.HasSingleton()) - { - var ledger = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); - StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre); - } - if (SystemAPI.HasComponent(cycleEntity)) - { - // +1 toward the goal per cleared expedition, CLAMPED to Target (single production writer). - var goal = SystemAPI.GetComponent(cycleEntity); - goal.Charge = math.min(goal.Charge + 1, goal.Target); - SystemAPI.SetComponent(cycleEntity, goal); - } - // Checkpoint the hard-won clear (replaces the deleted survived-siege autosave in CyclePhaseSystem). - if (SystemAPI.HasComponent(cycleEntity)) - SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 }); - runtime.LastRewardedEpoch = runtime.ExpeditionEpoch; - SystemAPI.SetComponent(cycleEntity, runtime); - } - } - } - } - } -} diff --git a/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs.meta b/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs.meta deleted file mode 100644 index 90ce049a2..000000000 --- a/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4292536f663eb5c4d92688f6c5bb0368 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs b/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs new file mode 100644 index 000000000..69750d111 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs @@ -0,0 +1,61 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server receiver for : resolves the sender (SourceConnection → NetworkId → + /// GhostOwner → player entity, the AbilityUpgradeSystem idiom) and SETS . + /// Honored ONLY while the run FSM is in Staging or Launching — an un-ready during the Launching countdown is the + /// launch-abort escape hatch (RunDirectorSystem reverts to Staging); toggles arriving mid-run are dropped (the + /// Returning edge clears every flag anyway). Ordered BEFORE RunDirectorSystem so a toggle lands the same tick the + /// ready-count is derived. Plain server group (one-off RPC effects never run in the predicted loop); the request + /// entity is ALWAYS destroyed. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateBefore(typeof(RunDirectorSystem))] + public partial struct ReadyToggleSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + var builder = new EntityQueryBuilder(Allocator.Temp) + .WithAll(); + state.RequireForUpdate(state.GetEntityQuery(builder)); + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + byte lifecycle = SystemAPI.GetSingleton().Lifecycle; + bool accept = lifecycle == RunLifecycle.Staging || lifecycle == RunLifecycle.Launching; + + var playerByConn = new NativeHashMap(8, Allocator.Temp); + foreach (var (owner, entity) in + SystemAPI.Query>().WithAll().WithEntityAccess()) + playerByConn[owner.ValueRO.NetworkId] = entity; + + var ecb = new EntityCommandBuffer(Allocator.Temp); + foreach (var (receive, req, requestEntity) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + var conn = receive.ValueRO.SourceConnection; + if (accept + && SystemAPI.HasComponent(conn) + && playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var player)) + { + SystemAPI.SetComponent(player, new PlayerReady { Value = (byte)(req.ValueRO.Ready != 0 ? 1 : 0) }); + } + ecb.DestroyEntity(requestEntity); + } + ecb.Playback(state.EntityManager); + playerByConn.Dispose(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs.meta b/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs.meta new file mode 100644 index 000000000..d373060a5 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/ReadyToggleSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7392579cf8b92e64f9686b58da99f7c2 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs b/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs new file mode 100644 index 000000000..83c6197a9 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs @@ -0,0 +1,96 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server receiver for — the co-op route choice (any-player-first-commits, the + /// operator's locked authority model). Validates each pick server-authoritatively: + /// Lifecycle == RouteSelect · run identity (uint)ForRunEpoch == RunRuntime.RunSeed (the Step-8 + /// review re-mean: the replicated seed IS the run token; the server-only RunEpoch is not client-knowable) · + /// ForLayer == RunInfo.CurrentRoom (the gate's un-incremented cleared layer) · OptionIndex within + /// the replicated RouteOptionCount · the SENDER's is Expedition (N3 — a + /// base-bound joiner cannot commit the party's route) · nothing accepted yet this gate. + /// + /// FIRST-COMMIT LATCH: the accepted pick is written to the server-only via an + /// IMMEDIATE in-place SystemAPI.SetComponent INSIDE the drain loop plus a local accepted flag (the DR-014 + /// atomicity idiom) — two same-tick picks can never both observe an open gate; a hoisted read would re-create + /// the exact N1 race the design review killed. is stamped from the TRUE + /// server-only epoch (never the client-echoed value). Requests are ALWAYS destroyed. This system writes ONLY + /// RouteCommand — RunDirectorSystem stays the sole RunInfo/RunRuntime writer and consumes the latch + /// (abort → pick → grace, in that order). Ordered before it so a pick can land the same tick it is consumed; + /// NO CyclePhase edge (the room-chain hard rule). + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateBefore(typeof(RunDirectorSystem))] + public partial struct RouteSelectSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + var builder = new EntityQueryBuilder(Allocator.Temp) + .WithAll(); + state.RequireForUpdate(state.GetEntityQuery(builder)); + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var dirEntity = SystemAPI.GetSingletonEntity(); + var info = SystemAPI.GetComponent(dirEntity); + var run = SystemAPI.GetComponent(dirEntity); + bool gateOpen = info.Lifecycle == RunLifecycle.RouteSelect; + + // Sender-region lookup (N3): connection NetworkId -> the player's CURRENT region. A player entity + // without RegionTag simply never enters the map -> its pick is a clean reject, never a throw. + var regionByConn = new NativeHashMap(8, Allocator.Temp); + foreach (var (owner, region) in + SystemAPI.Query, RefRO>().WithAll()) + regionByConn[owner.ValueRO.NetworkId] = region.ValueRO.Region; + + // Local accepted flag beside the in-place write = the first-commit latch (nothing else writes + // RouteCommand mid-loop; RunDirector's gate-entry clear ran on a previous tick by construction). + bool accepted = SystemAPI.GetComponent(dirEntity).HasPick != 0; + + var ecb = new EntityCommandBuffer(Allocator.Temp); + foreach (var (receive, req, requestEntity) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + var conn = receive.ValueRO.SourceConnection; + bool valid = gateOpen + && !accepted + && (uint)req.ValueRO.ForRunEpoch == run.RunSeed + && req.ValueRO.ForLayer == info.CurrentRoom + && req.ValueRO.OptionIndex < info.RouteOptionCount + && SystemAPI.HasComponent(conn) + && regionByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out byte senderRegion) + && senderRegion == RegionId.Expedition; + + if (valid) + { + // IMMEDIATE in-place commit (never an ECB-deferred write) + the local flag: first pick wins. + SystemAPI.SetComponent(dirEntity, new RouteCommand + { + HasPick = 1, + OptionIndex = req.ValueRO.OptionIndex, + ForRunEpoch = run.RunEpoch, // the TRUE server epoch — never echo the client value + ForLayer = req.ValueRO.ForLayer, + }); + accepted = true; + } + + ecb.DestroyEntity(requestEntity); + } + ecb.Playback(state.EntityManager); + regionByConn.Dispose(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs.meta b/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs.meta new file mode 100644 index 000000000..4a7c5c9f6 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/RouteSelectSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f56898976ef03af499c200e0d6f43b0d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs new file mode 100644 index 000000000..dfd98eea3 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs @@ -0,0 +1,409 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Server +{ + /// + /// SOLE writer of the replicated run-lifecycle FSM () and its server-only working state + /// () — the expedition redesign's counterpart of CyclePhaseSystem's single-writer + /// discipline (that system stays the sole writer of the BASE Calm↔Siege posture; the two FSMs are distinct). + /// + /// Step-7 = the REAL LINEAR traversal: Staging (ready-check) → Launching (3-2-1 telegraph, un-ready aborts) → + /// InRoom (fight; the clear edge arrives as the replicated .State == Cleared, + /// consumed ONE-TICK-LATE by construction — RoomEnemyDirectorSystem writes it after this system each tick, so no + /// system-ordering back-edge exists) → RoomReward (cleared room TORN DOWN at entry via ; + /// boon picks gate the exit from Step 10, all-Pending==0 today) → advance (bump room/epoch, flip the ping-pong + /// sub-slot, teleport — teardown-at-entry + spawn-on-advance guarantees ≥1 empty tick and exactly ONE room alive) + /// → … → Boss clear → Returning (teleport home + the CLEAR-GATED terminal bank) → Staging. Branching route + /// choice (RouteSelect) replaces the fixed col-0 advance at Step 8. + /// + /// The terminal bank (once per RunEpoch, equality-latched): ALWAYS records the honest depth + /// (max(MaxDepthReached, RoomsClearedThisRun) — never the planned RoomCount) and re-stages; ONLY a genuine + /// boss-clear terminal () credits the win meter + /// (.Charge, clamped), RunsCompleted, the retaliation inputs + /// (.PendingReturns/ExpeditionsCompleted — carried from the retired gate, C7) and + /// requests a save. An abort/wipe banks NOTHING but the depth high-water (D-F3). + /// + /// Ordering: [UpdateBefore(CyclePhaseSystem)] ONLY (GoalReachedSystem is [UpdateAfter(CyclePhaseSystem)] — + /// transitively after this system, so the Charge credit lands before it reads the edge). Per the hard rule, + /// NOTHING in the room chain adds another CyclePhase edge (a sort cycle is invisible to EditMode and throws only + /// at Play world creation). + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateBefore(typeof(CyclePhaseSystem))] + public partial struct RunDirectorSystem : ISystem + { + /// "All ready → 3-2-1 → go" telegraph (~3 s @ 60). An un-ready during the countdown aborts. + const uint LaunchCountdownTicks = 180; + + /// Boon-pick grace (~30 s @ 60): RoomReward advances when every survivor picked OR this elapses + /// (the AFK/disconnect backstop; the un-picked-offer policy lands with the boons at Step 10). + const uint RewardGraceTicks = 1800; + + /// Route-choice grace (~30 s @ 60): the gate auto-picks the LOWEST-INDEX reachable option when it + /// elapses (the AFK backstop; an accepted pick always beats a same-tick expiry — review F2). + const uint RouteGraceTicks = 1800; + + EntityQuery m_RoomTagged; + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly()); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (!serverTick.IsValid) + return; + uint now = serverTick.TickIndexForValidTick; + + var dirEntity = SystemAPI.GetSingletonEntity(); + var info = SystemAPI.GetComponent(dirEntity); + var run = SystemAPI.GetComponent(dirEntity); + + float3 baseCenter = new float3(0f, 1f, 0f); + if (SystemAPI.TryGetSingleton(out var anchor)) + baseCenter = BaseGridMath.PlotCenter(anchor); + + // Ready-check + party-presence derivation, shared across the states below. The party is co-located at + // base while Staging (the N7 co-location invariant), so live PlayerTag ghosts ARE the roster; a + // disconnect drops the counts (LinkedEntityGroup despawn) and the checks re-derive clean. + int totalPlayers = 0, readyPlayers = 0, expeditionPlayers = 0; + foreach (var (ready, region) in + SystemAPI.Query, RefRO>().WithAll()) + { + totalPlayers++; + if (ready.ValueRO.Value != 0) readyPlayers++; + if (region.ValueRO.Region == RegionId.Expedition) expeditionPlayers++; + } + bool allReady = totalPlayers > 0 && readyPlayers == totalPlayers; + + switch (info.Lifecycle) + { + case RunLifecycle.Staging: + { + // F2 cross-FSM launch guard: no new run while a final siege arms/runs or the outcome latched. + // Guards default OPEN when the server-only markers are absent (EditMode worlds). + bool launchAllowed = + (!SystemAPI.HasComponent(dirEntity) + || SystemAPI.GetComponent(dirEntity).Value == RunPhaseId.Normal) + && (!SystemAPI.HasComponent(dirEntity) + || SystemAPI.GetComponent(dirEntity).Value == RunOutcomeId.InProgress); + + if (allReady && run.WasAllReady == 0 && launchAllowed) + { + // Rising edge → Launching. Seed the run: monotonic epoch + per-playthrough salt lineage, + // never a tick, never 0, equality-compared downstream. + run.RunEpoch += 1; + run.HostSalt = RunMapMath.Hash(run.HostSalt, (uint)run.RunEpoch); + run.RunSeed = math.max(1u, RunMapMath.Hash((uint)run.RunEpoch, run.HostSalt)); + run.NodeBudgetRemaining = Tuning.ExpeditionNodeBudget; + run.RoomsClearedThisRun = 0; + run.BoonPickCounter = 0; // fresh boon-band provenance per run + run.LastTerminalCleared = 0; + + var map = RunMapMath.Generate(run.RunSeed); + info.RunSeed = run.RunSeed; + info.RoomCount = map.LayerCount; + info.LaunchTick = TickUtil.NonZero(now + LaunchCountdownTicks); + info.Lifecycle = RunLifecycle.Launching; + } + run.WasAllReady = (byte)(allReady ? 1 : 0); + break; + } + + case RunLifecycle.Launching: + { + // Un-ready during the countdown aborts back to Staging (the telegraph's escape hatch). + if (!allReady) + { + info.LaunchTick = 0u; + info.Lifecycle = RunLifecycle.Staging; + run.WasAllReady = 0; + break; + } + + bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick); + if (due) + { + // Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0). + var map = RunMapMath.Generate(run.RunSeed); + EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true); + info.LaunchTick = 0u; + } + break; + } + + case RunLifecycle.InRoom: + { + // All expedition players gone (disconnect/death-warp edge) → clean abort, no credit. + if (expeditionPlayers == 0) + { + run.LastTerminalCleared = 0; + info.Lifecycle = RunLifecycle.Returning; + break; + } + + // The room clear edge — the replicated objective RoomEnemyDirectorSystem computed LAST tick + // (one-tick-late by construction; no ordering back-edge). Teardown happens AT THIS ENTRY, the + // next room spawns on the advance tick → ≥1 empty tick, exactly one room alive. + if (SystemAPI.HasComponent(dirEntity) + && SystemAPI.GetComponent(dirEntity).State == ExpeditionObjectiveState.Cleared) + { + var ecb = new EntityCommandBuffer(Allocator.Temp); + RoomTeardown.DestroyRoom(m_RoomTagged, ecb, (byte)(info.CurrentRoom & 0xFF)); + ecb.Playback(state.EntityManager); + ecb.Dispose(); + + run.RoomsClearedThisRun += 1; + if (info.CurrentRoom >= info.RoomCount - 1) + run.LastTerminalCleared = 1; // the Boss fell — a genuine terminal clear + run.RewardGraceTick = TickUtil.NonZero(now + RewardGraceTicks); + info.Lifecycle = RunLifecycle.RoomReward; + } + break; + } + + case RunLifecycle.RoomReward: + { + if (expeditionPlayers == 0 && run.LastTerminalCleared == 0) + { + info.Lifecycle = RunLifecycle.Returning; + break; + } + + // Exit gate: every SURVIVING player has picked (BoonOffer.Pending==0 — inert until Step 10) + // OR the grace elapsed (wrap-safe IsNewerThan, never raw uint — F4). + bool anyPending = false; + foreach (var offer in SystemAPI.Query>().WithAll()) + if (offer.ValueRO.Pending != 0) { anyPending = true; break; } + bool graceElapsed = run.RewardGraceTick == 0u + || !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick); + if (anyPending && !graceElapsed) + break; + + run.RewardGraceTick = 0u; + if (run.LastTerminalCleared != 0) + { + info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner + } + else + { + // Open the ROUTE GATE (Step 8 — the branching choice): publish the AUTHORITATIVE reachable + // options (the client map panel is regen-for-display; the clickable buttons bind to these + // bytes). The cleared room is already gone — RouteSelect IS the teardown gap; the next room + // materializes only when the choice commits. + var map = RunMapMath.Generate(run.RunSeed); + int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, + out var cols); + if (optionCount == 0) + { + // Unreachable by construction (every non-terminal node has an out-edge) — a future + // generator regression must abort CLEANLY, never wedge on stale options (review F4). + info.RouteOptionCount = 0; + info.Lifecycle = RunLifecycle.Returning; + } + else + { + int nextLayer = info.CurrentRoom + 1; + info.RouteOptionCount = (byte)math.min(optionCount, 3); + info.RouteOpt0Col = cols.Length > 0 ? cols[0] : (byte)0; + info.RouteOpt1Col = cols.Length > 1 ? cols[1] : (byte)0; + info.RouteOpt2Col = cols.Length > 2 ? cols[2] : (byte)0; + info.RouteOpt0Type = cols.Length > 0 ? map.Node(nextLayer, cols[0]).RoomType : (byte)0; + info.RouteOpt1Type = cols.Length > 1 ? map.Node(nextLayer, cols[1]).RoomType : (byte)0; + info.RouteOpt2Type = cols.Length > 2 ? map.Node(nextLayer, cols[2]).RoomType : (byte)0; + run.RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks); + // Entry-clear: any accepted pick provably belongs to THIS gate (RouteSelectSystem runs + // BEFORE this system, so it cannot accept on the entry tick). + if (SystemAPI.HasComponent(dirEntity)) + SystemAPI.SetComponent(dirEntity, default(RouteCommand)); + info.Lifecycle = RunLifecycle.RouteSelect; + } + } + break; + } + + case RunLifecycle.RouteSelect: + { + // Predicate order is LOAD-BEARING (review F2): abort → pick-consume → grace. A same-tick pick + // from a vanishing party must never resurrect the run (EnterRoom would conscript base players); + // an accepted pick must beat a same-tick grace expiry (the player was told "committed"). + if (expeditionPlayers == 0) + { + info.RouteOptionCount = 0; // close the gate ON the abort edge itself (review F3) + run.LastTerminalCleared = 0; + info.Lifecycle = RunLifecycle.Returning; + break; + } + + var cmd = SystemAPI.HasComponent(dirEntity) + ? SystemAPI.GetComponent(dirEntity) + : default; + bool routeGraceElapsed = run.RouteGraceTick == 0u + || !new NetworkTick(run.RouteGraceTick).IsNewerThan(serverTick); + + if (cmd.HasPick != 0) + { + // The party's committed choice (first-accepted-wins latch; any-player-first-commits). + byte chosenCol = cmd.OptionIndex == 2 ? info.RouteOpt2Col + : cmd.OptionIndex == 1 ? info.RouteOpt1Col : info.RouteOpt0Col; + if (SystemAPI.HasComponent(dirEntity)) + SystemAPI.SetComponent(dirEntity, default(RouteCommand)); + run.RouteGraceTick = 0u; + var map = RunMapMath.Generate(run.RunSeed); + EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, chosenCol, baseCenter, bumpEpoch: true); + } + else if (routeGraceElapsed) + { + // AFK backstop: deterministic LOWEST-INDEX reachable option (RouteOpt0 is ascending-first). + run.RouteGraceTick = 0u; + var map = RunMapMath.Generate(run.RunSeed); + EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, info.RouteOpt0Col, baseCenter, bumpEpoch: true); + } + break; + } + + case RunLifecycle.Returning: + { + // Party teleport HOME + region flip back to Base. + int idx = 0; + foreach (var (region, xform) in + SystemAPI.Query, RefRW>().WithAll()) + { + region.ValueRW.Region = RegionId.Base; + var p = baseCenter; + p.x += 1.5f * idx; + p.y = xform.ValueRO.Position.y; + xform.ValueRW.Position = p; + idx++; + } + + // THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3). + if (run.LastBankedRunEpoch != run.RunEpoch) + { + run.LastBankedRunEpoch = run.RunEpoch; + + // Always: the honest depth high-water (actual rooms cleared, never the planned count). + if (SystemAPI.HasComponent(dirEntity)) + { + var meta = SystemAPI.GetComponent(dirEntity); + meta.MaxDepthReached = math.max(meta.MaxDepthReached, run.RoomsClearedThisRun); + if (run.LastTerminalCleared != 0) + meta.RunsCompleted += 1; + SystemAPI.SetComponent(dirEntity, meta); + info.RunsCompleted = meta.RunsCompleted; // HUD mirror + info.MaxDepthReached = meta.MaxDepthReached; // HUD mirror + } + + // Boss-clear only: the win meter, the retaliation inputs (C7), and a save checkpoint. + if (run.LastTerminalCleared != 0) + { + if (SystemAPI.HasComponent(dirEntity)) + { + var goal = SystemAPI.GetComponent(dirEntity); + goal.Charge = math.min(goal.Charge + 1, goal.Target); + SystemAPI.SetComponent(dirEntity, goal); + } + if (SystemAPI.HasComponent(dirEntity)) + { + var threat = SystemAPI.GetComponent(dirEntity); + threat.PendingReturns += 1; + threat.ExpeditionsCompleted += 1; + SystemAPI.SetComponent(dirEntity, threat); + } + if (SystemAPI.HasComponent(dirEntity)) + SystemAPI.SetComponent(dirEntity, new SaveRequest { Pending = 1 }); + } + } + + // TWO-CHANNEL strip (DR-037): run boons EXPIRE at home — one range-strip clears every + // boon-band StatModifier (replicates via the [GhostField] buffer; StatRecompute reverts the + // effective stats on both worlds) and zeroes any straggler offer. Class/meta/equip bands are + // disjoint and survive. Idempotent — safe on every Returning tick. + foreach (var (mods, offer) in + SystemAPI.Query, RefRW>().WithAll()) + { + TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase, + Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan); + offer.ValueRW = default; + } + + // Clear EVERY ready flag — the next run needs a fresh, deliberate ready-check from everyone. + foreach (var ready in SystemAPI.Query>().WithAll()) + ready.ValueRW.Value = 0; + + run.RewardGraceTick = 0u; + run.RouteGraceTick = 0u; // gate hygiene (review F5): no stale grace into the next run + if (SystemAPI.HasComponent(dirEntity)) + SystemAPI.SetComponent(dirEntity, default(RouteCommand)); // no leftover latch either + run.WasAllReady = 0; + run.LastTerminalCleared = 0; + + info.CurrentRoom = 0; + info.RouteOptionCount = 0; + info.LaunchTick = 0u; + info.Lifecycle = RunLifecycle.Staging; + break; + } + } + + // Single write-back point — RunInfo/RunRuntime are ALWAYS published (F12: the HUD readout can never + // freeze stale behind a branch's early-break). + SystemAPI.SetComponent(dirEntity, info); + SystemAPI.SetComponent(dirEntity, run); + } + + /// + /// Enter room (, ): publish the node as the single plan + /// authority (/CurrentRoomType — the field/enemy directors NEVER + /// re-derive it), flip the ping-pong sub-slot, bump so the room systems + /// reseed, and teleport the party onto the new origin (Position write in place — never FromPosition). + /// + void EnterRoom(ref SystemState state, ref RunInfo info, ref RunRuntime run, in RunMap map, + int layer, int col, float3 baseCenter, bool bumpEpoch) + { + var node = map.Node(layer, col); + run.ActiveSubSlot = (byte)(layer & 1); + run.CurrentNodeId = RunMap.NodeId(layer, col); + run.CurrentCol = (byte)col; + run.CurrentRoomType = node.RoomType; + if (bumpEpoch) + run.RoomEpoch += 1; + + info.CurrentRoom = layer; + info.CurrentCol = (byte)col; + info.CurrentRoomType = node.RoomType; + info.CurrentBiome = node.Biome; + info.RouteOptionCount = 0; + + float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); + int idx = 0; + foreach (var (region, xform) in + SystemAPI.Query, RefRW>().WithAll()) + { + region.ValueRW.Region = RegionId.Expedition; + var p = roomOrigin; + p.x += 1.5f * idx; // small spread so kinematic capsules don't stack + p.y = xform.ValueRO.Position.y; + xform.ValueRW.Position = p; + idx++; + } + + info.Lifecycle = RunLifecycle.InRoom; + } + } +} diff --git a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs.meta b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs.meta new file mode 100644 index 000000000..bc3751a4d --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b7c36de338378264e9aa0ad2c2512e7f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs index 8303b7689..27b9484e3 100644 --- a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs @@ -11,8 +11,9 @@ namespace ProjectM.Server /// Server-only composite ThreatDirector — the data-driven base-attack SCHEDULER. It owns the decision of WHEN /// and HOW BIG a siege is; owns the Calm↔Siege transition. The single documented /// hand-off is (this system sets it; CyclePhaseSystem consumes it). - /// This slice wires ONE source — POST-EXPEDITION retaliation: a player returning to base (counted as - /// by ) arms a siege of + /// This slice wires ONE source — POST-EXPEDITION retaliation: a completed RUN (banked on the boss-clear + /// return by into — the retired + /// walk-in ExpeditionGateSystem's carry, Step 11) arms a siege ofe of /// Husks after a /// telegraph. The Heat/Schedule sources are reserved (config baked-but-inert) so they drop in additively with /// no re-bake. It also enforces a BOUNDED siege lifetime (): an @@ -24,7 +25,7 @@ namespace ProjectM.Server [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(ExpeditionGateSystem))] + [UpdateAfter(typeof(RunDirectorSystem))] [UpdateBefore(typeof(CyclePhaseSystem))] public partial struct ThreatDirectorSystem : ISystem { diff --git a/Assets/_Project/Scripts/Simulation/Building/AbilityUpgradeRequest.cs b/Assets/_Project/Scripts/Simulation/Building/AbilityUpgradeRequest.cs deleted file mode 100644 index 1f31ca966..000000000 --- a/Assets/_Project/Scripts/Simulation/Building/AbilityUpgradeRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Client -> server request to upgrade the sender's ability damage one tier, spending Aether from the - /// shared ledger. A one-off RPC. The server grows a single damage on the - /// player (replace-by-SourceId so the buffer stays bounded), which StatRecomputeSystem folds into - /// EffectiveAbilityStats.Damage on both worlds — no new replicated component. - /// - public struct AbilityUpgradeRequest : IRpcCommand { } -} diff --git a/Assets/_Project/Scripts/Simulation/Building/AbilityUpgradeRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Building/AbilityUpgradeRequest.cs.meta deleted file mode 100644 index c46c2812c..000000000 --- a/Assets/_Project/Scripts/Simulation/Building/AbilityUpgradeRequest.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 1236d3751a5740741a4a10e0a653565f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs b/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs new file mode 100644 index 000000000..edd0b81d1 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs @@ -0,0 +1,180 @@ +using Unity.Collections; +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// One authored boon in the catalog blob: a thin wrapper over the existing stat pipeline — + /// // map 1:1 onto a row + /// (bytes, never enums, on the baked path). is the stable APPEND-ONLY key the replicated + /// BoonOffer options and pick RPC carry. is the rarity draw weight + /// (common 100 / rare 30 / epic 10). gates by class: bit0 = Warrior (classId 0), + /// bit1 = Ranger (classId 1), 3 = both. + /// + public struct BoonDefBlob + { + public byte Id; + public byte Target; // StatTarget as byte + public byte Op; // ModOp as byte + public float Value; + public byte Weight; + public byte ClassMask; + public FixedString64Bytes Name; + public FixedString128Bytes Desc; + } + + /// The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern). + public struct BoonCatalogBlob + { + public BlobArray Defs; + } + + /// Singleton component carrying the baked catalog (place ONE BoonCatalogAuthoring in the subscene). + public struct BoonCatalog : IComponentData + { + public BlobAssetReference Value; + } + + /// + /// Server-only bookkeeping for BoonOfferSystem, attached at runtime beside the catalog singleton (the + /// RoomFieldState idiom): the RoomEpoch offers were last drawn for — int equality, one offer set per room. + /// + public struct BoonOfferState : IComponentData + { + public int OfferedRoomEpoch; + } + + /// + /// Pure, deterministic boon selection math — integer-hash only ( chain, + /// no RNG state), so an offer is a reproducible function of (runSeed, room, player). EditMode-tested. + /// + public static class BoonMath + { + /// Class-mask bit for a wire class id (0 = Warrior, 1 = Ranger). + public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1)); + + /// + /// Draw 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per + /// . If the class-legal pool has fewer than 3 entries the tail repeats the + /// last-drawn candidates (a catalog authoring smell, not a crash). Returns the number of distinct ids. + /// + public static int PickBoons(uint offerSeed, byte classId, ref BoonCatalogBlob pool, + out byte o0, out byte o1, out byte o2) + { + byte classBit = MaskFor(classId); + + // Class-legal candidate indices + the total weight. + var candidates = new FixedList128Bytes(); + int totalWeight = 0; + for (int i = 0; i < pool.Defs.Length && candidates.Length < candidates.Capacity; i++) + { + if ((pool.Defs[i].ClassMask & classBit) == 0) continue; + if (pool.Defs[i].Weight == 0) continue; + candidates.Add((byte)i); + totalWeight += pool.Defs[i].Weight; + } + + o0 = o1 = o2 = 0; + if (candidates.Length == 0) + return 0; + + var picked = new FixedList32Bytes(); // picked catalog indices + uint salt = 0; + while (picked.Length < 3 && picked.Length < candidates.Length) + { + // Weighted draw with rejection on duplicates (bounded; falls through to a linear fill). + uint roll = RunMapMath.Hash(offerSeed, (uint)picked.Length, salt) % (uint)totalWeight; + byte drawn = candidates[candidates.Length - 1]; + int acc = 0; + for (int c = 0; c < candidates.Length; c++) + { + acc += pool.Defs[candidates[c]].Weight; + if (roll < (uint)acc) { drawn = candidates[c]; break; } + } + + bool dup = false; + for (int p = 0; p < picked.Length; p++) + if (picked[p] == drawn) { dup = true; break; } + + if (!dup) + { + picked.Add(drawn); + salt = 0; + } + else if (++salt > 16) + { + // Rejection budget spent — take the first unpicked candidate (still deterministic). + for (int c = 0; c < candidates.Length; c++) + { + bool used = false; + for (int p = 0; p < picked.Length; p++) + if (picked[p] == candidates[c]) { used = true; break; } + if (!used) { picked.Add(candidates[c]); break; } + } + salt = 0; + } + } + + o0 = picked.Length > 0 ? pool.Defs[picked[0]].Id : (byte)0; + o1 = picked.Length > 1 ? pool.Defs[picked[1]].Id : o0; + o2 = picked.Length > 2 ? pool.Defs[picked[2]].Id : o1; + return picked.Length; + } + + /// Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids). + public static int FindDef(ref BoonCatalogBlob pool, byte id) + { + for (int i = 0; i < pool.Defs.Length; i++) + if (pool.Defs[i].Id == id) return i; + return -1; + } + } + + /// + /// The DEFAULT v1 boon table + the blob builder the baker AND EditMode tests share (single source — the + /// authoring bakes this table verbatim when its designer-row list is empty). Append-only ids. + /// + public static class BoonCatalogData + { + /// Build the default catalog blob (caller owns/disposes the reference). + public static BlobAssetReference BuildDefault(Allocator allocator = Allocator.Persistent) + { + var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var defs = builder.Allocate(ref root.Defs, 12); + int i = 0; + // id, target, op, value, weight, mask(1=Warrior,2=Ranger,3=both), name, desc + defs[i++] = Make(1, StatTarget.Damage, ModOp.PercentAdd, 0.20f, 100, 3, "Honed Edge", "+20% ability damage"); + defs[i++] = Make(2, StatTarget.CooldownTicks, ModOp.PercentMult, -0.15f, 100, 3, "Swift Hands", "-15% ability cooldown"); + defs[i++] = Make(3, StatTarget.Range, ModOp.PercentAdd, 0.25f, 100, 2, "Long Reach", "+25% projectile range"); + defs[i++] = Make(4, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.12f, 100, 3, "Fleet Foot", "+12% move speed"); + defs[i++] = Make(5, StatTarget.MaxHealth, ModOp.Flat, 25f, 100, 3, "Iron Constitution", "+25 max health"); + defs[i++] = Make(6, StatTarget.MeleeDamage, ModOp.PercentAdd, 0.25f, 100, 1, "Heavy Blows", "+25% melee damage"); + defs[i++] = Make(7, StatTarget.MeleeRange, ModOp.PercentAdd, 0.20f, 60, 1, "Extended Haft", "+20% melee reach"); + defs[i++] = Make(8, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 0.25f, 60, 2, "Swift Bolts", "+25% projectile speed"); + defs[i++] = Make(9, StatTarget.AutoTargetRange, ModOp.PercentAdd, 0.20f, 60, 3, "Keen Instinct", "+20% auto-target range"); + defs[i++] = Make(10, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 30, 3, "Berserker's Pace", "-25% ability cooldown"); + defs[i++] = Make(11, StatTarget.MaxHealth, ModOp.Flat, 60f, 30, 3, "Titan's Vigor", "+60 max health"); + defs[i++] = Make(12, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 10, 3, "Executioner", "+50% ability damage"); + var blob = builder.CreateBlobAssetReference(allocator); + builder.Dispose(); + return blob; + } + + static BoonDefBlob Make(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask, + string name, string desc) + { + return new BoonDefBlob + { + Id = id, + Target = (byte)target, + Op = (byte)op, + Value = value, + Weight = weight, + ClassMask = mask, + Name = new FixedString64Bytes(name), + Desc = new FixedString128Bytes(desc), + }; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs.meta new file mode 100644 index 000000000..ef05dedf3 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 00909311d983d7a43afc195595aff217 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs b/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs new file mode 100644 index 000000000..177ba19bf --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs @@ -0,0 +1,17 @@ +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Client → server boon pick: (0/1/2) into the sender's OWN replicated BoonOffer + /// options. Server-validated (Pending==1, index in range, RunInfo.Lifecycle==RoomReward — the + /// D-F4 gate that stops a grace-timeout straggler pick landing after the Returning-edge strip). UNCONDITIONAL + /// wire type, blittable scalar. Declared at Step 3 (wire front-load); consumed by BoonApplySystem from + /// Step 10. + /// + public struct BoonPickRequest : IRpcCommand + { + /// The chosen option slot: 0, 1, or 2. + public byte Index; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs.meta new file mode 100644 index 000000000..e391550df --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/BoonPickRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cc9680d4a4c9a334396b60bb97d75b3b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs b/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs index dbd64c494..4ffafe944 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs @@ -39,6 +39,12 @@ namespace ProjectM.Simulation public static byte AbilityFor(byte classId) => classId == RangerClass ? (byte)AbilityId.Primary : (byte)AbilityId.WarriorCone; + /// The class a Fire-slot ability id implies — the exact inverse of (Ranger + /// iff Primary). Lets the CLIENT derive the local class from the replicated (tracks + /// the dev class-switch, unlike the menu's ClassSelection static; PlayerClass itself is server-only). + public static byte ClassForAbility(byte abilityId) + => abilityId == (byte)AbilityId.Primary ? RangerClass : WarriorClass; + /// True when a modifier's SourceId is in the reserved class-seed range [ClassSourceId, +ClassSeedCount). public static bool IsClassSeed(uint sourceId) => sourceId >= Tuning.ClassSourceId && sourceId < Tuning.ClassSourceId + (uint)ClassSeedCount; diff --git a/Assets/_Project/Scripts/Simulation/Combat/StatModifier.cs b/Assets/_Project/Scripts/Simulation/Combat/StatModifier.cs index a220a9074..fe15a8184 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/StatModifier.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/StatModifier.cs @@ -16,7 +16,9 @@ namespace ProjectM.Simulation /// enum-codegen hazard that already de-Bursted ProjectileClassificationSystem. /// [GhostComponent(OwnerSendType = SendToOwnerType.All)] - [InternalBufferCapacity(8)] + // Capacity 32 (was 8): class seeds + permanent meta rows + equip + up to ~10 run boons must stay chunk-internal + // (a chunk-layout hint only — NOT ghost serializer metadata, so this is NOT a re-bake; overflow spills to heap). + [InternalBufferCapacity(32)] public struct StatModifier : IBufferElementData { /// The this modifier applies to (stored as a byte). diff --git a/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs b/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs index 44c06a8d4..6d3c9611e 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/TimedModifier.cs @@ -31,5 +31,16 @@ namespace ProjectM.Simulation if (mods[j].SourceId == sourceId) { mods.RemoveAtSwapBack(j); removed++; } return removed; } + + /// Remove every row whose SourceId lies in [lo, hiExclusive) — the + /// run-scoped BOON strip (per-pick distinct ids share the band; one call clears the whole run's boons while + /// class/meta/equip bands survive untouched). Idempotent; RemoveAtSwapBack. Returns the count removed. + public static int RemoveBySourceIdRange(DynamicBuffer mods, uint lo, uint hiExclusive) + { + int removed = 0; + for (int j = mods.Length - 1; j >= 0; j--) + if (mods[j].SourceId >= lo && mods[j].SourceId < hiExclusive) { mods.RemoveAtSwapBack(j); removed++; } + return removed; + } } } diff --git a/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs b/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs deleted file mode 100644 index 035bc096b..000000000 --- a/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// Baked singleton describing the HOME-BASE mining field: a ring of harvestable resource nodes scattered - /// around the base so the gather -> build -> survive loop lives on ONE screen (no expedition trip required). - /// DISTINCT from (the expedition field) so the two never collide on a - /// GetSingleton. keeps the live RegionTag{Base} node count - /// topped up to on a tick cadence; nodes scatter UNIFORMLY-IN-RADIUS in the annulus - /// [, ] around BaseGridMath.PlotCenter (inner clears the - /// square build plot + spawn ring, outer stays inside the walkable boundary ring) and are overridden to - /// RegionTag{Base} + ResourceId.Ore (the sole build currency, kept legible — never the expedition's - /// Aether/Ore/Biomass round-robin). Place ONE authoring in the gameplay subscene. - /// - public struct BaseFieldSpawner : IComponentData - { - /// Baked resource-node ghost prefab to instantiate (reuses the expedition node prefab). - public Entity Prefab; - - /// Desired live base-node count; the system refills toward this each respawn pass. - public int TargetCount; - - /// Inner scatter radius (world units) from the plot center — must clear the build plot + spawn ring. - public float InnerRadius; - - /// Outer scatter radius (world units) — must stay inside the boundary ring so nodes are reachable. - public float OuterRadius; - - /// Server ticks between top-up passes (a depleted field refills toward TargetCount on this cadence). - public int RespawnIntervalTicks; - } - - /// - /// Server-only runtime state for , baked beside - /// . NOT replicated. seeds the per-node scatter RNG - /// (monotonic, so a top-up never repeats a layout); gates the cadence (wrap-safe - /// via TickUtil.NonZero + NetworkTick.IsNewerThan, never raw uint). NextSpawnTick == 0 means "fire now" so the - /// first pass seeds the field without waiting for a tick that never comes. - /// - public struct BaseFieldRuntime : IComponentData - { - public int Epoch; - public uint NextSpawnTick; - } -} diff --git a/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs.meta b/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs.meta deleted file mode 100644 index 34b6ade31..000000000 --- a/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7a18d4457f559ef49bcd3122dcdb6d82 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Economy/ResourceFieldSpawner.cs b/Assets/_Project/Scripts/Simulation/Economy/ResourceFieldSpawner.cs index 1bc3cba37..43933f576 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/ResourceFieldSpawner.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/ResourceFieldSpawner.cs @@ -18,4 +18,14 @@ namespace ProjectM.Simulation /// Scatter radius (world units) around the expedition region origin. public float Radius; } + + /// + /// Server-only bookkeeping for , attached at runtime beside the + /// baked spawner singleton (the BaseFieldRuntime idiom): the RunRuntime.RoomEpoch this system last seeded, + /// int-equality-compared so each room's field scatters exactly once. NOT replicated; session-scoped. + /// + public struct RoomFieldState : IComponentData + { + public int LastSpawnedRoomEpoch; + } } diff --git a/Assets/_Project/Scripts/Simulation/Meta.meta b/Assets/_Project/Scripts/Simulation/Meta.meta new file mode 100644 index 000000000..aba38dcff Binary files /dev/null and b/Assets/_Project/Scripts/Simulation/Meta.meta differ diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs b/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs new file mode 100644 index 000000000..157c4db3d --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs @@ -0,0 +1,116 @@ +using Unity.Collections; +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// One authored PERMANENT meta upgrade: tiered (buy tier owned+1 up to ), priced in Aether + /// with a linear ramp (cost(owned) = BaseCost + owned*CostGrowth), class-gated by + /// (bit0 = Warrior, bit1 = Ranger — resolve via , NEVER a raw 1<<ClassId: + /// the stored ClassId is the normalized CharacterId 2/3). is the stable APPEND-ONLY key + /// persisted in SaveData v6 and keyed into the live StatModifier as Tuning.MetaSourceIdBase + Id. + /// DISTINCT from — boons are run-scoped single-shots; overloading one catalog would + /// blur the two channels (DR-037). = 0xFF means no prerequisite (v1 ships a FLAT catalog + /// — the operator default; trees are an authoring change, not a code change). + /// + public struct MetaUpgradeDefBlob + { + public byte Id; + public byte ClassMask; + public byte Target; // StatTarget as byte + public byte Op; // ModOp as byte + public byte MaxTier; + public float ValuePerTier; + public int BaseCost; + public int CostGrowth; + public byte PrereqId; // 0xFF = none + public byte PrereqTier; + public FixedString64Bytes Name; + public FixedString128Bytes Desc; + } + + /// The baked permanent-upgrade pool (config blob, both worlds, NOT replicated). + public struct MetaUpgradeCatalogBlob + { + public BlobArray Defs; + } + + /// Singleton carrying the baked meta catalog (ONE MetaCatalogAuthoring in the gameplay subscene). + public struct MetaUpgradeCatalog : IComponentData + { + public BlobAssetReference Value; + } + + /// Pure helpers over the meta catalog + the director's record. + public static class MetaMath + { + /// Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids). + public static int FindDef(ref MetaUpgradeCatalogBlob pool, byte id) + { + for (int i = 0; i < pool.Defs.Length; i++) + if (pool.Defs[i].Id == id) return i; + return -1; + } + + /// The owned tier of (class, upgrade) in the record buffer (absent row = 0). + public static byte TierOf(DynamicBuffer record, byte classId, byte upgradeId) + { + for (int i = 0; i < record.Length; i++) + if (record[i].ClassId == classId && record[i].UpgradeId == upgradeId) return record[i].Tier; + return 0; + } + + /// Aether cost of buying tier owned+1 (linear ramp; compute from the CLAMPED owned tier). + public static int CostForTier(in MetaUpgradeDefBlob def, byte ownedClamped) + => def.BaseCost + ownedClamped * def.CostGrowth; + } + + /// + /// The DEFAULT v1 meta table + the blob builder the baker AND EditMode tests share (the BoonCatalogData + /// pattern; an empty designer-row list on the authoring bakes this verbatim). FLAT catalog — every + /// PrereqId = 0xFF (operator default: validate the economy before prereq trees). Ids append-only. + /// Priced for the 15%-Aether node economy (~2-5 Aether per lucky room). + /// + public static class MetaCatalogData + { + public static BlobAssetReference BuildDefault(Allocator allocator = Allocator.Persistent) + { + var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var defs = builder.Allocate(ref root.Defs, 8); + int i = 0; + // id, mask(1=Warrior,2=Ranger,3=both), target, op, maxTier, valuePerTier, baseCost, growth, name, desc + defs[i++] = Make(1, 3, StatTarget.MaxHealth, ModOp.Flat, 5, 15f, 10, 5, "Reinforced Frame", "+15 max health per tier"); + defs[i++] = Make(2, 3, StatTarget.Damage, ModOp.PercentAdd, 5, 0.08f, 12, 6, "Sharpened Arsenal", "+8% ability damage per tier"); + defs[i++] = Make(3, 3, StatTarget.CooldownTicks, ModOp.PercentMult, 3, -0.06f, 15, 10, "Swift Recovery", "-6% ability cooldown per tier"); + defs[i++] = Make(4, 3, StatTarget.MoveSpeed, ModOp.PercentAdd, 3, 0.05f, 10, 8, "Fleet Stride", "+5% move speed per tier"); + defs[i++] = Make(5, 1, StatTarget.MeleeDamage, ModOp.PercentAdd, 4, 0.10f, 12, 6, "Warrior's Might", "+10% melee damage per tier"); + defs[i++] = Make(6, 1, StatTarget.MeleeRange, ModOp.PercentAdd, 3, 0.08f, 10, 6, "Warrior's Reach", "+8% melee reach per tier"); + defs[i++] = Make(7, 2, StatTarget.Range, ModOp.PercentAdd, 4, 0.10f, 12, 6, "Ranger's Longshot", "+10% projectile range per tier"); + defs[i++] = Make(8, 2, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 3, 0.10f, 10, 6, "Ranger's Velocity", "+10% projectile speed per tier"); + var blob = builder.CreateBlobAssetReference(allocator); + builder.Dispose(); + return blob; + } + + static MetaUpgradeDefBlob Make(byte id, byte mask, StatTarget target, ModOp op, byte maxTier, + float valuePerTier, int baseCost, int growth, string name, string desc) + { + return new MetaUpgradeDefBlob + { + Id = id, + ClassMask = mask, + Target = (byte)target, + Op = (byte)op, + MaxTier = maxTier, + ValuePerTier = valuePerTier, + BaseCost = baseCost, + CostGrowth = growth, + PrereqId = 0xFF, + PrereqTier = 0, + Name = new FixedString64Bytes(name), + Desc = new FixedString128Bytes(desc), + }; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs.meta b/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs.meta new file mode 100644 index 000000000..dfe41e314 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Meta/MetaCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ee5e173c41773dc4189279241bf322b7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs b/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs new file mode 100644 index 000000000..b9e2c7535 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs @@ -0,0 +1,63 @@ +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// One owned permanent meta-upgrade tier, keyed by (class, upgrade). The CycleDirector's DynamicBuffer of these is + /// the authoritative per-class meta-progression record. A GLOBAL [GhostField] buffer on the ownerless + /// interpolated director ghost (no OwnerSendType — the party invests together and every client reads it for the + /// shop), mirroring . Persisted to disk (SaveData v6) and re-applied born-correct at + /// player spawn as meta-band s. Bytes only (Burst/serialization safe). Sparse — an + /// absent (class, upgrade) row means tier 0. + /// + [InternalBufferCapacity(24)] + public struct MetaTierState : IBufferElementData + { + /// Owning class id (Warrior/Ranger — the CharacterId anchor). + [GhostField] public byte ClassId; + /// Upgrade id (append-only key into the meta catalog). + [GhostField] public byte UpgradeId; + /// Owned tier (>=1; an absent row = 0). + [GhostField] public byte Tier; + } + + /// + /// Server-only singleton on the CycleDirector: the FIRST-COMMIT latch for the co-op route choice. Written IN-PLACE + /// (immediate SystemAPI.SetComponent, not a deferred ECB) inside RouteSelectSystem's drain loop so two + /// same-tick picks cannot both observe ==0 (the DR-014 atomicity idiom). NOT replicated. + /// + public struct RouteCommand : IComponentData + { + /// 1 once a route has been committed for the current (RunEpoch, layer). + public byte HasPick; + /// The committed option index (into the replicated RouteOpt* set). + public byte OptionIndex; + /// Run epoch the pick is for (stale-reject guard). + public int ForRunEpoch; + /// Layer the pick is for (stale-reject guard). + public int ForLayer; + } + + /// + /// Server-only persisted meta counters on the CycleDirector (mirrored to the replicated for + /// the HUD). Added UNCONDITIONALLY at director spawn (like CycleRuntime/ThreatState/RunPhase) so a New-Game boot + /// has the component the bank block reads; restored values are SetComponent'd only inside the save-present block. + /// NOT replicated. + /// + public struct MetaCounters : IComponentData + { + public int RunsCompleted; + public int MaxDepthReached; + } + + /// + /// Server-only tag of a player's class id, added at spawn by GoInGameServerSystem. Lets the meta systems + /// resolve which per-class tier record to seed / spend against (class was previously only an AbilityRef / + /// wire concern). NOT replicated. + /// + public struct PlayerClass : IComponentData + { + public byte ClassId; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs.meta new file mode 100644 index 000000000..347716e10 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Meta/MetaComponents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 307374d6819017f4da4bbfe64f11e7c5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs b/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs new file mode 100644 index 000000000..3793f4d45 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs @@ -0,0 +1,18 @@ +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Client → server permanent meta-upgrade purchase: keys the baked meta catalog. The + /// TIER is SERVER-COMPUTED (a purchase always buys owned+1) — putting a tier on the wire would invite + /// desync/cheat. Server-validated (RunInfo.Lifecycle==Staging phase gate, class mask, prereq, MaxTier, + /// Aether affordability) with DR-014 in-loop ledger atomicity so two same-tick purchases on barely-enough Aether + /// cannot both pass. UNCONDITIONAL wire type. Declared at Step 3 (wire front-load); consumed by + /// MetaSpendSystem from Step 13. + /// + public struct MetaSpendRequest : IRpcCommand + { + /// Meta-catalog upgrade id (append-only key). + public byte UpgradeId; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs.meta new file mode 100644 index 000000000..8d9b5a01a --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Meta/MetaSpendRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 71ddd683487e8704197b8eeddcb2c339 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs b/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs new file mode 100644 index 000000000..def18d911 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs @@ -0,0 +1,38 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// The ONE collector of the permanent-meta save slice (the SaveStructureScan idiom): reads the director's + /// replicated buffer + server-only into the + /// v6 fields. Shared by BOTH save writers — SaveWriteSystem (autosave) AND + /// WorldLauncher.TrySaveFromServer (quit-to-menu) — so the writers can never drift; the quit path + /// silently omitting these fields would WIPE all permanent progression on the most common exit (the meta + /// review's top blocker). Rows are copied VERBATIM (unknown ids round-trip). + /// + public static class MetaSaveScan + { + public static void Collect(EntityManager em, Entity director, + out MetaUpgradeSave[] rows, out int runsCompleted, out int maxDepthReached) + { + rows = System.Array.Empty(); + runsCompleted = 0; + maxDepthReached = 0; + + if (em.HasBuffer(director)) + { + var buf = em.GetBuffer(director, true); + rows = new MetaUpgradeSave[buf.Length]; + for (int i = 0; i < buf.Length; i++) + rows[i] = new MetaUpgradeSave { ClassId = buf[i].ClassId, UpgradeId = buf[i].UpgradeId, Tier = buf[i].Tier }; + } + + if (em.HasComponent(director)) + { + var counters = em.GetComponentData(director); + runsCompleted = counters.RunsCompleted; + maxDepthReached = counters.MaxDepthReached; + } + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs.meta new file mode 100644 index 000000000..67a201a7f --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/MetaSaveScan.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 83b313e94b9f4304993358deca12e6e2 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs index a82508297..8ebcf9e7a 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs @@ -22,6 +22,10 @@ namespace ProjectM.Simulation /// 2 = Loss -> the run loads finished + halted, no re-arm). Born-correct at director spawn. public byte RunOutcome; + /// v6: persisted run counters to restore into MetaCounters + the born-correct RunInfo HUD mirror. + public int RunsCompleted; + public int MaxDepthReached; + /// 0 = nothing staged (New Game); non-zero = apply the staged slice at director spawn. public byte HasData; } @@ -32,6 +36,17 @@ namespace ProjectM.Simulation public ushort ItemId; public int Count; } + + /// One staged PERMANENT meta tier for a Continue session (v6) — copied VERBATIM into the director's + /// replicated MetaTierState buffer at spawn (unknown ids preserved for round-trip; clamping happens only at + /// seed/shop/spend). The buffer is added UNCONDITIONALLY at staging (empty OK) — the Bursted spawn system + /// GetBuffers it inside the HasData block and a missing buffer would throw on any v<=5 Continue. + public struct PendingMetaRow : IBufferElementData + { + public byte ClassId; + public byte UpgradeId; + public byte Tier; + } /// One staged player-built structure row for a Continue session (M7); BaseRestoreSystem replays it /// charge-free into the freshly-streamed base. Mirrors but as an unmanaged ECS /// buffer element (staged in the ServerWorld before the subscene streams). diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs index a96924a88..41fd9a7ea 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs @@ -9,6 +9,18 @@ namespace ProjectM.Simulation public int ItemId; public int Count; } + + /// One persisted per-class PERMANENT meta-upgrade tier (v6). ClassId = the normalized CharacterId + /// (Warrior=2/Ranger=3); UpgradeId = the append-only meta-catalog key (unknown ids round-trip preserved and are + /// skipped live); Tier clamps to the catalog's MaxTier at seed/shop/spend, never at rest. + [Serializable] + public struct MetaUpgradeSave + { + public byte ClassId; + public byte UpgradeId; + public byte Tier; + } + /// /// One serialized player-built structure (M7). Flat scalars (JsonUtility has no int2). The production /// cooldown is stored as REMAINING ticks (epoch-independent) so it survives the server-tick origin reset on a @@ -51,7 +63,7 @@ namespace ProjectM.Simulation [Serializable] public class SaveData { - public const int CurrentVersion = 5; // END-2: v5 adds RunOutcome (a won/lost run loads finished); v4 added CoreCurrent + public const int CurrentVersion = 6; // v6: permanent META (per-class upgrade tiers + run counters); v5 added RunOutcomeoreCurrent /// Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP. public const int MinLoadableVersion = 2; @@ -62,6 +74,11 @@ namespace ProjectM.Simulation public int CoreCurrent; // END-1: Engine Core integrity at save time (0 from a pre-v4 save -> restored to baked Max) public int RunOutcome; // END-2: 0=InProgress (also any pre-v5 save) / 1=Victory / 2=Loss -> a finished run loads finished + // v6 — permanent meta-progression (0/empty-defaults on any v<=5 save): + public int RunsCompleted; // boss-cleared runs (the HUD counter + the HostSalt fold at restore) + public int MaxDepthReached; // deepest room actually CLEARED across all runs (honest depth, never planned) + public MetaUpgradeSave[] MetaUpgrades = Array.Empty(); // sparse per-class tiers + public LedgerRow[] Ledger = Array.Empty(); public StructureSave[] Structures = Array.Empty(); public StructureIoRow[] StructureIo = Array.Empty(); diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs index 2a8a0ade8..14aa86cd7 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs @@ -27,6 +27,7 @@ namespace ProjectM.Simulation // field 0-defaults and the restore guard maps 0 -> baked Max); v0/v1 garbage is still rejected. if (data == null || data.Version < SaveData.MinLoadableVersion || data.Version > SaveData.CurrentVersion) return null; data.Ledger ??= Array.Empty(); + data.MetaUpgrades ??= Array.Empty(); // v6: null on any v<=5 file(); return data; } catch (Exception e) diff --git a/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs b/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs new file mode 100644 index 000000000..bfea5e92f --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs @@ -0,0 +1,28 @@ +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// A player's private choice-of-3 boon offer for the just-cleared room. OWNER-ONLY replication + /// (): the offer is an observe-only HUD read of the LOCAL player — no + /// prediction, no teammate read — so the traffic-minimal owner-only path is correct (Play-validated at Step 9; + /// the proven fallback is , which for HUD purposes still only surfaces each + /// player's component on the client that owns that ghost). Written by BoonOfferSystem on the RoomReward + /// entry edge (options drawn deterministically from Hash(RunSeed, room, NetworkId)); is + /// cleared by BoonApplySystem on a valid pick and zeroed by the Returning-edge strip. INERT until Step 9 — + /// baked at Step 3 so the player ghost re-bakes exactly ONCE for the whole redesign. + /// + [GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)] + public struct BoonOffer : IComponentData + { + /// 1 = awaiting this player's pick. + [GhostField] public byte Pending; + /// Boon catalog id of option 0. + [GhostField] public byte Option0; + /// Boon catalog id of option 1. + [GhostField] public byte Option1; + /// Boon catalog id of option 2. + [GhostField] public byte Option2; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs.meta b/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs.meta new file mode 100644 index 000000000..11c8e323d --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Player/BoonOffer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c3c9cb8a1b819fd4b8474206e766076e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs b/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs new file mode 100644 index 000000000..2c04cb856 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs @@ -0,0 +1,20 @@ +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// The player's expedition ready-check flag — a plain send-to-all [GhostField] byte on the player ghost so + /// EVERY client can render the "N/M READY" staging panel (owner-only would hide teammates' readiness). Written + /// server-only by ReadyToggleSystem from the RPC — honored during Staging + /// AND the Launching countdown (an un-ready during the countdown is the launch-abort escape hatch); cleared for + /// all players by RunDirectorSystem on the Returning edge. The N/M derivation counts live PlayerTag ghosts, + /// which is valid ONLY because the party is co-located at base while Staging (the co-location invariant — N7); + /// ready toggles are refused in every other lifecycle state. + /// + public struct PlayerReady : IComponentData + { + /// 1 = ready to launch; 0 = not ready. + [GhostField] public byte Value; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs.meta b/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs.meta new file mode 100644 index 000000000..045ce581f --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Player/PlayerReady.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5423c043f8023a246b4244eb34e5fbdd \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 0ad55bc0f..d9eb069c2 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -74,6 +74,22 @@ namespace ProjectM.Simulation /// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives. public const int StartingOre = 50; + // ---- Expedition run economy (RoomFieldSystem / RunDirectorSystem) ---- + + /// Run-wide resource-node allotment: RunDirectorSystem stages it into RunRuntime.NodeBudgetRemaining + /// at the launch edge; RoomFieldSystem floors each room's scatter to what remains and spends it down. THE + /// scarcity knob — ~1.5 nodes/room over an 8-room run (dense Reward rooms drain it fastest; late rooms can + /// run dry, which is the intended tension). + public const int ExpeditionNodeBudget = 12; + + /// Boss-room health multiplier over the base enemy prefab (RoomEnemyDirectorSystem spawns ONE + /// boss per Boss room; v1 = a beefed Charger — a real boss kit is a later rung). + public const float BossHealthMultiplier = 8f; + + /// Boss-room visual scale multiplier (LocalTransform.Scale is a replicated [GhostField] — set once + /// at spawn on the baked value, legible at a glance). + public const float BossScaleMultiplier = 1.6f; + // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- /// Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger. @@ -87,7 +103,26 @@ namespace ProjectM.Simulation // inline mods share that one id and are stripped target-agnostically via // TimedModifierUtil.RemoveBySourceId on unequip/swap. Full StatModifier SourceId map (keep DISJOINT): // 0u = pickups + debug-injection; 0x00A0E711 = ability-damage upgrade; 0x00DEB061 = debug stat command; - // 0x00E91000.. = equipment (4 slots); 0x00C1A550.. = class traits (Slice 2, permanent). + // 0x00B00000..0x00B10000 = run-scoped BOONS (stripped on return); 0x00C1A550.. = class traits (permanent); + // 0x00E7A000..0x00E7A100 = permanent META upgrades (Step 12a); 0x00E91000.. = equipment (4 slots). + + /// Base of the run-scoped BOON SourceId band: each applied pick draws BoonSourceIdBase + + /// (BoonPickCounter++ % BoonSourceIdSpan), so boons stack as distinct rows and ONE range-strip on the + /// Returning edge clears the whole run's boons (the two-channel model — boons never persist). + public const uint BoonSourceIdBase = 0x00B00000u; + + /// Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count. + public const uint BoonSourceIdSpan = 0x10000u; + + /// Base of the PERMANENT meta-upgrade SourceId band: a purchased tier's live StatModifier is + /// keyed MetaSourceIdBase + UpgradeId (absolute-value UPSERT — one row per owned upgrade, set to + /// ValuePerTier*tier). Persisted via MetaTierState (SaveData v6) and re-applied born-correct at spawn. + /// DISJOINT from every other band; NEVER stripped (the permanent channel of the two-channel model). + public const uint MetaSourceIdBase = 0x00E7A000u; + + /// Width of the meta band [Base, Base+Span) — bounds UpgradeId to a byte-sized catalog. + public const uint MetaSourceIdSpan = 0x100u; + /// Base for per-slot equipment SourceIds; slot i tags its mods with EquipSourceIdBase + i. public const uint EquipSourceIdBase = 0x00E91000u; diff --git a/Assets/_Project/Scripts/Simulation/World/ExpeditionGate.cs b/Assets/_Project/Scripts/Simulation/World/ExpeditionGate.cs deleted file mode 100644 index a50cb085c..000000000 --- a/Assets/_Project/Scripts/Simulation/World/ExpeditionGate.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Unity.Entities; -using Unity.Mathematics; - -namespace ProjectM.Simulation -{ - /// - /// A walk-in travel gate between world regions. A baked entity (visible mesh + this component) at a fixed - /// position; the server ExpeditionGateSystem transits a player who walks within - /// and whose region matches to , placing them at - /// (offset from the destination gate so they do not immediately re-trigger). - /// Returning to the base during the Expedition phase also starts Defend early (the "timer cap + early - /// return" pacing). - /// - public struct ExpeditionGate : IComponentData - { - /// Region a player must currently be in for this gate to act on them (see ). - public byte FromRegion; - - /// Region the player is transited to. - public byte ToRegion; - - /// Planar (XZ) trigger radius in world units. - public float Radius; - - /// World position the player arrives at in the destination region. - public float3 ArrivalPos; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/ExpeditionGate.cs.meta b/Assets/_Project/Scripts/Simulation/World/ExpeditionGate.cs.meta deleted file mode 100644 index b87506ae6..000000000 --- a/Assets/_Project/Scripts/Simulation/World/ExpeditionGate.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ed28d6b4a4f0b0844b851cecaadeb93f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs b/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs new file mode 100644 index 000000000..210800b0c --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs @@ -0,0 +1,16 @@ +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Client → server ready-check toggle — an explicit SET (not a flip), so a duplicated/late RPC is idempotent. + /// UNCONDITIONAL wire type (never #if — the reflection-built RpcCollection hash must match across peers; only + /// send/receive SYSTEMS may be gated). Blittable scalar payload per the project RPC rules. Handled by + /// ReadyToggleSystem (Staging/Launching only). + /// + public struct ReadyToggleRequest : IRpcCommand + { + /// 1 = ready, 0 = not ready. + public byte Ready; + } +} diff --git a/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs.meta b/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs.meta new file mode 100644 index 000000000..1c553fb98 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/ReadyToggleRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7489e354b7c8d0a46a155fa1d2c22bcc \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RegionComponents.cs b/Assets/_Project/Scripts/Simulation/World/RegionComponents.cs index 0b479b7a5..cc5f235ee 100644 --- a/Assets/_Project/Scripts/Simulation/World/RegionComponents.cs +++ b/Assets/_Project/Scripts/Simulation/World/RegionComponents.cs @@ -35,14 +35,31 @@ namespace ProjectM.Simulation /// public static class RegionMath { - /// World-space X offset of the expedition region from the base region. + /// World-space X offset of the expedition region (room sub-slot 0) from the base region. public const float ExpeditionOffsetX = 1000f; - /// World-space origin of , given the base center (BaseGridMath.PlotCenter). + /// X stride between the two ping-pong room sub-slots — kept >= any sweep/AI/aggro range so two + /// transiently-coexisting arenas can never interact in the shared PhysicsWorld. + public const float RoomStrideX = 500f; + + /// + /// World-space origin of expedition room sub-slot (0 or 1 — the run FSM + /// ping-pongs consecutive rooms between two offsets so the next room spawns at the idle slot while the + /// cleared one is torn down). THE single expedition coordinate authority: every expedition placement + /// (field scatter, enemy ring, party teleport) resolves through here. + /// + public static float3 ExpeditionRoomOrigin(float3 baseCenter, byte subSlot) + { + return baseCenter + new float3(ExpeditionOffsetX + subSlot * RoomStrideX, 0f, 0f); + } + + /// World-space origin of , given the base center (BaseGridMath.PlotCenter). + /// The expedition resolves to room sub-slot 0 (legacy call sites; room-aware systems pass the ACTIVE + /// sub-slot to directly). public static float3 RegionOrigin(byte region, float3 baseCenter) { return region == RegionId.Expedition - ? baseCenter + new float3(ExpeditionOffsetX, 0f, 0f) + ? ExpeditionRoomOrigin(baseCenter, 0) : baseCenter; } } diff --git a/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs b/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs new file mode 100644 index 000000000..1b5a62ead --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs @@ -0,0 +1,139 @@ +using Unity.Mathematics; + +namespace ProjectM.Simulation +{ + /// + /// Pure, deterministic per-room layout math: resolves a map node () into a concrete + /// and scatters points within the room's shape. No RNG state (scatter takes a + /// caller-seeded by ref); no wall-clock — EditMode-unit-testable and save/replay reproducible + /// (mirrors / ). Archetype numbers (per-shape radius, per-type + /// node counts) are const tables here; an authored RoomArchetype blob can later back these when RoomFieldSystem + /// wants designer-tuned variety, without changing this signature's callers. + /// + public static class RoomLayoutMath + { + /// Resolve a map node + its depth into the concrete room spec the server lays out. + public static RoomPlan Plan(in RunMapNode node, int layer, int roomCount) + { + return new RoomPlan + { + RoomType = node.RoomType, + Biome = node.Biome, + ShapeId = node.ShapeId, + Radius = ShapeRadius(node.ShapeId), + NodeCount = BaseNodeCount(node.RoomType), + DifficultyEpoch = DifficultyEpoch(layer, node.RoomType), + }; + } + + /// + /// Depth-based difficulty rung fed to : deeper rooms are harder (layer+1 floor), + /// with Elite/Boss bumps. Lower-bounded at 1. Pure integer. + /// + public static int DifficultyEpoch(int layer, byte roomType) + { + int d = math.max(1, layer + 1); + if (roomType == RoomTypeId.Elite) d += 2; + if (roomType == RoomTypeId.Boss) d += 3; + return d; + } + + /// Base resource-node count per room type (before the run-wide scarcity budget floors it). Reward + /// rooms are dense; combat/elite lean; the Boss room is minimal. + public static int BaseNodeCount(byte roomType) + { + switch (roomType) + { + case RoomTypeId.Reward: return 5; + case RoomTypeId.Combat: return 2; + case RoomTypeId.Elite: return 2; + case RoomTypeId.Boss: return 1; + default: return 2; + } + } + + /// Arena scatter radius (world units) for a shape id. + public static float ShapeRadius(byte shapeId) + { + switch (shapeId) + { + case RoomShapeId.Wide: return 24f; + case RoomShapeId.Long: return 24f; + case RoomShapeId.Cross: return 22f; + case RoomShapeId.Disk: + default: return 18f; + } + } + + /// + /// Deterministic scatter of point of within the room's + /// shape around , using a caller-seeded RNG. Every returned point satisfies + /// for the same shape/center (asserted in tests). Y is preserved from + /// . / are reserved for future + /// even-spacing variants; today the RNG draw is the sole source of position. + /// + public static float3 ScatterInShape(byte shapeId, float3 center, int index, int count, ref Random rng) + { + float r = ShapeRadius(shapeId); + switch (shapeId) + { + case RoomShapeId.Wide: + { + float x = rng.NextFloat(-r, r); + float z = rng.NextFloat(-r * 0.5f, r * 0.5f); + return new float3(center.x + x, center.y, center.z + z); + } + case RoomShapeId.Long: + { + float x = rng.NextFloat(-r * 0.5f, r * 0.5f); + float z = rng.NextFloat(-r, r); + return new float3(center.x + x, center.y, center.z + z); + } + case RoomShapeId.Cross: + { + bool horiz = rng.NextInt(0, 2) == 0; + float along = rng.NextFloat(-r, r); + float across = rng.NextFloat(-r * 0.25f, r * 0.25f); + return horiz + ? new float3(center.x + along, center.y, center.z + across) + : new float3(center.x + across, center.y, center.z + along); + } + case RoomShapeId.Disk: + default: + { + float ang = rng.NextFloat(0f, math.PI * 2f); + float rad = r * math.sqrt(rng.NextFloat(0f, 1f)); // area-uniform + return new float3(center.x + math.cos(ang) * rad, center.y, center.z + math.sin(ang) * rad); + } + } + } + + /// + /// True iff planar point lies within the shape's footprint around + /// (the exact bound produces). Used to validate scatter and (later) placement. + /// + public static bool ContainsPoint(byte shapeId, float3 center, float3 p) + { + const float eps = 1e-3f; + float r = ShapeRadius(shapeId); + float dx = p.x - center.x; + float dz = p.z - center.z; + switch (shapeId) + { + case RoomShapeId.Wide: + return math.abs(dx) <= r + eps && math.abs(dz) <= r * 0.5f + eps; + case RoomShapeId.Long: + return math.abs(dx) <= r * 0.5f + eps && math.abs(dz) <= r + eps; + case RoomShapeId.Cross: + { + bool horizArm = math.abs(dx) <= r + eps && math.abs(dz) <= r * 0.25f + eps; + bool vertArm = math.abs(dz) <= r + eps && math.abs(dx) <= r * 0.25f + eps; + return horizArm || vertArm; + } + case RoomShapeId.Disk: + default: + return dx * dx + dz * dz <= (r + eps) * (r + eps); + } + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs.meta b/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs.meta new file mode 100644 index 000000000..3d3e66352 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RoomLayoutMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7979eb74587ba004885f89b140b15f00 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs b/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs new file mode 100644 index 000000000..f27d63ab8 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs @@ -0,0 +1,24 @@ +namespace ProjectM.Simulation +{ + /// + /// The resolved, concrete spec for ONE room the party is about to enter — a pure function of its map node + /// () + its depth, produced by . Consumed server-side by + /// the room field/enemy directors to lay out resources + seed the enemy wave; transient (never replicated — + /// the client only needs the small published RunInfo mirror for the HUD). All-value, unmanaged, Burst-safe. + /// + public struct RoomPlan + { + /// (drives node/enemy density + the difficulty bump). + public byte RoomType; + /// (cosmetic, forwarded to the client HUD/atmosphere). + public byte Biome; + /// (the arena footprint scatter uses). + public byte ShapeId; + /// Arena scatter radius (world units) for this shape. + public float Radius; + /// Base number of resource nodes to scatter (before the run-wide scarcity budget floors it). + public int NodeCount; + /// Depth-based difficulty rung fed to ZoneEnemyMath (higher = harder; Elite/Boss bump it). + public int DifficultyEpoch; + } +} diff --git a/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs.meta b/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs.meta new file mode 100644 index 000000000..22a7b7b08 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RoomPlan.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc7dbc4b8c341e746b1cbe11f3a9ad86 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RoomTag.cs b/Assets/_Project/Scripts/Simulation/World/RoomTag.cs new file mode 100644 index 000000000..30e7697a4 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RoomTag.cs @@ -0,0 +1,44 @@ +using Unity.Collections; +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Stamps a runtime-spawned expedition ghost as belonging to ONE room of the current run (nodes, clutter, zone + /// enemies — everything the room's directors instantiate). Server-only, NOT a [GhostField] (clients never + /// see rooms, only relevancy-scoped ghosts). Teardown of room i filters on — the + /// hard-learned DR-031/DR-040 lesson that a shared-tag global cull wipes the OTHER room the moment two rooms + /// transiently coexist (the ping-pong sub-slot handoff). = CurrentRoom & 0xFF. + /// + public struct RoomTag : IComponentData + { + /// The 0-based room index this entity belongs to (low byte). + public byte Room; + } + + /// + /// The ONE way a room's contents die: a -filtered destroy. Type-agnostic — every room-scoped + /// ghost carries the tag, so one query covers nodes/clutter/enemies with no per-type sweep and no double-destroy + /// (each entity is visited exactly once). Callers pass their cached all- query + an ECB + /// (structural changes stay batched). Pure/static so EditMode pins the cross-room-wipe regression directly. + /// + public static class RoomTeardown + { + /// Queue destruction of every entity stamped .Room == . + public static int DestroyRoom(EntityQuery allRoomTagged, EntityCommandBuffer ecb, byte room) + { + var entities = allRoomTagged.ToEntityArray(Allocator.Temp); + var tags = allRoomTagged.ToComponentDataArray(Allocator.Temp); + int destroyed = 0; + for (int i = 0; i < entities.Length; i++) + { + if (tags[i].Room != room) continue; + ecb.DestroyEntity(entities[i]); + destroyed++; + } + entities.Dispose(); + tags.Dispose(); + return destroyed; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/World/RoomTag.cs.meta b/Assets/_Project/Scripts/Simulation/World/RoomTag.cs.meta new file mode 100644 index 000000000..b36675319 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RoomTag.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7fd0f5749da96e14db98fc4e6ada65b0 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs b/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs new file mode 100644 index 000000000..0e5d4eea0 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs @@ -0,0 +1,27 @@ +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Client → server route choice at a RouteSelect gate. indexes the REPLICATED + /// RunInfo.RouteOpt* option set (never a raw map column — the server re-validates against its own + /// NextMask, so a divergent client can only send an index the server rejects). + /// / stale-reject a pick that arrives after the party already + /// advanced. First ACCEPTED commit wins (the in-place RouteCommand latch — DR-014 atomicity). + /// UNCONDITIONAL wire type, blittable scalars only. Declared at Step 3 (wire front-load, one RpcCollection hash + /// change for the whole redesign); consumed by RouteSelectSystem from Step 8. + /// + public struct RouteSelectRequest : IRpcCommand + { + /// Index into the replicated RouteOpt* set (0..RouteOptionCount-1). + public byte OptionIndex; + /// RE-MEANED (Step-8 review, zero wire churn): carries (int)RunInfo.RunSeed — the + /// replicated, per-run-unique, never-zero run-identity token — NOT the server-only RunEpoch (which a client + /// cannot know). The server accepts iff (uint)ForRunEpoch == RunRuntime.RunSeed: the full cross-run + /// stale-reject at zero RpcCollection-hash cost (re-mean bytes, don't rename). + public int ForRunEpoch; + /// The layer this pick was made for — RunInfo.CurrentRoom VERBATIM (during a gate that is + /// still the just-CLEARED layer; never +1 — the server compares the same un-incremented value). + public int ForLayer; + } +} diff --git a/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs.meta b/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs.meta new file mode 100644 index 000000000..d1c783443 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RouteSelectRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 24000ef6fe52691408d4247341cbb189 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunInfo.cs b/Assets/_Project/Scripts/Simulation/World/RunInfo.cs new file mode 100644 index 000000000..d13f129f4 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunInfo.cs @@ -0,0 +1,80 @@ +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Lifecycle states for a co-op expedition run (). A byte, never an enum + /// (Burst/serialization safe), APPEND-ONLY. is appended after the original four so no + /// value is re-meaned. The party is at the base hub in ; a discrete run spans + /// (loop) → + /// . + /// + public static class RunLifecycle + { + /// Party in the base hub; ready-check active; no expedition ghosts exist. + public const byte Staging = 0; + /// All-ready launch transient: seed chosen, party teleporting into room 0. + public const byte Launching = 1; + /// Active room populated; party fighting/looting. + public const byte InRoom = 2; + /// Room cleared; per-player boon offers pending; room torn down. + public const byte RoomReward = 3; + /// Run ended (boss cleared / party wiped / all left): teleport home, bank, → Staging. + public const byte Returning = 4; + /// Boons picked; party choosing the next branch (no room materialized — the teardown gap). + public const byte RouteSelect = 5; + } + + /// + /// The REPLICATED run-lifecycle summary the whole party observes — a server-decided, client-observed FSM on the + /// GLOBAL untagged CycleDirector ghost (so it is relevant cross-region for free, like / + /// /). SOLE writer: RunDirectorSystem. Distinct from + /// (that stays the BASE Calm↔Siege posture for retaliation/final sieges). + /// + /// Fields split three ways: the lifecycle/room readout (HUD "Room i/N", biome cross-fade), the branching-map + /// wire ( so the client regenerates the map for DISPLAY, + and the + /// authoritative reachable RouteOpt* the clickable options bind to), and a two-field mirror of the + /// persisted meta counters for the HUD. All integers/bytes → replicate exact (no quantization). Adding this + /// [GhostField] component re-hashes the runtime-spawned director ghost (server + client bake the same + /// prefab → hash matches), exactly like /. + /// + public struct RunInfo : IComponentData + { + // ---- lifecycle + room readout ---- + /// . + [GhostField] public byte Lifecycle; + /// 0-based depth of the active room (HUD "Room CurrentRoom+1 / RoomCount"). + [GhostField] public int CurrentRoom; + /// Total rooms this run (== map layer count, seed-varied in [6,10]). + [GhostField] public int RoomCount; + /// of the active room. + [GhostField] public byte CurrentRoomType; + /// of the active room (client atmosphere cross-fade). + [GhostField] public byte CurrentBiome; + /// Server tick the launch countdown elapses (0 = none). Via ; compared with IsNewerThan. + [GhostField] public uint LaunchTick; + + // ---- branching map wire ---- + /// The run seed — clients regenerate the map layout for DISPLAY via (no gameplay authority). + [GhostField] public uint RunSeed; + /// The party's current column in the active layer. + [GhostField] public byte CurrentCol; + /// Number of reachable next-room options (0 unless ). + [GhostField] public byte RouteOptionCount; + /// Reachable next-layer column for option 0 (authoritative — the clickable button binds to this, not the regen). + [GhostField] public byte RouteOpt0Col; + [GhostField] public byte RouteOpt1Col; + [GhostField] public byte RouteOpt2Col; + /// of option 0 (so the HUD labels the choice). + [GhostField] public byte RouteOpt0Type; + [GhostField] public byte RouteOpt1Type; + [GhostField] public byte RouteOpt2Type; + + // ---- persisted-meta HUD mirror ---- + /// Runs completed (boss-cleared), mirrored for the HUD from the persisted meta counters. + [GhostField] public int RunsCompleted; + /// Deepest room reached across runs, mirrored for the HUD. + [GhostField] public int MaxDepthReached; + } +} diff --git a/Assets/_Project/Scripts/Simulation/World/RunInfo.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunInfo.cs.meta new file mode 100644 index 000000000..0a3d38575 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 71c6d427dd2c6aa4789f0ab94833ac71 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunMap.cs b/Assets/_Project/Scripts/Simulation/World/RunMap.cs new file mode 100644 index 000000000..c04101b6f --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunMap.cs @@ -0,0 +1,108 @@ +using System; +using Unity.Collections; + +namespace ProjectM.Simulation +{ + /// + /// Room-TYPE ids for a run-map node. A byte, never a C# enum — kept Burst-safe, serialization/replay-stable, + /// and APPEND-ONLY (a persisted meta / replay reproducibility depends on these values never being re-meaned). + /// + public static class RoomTypeId + { + public const byte Combat = 0; + public const byte Elite = 1; + public const byte Reward = 2; + public const byte Boss = 3; + public const byte Count = 4; + } + + /// + /// Room SHAPE ids — the arena footprint places nodes/enemies within. + /// A byte (append-only). Resolved to a concrete radius/footprint by . + /// + public static class RoomShapeId + { + public const byte Disk = 0; // circular arena (area-uniform scatter) + public const byte Wide = 1; // rectangle, wider on X + public const byte Long = 2; // rectangle, longer on Z + public const byte Cross = 3; // plus/cross of two bars + public const byte Count = 4; + } + + /// + /// Cosmetic BIOME ids — resolved to atmosphere/fog/tint by the client presentation layer (WorldAtmosphereSystem) + /// per room. A byte (append-only); purely visual, no gameplay authority. + /// + public static class RoomBiomeId + { + public const byte Meadow = 0; + public const byte Arid = 1; + public const byte Cavern = 2; + public const byte Blight = 3; + public const byte Count = 4; + } + + /// + /// One node in the branching run-map DAG (Slay-the-Spire style). 4 bytes, unmanaged. is a + /// bit set: bit j ⇒ this node can advance to column j of the NEXT layer (j < RunMap.MaxWidth). + /// A node with == 0 is a terminal (the single Boss node). Generated purely from the run seed + /// by , so it is identical on server + client (client regenerates for display). + /// + public struct RunMapNode : IEquatable + { + /// . + public byte RoomType; + /// (cosmetic). + public byte Biome; + /// . + public byte ShapeId; + /// Reachable next-layer columns: bit j ⇒ column j of the next layer. 0 = terminal (Boss). + public byte NextMask; + + public bool Equals(RunMapNode o) => + RoomType == o.RoomType && Biome == o.Biome && ShapeId == o.ShapeId && NextMask == o.NextMask; + public override bool Equals(object o) => o is RunMapNode n && Equals(n); + public override int GetHashCode() => RoomType | (Biome << 8) | (ShapeId << 16) | (NextMask << 24); + } + + /// + /// A generated branching run map: a layered DAG the party traverses one node per layer. TRANSIENT — regenerated + /// from the run seed via and NEVER a ghost buffer / never persisted (only the + /// seed + the party's current column ride the wire). Fixed stride of per layer, so the + /// stable node key nodeId = layer*MaxWidth + col resolves the SAME room regardless of the path taken — + /// which keeps per-room content (layout, boons) deterministic. Bounded to so it lives in a + /// (30 × 4 B = 120 B). + /// + public struct RunMap + { + /// Max layers (run length is seed-varied within [6, ]). + public const int MaxLayers = 10; + /// Max nodes per layer (branch width 1–3). + public const int MaxWidth = 3; + /// Node-buffer capacity (fixed stride): × . + public const int MaxNodes = MaxLayers * MaxWidth; + + /// Nodes, fixed stride per layer (LayerCount*MaxWidth entries; columns + /// ≥ are absent/unused). + public FixedList512Bytes Nodes; + /// Per-layer branch width ( entries, each in [1, ]). + public FixedList64Bytes LayerWidths; + /// Number of layers this run (== room count, in [6, ]). + public byte LayerCount; + + /// Stable node key for a (layer, col) — fixed stride, so the same key is the same room on any path. + public static int NodeId(int layer, int col) => layer * MaxWidth + col; + /// Branch width of a layer. + public int Width(int layer) => LayerWidths[layer]; + /// Node at (layer, col). + public RunMapNode Node(int layer, int col) => Nodes[NodeId(layer, col)]; + /// Node by stable id. + public RunMapNode NodeAt(int nodeId) => Nodes[nodeId]; + /// Layer of a node id. + public int LayerOf(int nodeId) => nodeId / MaxWidth; + /// Column of a node id. + public int ColOf(int nodeId) => nodeId % MaxWidth; + /// The single Boss node id (last layer, column 0). + public int BossNodeId => NodeId(LayerCount - 1, 0); + } +} diff --git a/Assets/_Project/Scripts/Simulation/World/RunMap.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunMap.cs.meta new file mode 100644 index 000000000..463ad7e9d --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunMap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59ab777c8e8ab794895a7236f5212758 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs b/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs new file mode 100644 index 000000000..aa26c8a25 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs @@ -0,0 +1,225 @@ +using Unity.Collections; +using Unity.Mathematics; + +namespace ProjectM.Simulation +{ + /// + /// Pure, deterministic generator for the branching run-map DAG — no RNG state, no wall-clock, INTEGER-HASH ONLY + /// (no , whose draw order is fragile across the multi-pass edge build and + /// which the client would have to replay bit-identically). A run map is therefore a pure function of the run seed, + /// so server + client regenerate the SAME graph — the server keeps gameplay authority (the party's column + the + /// reachable options ride the wire), the client regenerates only to DRAW the map. Mirrors the + /// pure-math discipline. + /// + /// Structure: layer 0 = a single Combat landing node; interior layers width 2–3, weighted-typed + /// (Combat 60 / Reward 25 / Elite 15, with an all-Reward-layer guard); the second-to-last layer is an all-Elite + /// gate (so every start→boss path passes ≥1 Elite); the last layer = the single Boss terminal. Edges: a primary + /// pass (every source gets ≥1 proportional out-edge, jittered, sometimes widened) + a coverage pass (every target + /// gets ≥1 in-edge), which together guarantee full reachability from the root and exactly one terminal. + /// + public static class RunMapMath + { + // ---- deterministic integer hashing (order-independent combine + a final avalanche) ---- + + static uint Mix(uint h) + { + h ^= h >> 16; h *= 0x7feb352du; + h ^= h >> 15; h *= 0x846ca68bu; + h ^= h >> 16; + return h; + } + + static uint Combine(uint h, uint v) + { + // boost-style hash_combine + h ^= v + 0x9e3779b9u + (h << 6) + (h >> 2); + return h; + } + + /// Deterministic hash of a salt tuple (integer-only, well-mixed, never dependent on draw order). + public static uint Hash(uint a) => Mix(Combine(0x811c9dc5u, a)); + public static uint Hash(uint a, uint b) => Mix(Combine(Combine(0x811c9dc5u, a), b)); + public static uint Hash(uint a, uint b, uint c) => Mix(Combine(Combine(Combine(0x811c9dc5u, a), b), c)); + public static uint Hash(uint a, uint b, uint c, uint d) => + Mix(Combine(Combine(Combine(Combine(0x811c9dc5u, a), b), c), d)); + + /// + /// Generate the branching run map for . Deterministic + identical on both worlds. + /// + public static RunMap Generate(uint runSeed) + { + uint s = math.max(1u, runSeed); + int L = 6 + (int)(Hash(s, 0x1Au) % 5u); // run length in [6,10] + + var map = new RunMap { LayerCount = (byte)L }; + + // Per-layer branch widths: single landing + single boss, interior 2–3. + map.LayerWidths = new FixedList64Bytes(); + for (int layer = 0; layer < L; layer++) + { + byte w = (layer == 0 || layer == L - 1) + ? (byte)1 + : (byte)(2 + (int)(Hash(s, (uint)layer, 0x11u) % 2u)); // 2 or 3 + map.LayerWidths.Add(w); + } + + // Nodes: fixed stride MaxWidth per layer (absent columns left default). + map.Nodes = new FixedList512Bytes(); + int slots = L * RunMap.MaxWidth; + for (int i = 0; i < slots; i++) map.Nodes.Add(default); + + // Types / biome / shape. + for (int layer = 0; layer < L; layer++) + { + int w = map.LayerWidths[layer]; + byte layerBiome = (byte)(Hash(s, (uint)layer, 0xB1u) % RoomBiomeId.Count); + bool anyNonReward = false; + for (int col = 0; col < w; col++) + { + byte type = PickType(s, layer, col, L); + if (type != RoomTypeId.Reward) anyNonReward = true; + byte shape = (byte)(Hash(s, (uint)layer, (uint)col, 0x5Au) % RoomShapeId.Count); + map.Nodes[RunMap.NodeId(layer, col)] = new RunMapNode + { + RoomType = type, + Biome = layerBiome, + ShapeId = shape, + NextMask = 0, + }; + } + // Guard: never an entire interior layer of only Reward rooms → force column 0 to Combat. + if (!anyNonReward && w > 0) + { + int id0 = RunMap.NodeId(layer, 0); + var n = map.Nodes[id0]; + n.RoomType = RoomTypeId.Combat; + map.Nodes[id0] = n; + } + } + + BuildEdges(ref map, s); + return map; + } + + static byte PickType(uint s, int layer, int col, int L) + { + if (layer == 0) return RoomTypeId.Combat; // guaranteed landing room + if (layer == L - 1) return RoomTypeId.Boss; // single terminal + if (layer == L - 2) return RoomTypeId.Elite; // all-Elite gate (≥1 Elite on every path) + uint r = Hash(s, (uint)layer, (uint)col, 0xC0u) % 100u; // Combat 60 / Reward 25 / Elite 15 + if (r < 60u) return RoomTypeId.Combat; + if (r < 85u) return RoomTypeId.Reward; + return RoomTypeId.Elite; + } + + static void BuildEdges(ref RunMap map, uint s) + { + int L = map.LayerCount; + for (int l = 0; l < L - 1; l++) + { + int w = map.LayerWidths[l]; + int wn = map.LayerWidths[l + 1]; + + // Primary: every source gets a proportional out-edge (± jitter), sometimes widened to a neighbor. + for (int c = 0; c < w; c++) + { + int t = ProportionalCol(c, w, wn); + int jitter = (int)(Hash(s, (uint)l, (uint)c, 0xEDu) % 3u) - 1; // -1, 0, +1 + t = math.clamp(t + jitter, 0, wn - 1); + SetEdge(ref map, l, c, t); + + if (wn > 1 && Hash(s, (uint)l, (uint)c, 0x2Bu) % 100u < 35u) + { + int dir = (Hash(s, (uint)l, (uint)c, 0x2Cu) % 2u) == 0u ? -1 : 1; + int t2 = math.clamp(t + dir, 0, wn - 1); + SetEdge(ref map, l, c, t2); + } + } + + // Coverage: every target in the next layer must have ≥1 in-edge (forces convergence on the Boss). + for (int tcol = 0; tcol < wn; tcol++) + { + if (!HasInEdge(ref map, l, tcol)) + { + int src = ProportionalCol(tcol, wn, w); + SetEdge(ref map, l, src, tcol); + } + } + } + } + + static int ProportionalCol(int from, int fromWidth, int toWidth) + { + if (fromWidth <= 1 || toWidth <= 1) return toWidth / 2; + return (int)math.round((float)from * (toWidth - 1) / (fromWidth - 1)); + } + + static void SetEdge(ref RunMap map, int layer, int col, int targetCol) + { + int id = RunMap.NodeId(layer, col); + var n = map.Nodes[id]; + n.NextMask |= (byte)(1 << targetCol); + map.Nodes[id] = n; + } + + static bool HasInEdge(ref RunMap map, int layer, int targetCol) + { + int w = map.LayerWidths[layer]; + byte bit = (byte)(1 << targetCol); + for (int c = 0; c < w; c++) + if ((map.Nodes[RunMap.NodeId(layer, c)].NextMask & bit) != 0) return true; + return false; + } + + /// + /// The columns of the NEXT layer reachable from node (, ). + /// Empty for the Boss/last layer. This is the authoritative set the route-choice offer is drawn from. + /// + public static int ReachableOptions(in RunMap map, int layer, int col, out FixedList32Bytes cols) + { + cols = new FixedList32Bytes(); + if (layer < 0 || layer >= map.LayerCount - 1) return 0; + byte mask = map.Node(layer, col).NextMask; + int wn = map.Width(layer + 1); + for (int j = 0; j < wn; j++) + if ((mask & (1 << j)) != 0) cols.Add((byte)j); + return cols.Length; + } + + /// + /// True iff every PRESENT node is reachable from the root (0,0) via the edges (BFS). Used to assert the + /// generator never strands a node or the Boss. O(nodes). + /// + public static bool AllNodesReachable(in RunMap map) + { + var visited = new FixedList128Bytes(); + for (int i = 0; i < RunMap.MaxNodes; i++) visited.Add(0); + + var stack = new FixedList128Bytes(); + int root = RunMap.NodeId(0, 0); + visited[root] = 1; + stack.Add((byte)root); + + while (stack.Length > 0) + { + int id = stack[stack.Length - 1]; + stack.RemoveAt(stack.Length - 1); + int layer = map.LayerOf(id); + if (layer >= map.LayerCount - 1) continue; + byte mask = map.NodeAt(id).NextMask; + int wn = map.Width(layer + 1); + for (int j = 0; j < wn; j++) + { + if ((mask & (1 << j)) == 0) continue; + int nid = RunMap.NodeId(layer + 1, j); + if (visited[nid] == 0) { visited[nid] = 1; stack.Add((byte)nid); } + } + } + + for (int layer = 0; layer < map.LayerCount; layer++) + for (int col = 0; col < map.Width(layer); col++) + if (visited[RunMap.NodeId(layer, col)] == 0) return false; + return true; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs.meta new file mode 100644 index 000000000..eb7f2f9b6 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6f2e10313b5eeac468bef027c5c7be62 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs b/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs new file mode 100644 index 000000000..5c0b2e9f8 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs @@ -0,0 +1,55 @@ +namespace ProjectM.Simulation +{ + /// + /// Server-only working state for the run FSM — lives on the CycleDirector beside but is + /// NOT replicated (adding fields here never re-bakes the ghost). Owned/written by RunDirectorSystem. + /// Determinism: = max(1, Hash(, )) — monotonic + /// int, never a tick, equality-compared. Tick sentinels (/) + /// route through and compare via NetworkTick.IsNewerThan (never raw uint). + /// + public struct RunRuntime : Unity.Entities.IComponentData + { + // ---- run identity / seed ---- + /// Working copy of the run seed (mirrored to the replicated ). + public uint RunSeed; + /// Monotonic run counter; bumped on the Staging→Launching edge so each run reseeds. Equality-compared. + public int RunEpoch; + /// Per-playthrough salt folded into for cross-session map variety (seeded at spawn, non-tick). + public uint HostSalt; + + // ---- room traversal ---- + /// Monotonic room-seed counter; bumped per room advance so the field/enemy directors reseed. Equality-compared. + public int RoomEpoch; + /// Which of the two ping-pong sub-arena slots the active room occupies (CurrentRoom & 1). + public byte ActiveSubSlot; + /// The active room's stable map node id (single plan authority — field/enemy directors read this, never re-derive). + public int CurrentNodeId; + /// The active room's column (mirrors ). + public byte CurrentCol; + /// The active room's (single plan authority). + public byte CurrentRoomType; + + // ---- scarcity / banking latches ---- + /// Run-wide remaining resource-node allotment (floors each room's scatter; decrements per node) → true scarcity. + public int NodeBudgetRemaining; + /// The the terminal bank last fired for — equality latch so a multi-tick Returning banks once. + public int LastBankedRunEpoch; + /// 1 iff the run ended by a genuine BOSS clear (gates the win-meter/RunsCompleted credit; 0 on abort/wipe). + public byte LastTerminalCleared; + /// Rooms actually CLEARED this run (bumped on each InRoom→RoomReward edge; reset at launch) — the + /// honest depth the terminal bank records into MaxDepthReached (never the planned RoomCount — D-F3). + public int RoomsClearedThisRun; + + // ---- ready / grace ---- + /// Previous-tick all-ready state (rising-edge latch for the Staging→Launching launch). + public byte WasAllReady; + /// Server tick the RoomReward boon-pick grace elapses (NonZero; IsNewerThan-compared). + public uint RewardGraceTick; + /// Server tick the RouteSelect grace elapses → auto-pick lowest-index reachable (NonZero; IsNewerThan-compared). + public uint RouteGraceTick; + + // ---- boons ---- + /// Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run. + public uint BoonPickCounter; + } +} diff --git a/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs.meta new file mode 100644 index 000000000..a24449b30 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunRuntime.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6936c1ec155a69a45a957a2f2dac1c3f \ No newline at end of file diff --git a/Assets/_Project/Subscenes/Gameplay.unity b/Assets/_Project/Subscenes/Gameplay.unity index d42b4ced6..f44cc6803 100644 --- a/Assets/_Project/Subscenes/Gameplay.unity +++ b/Assets/_Project/Subscenes/Gameplay.unity @@ -689,6 +689,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.WorldCollisionAuthoring EnvironmentLayerName: Environment + StructureLayerName: Structure --- !u!4 &175286058 Transform: m_ObjectHideFlags: 0 @@ -757,113 +758,6 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 13.461232, y: 5, z: 1.5} m_Center: {x: 0, y: 2.5, z: 0} ---- !u!1 &236770150 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 236770154} - - component: {fileID: 236770153} - - component: {fileID: 236770152} - - component: {fileID: 236770151} - m_Layer: 0 - m_Name: ReturnGate - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &236770151 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 236770150} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 22f744b59ad23834abe28fc09b661005, type: 3} - m_Name: - m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ExpeditionGateAuthoring - From: 1 - To: 0 - Radius: 2.5 - ArrivalPos: {x: 0, y: 1, z: 0} ---- !u!23 &236770152 -MeshRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 236770150} - 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: 175e5bfb2ad02704caa3d1753aad499d, 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!33 &236770153 -MeshFilter: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 236770150} - m_Mesh: {fileID: 10206, guid: 0000000000000000e000000000000000, type: 0} ---- !u!4 &236770154 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 236770150} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 1000, y: 1, z: 8} - m_LocalScale: {x: 1.5, y: 2.5, z: 1.5} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &380046993 GameObject: m_ObjectHideFlags: 0 @@ -2196,7 +2090,6 @@ GameObject: - component: {fileID: 1192434518} - component: {fileID: 1192434517} - component: {fileID: 1192434516} - - component: {fileID: 1192434515} m_Layer: 0 m_Name: BaseGate m_TagString: Untagged @@ -2204,22 +2097,6 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &1192434515 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1192434514} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 22f744b59ad23834abe28fc09b661005, type: 3} - m_Name: - m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ExpeditionGateAuthoring - From: 0 - To: 1 - Radius: 2.5 - ArrivalPos: {x: 1000, y: 1, z: 0} --- !u!23 &1192434516 MeshRenderer: m_ObjectHideFlags: 0 @@ -2600,6 +2477,51 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1427126582 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1427126584} + - component: {fileID: 1427126583} + m_Layer: 0 + m_Name: BoonCatalog + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1427126583 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1427126582} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 075a570afb8ef9541b6307280b53f4e7, type: 3} + m_Name: + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.BoonCatalogAuthoring + Rows: [] +--- !u!4 &1427126584 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1427126582} + 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!1 &1435545286 GameObject: m_ObjectHideFlags: 0 @@ -2858,6 +2780,51 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1518140826 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1518140828} + - component: {fileID: 1518140827} + m_Layer: 0 + m_Name: MetaCatalog + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1518140827 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1518140826} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4f007f7870c0afd4e93ea5b86fd21c8b, type: 3} + m_Name: + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.MetaCatalogAuthoring + Rows: [] +--- !u!4 &1518140828 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1518140826} + 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!1 &1542636139 GameObject: m_ObjectHideFlags: 0 @@ -3742,55 +3709,6 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 15.610933, y: 5, z: 1.5} m_Center: {x: 0, y: 2.5, z: 0} ---- !u!1 &2145598868 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 2145598870} - - component: {fileID: 2145598869} - m_Layer: 0 - m_Name: BaseFieldSpawner - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &2145598869 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2145598868} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: c4055a6a779d06949ae23b16334b810e, type: 3} - m_Name: - m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.BaseFieldSpawnerAuthoring - NodePrefab: {fileID: 3885353946372160549, guid: 8565e5eb00679fb45b8b7dac1e2ae9f3, type: 3} - TargetCount: 12 - InnerRadius: 6 - OuterRadius: 11.5 - RespawnIntervalTicks: 600 ---- !u!4 &2145598870 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2145598868} - 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!1660057539 &9223372036854775807 SceneRoots: m_ObjectHideFlags: 0 @@ -3805,13 +3723,13 @@ SceneRoots: - {fileID: 2038854532} - {fileID: 17637047} - {fileID: 1192434518} - - {fileID: 236770154} - {fileID: 380046995} - {fileID: 175286058} - {fileID: 1698085403} - {fileID: 450973138} - {fileID: 1268920429} - {fileID: 722706770} - - {fileID: 2145598870} - {fileID: 1957070224} - {fileID: 1871304250} + - {fileID: 1427126584} + - {fileID: 1518140828} diff --git a/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs b/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs deleted file mode 100644 index dc2f1c6b5..000000000 --- a/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs +++ /dev/null @@ -1,145 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Core; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the server-only — the home-base mining - /// field. A bare world is seeded with a NetworkTime singleton, a BaseAnchor (plot centred on origin), a - /// ResourceNode prefab (Prefab-tagged so it is excluded from the live count) and a BaseFieldSpawner + - /// BaseFieldRuntime. Pins: the first pass seeds the field to TargetCount with every node RegionTag{Base} + - /// ResourceId.Ore inside the [Inner,Outer] annulus; the cadence gate suppresses a respawn before the interval - /// elapses; and a depleted field tops back up to TargetCount once the interval passes (no economy soft-lock). - /// - public class BaseFieldSpawnSystemTests - { - static (World world, SimulationSystemGroup group, Entity spawner, Entity prefab) MakeWorld(string name, uint serverTick, int target, float inner, float outer, int interval) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - - var nt = em.CreateEntity(typeof(NetworkTime)); - em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) }); - - // Plot centred on origin: GridOrigin -16, dims 16, cell 2 => PlotCenter = (0,0,0). - var anchor = em.CreateEntity(typeof(BaseAnchor)); - em.SetComponentData(anchor, new BaseAnchor - { - AnchorPos = float3.zero, - GridOrigin = new float3(-16f, 0f, -16f), - CellSize = 2f, - GridDims = new int2(16, 16), - }); - - // The node prefab: Prefab-tagged so the live-count query (and the system's) skip it. - var prefab = em.CreateEntity(typeof(Prefab), typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag), typeof(HitRadius)); - em.SetComponentData(prefab, LocalTransform.FromPosition(float3.zero)); - em.SetComponentData(prefab, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f }); - em.SetComponentData(prefab, new RegionTag { Region = RegionId.Expedition }); - em.SetComponentData(prefab, new HitRadius { Value = 1.2f }); - - var spawner = em.CreateEntity(typeof(BaseFieldSpawner), typeof(BaseFieldRuntime)); - em.SetComponentData(spawner, new BaseFieldSpawner - { - Prefab = prefab, - TargetCount = target, - InnerRadius = inner, - OuterRadius = outer, - RespawnIntervalTicks = interval, - }); - em.SetComponentData(spawner, new BaseFieldRuntime { Epoch = 0, NextSpawnTick = 0u }); - return (world, group, spawner, prefab); - } - - static Entity[] LiveNodes(EntityManager em) - { - // Default query options exclude Prefab + Disabled, so the prefab is not counted. - using var q = em.CreateEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly()); - return q.ToEntityArray(Allocator.Temp).ToArray(); - } - - static void SetServerTick(EntityManager em, uint tick) - { - using var q = em.CreateEntityQuery(ComponentType.ReadWrite()); - var nt = q.GetSingletonEntity(); - em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(tick) }); - } - - [Test] - public void First_Pass_Seeds_Target_Count_Base_Ore_Nodes_In_Annulus() - { - var (world, group, _, _) = MakeWorld("BaseFieldSeed", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600); - using (world) - { - var em = world.EntityManager; - - group.Update(); - - var nodes = LiveNodes(em); - Assert.AreEqual(8, nodes.Length, "The first pass seeds exactly TargetCount base nodes."); - foreach (var n in nodes) - { - Assert.AreEqual(RegionId.Base, em.GetComponentData(n).Region, "Base nodes are RegionTag.Base so RegionRelevancy keeps them for base players."); - Assert.AreEqual(ResourceId.Ore, em.GetComponentData(n).ResourceId, "Base nodes are Ore-only (the build currency)."); - float2 xz = em.GetComponentData(n).Position.xz; - float r = math.length(xz); - Assert.GreaterOrEqual(r, 10f - 0.01f, "A node is no nearer than InnerRadius (clears the build plot)."); - Assert.LessOrEqual(r, 20f + 0.01f, "A node is no farther than OuterRadius (stays reachable)."); - } - } - } - - [Test] - public void Does_Not_Respawn_Before_The_Interval_Elapses() - { - var (world, group, _, _) = MakeWorld("BaseFieldCadence", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600); - using (world) - { - var em = world.EntityManager; - group.Update(); // seeds 8, NextSpawnTick = 700 - - // Deplete 3 nodes. - var nodes = LiveNodes(em); - for (int i = 0; i < 3; i++) - em.DestroyEntity(nodes[i]); - Assert.AreEqual(5, LiveNodes(em).Length); - - // Same tick (100 < 700): the cadence gate suppresses a refill. - group.Update(); - Assert.AreEqual(5, LiveNodes(em).Length, "No top-up before RespawnIntervalTicks elapses."); - } - } - - [Test] - public void Tops_Up_To_Target_After_Depletion_Once_Interval_Passes() - { - var (world, group, _, _) = MakeWorld("BaseFieldTopUp", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600); - using (world) - { - var em = world.EntityManager; - group.Update(); // seeds 8, NextSpawnTick = 700 - - var nodes = LiveNodes(em); - for (int i = 0; i < 3; i++) - em.DestroyEntity(nodes[i]); - Assert.AreEqual(5, LiveNodes(em).Length); - - // Advance past the interval (800 > 700): refill the 3-node deficit back to TargetCount. - SetServerTick(em, 800); - group.Update(); - Assert.AreEqual(8, LiveNodes(em).Length, "A depleted field tops back up to TargetCount (no economy soft-lock)."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs.meta deleted file mode 100644 index e3c7c5a1c..000000000 --- a/Assets/_Project/Tests/EditMode/BaseFieldSpawnSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: a7a8c39aa67d45d4586a269f136999c5 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/BoonApplyTests.cs b/Assets/_Project/Tests/EditMode/BoonApplyTests.cs new file mode 100644 index 000000000..b4bfdde9d --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BoonApplyTests.cs @@ -0,0 +1,174 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Core; +using Unity.Entities; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// Pins the two-channel boon lifecycle: (a valid pick appends exactly ONE + /// boon-band and clears Pending; out-of-range / not-pending / closed-lifecycle picks + /// are rejected; the grace auto-pick deals Option0) and the RunDirector Returning-edge RANGE STRIP (every + /// boon-band row dies; class/meta/equip bands survive; offers zeroed) — run boons NEVER persist (DR-037). + /// + public class BoonApplyTests + { + const uint T0 = 3000; + + static (World world, SimulationSystemGroup group, Entity dir, Entity catalog) MakeWorld(byte lifecycle) + { + var world = new World("BoonApplyTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + var nt = em.CreateEntity(typeof(NetworkTime)); + em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) }); + + var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); + em.SetComponentData(dir, new RunInfo { Lifecycle = lifecycle, CurrentRoom = 1 }); + em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RoomEpoch = 2, RewardGraceTick = T0 + 1800 }); + + var catalog = em.CreateEntity(typeof(BoonCatalog)); + em.SetComponentData(catalog, new BoonCatalog { Value = BoonCatalogData.BuildDefault() }); + return (world, group, dir, catalog); + } + + static Entity MakePicker(EntityManager em, int netId, byte o0 = 1, byte o1 = 4, byte o2 = 5) + { + var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag)); + em.AddBuffer(e); + em.SetComponentData(e, new GhostOwner { NetworkId = netId }); + em.SetComponentData(e, new RegionTag { Region = RegionId.Expedition }); + em.SetComponentData(e, new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 }); + return e; + } + + static void SendPick(EntityManager em, int netId, byte index) + { + var conn = em.CreateEntity(typeof(NetworkId)); + em.SetComponentData(conn, new NetworkId { Value = netId }); + var req = em.CreateEntity(typeof(BoonPickRequest), typeof(ReceiveRpcCommandRequest)); + em.SetComponentData(req, new BoonPickRequest { Index = index }); + em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn }); + } + + static int BoonRows(EntityManager em, Entity player) + { + var mods = em.GetBuffer(player); + int n = 0; + for (int i = 0; i < mods.Length; i++) + if (mods[i].SourceId >= Tuning.BoonSourceIdBase + && mods[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan) n++; + return n; + } + + [Test] + public void ValidPick_AppendsBoonBandRow_AndClearsPending() + { + var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward); + var em = world.EntityManager; + var player = MakePicker(em, 1); + SendPick(em, 1, index: 1); // Option1 = id 4 (Fleet Foot, MoveSpeed +12%) + + group.Update(); + + Assert.AreEqual(1, BoonRows(em, player), "exactly one boon-band row appended"); + var mods = em.GetBuffer(player); + Assert.AreEqual((byte)StatTarget.MoveSpeed, mods[0].Target, "the picked def's target"); + Assert.AreEqual((byte)ModOp.PercentAdd, mods[0].Op); + Assert.AreEqual(0.12f, mods[0].Value, 1e-4f); + Assert.AreEqual(0, em.GetComponentData(player).Pending, "pick consumed"); + Assert.AreEqual(1u, em.GetComponentData(dir).BoonPickCounter, "band provenance advanced"); + world.Dispose(); + } + + [Test] + public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean() + { + // Not pending. + var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward); + var p1 = MakePicker(w1.EntityManager, 1); + w1.EntityManager.SetComponentData(p1, new BoonOffer { Pending = 0, Option0 = 1 }); + SendPick(w1.EntityManager, 1, 0); + g1.Update(); + Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected"); + w1.Dispose(); + + // Lifecycle closed (Returning): the straggler pick dies BEFORE any strip could be out-run (D-F4). + var (w2, g2, d2, c2) = MakeWorld(RunLifecycle.Returning); + var p2 = MakePicker(w2.EntityManager, 1); + SendPick(w2.EntityManager, 1, 0); + g2.Update(); + Assert.AreEqual(0, BoonRows(w2.EntityManager, p2), "closed-lifecycle pick rejected"); + using (var q = w2.EntityManager.CreateEntityQuery(typeof(BoonPickRequest))) + Assert.AreEqual(0, q.CalculateEntityCount(), "request still consumed"); + w2.Dispose(); + } + + [Test] + public void GraceElapsed_AutoPicksOption0_ForPendingExpeditionPlayers() + { + var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward); + var em = world.EntityManager; + var afk = MakePicker(em, 1, o0: 5); // Option0 = id 5 (Iron Constitution, +25 MaxHealth) + var run = em.GetComponentData(dir); + run.RewardGraceTick = T0 - 10; // already elapsed + em.SetComponentData(dir, run); + + group.Update(); + + Assert.AreEqual(1, BoonRows(em, afk), "AFK player auto-dealt Option0"); + var mods = em.GetBuffer(afk); + Assert.AreEqual((byte)StatTarget.MaxHealth, mods[0].Target); + Assert.AreEqual(0, em.GetComponentData(afk).Pending, "gate released"); + world.Dispose(); + } + + [Test] + public void ReturningStrip_KillsBoonBand_SparesClassMetaEquip() + { + // Drive the REAL RunDirectorSystem Returning edge over a player carrying all four bands. + var world = new World("BoonStripTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + var nt = em.CreateEntity(typeof(NetworkTime)); + em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) }); + + var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(RouteCommand)); + em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.Returning, CurrentRoom = 3, RoomCount = 8 }); + em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RunEpoch = 1, RoomsClearedThisRun = 3 }); + + var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(BoonOffer), + typeof(RegionTag), typeof(LocalTransform)); + em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); + em.SetComponentData(player, LocalTransform.Identity); + em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 }); + var mods = em.AddBuffer(player); + mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.2f, SourceId = Tuning.BoonSourceIdBase }); // boon + mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.5f, SourceId = Tuning.BoonSourceIdBase + 1 }); // boon + mods.Add(new StatModifier { Target = 6, Op = 1, Value = 0.1f, SourceId = Tuning.ClassSourceId }); // class + mods.Add(new StatModifier { Target = 8, Op = 0, Value = 10f, SourceId = 0x00E7A000u }); // meta (12a band) + mods.Add(new StatModifier { Target = 0, Op = 0, Value = 5f, SourceId = Tuning.EquipSourceIdBase }); // equip + + group.Update(); // Returning: strip + bank + home -> Staging + + var after = em.GetBuffer(player); + Assert.AreEqual(3, after.Length, "both boon rows stripped, all three permanent bands survive"); + for (int i = 0; i < after.Length; i++) + Assert.IsFalse(after[i].SourceId >= Tuning.BoonSourceIdBase + && after[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan, "no boon-band survivor"); + Assert.AreEqual(0, em.GetComponentData(player).Pending, "straggler offer zeroed"); + Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); + world.Dispose(); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/BoonApplyTests.cs.meta b/Assets/_Project/Tests/EditMode/BoonApplyTests.cs.meta new file mode 100644 index 000000000..188658e6d --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BoonApplyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f703eba535e33a3489b833c59cbd3803 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/BoonOfferTests.cs b/Assets/_Project/Tests/EditMode/BoonOfferTests.cs new file mode 100644 index 000000000..50ea9d32e --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BoonOfferTests.cs @@ -0,0 +1,104 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Core; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Tests +{ + /// + /// Pins the boon pool math (: deterministic, 3 distinct, class-filtered, + /// weight-0 excluded) and (one deal per RoomEpoch; expedition players only; + /// owner-seeded per player so co-op offers differ). + /// + public class BoonOfferTests + { + [Test] + public void PickBoons_Deterministic_Distinct_ClassFiltered() + { + var blob = BoonCatalogData.BuildDefault(Allocator.Temp); + ref var pool = ref blob.Value; + + for (byte classId = 0; classId <= 1; classId++) + { + for (uint seed = 1; seed < 200; seed += 7) + { + int n = BoonMath.PickBoons(seed, classId, ref pool, out byte a0, out byte a1, out byte a2); + Assert.AreEqual(3, n, "the default pool always fills 3 options"); + Assert.AreNotEqual(a0, a1, "distinct"); + Assert.AreNotEqual(a1, a2, "distinct"); + Assert.AreNotEqual(a0, a2, "distinct"); + + // Deterministic re-draw. + BoonMath.PickBoons(seed, classId, ref pool, out byte b0, out byte b1, out byte b2); + Assert.AreEqual(a0, b0); + Assert.AreEqual(a1, b1); + Assert.AreEqual(a2, b2); + + // Every option is class-legal. + byte bit = BoonMath.MaskFor(classId); + foreach (var id in new[] { a0, a1, a2 }) + { + int idx = BoonMath.FindDef(ref pool, id); + Assert.GreaterOrEqual(idx, 0, "offered id exists"); + Assert.AreNotEqual(0, pool.Defs[idx].ClassMask & bit, + $"boon {id} offered to class {classId} must pass its ClassMask"); + } + } + } + blob.Dispose(); + } + + [Test] + public void OfferSystem_DealsOncePerRoom_ExpeditionOnly_PerPlayerSeeds() + { + var world = new World("BoonOfferTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + + var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); + em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.RoomReward, CurrentRoom = 1 }); + em.SetComponentData(dir, new RunRuntime { RunSeed = 4242u, RoomEpoch = 2 }); + + var catalog = em.CreateEntity(typeof(BoonCatalog)); + em.SetComponentData(catalog, new BoonCatalog { Value = BoonCatalogData.BuildDefault() }); + + Entity MakePlayer(int netId, byte region, byte classId) + { + var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), + typeof(RegionTag), typeof(PlayerClass)); + em.SetComponentData(e, new GhostOwner { NetworkId = netId }); + em.SetComponentData(e, new RegionTag { Region = region }); + em.SetComponentData(e, new PlayerClass { ClassId = classId }); + return e; + } + var out1 = MakePlayer(1, RegionId.Expedition, 0); + var out2 = MakePlayer(2, RegionId.Expedition, 1); + var home = MakePlayer(3, RegionId.Base, 0); + + group.Update(); // one-shot state attach + group.Update(); // deal + + var offer1 = em.GetComponentData(out1); + var offer2 = em.GetComponentData(out2); + Assert.AreEqual(1, offer1.Pending, "expedition player 1 dealt"); + Assert.AreEqual(1, offer2.Pending, "expedition player 2 dealt"); + Assert.AreEqual(0, em.GetComponentData(home).Pending, "home player dealt NOTHING"); + bool differ = offer1.Option0 != offer2.Option0 || offer1.Option1 != offer2.Option1 + || offer1.Option2 != offer2.Option2; + Assert.IsTrue(differ, "per-player seeds -> co-op offers differ (seed folds NetworkId)"); + + // Same epoch -> no re-deal (clear one offer and confirm it stays cleared). + em.SetComponentData(out1, default(BoonOffer)); + group.Update(); + Assert.AreEqual(0, em.GetComponentData(out1).Pending, "one deal per RoomEpoch (latch)"); + + world.Dispose(); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/BoonOfferTests.cs.meta b/Assets/_Project/Tests/EditMode/BoonOfferTests.cs.meta new file mode 100644 index 000000000..fea7a0fcd --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BoonOfferTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1e10416fe4cd405479a526eddd91bc1f \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs b/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs deleted file mode 100644 index 792de4da2..000000000 --- a/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.Mathematics; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Regression test for the teardown region-filter. When the last player - /// leaves the expedition the field is cleared — but that teardown must destroy ONLY RegionTag{Expedition} - /// nodes, never the permanent RegionTag{Base} home-base mining field. Before the fix the unfiltered teardown - /// wiped every ResourceNode on the empty edge (a despawn storm beside base players that broke the core loop). - /// - public class ExpeditionFieldTeardownTests - { - [Test] - public void Expedition_Empty_Edge_Destroys_Only_Expedition_Nodes_Base_Field_Survives() - { - var world = new World("ExpeditionTeardown"); - using (world) - { - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - - // Cycle director: was occupied last tick, nobody out there now => the occupied->empty edge fires. - var cycle = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime)); - em.SetComponentData(cycle, new CycleState { Phase = CyclePhase.Calm, CycleNumber = 1 }); - em.SetComponentData(cycle, new CycleRuntime { PrevExpeditionOccupied = 1 }); - - // Spawner singleton (required); null prefab so the spawn branch is inert. - var spawnerE = em.CreateEntity(typeof(ResourceFieldSpawner)); - em.SetComponentData(spawnerE, new ResourceFieldSpawner { Prefab = Entity.Null, Count = 5, Radius = 10f }); - - var baseNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag)); - em.SetComponentData(baseNode, LocalTransform.FromPosition(new float3(20, 0, 0))); - em.SetComponentData(baseNode, new ResourceNode { ResourceId = ResourceId.Ore, Remaining = 30, HarvestPerHit = 5f }); - em.SetComponentData(baseNode, new RegionTag { Region = RegionId.Base }); - - var expNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag)); - em.SetComponentData(expNode, LocalTransform.FromPosition(new float3(1020, 0, 0))); - em.SetComponentData(expNode, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f }); - em.SetComponentData(expNode, new RegionTag { Region = RegionId.Expedition }); - - group.Update(); - - Assert.IsTrue(em.Exists(baseNode), "The permanent base mining field survives the expedition teardown."); - Assert.IsFalse(em.Exists(expNode), "Only the expedition node is cleared when the last player leaves."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs.meta b/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs.meta deleted file mode 100644 index 4e6b5916c..000000000 --- a/Assets/_Project/Tests/EditMode/ExpeditionFieldTeardownTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 269ed0f5b1c5cb6418682ccf05db45dd \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs b/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs deleted file mode 100644 index 258ca549d..000000000 --- a/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs +++ /dev/null @@ -1,137 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.Mathematics; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into - /// (DR-040 BLOCKER 4 + DR-042). A returning player banks flat Ore to the - /// shared ledger AND advances the long-arc win meter (GoalProgress.Charge — DR-042: EXPEDITION CLEARS, not - /// survived sieges, are the win-driver) IFF this epoch's expedition wave was actually cleared and not yet - /// rewarded — and never twice for the same epoch (the co-op same-tick / gate-re-entry de-dup; Ore + Charge - /// share the one LastRewardedEpoch latch so they always share fate). - /// - public class ExpeditionGateRewardTests - { - static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name, - int epoch, byte clearedThisEpoch, int lastRewardedEpoch) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - - // CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state + goal meter. - var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger), - typeof(ThreatState), typeof(GoalProgress)); - em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm }); - em.SetComponentData(cyc, new CycleRuntime - { - ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch, - }); - em.SetComponentData(cyc, new GoalProgress { Charge = 0, Target = 4 }); - em.AddBuffer(cyc); - - // Zone-enemy director singleton (only RewardOre matters to the reward fold). - var dir = em.CreateEntity(typeof(ZoneEnemyDirector)); - em.SetComponentData(dir, new ZoneEnemyDirector { RewardOre = 25 }); - - // A gate Expedition->Base sitting at the expedition origin. - var gate = em.CreateEntity(typeof(ExpeditionGate), typeof(LocalTransform)); - em.SetComponentData(gate, new ExpeditionGate - { - FromRegion = RegionId.Expedition, ToRegion = RegionId.Base, Radius = 3f, ArrivalPos = new float3(0, 1, 0), - }); - em.SetComponentData(gate, LocalTransform.FromPosition(new float3(1000, 1, 0))); - - return (world, group, cyc); - } - - static Entity MakeExpeditionPlayerAtGate(EntityManager em) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition }); - em.AddComponentData(e, LocalTransform.FromPosition(new float3(1000, 1, 0))); - em.AddComponent(e); - return e; - } - - static int OreInLedger(EntityManager em, Entity cyc) - { - var buf = em.GetBuffer(cyc); - for (int i = 0; i < buf.Length; i++) - if (buf[i].ItemId == (ushort)ResourceId.Ore) return buf[i].Count; - return 0; - } - - [Test] - public void Cleared_Return_Banks_Ore_And_Charge_Once() - { - var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0); - using (world) - { - var em = world.EntityManager; - var player = MakeExpeditionPlayerAtGate(em); - - group.Update(); // player walks the gate back to base -> reward - - Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger"); - Assert.AreEqual(1, em.GetComponentData(cyc).Charge, - "DR-042: a cleared return also advances the win meter by one (the new win-driver)."); - Assert.AreEqual(1, em.GetComponentData(cyc).LastRewardedEpoch, "the epoch is marked rewarded"); - - // Force a second same-epoch return (the player is back in the expedition at the gate). - em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); - em.SetComponentData(player, LocalTransform.FromPosition(new float3(1000, 1, 0))); - - group.Update(); // returns again, but the epoch was already rewarded - - Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)"); - Assert.AreEqual(1, em.GetComponentData(cyc).Charge, - "the same epoch never double-credits the win meter either (shared LastRewardedEpoch latch)."); - } - } - - [Test] - public void Cleared_Return_Clamps_Charge_At_Target() - { - // DR-042: the win credit clamps at Target (min(Charge+1, Target)) — a cleared return at the cap never overshoots. - var (world, group, cyc) = MakeWorld("GateRewardClamp", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0); - using (world) - { - var em = world.EntityManager; - em.SetComponentData(cyc, new GoalProgress { Charge = 4, Target = 4 }); // already at the cap - MakeExpeditionPlayerAtGate(em); - - group.Update(); - - Assert.AreEqual(4, em.GetComponentData(cyc).Charge, - "a cleared return at the cap clamps at Target (never overshoots)."); - } - } - - [Test] - public void Uncleared_Return_Banks_Nothing() - { - var (world, group, cyc) = MakeWorld("GateRewardUncleared", epoch: 1, clearedThisEpoch: 0, lastRewardedEpoch: 0); - using (world) - { - var em = world.EntityManager; - MakeExpeditionPlayerAtGate(em); - - group.Update(); - - Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)"); - Assert.AreEqual(0, em.GetComponentData(cyc).Charge, - "an uncleared return advances neither Ore nor the win meter."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs.meta b/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs.meta deleted file mode 100644 index 9cd6cf164..000000000 --- a/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 825422a92eb4b1d4eb4cdafc57884a01 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs b/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs deleted file mode 100644 index d693bf67c..000000000 --- a/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.Mathematics; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the server-only (walk-in region - /// transit). A bare world is seeded with an ExpeditionGate (+ LocalTransform) and a player - /// (RegionTag + LocalTransform + PlayerTag). A player whose region matches the gate's FromRegion and who is - /// within the gate radius is transited (RegionTag flipped + LocalTransform teleported to ArrivalPos). - /// Returning to base signals the ThreatDirector (the post-expedition retaliation source) exactly once. Pins - /// the proximity gate, the region/radius guards, and the return signal. - /// - public class ExpeditionGateSystemTests - { - static (World world, SimulationSystemGroup group) MakeWorld(string name) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - return (world, group); - } - - static void MakeGate(EntityManager em, float3 pos, byte from, byte to, float radius, float3 arrival) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponentData(e, new ExpeditionGate { FromRegion = from, ToRegion = to, Radius = radius, ArrivalPos = arrival }); - } - - static Entity MakePlayer(EntityManager em, float3 pos, byte region) - { - var e = em.CreateEntity(); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponentData(e, new RegionTag { Region = region }); - em.AddComponent(e); - return e; - } - - [Test] - public void Player_In_Gate_Radius_Is_Transited_And_Teleported() - { - var (world, group) = MakeWorld("GateTransitWorld"); - using (world) - { - var em = world.EntityManager; - var arrival = new float3(1000, 1, 0); - MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: arrival); - var player = MakePlayer(em, new float3(5, 1, 0), RegionId.Base); - - group.Update(); - - Assert.AreEqual(RegionId.Expedition, em.GetComponentData(player).Region, - "Region flips to the gate's ToRegion."); - var p = em.GetComponentData(player).Position; - Assert.AreEqual(1000f, p.x, 1e-3f, "Player is teleported to the gate's ArrivalPos (x)."); - Assert.AreEqual(0f, p.z, 1e-3f, "Player is teleported to the gate's ArrivalPos (z)."); - } - } - - [Test] - public void Player_Outside_Radius_Is_Not_Transited() - { - var (world, group) = MakeWorld("GateNoTransitWorld"); - using (world) - { - var em = world.EntityManager; - MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: new float3(1000, 1, 0)); - var player = MakePlayer(em, new float3(50, 1, 0), RegionId.Base); - - group.Update(); - - Assert.AreEqual(RegionId.Base, em.GetComponentData(player).Region, - "A player beyond the gate radius stays in its region."); - } - } - - [Test] - public void Player_Wrong_Region_Is_Not_Transited() - { - var (world, group) = MakeWorld("GateWrongRegionWorld"); - using (world) - { - var em = world.EntityManager; - // Gate only acts on players currently in the Base region. - MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: new float3(1000, 1, 0)); - var player = MakePlayer(em, new float3(1, 1, 0), RegionId.Expedition); - - group.Update(); - - Assert.AreEqual(RegionId.Expedition, em.GetComponentData(player).Region, - "A player whose region does not match FromRegion is ignored even inside the radius."); - } - } - - [Test] - public void Return_To_Base_Signals_ThreatDirector_Once() - { - var (world, group) = MakeWorld("GateReturnSignalWorld"); - using (world) - { - var em = world.EntityManager; - MakeGate(em, new float3(0, 1, 0), RegionId.Expedition, RegionId.Base, radius: 15f, arrival: new float3(0, 1, 0)); - MakePlayer(em, new float3(3, 1, 0), RegionId.Expedition); - - var threat = em.CreateEntity(typeof(ThreatState)); - em.SetComponentData(threat, new ThreatState()); - - group.Update(); - - var ts = em.GetComponentData(threat); - Assert.AreEqual(1, ts.PendingReturns, - "Returning to base signals the ThreatDirector exactly once (the gate teleports the returner out of its radius)."); - Assert.AreEqual(1, ts.ExpeditionsCompleted, "A completed expedition is counted."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs.meta deleted file mode 100644 index fa9a62453..000000000 --- a/Assets/_Project/Tests/EditMode/ExpeditionGateSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: dfddde749d3109843901804073127701 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs b/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs new file mode 100644 index 000000000..eb21d2f85 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs @@ -0,0 +1,131 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Core; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// Pins Step 12a — the born-correct PERMANENT meta seeding in : a spawning + /// player replays its class's persisted tiers as meta-band StatModifiers + /// (Value = ValuePerTier * tier, SourceId = MetaSourceIdBase + id); other-class rows and unknown ids are + /// skipped; an over-MaxTier saved row is CLAMPED (D-F5); and the availability guard blocks the WHOLE spawn + /// (request preserved, nothing consumed) when the catalog is absent (N2). + /// + public class MetaSeedingTests + { + static (World world, SimulationSystemGroup group) MakeWorld() + { + var world = new World("MetaSeedTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + return (world, group); + } + + static Entity MakePlayerPrefab(EntityManager em) + { + var e = em.CreateEntity(typeof(LocalTransform), typeof(GhostOwner), typeof(AbilityRef), typeof(PlayerTag)); + em.SetComponentData(e, LocalTransform.Identity); + em.AddBuffer(e); + em.AddComponent(e); + return e; + } + + static void MakeSpawnRequest(EntityManager em, byte classId) + { + var conn = em.CreateEntity(typeof(NetworkId)); + em.SetComponentData(conn, new NetworkId { Value = 1 }); + em.AddBuffer(conn); + var req = em.CreateEntity(typeof(GoInGameRequest), typeof(ReceiveRpcCommandRequest)); + em.SetComponentData(req, new GoInGameRequest { ClassId = classId }); + em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn }); + } + + static int MetaRows(EntityManager em, Entity player, out float firstValue, out uint firstSource) + { + firstValue = 0f; firstSource = 0; + var mods = em.GetBuffer(player); + int n = 0; + for (int i = 0; i < mods.Length; i++) + if (mods[i].SourceId >= Tuning.MetaSourceIdBase + && mods[i].SourceId < Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan) + { + if (n == 0) { firstValue = mods[i].Value; firstSource = mods[i].SourceId; } + n++; + } + return n; + } + + [Test] + public void Spawn_SeedsClassTiers_SkipsOthers_ClampsOverMax() + { + var (world, group) = MakeWorld(); + var em = world.EntityManager; + var prefab = MakePlayerPrefab(em); + var spawnerE = em.CreateEntity(typeof(PlayerSpawner)); + em.SetComponentData(spawnerE, new PlayerSpawner { PlayerPrefab = prefab, SpawnRingRadius = 2f, RingSlots = 8 }); + + var catalogE = em.CreateEntity(typeof(MetaUpgradeCatalog)); + em.SetComponentData(catalogE, new MetaUpgradeCatalog { Value = MetaCatalogData.BuildDefault() }); + var record = em.AddBuffer(catalogE); // the tier record rides any singleton entity in tests + byte warrior = ClassTraits.WarriorClass; // normalized CharacterId (2) + record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 1, Tier = 2 }); // Reinforced Frame t2 -> +30 + record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 5, Tier = 9 }); // Warrior's Might, saved OVER MaxTier(4) -> clamp + record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 200, Tier = 1 }); // unknown id -> skipped + record.Add(new MetaTierState { ClassId = ClassTraits.RangerClass, UpgradeId = 2, Tier = 3 }); // other class -> skipped + record.Add(new MetaTierState { ClassId = warrior, UpgradeId = 7, Tier = 1 }); // Ranger-masked (Longshot) -> skipped + + MakeSpawnRequest(em, warrior); + group.Update(); + + var pq = em.CreateEntityQuery(typeof(PlayerTag), typeof(PlayerClass)); + var players = pq.ToEntityArray(Allocator.Temp); + Assert.AreEqual(1, players.Length, "player spawned"); + var player = players[0]; + players.Dispose(); pq.Dispose(); // BEFORE the world + + var mods = em.GetBuffer(player); + float frameValue = 0f, mightValue = 0f; + int metaRows = 0; + for (int i = 0; i < mods.Length; i++) + { + if (mods[i].SourceId == Tuning.MetaSourceIdBase + 1) { frameValue = mods[i].Value; metaRows++; } + else if (mods[i].SourceId == Tuning.MetaSourceIdBase + 5) { mightValue = mods[i].Value; metaRows++; } + else if (mods[i].SourceId >= Tuning.MetaSourceIdBase + && mods[i].SourceId < Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan) metaRows++; + } + Assert.AreEqual(2, metaRows, "exactly the two legal Warrior tiers seeded (unknown/other-class/other-mask skipped)"); + Assert.AreEqual(30f, frameValue, 1e-3f, "Reinforced Frame tier 2 = 15 * 2"); + Assert.AreEqual(0.40f, mightValue, 1e-3f, "Warrior's Might CLAMPED to MaxTier 4 = 0.10 * 4 (D-F5)"); + world.Dispose(); + } + + [Test] + public void MissingCatalog_BlocksSpawn_PreservesRequest() + { + var (world, group) = MakeWorld(); + var em = world.EntityManager; + var prefab = MakePlayerPrefab(em); + var spawnerE = em.CreateEntity(typeof(PlayerSpawner)); + em.SetComponentData(spawnerE, new PlayerSpawner { PlayerPrefab = prefab, SpawnRingRadius = 2f, RingSlots = 8 }); + // NO catalog, NO tier record. + MakeSpawnRequest(em, ClassTraits.WarriorClass); + + group.Update(); + + var pq = em.CreateEntityQuery(typeof(PlayerClass)); + Assert.AreEqual(0, pq.CalculateEntityCount(), "no player spawned while blocked (N2)"); + var rq = em.CreateEntityQuery(typeof(GoInGameRequest)); + Assert.AreEqual(1, rq.CalculateEntityCount(), "request PRESERVED — the spawn retries when the catalog streams in"); + pq.Dispose(); rq.Dispose(); // BEFORE the world (a using-var here would outlive it)in"); + world.Dispose(); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs.meta b/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs.meta new file mode 100644 index 000000000..dd6ec01a1 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/MetaSeedingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 923f2724cee01c34f87f8fda67885b11 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs b/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs new file mode 100644 index 000000000..50e11f54c --- /dev/null +++ b/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs @@ -0,0 +1,190 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Core; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Tests +{ + /// + /// Pins Step 13 — : the Staging-gated Aether→tier purchase with DR-014 in-loop + /// ledger atomicity (two same-tick barely-enough purchases → exactly ONE succeeds), the TotalOf pre-check + /// (Withdraw clamps, it never rejects), the ABSOLUTE-value modifier upsert on every live class member (R-F1/2), + /// tier bump-or-append on the director record, the SaveRequest flag, and the reject paths (wrong class mask, + /// MaxTier cap, non-Staging lifecycle) — with requests always consumed. + /// + public class MetaSpendSystemTests + { + static (World world, SimulationSystemGroup group) MakeWorld() + { + var world = new World("MetaSpendTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + return (world, group); + } + + static Entity MakeDirector(EntityManager em, byte lifecycle, int aether) + { + var dir = em.CreateEntity(typeof(RunInfo), typeof(ResourceLedger), typeof(SaveRequest), + typeof(MetaUpgradeCatalog)); + em.SetComponentData(dir, new RunInfo { Lifecycle = lifecycle }); + em.SetComponentData(dir, new MetaUpgradeCatalog { Value = MetaCatalogData.BuildDefault() }); + var ledger = em.AddBuffer(dir); + if (aether > 0) ledger.Add(new StorageEntry { ItemId = ResourceId.Aether, Count = aether }); + em.AddBuffer(dir); + return dir; + } + + static Entity MakePlayer(EntityManager em, int networkId, byte classId) + { + var conn = em.CreateEntity(typeof(NetworkId)); + em.SetComponentData(conn, new NetworkId { Value = networkId }); + var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(PlayerClass)); + em.SetComponentData(player, new GhostOwner { NetworkId = networkId }); + em.SetComponentData(player, new PlayerClass { ClassId = classId }); + em.AddBuffer(player); + em.AddComponentData(player, new ConnRef { Conn = conn }); + return player; + } + + /// Test-only pointer so a request can be issued from the player's own connection. + struct ConnRef : IComponentData { public Entity Conn; } + + static void SendRequest(EntityManager em, Entity player, byte upgradeId) + { + var conn = em.GetComponentData(player).Conn; + var req = em.CreateEntity(typeof(MetaSpendRequest), typeof(ReceiveRpcCommandRequest)); + em.SetComponentData(req, new MetaSpendRequest { UpgradeId = upgradeId }); + em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn }); + } + + static int PendingRequests(EntityManager em) + { + var q = em.CreateEntityQuery(typeof(MetaSpendRequest)); + int n = q.CalculateEntityCount(); + q.Dispose(); + return n; + } + + static float MetaModValue(EntityManager em, Entity player, byte upgradeId, out int rowCount) + { + var mods = em.GetBuffer(player, true); + float value = 0f; + rowCount = 0; + for (int i = 0; i < mods.Length; i++) + if (mods[i].SourceId == Tuning.MetaSourceIdBase + upgradeId) { value = mods[i].Value; rowCount++; } + return value; + } + + [Test] + public void Purchase_BumpsTier_Withdraws_UpsertsAllClassMembers_FlagsSave() + { + var (world, group) = MakeWorld(); + var em = world.EntityManager; + var dir = MakeDirector(em, RunLifecycle.Staging, 25); + var warriorA = MakePlayer(em, 1, ClassTraits.WarriorClass); + var warriorB = MakePlayer(em, 2, ClassTraits.WarriorClass); // classmate: shared per-class pool + var ranger = MakePlayer(em, 3, ClassTraits.RangerClass); // other class: untouched + + SendRequest(em, warriorA, 1); // Reinforced Frame: BaseCost 10, +15/tier + group.Update(); + + var record = em.GetBuffer(dir, true); + Assert.AreEqual(1, MetaMath.TierOf(record, ClassTraits.WarriorClass, 1), "tier bumped to 1"); + Assert.AreEqual(15, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether), + "cost 10 withdrawn from 25"); + Assert.AreEqual(15f, MetaModValue(em, warriorA, 1, out int rowsA), 1e-3f, "buyer modifier = 15 * tier1"); + Assert.AreEqual(1, rowsA); + Assert.AreEqual(15f, MetaModValue(em, warriorB, 1, out _), 1e-3f, "live classmate upserted too (R-F2)"); + Assert.AreEqual(0f, MetaModValue(em, ranger, 1, out int rowsR), 1e-3f, "other class untouched"); + Assert.AreEqual(0, rowsR); + Assert.AreEqual(1, em.GetComponentData(dir).Pending, "purchase flags the autosave"); + Assert.AreEqual(0, PendingRequests(em), "request consumed"); + world.Dispose(); + } + + [Test] + public void TwoSameTick_BarelyEnough_ExactlyOneSucceeds() + { + var (world, group) = MakeWorld(); + var em = world.EntityManager; + var dir = MakeDirector(em, RunLifecycle.Staging, 10); // ids 1 and 4 BOTH cost 10 at tier 0 + var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass); + + SendRequest(em, warrior, 1); + SendRequest(em, warrior, 4); + group.Update(); + + var record = em.GetBuffer(dir, true); + int bought = MetaMath.TierOf(record, ClassTraits.WarriorClass, 1) + + MetaMath.TierOf(record, ClassTraits.WarriorClass, 4); + Assert.AreEqual(1, bought, "in-loop atomicity: barely-enough Aether buys exactly ONE (DR-014)"); + Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether), + "the single cost fully drained the ledger — and never went negative (TotalOf pre-check)"); + Assert.AreEqual(0, PendingRequests(em), "both requests consumed"); + world.Dispose(); + } + + [Test] + public void SecondPurchase_AbsoluteUpsert_SingleRow_RampedCost() + { + var (world, group) = MakeWorld(); + var em = world.EntityManager; + var dir = MakeDirector(em, RunLifecycle.Staging, 30); // tier1 = 10, tier2 = 10 + 1*5 = 15 + var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass); + + SendRequest(em, warrior, 1); + group.Update(); + SendRequest(em, warrior, 1); + group.Update(); + + var record = em.GetBuffer(dir, true); + Assert.AreEqual(2, MetaMath.TierOf(record, ClassTraits.WarriorClass, 1), "tier 2 owned"); + Assert.AreEqual(1, record.Length, "record row BUMPED in place, not duplicated"); + Assert.AreEqual(30f, MetaModValue(em, warrior, 1, out int rows), 1e-3f, + "ABSOLUTE upsert: 15 * tier2 (R-F1)"); + Assert.AreEqual(1, rows, "one modifier row — an incremental append would double-count in recompute"); + Assert.AreEqual(5, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether), + "linear ramp: 30 - 10 - 15"); + world.Dispose(); + } + + [Test] + public void Rejects_WrongClassMask_MaxTierCap_NonStaging() + { + var (world, group) = MakeWorld(); + var em = world.EntityManager; + var dir = MakeDirector(em, RunLifecycle.Staging, 999); + var warrior = MakePlayer(em, 1, ClassTraits.WarriorClass); + + // (a) Ranger-masked upgrade requested by a Warrior — dropped, nothing withdrawn. + SendRequest(em, warrior, 7); // Ranger's Longshot (mask 2) + group.Update(); + Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether), + "class-mask reject leaves the ledger untouched"); + + // (b) at MaxTier — dropped. Fleet Stride (id 4) MaxTier 3. + var record = em.GetBuffer(dir); + record.Add(new MetaTierState { ClassId = ClassTraits.WarriorClass, UpgradeId = 4, Tier = 3 }); + SendRequest(em, warrior, 4); + group.Update(); + Assert.AreEqual(3, MetaMath.TierOf(em.GetBuffer(dir, true), ClassTraits.WarriorClass, 4), + "MaxTier cap holds"); + Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether)); + + // (c) mid-run — dropped (N4: the shop is a between-runs surface). + em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.InRoom }); + SendRequest(em, warrior, 1); + group.Update(); + Assert.AreEqual(0, MetaMath.TierOf(em.GetBuffer(dir, true), ClassTraits.WarriorClass, 1), + "non-Staging purchase dropped"); + Assert.AreEqual(999, StorageMath.TotalOf(em.GetBuffer(dir, true), ResourceId.Aether)); + Assert.AreEqual(0, PendingRequests(em), "every request consumed, accepted or not"); + world.Dispose(); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs.meta new file mode 100644 index 000000000..fa0234433 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/MetaSpendSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4b835275276dd3149b248a6b6a031ad3 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs b/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs new file mode 100644 index 000000000..1b5927187 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs @@ -0,0 +1,210 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Core; +using Unity.Entities; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for the ready-check spine: (RPC → PlayerReady, + /// Staging/Launching-only) ordered before (the all-ready rising-edge launch, the + /// un-ready countdown abort, the F2 outcome guard, the sub-slot teleport out/home, and the Returning-edge + /// ready-flag clear). Ticks are driven manually through NetworkTime, so the countdown/dwell paths that Play-mode + /// polling races past are pinned deterministically here. + /// + public class ReadyCheckSystemTests + { + const uint T0 = 1000; + + static (World world, SimulationSystemGroup group, Entity dir) MakeWorld() + { + var world = new World("ReadyCheckTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + var nt = em.CreateEntity(typeof(NetworkTime)); + em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) }); + var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); + em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.Staging }); + em.SetComponentData(dir, new RunRuntime { HostSalt = 1u }); + return (world, group, dir); + } + + static void SetTick(World world, uint tick) + { + var em = world.EntityManager; + using var q = em.CreateEntityQuery(typeof(NetworkTime)); + em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) }); + } + + static Entity MakePlayer(EntityManager em, int networkId) + { + var e = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(GhostOwner), + typeof(RegionTag), typeof(LocalTransform)); + em.SetComponentData(e, new GhostOwner { NetworkId = networkId }); + em.SetComponentData(e, new RegionTag { Region = RegionId.Base }); + em.SetComponentData(e, LocalTransform.Identity); + return e; + } + + static Entity MakeConnection(EntityManager em, int networkId) + { + var e = em.CreateEntity(typeof(NetworkId)); + em.SetComponentData(e, new NetworkId { Value = networkId }); + return e; + } + + static void SendToggle(EntityManager em, Entity conn, byte ready) + { + var e = em.CreateEntity(typeof(ReadyToggleRequest), typeof(ReceiveRpcCommandRequest)); + em.SetComponentData(e, new ReadyToggleRequest { Ready = ready }); + em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn }); + } + + static int PendingRequests(EntityManager em) + { + using var q = em.CreateEntityQuery(typeof(ReadyToggleRequest)); + return q.CalculateEntityCount(); + } + + [Test] + public void Toggle_SetsReady_AndSoloLaunches_SameTick() + { + var (world, group, dir) = MakeWorld(); + var em = world.EntityManager; + var player = MakePlayer(em, 1); + var conn = MakeConnection(em, 1); + + SendToggle(em, conn, 1); + group.Update(); + + Assert.AreEqual(1, em.GetComponentData(player).Value, "toggle landed"); + Assert.AreEqual(0, PendingRequests(em), "request consumed"); + var info = em.GetComponentData(dir); + Assert.AreEqual(RunLifecycle.Launching, info.Lifecycle, "1/1 ready -> rising edge -> Launching"); + Assert.AreNotEqual(0u, info.LaunchTick, "countdown telegraph armed"); + Assert.AreNotEqual(0u, info.RunSeed, "run seeded"); + Assert.GreaterOrEqual(info.RoomCount, 6, "seed-varied length floor"); + Assert.LessOrEqual(info.RoomCount, 10, "seed-varied length cap"); + world.Dispose(); + } + + [Test] + public void Toggle_Ignored_MidRun() + { + var (world, group, dir) = MakeWorld(); + var em = world.EntityManager; + var player = MakePlayer(em, 1); + var conn = MakeConnection(em, 1); + var info = em.GetComponentData(dir); + info.Lifecycle = RunLifecycle.InRoom; + em.SetComponentData(dir, info); + + SendToggle(em, conn, 1); + group.Update(); + + Assert.AreEqual(0, em.GetComponentData(player).Value, "mid-run toggle dropped"); + Assert.AreEqual(0, PendingRequests(em), "request still consumed"); + world.Dispose(); + } + + [Test] + public void PartialReady_DoesNotLaunch() + { + var (world, group, dir) = MakeWorld(); + var em = world.EntityManager; + MakePlayer(em, 1); + MakePlayer(em, 2); + var conn1 = MakeConnection(em, 1); + + SendToggle(em, conn1, 1); + group.Update(); + + Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle, + "1/2 ready must NOT launch"); + world.Dispose(); + } + + [Test] + public void UnReady_DuringCountdown_Aborts() + { + var (world, group, dir) = MakeWorld(); + var em = world.EntityManager; + MakePlayer(em, 1); + var conn = MakeConnection(em, 1); + + SendToggle(em, conn, 1); + group.Update(); + Assert.AreEqual(RunLifecycle.Launching, em.GetComponentData(dir).Lifecycle); + + SendToggle(em, conn, 0); // change of heart during the 3-2-1 + group.Update(); + + var info = em.GetComponentData(dir); + Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle, "un-ready aborts the countdown"); + Assert.AreEqual(0u, info.LaunchTick, "telegraph cleared"); + world.Dispose(); + } + + [Test] + public void Launch_TeleportsPartyOut_ThenHome_AndClearsReady() + { + var (world, group, dir) = MakeWorld(); + var em = world.EntityManager; + var player = MakePlayer(em, 1); + var conn = MakeConnection(em, 1); + + SendToggle(em, conn, 1); + group.Update(); // Staging -> Launching (countdown armed at T0) + + SetTick(world, T0 + 200); // past the 180-tick countdown + group.Update(); // enter room 0 (the real traversal, Step 7) + + Assert.AreEqual(RegionId.Expedition, em.GetComponentData(player).Region, + "party region flipped to Expedition"); + Assert.GreaterOrEqual(em.GetComponentData(player).Position.x, 999f, + "party teleported to the expedition room origin (sub-slot 0 at +1000)"); + Assert.AreEqual(1f, em.GetComponentData(player).Scale, 1e-4f, + "Scale preserved through the teleport (never FromPosition)"); + Assert.AreEqual(RunLifecycle.InRoom, em.GetComponentData(dir).Lifecycle, "room 0 active"); + + // Simulate the all-left abort (disconnect edge): drop the player's region externally. + em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); + SetTick(world, T0 + 260); + group.Update(); // InRoom -> Returning (abort, no credit) + SetTick(world, T0 + 320); + group.Update(); // Returning: teleport home + clear ready flags -> StagingStaging + + Assert.AreEqual(RegionId.Base, em.GetComponentData(player).Region, "back home"); + Assert.Less(em.GetComponentData(player).Position.x, 100f, "position restored to base"); + Assert.AreEqual(0, em.GetComponentData(player).Value, "ready flag cleared on return"); + var run = em.GetComponentData(dir); + Assert.AreEqual(run.RunEpoch, run.LastBankedRunEpoch, "terminal bank latch fired once"); + Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); + world.Dispose(); + } + + [Test] + public void LaunchGuard_BlocksWhenOutcomeLatched() + { + var (world, group, dir) = MakeWorld(); + var em = world.EntityManager; + MakePlayer(em, 1); + var conn = MakeConnection(em, 1); + em.AddComponentData(dir, new RunOutcome { Value = RunOutcomeId.Victory }); + + SendToggle(em, conn, 1); + group.Update(); + + Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle, + "a decided run must not launch (F2 guard)"); + world.Dispose(); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs.meta new file mode 100644 index 000000000..28ba10407 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 046f63edcabb72548843c84fcd99dda1 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs b/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs new file mode 100644 index 000000000..b59a095d0 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs @@ -0,0 +1,178 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Core; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for (the Step-6 successor of the retired + /// ZoneEnemyDirectorSystem). Pins: the per-RoomEpoch reseed sized by ZoneEnemyMath on the room's DifficultyEpoch; + /// spawns at the ACTIVE sub-slot origin carrying the full tag stack (RegionTag{Expedition} + ZoneEnemyTag + + /// RoomTag) with baked Scale preserved; the Boss room's single scaled boss; the MaxAlive pack-fit wait; and the + /// ExpeditionObjective Cleared/Idle latch written above the early-returns. + /// + public class RoomEnemyDirectorSystemTests + { + const uint Seed = 777u; + const uint T0 = 500; + + static (World world, SimulationSystemGroup group, Entity runDir, Entity zoneDir) MakeWorld( + int currentRoom, int currentNodeId, byte activeSubSlot, int maxAlive = 10) + { + var world = new World("RoomEnemyTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + + var nt = em.CreateEntity(typeof(NetworkTime)); + em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) }); + + var map = RunMapMath.Generate(Seed); + var runDir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective)); + em.SetComponentData(runDir, new RunInfo + { + Lifecycle = RunLifecycle.InRoom, + CurrentRoom = currentRoom, + RoomCount = map.LayerCount, + }); + em.SetComponentData(runDir, new RunRuntime + { + RunSeed = Seed, + RoomEpoch = 1, + CurrentNodeId = currentNodeId, + ActiveSubSlot = activeSubSlot, + }); + + var grunt = MakeEnemyPrefab(em); + var charger = MakeEnemyPrefab(em); + var zoneDir = em.CreateEntity(typeof(ZoneEnemyDirector), typeof(ZoneEnemyState)); + em.SetComponentData(zoneDir, new ZoneEnemyDirector + { + MaxAlive = maxAlive, RingRadius = 14f, RingSlots = 10, SpawnIntervalTicks = 10, + GruntsPerWave = 4, ChargersPerWave = 1, SwarmerPackSize = 3, ClusterTightRadius = 1.5f, RewardOre = 25, + }); + var buf = em.AddBuffer(zoneDir); + buf.Add(new ZoneEnemyPrefab { Prefab = grunt }); + buf.Add(new ZoneEnemyPrefab { Prefab = charger }); + return (world, group, runDir, zoneDir); + } + + static Entity MakeEnemyPrefab(EntityManager em) + { + var e = em.CreateEntity(typeof(LocalTransform), typeof(EnemyTag), typeof(Health)); + em.SetComponentData(e, LocalTransform.Identity); // Scale = 1 so WithPosition keeps it + em.SetComponentData(e, new Health { Current = 50f, Max = 50f }); + em.AddComponent(e); + return e; + } + + static MixBands Bands() => new MixBands { GruntBase = 4, ChargerBase = 1 }; + + static int Alive(EntityManager em) + { + using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag)); + return q.CalculateEntityCount(); + } + + [Test] + public void Seeds_ByRoomDifficulty_AndSpawnsTaggedAtActiveSlotOrigin() + { + var (world, group, runDir, zoneDir) = MakeWorld(currentRoom: 0, currentNodeId: RunMap.NodeId(0, 0), activeSubSlot: 0); + var em = world.EntityManager; + var map = RunMapMath.Generate(Seed); + var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount); + int slots = ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, Bands()); + + group.Update(); // seeds + first slot spawns this tick + + var zs = em.GetComponentData(zoneDir); + Assert.AreEqual(1, zs.SeededEpoch, "seeded for RoomEpoch 1"); + Assert.AreEqual(slots - 1, zs.RemainingToSpawn, "wave sized by ZoneEnemyMath on the room's DifficultyEpoch"); + Assert.AreEqual(1, Alive(em), "first slot drip-spawned"); + + var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(RoomTag), typeof(RegionTag), typeof(LocalTransform)); + Assert.AreEqual(1, q.CalculateEntityCount(), "spawn carries the FULL tag stack (Zone + Room + Region)"); + var xfs = q.ToComponentDataArray(Allocator.Temp); + var regs = q.ToComponentDataArray(Allocator.Temp); + var rooms = q.ToComponentDataArray(Allocator.Temp); + Assert.AreEqual(RegionId.Expedition, regs[0].Region); + Assert.AreEqual(0, rooms[0].Room); + Assert.AreEqual(1f, xfs[0].Scale, 1e-4f, "baked Scale preserved"); + float3 origin = RegionMath.ExpeditionRoomOrigin(new float3(0f, 1f, 0f), 0); + Assert.LessOrEqual(math.distance(xfs[0].Position.xz, origin.xz), 14f + 0.01f, + "ring-spawned around the ACTIVE sub-slot origin"); + xfs.Dispose(); regs.Dispose(); rooms.Dispose(); q.Dispose(); // dispose BEFORE the worldispose(); + world.Dispose(); + } + + [Test] + public void BossRoom_SpawnsSingleScaledBoss() + { + var map = RunMapMath.Generate(Seed); + int bossLayer = map.LayerCount - 1; + var (world, group, runDir, zoneDir) = MakeWorld(bossLayer, map.BossNodeId, activeSubSlot: (byte)(bossLayer & 1)); + var em = world.EntityManager; + + group.Update(); + + var zs = em.GetComponentData(zoneDir); + Assert.AreEqual(0, zs.RemainingToSpawn, "a boss room is a single-slot wave, fully spawned"); + Assert.AreEqual(1, Alive(em), "exactly one boss"); + + var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(Health), typeof(LocalTransform)); + var hps = q.ToComponentDataArray(Allocator.Temp); + var xfs = q.ToComponentDataArray(Allocator.Temp); + Assert.AreEqual(50f * Tuning.BossHealthMultiplier, hps[0].Max, 1e-3f, "boss health scaled"); + Assert.AreEqual(Tuning.BossScaleMultiplier, xfs[0].Scale, 1e-3f, "boss visual scale bumped"); + hps.Dispose(); xfs.Dispose(); q.Dispose(); // dispose BEFORE the world (a using-var here outlives it); + world.Dispose(); + } + + [Test] + public void Objective_ClearedLatch_AndIdleOutsideRooms() + { + var (world, group, runDir, zoneDir) = MakeWorld(0, RunMap.NodeId(0, 0), 0); + var em = world.EntityManager; + + // Fabricate a fully-spawned, fully-dead wave for the CURRENT room epoch. + em.SetComponentData(zoneDir, new ZoneEnemyState { SeededEpoch = 1, RemainingToSpawn = 0, SpawnCounter = 5 }); + group.Update(); + Assert.AreEqual(ExpeditionObjectiveState.Cleared, em.GetComponentData(runDir).State, + "fully-spawned + zero-alive latches Cleared for the seeded epoch"); + + var info = em.GetComponentData(runDir); + info.Lifecycle = RunLifecycle.Staging; + em.SetComponentData(runDir, info); + group.Update(); + Assert.AreEqual(ExpeditionObjectiveState.Idle, em.GetComponentData(runDir).State, + "no active room -> Idle (objective still written above the early-return)"); + world.Dispose(); + } + + [Test] + public void MaxAlive_PackFitWaits_WithoutConsumingTheSlot() + { + var (world, group, runDir, zoneDir) = MakeWorld(0, RunMap.NodeId(0, 0), 0, maxAlive: 1); + var em = world.EntityManager; + // One zone enemy already alive fills the cap. + var blocker = em.CreateEntity(typeof(ZoneEnemyTag)); + // Pre-seed so the tick goes straight to the drip branch. + em.SetComponentData(zoneDir, new ZoneEnemyState { SeededEpoch = 1, RemainingToSpawn = 2, NextSpawnTick = 0 }); + + group.Update(); + + var zs = em.GetComponentData(zoneDir); + Assert.AreEqual(1, Alive(em), "cap full -> nothing spawned"); + Assert.AreEqual(2, zs.RemainingToSpawn, "the slot WAITS (not consumed) until the pack fits"); + world.Dispose(); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs.meta new file mode 100644 index 000000000..8bc94335d --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0a5bf96c5c42e5240a8db55fc3e2a01a \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs b/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs new file mode 100644 index 000000000..a845ea927 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs @@ -0,0 +1,153 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Core; +using Unity.Entities; +using Unity.Mathematics; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for (the Step-5 successor of the retired + /// ExpeditionFieldSystem + its teardown regression). Pins: exactly one scatter per RoomEpoch (int-equality + /// reseed), the run-wide scarcity budget flooring + spend-down, RoomTag stamping + baked-Scale preservation + + /// in-shape placement at the active sub-slot origin, and the Staging defensive sweep that kills room ghosts while + /// UNTAGGED entities (the old base-field-survives regression, now structural) are untouched. + /// + public class RoomFieldSystemTests + { + const uint Seed = 777u; + + static (World world, SimulationSystemGroup group, Entity dir, Entity spawnerE, Entity prefab) MakeWorld( + int nodeBudget) + { + var world = new World("RoomFieldTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + + var map = RunMapMath.Generate(Seed); + var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); + em.SetComponentData(dir, new RunInfo + { + Lifecycle = RunLifecycle.InRoom, + CurrentRoom = 0, + RoomCount = map.LayerCount, + }); + em.SetComponentData(dir, new RunRuntime + { + RunSeed = Seed, + RoomEpoch = 1, + CurrentNodeId = RunMap.NodeId(0, 0), + ActiveSubSlot = 0, + NodeBudgetRemaining = nodeBudget, + }); + + // Node ghost prefab: Scale=2 pins the WithPosition (never FromPosition) preservation. + var prefab = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode)); + em.SetComponentData(prefab, new LocalTransform { Position = float3.zero, Rotation = quaternion.identity, Scale = 2f }); + em.SetComponentData(prefab, new ResourceNode { ResourceId = ResourceId.Ore, Remaining = 30, HarvestPerHit = 5f }); + em.AddComponent(prefab); + + // Spawner singleton with the runtime state PRE-attached (skips the one-shot attach tick). + var spawnerE = em.CreateEntity(typeof(ResourceFieldSpawner), typeof(RoomFieldState)); + em.SetComponentData(spawnerE, new ResourceFieldSpawner { Prefab = prefab, Count = 99, Radius = 0f }); + + return (world, group, dir, spawnerE, prefab); + } + + static int LiveNodes(EntityManager em) + { + using var q = em.CreateEntityQuery(typeof(ResourceNode), typeof(RoomTag)); + return q.CalculateEntityCount(); + } + + [Test] + public void Spawns_OncePerRoomEpoch_PlanCount_TaggedInShape_ScalePreserved() + { + var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 12); + var em = world.EntityManager; + var map = RunMapMath.Generate(Seed); + var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount); + int expected = math.min(plan.NodeCount, 12); + float3 origin = RegionMath.ExpeditionRoomOrigin(new float3(0f, 1f, 0f), 0); + + group.Update(); + Assert.AreEqual(expected, LiveNodes(em), "one room's plan-count scatter"); + + using (var q = em.CreateEntityQuery(typeof(ResourceNode), typeof(RoomTag), typeof(LocalTransform))) + { + var tags = q.ToComponentDataArray(Allocator.Temp); + var xfs = q.ToComponentDataArray(Allocator.Temp); + for (int i = 0; i < tags.Length; i++) + { + Assert.AreEqual(0, tags[i].Room, "stamped with the active room index"); + Assert.AreEqual(2f, xfs[i].Scale, 1e-4f, "baked Scale preserved (WithPosition, never FromPosition)"); + Assert.IsTrue(RoomLayoutMath.ContainsPoint(plan.ShapeId, origin, xfs[i].Position), + "scattered inside the room shape at the sub-slot-0 origin"); + Assert.GreaterOrEqual(xfs[i].Position.x, 900f, "placed at the EXPEDITION origin, not the base"); + } + tags.Dispose(); + xfs.Dispose(); + } + + Assert.AreEqual(12 - expected, em.GetComponentData(dir).NodeBudgetRemaining, + "budget spent down by exactly the scattered count"); + + group.Update(); // same RoomEpoch — must NOT scatter again + Assert.AreEqual(expected, LiveNodes(em), "int-equality reseed: one scatter per RoomEpoch"); + world.Dispose(); + } + + [Test] + public void Budget_FloorsSpawnCount_AndExhausts() + { + var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 1); + var em = world.EntityManager; + + group.Update(); + Assert.AreEqual(1, LiveNodes(em), "budget of 1 floors the room to a single node"); + Assert.AreEqual(0, em.GetComponentData(dir).NodeBudgetRemaining); + + // Advance to the next room with a DRY budget — nothing more may spawn. + var run = em.GetComponentData(dir); + run.RoomEpoch = 2; + run.CurrentNodeId = RunMap.NodeId(1, 0); + run.ActiveSubSlot = 1; + em.SetComponentData(dir, run); + var info = em.GetComponentData(dir); + info.CurrentRoom = 1; + em.SetComponentData(dir, info); + + group.Update(); + Assert.AreEqual(1, LiveNodes(em), "a dry budget spawns nothing (scarcity holds run-wide)"); + world.Dispose(); + } + + [Test] + public void StagingSweep_KillsRoomGhosts_SparesUntagged() + { + var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 12); + var em = world.EntityManager; + group.Update(); + Assert.Greater(LiveNodes(em), 0, "room content exists"); + + // An untagged node (e.g. the base mining field) must be structurally untouchable. + var baseNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode)); + em.SetComponentData(baseNode, LocalTransform.FromPosition(new float3(20f, 0f, 0f))); + + var info = em.GetComponentData(dir); + info.Lifecycle = RunLifecycle.Staging; + em.SetComponentData(dir, info); + + group.Update(); + Assert.AreEqual(0, LiveNodes(em), "Staging sweep cleared every room ghost"); + Assert.IsTrue(em.Exists(baseNode), "untagged entities survive (the old base-field regression, structural now)"); + world.Dispose(); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs.meta new file mode 100644 index 000000000..f2b727a77 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RoomFieldSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9b72710f2e3958f4a827cb3befcc8850 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs b/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs new file mode 100644 index 000000000..f5f2aa9ea --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs @@ -0,0 +1,93 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.Mathematics; + +namespace ProjectM.Tests +{ + /// + /// Pure-function tests for — resolving a map node into a and + /// scattering points within a room's shape. Pins the plan mapping, the depth/type difficulty ramp, the per-type + /// node density, and (the swept-scatter safety net) that every scattered point lies within its shape footprint. + /// + public class RoomLayoutMathTests + { + [Test] + public void Plan_CopiesNodeFields_AndResolvesArchetype() + { + var node = new RunMapNode + { + RoomType = RoomTypeId.Reward, + Biome = RoomBiomeId.Cavern, + ShapeId = RoomShapeId.Wide, + NextMask = 1, + }; + var plan = RoomLayoutMath.Plan(node, layer: 3, roomCount: 8); + Assert.AreEqual(RoomTypeId.Reward, plan.RoomType); + Assert.AreEqual(RoomBiomeId.Cavern, plan.Biome); + Assert.AreEqual(RoomShapeId.Wide, plan.ShapeId); + Assert.AreEqual(RoomLayoutMath.ShapeRadius(RoomShapeId.Wide), plan.Radius); + Assert.AreEqual(RoomLayoutMath.BaseNodeCount(RoomTypeId.Reward), plan.NodeCount); + Assert.AreEqual(RoomLayoutMath.DifficultyEpoch(3, RoomTypeId.Reward), plan.DifficultyEpoch); + } + + [Test] + public void DifficultyEpoch_DeeperIsHarder_EliteAndBossBump() + { + Assert.AreEqual(1, RoomLayoutMath.DifficultyEpoch(0, RoomTypeId.Combat), "layer 0 floors at 1"); + Assert.Greater(RoomLayoutMath.DifficultyEpoch(5, RoomTypeId.Combat), + RoomLayoutMath.DifficultyEpoch(2, RoomTypeId.Combat), "deeper is harder"); + Assert.AreEqual(RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Combat) + 2, + RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Elite), "Elite +2"); + Assert.AreEqual(RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Combat) + 3, + RoomLayoutMath.DifficultyEpoch(4, RoomTypeId.Boss), "Boss +3"); + } + + [Test] + public void BaseNodeCount_RewardDense_BossMinimal() + { + Assert.Greater(RoomLayoutMath.BaseNodeCount(RoomTypeId.Reward), + RoomLayoutMath.BaseNodeCount(RoomTypeId.Combat), "reward rooms are resource-dense"); + Assert.AreEqual(1, RoomLayoutMath.BaseNodeCount(RoomTypeId.Boss), "boss room is minimal"); + Assert.GreaterOrEqual(RoomLayoutMath.BaseNodeCount(RoomTypeId.Combat), 1); + } + + [Test] + public void ShapeRadius_AllShapesPositive() + { + for (byte s = 0; s < RoomShapeId.Count; s++) + Assert.Greater(RoomLayoutMath.ShapeRadius(s), 0f, $"shape {s}"); + } + + [Test] + public void ScatterInShape_AlwaysWithinShapeFootprint() + { + var center = new float3(1000f, 1f, 5f); // offset origin (expedition region) + nonzero Y preserved + for (byte shape = 0; shape < RoomShapeId.Count; shape++) + { + var rng = new Random(9871u + shape); + for (int i = 0; i < 500; i++) + { + float3 p = RoomLayoutMath.ScatterInShape(shape, center, i, 500, ref rng); + Assert.AreEqual(center.y, p.y, 1e-4f, $"shape {shape}: Y preserved"); + Assert.IsTrue(RoomLayoutMath.ContainsPoint(shape, center, p), + $"shape {shape}: scattered point {p} escaped the footprint"); + } + } + } + + [Test] + public void ScatterInShape_Deterministic_ForSameSeedSequence() + { + var center = new float3(0f, 0f, 0f); + var a = new Random(4242u); + var b = new Random(4242u); + for (int i = 0; i < 50; i++) + { + float3 pa = RoomLayoutMath.ScatterInShape(RoomShapeId.Cross, center, i, 50, ref a); + float3 pb = RoomLayoutMath.ScatterInShape(RoomShapeId.Cross, center, i, 50, ref b); + Assert.AreEqual(pa.x, pb.x, 1e-6f); + Assert.AreEqual(pa.z, pb.z, 1e-6f); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs.meta b/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs.meta new file mode 100644 index 000000000..671f0f611 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RoomLayoutMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 12aadace80f53cd46a8b2699d232f888 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs b/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs new file mode 100644 index 000000000..7d9b2af1d --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs @@ -0,0 +1,71 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Entities; + +namespace ProjectM.Tests +{ + /// + /// Pins the room-scoped teardown contract (): destroying room i kills ONLY + /// room-i entities — the other room survives (the DR-031/DR-040 cross-room-wipe regression, load-bearing for the + /// ping-pong sub-slot handoff where two rooms transiently coexist), untagged entities are untouched, and each + /// entity is destroyed at most once (single-visit ⇒ no double-destroy Playback throw). + /// + public class RoomTeardownTests + { + static Entity MakeRoomEntity(EntityManager em, byte room, bool asNode) + { + // Mimic real room content: some entities look like resource nodes, some like zone enemies — the + // teardown must be type-agnostic (RoomTag is the only contract). + var e = asNode + ? em.CreateEntity(typeof(RoomTag), typeof(ResourceNode)) + : em.CreateEntity(typeof(RoomTag), typeof(ZoneEnemyTag)); + em.SetComponentData(e, new RoomTag { Room = room }); + return e; + } + + [Test] + public void DestroyRoom_KillsOnlyThatRoom_SparesOtherRoomAndUntagged() + { + using var world = new World("RoomTeardownTest"); + var em = world.EntityManager; + + for (int i = 0; i < 3; i++) MakeRoomEntity(em, 0, asNode: i % 2 == 0); // room 0: 3 entities + for (int i = 0; i < 2; i++) MakeRoomEntity(em, 1, asNode: i % 2 == 0); // room 1: 2 entities + var untagged = em.CreateEntity(typeof(ResourceNode)); // e.g. a base-field node + + using var roomQuery = em.CreateEntityQuery(typeof(RoomTag)); + var ecb = new EntityCommandBuffer(Allocator.Temp); + int destroyed = RoomTeardown.DestroyRoom(roomQuery, ecb, 0); + ecb.Playback(em); + ecb.Dispose(); + + Assert.AreEqual(3, destroyed, "exactly room-0's entities were queued"); + using var remaining = em.CreateEntityQuery(typeof(RoomTag)); + var tags = remaining.ToComponentDataArray(Allocator.Temp); + Assert.AreEqual(2, tags.Length, "room 1 survives intact"); + for (int i = 0; i < tags.Length; i++) + Assert.AreEqual(1, tags[i].Room, "every survivor belongs to room 1"); + tags.Dispose(); + Assert.IsTrue(em.Exists(untagged), "untagged (non-room) entities are never touched"); + } + + [Test] + public void DestroyRoom_EmptyRoom_IsANoOp() + { + using var world = new World("RoomTeardownTest2"); + var em = world.EntityManager; + MakeRoomEntity(em, 1, asNode: true); + + using var roomQuery = em.CreateEntityQuery(typeof(RoomTag)); + var ecb = new EntityCommandBuffer(Allocator.Temp); + int destroyed = RoomTeardown.DestroyRoom(roomQuery, ecb, 0); // room 0 has nothing + ecb.Playback(em); + ecb.Dispose(); + + Assert.AreEqual(0, destroyed); + using var remaining = em.CreateEntityQuery(typeof(RoomTag)); + Assert.AreEqual(1, remaining.CalculateEntityCount(), "room 1 untouched"); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs.meta b/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs.meta new file mode 100644 index 000000000..4d81f38f5 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RoomTeardownTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 95327306dceec754aa635447fc9d04d4 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs b/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs new file mode 100644 index 000000000..00e048fa1 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs @@ -0,0 +1,167 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Core; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Tests +{ + /// + /// Validation matrix for — the co-op route-pick receiver. Pins: a correctly + /// stamped pick latches (the review's non-maskable acceptance criterion — a validation bug here silently + /// degrades to the grace auto-pick and a linear game); the first-commit latch under two same-tick picks; the + /// re-meaned run-identity stale-reject ((uint)ForRunEpoch == RunSeed); the layer stale-reject; index bounds; + /// the N3 base-region sender reject; the closed-gate reject; and that requests are ALWAYS consumed. + /// + public class RouteSelectSystemTests + { + const uint Seed = 999u; + const int GateLayer = 2; + + static (World world, SimulationSystemGroup group, Entity dir) MakeGateWorld(byte lifecycle = RunLifecycle.RouteSelect) + { + var world = new World("RouteSelectTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + + var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(RouteCommand)); + em.SetComponentData(dir, new RunInfo + { + Lifecycle = lifecycle, + CurrentRoom = GateLayer, + RunSeed = Seed, + RouteOptionCount = 2, + RouteOpt0Col = 0, + RouteOpt1Col = 2, + }); + em.SetComponentData(dir, new RunRuntime { RunSeed = Seed, RunEpoch = 3 }); + return (world, group, dir); + } + + static Entity MakePlayer(EntityManager em, int networkId, byte region) + { + var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(RegionTag)); + em.SetComponentData(e, new GhostOwner { NetworkId = networkId }); + em.SetComponentData(e, new RegionTag { Region = region }); + return e; + } + + static void SendPick(EntityManager em, int networkId, byte optionIndex, int forSeed, int forLayer) + { + var conn = em.CreateEntity(typeof(NetworkId)); + em.SetComponentData(conn, new NetworkId { Value = networkId }); + var req = em.CreateEntity(typeof(RouteSelectRequest), typeof(ReceiveRpcCommandRequest)); + em.SetComponentData(req, new RouteSelectRequest { OptionIndex = optionIndex, ForRunEpoch = forSeed, ForLayer = forLayer }); + em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn }); + } + + static int PendingRequests(EntityManager em) + { + using var q = em.CreateEntityQuery(typeof(RouteSelectRequest)); + return q.CalculateEntityCount(); + } + + [Test] + public void ValidPick_Latches_WithTrueServerEpoch() + { + var (world, group, dir) = MakeGateWorld(); + var em = world.EntityManager; + MakePlayer(em, 1, RegionId.Expedition); + SendPick(em, 1, optionIndex: 1, forSeed: (int)Seed, forLayer: GateLayer); + + group.Update(); + + var cmd = em.GetComponentData(dir); + Assert.AreEqual(1, cmd.HasPick, "a correctly-stamped pick MUST latch (non-maskable criterion)"); + Assert.AreEqual(1, cmd.OptionIndex, "the picked option"); + Assert.AreEqual(3, cmd.ForRunEpoch, "stamped from the TRUE server epoch, never the client echo"); + Assert.AreEqual(0, PendingRequests(em), "request consumed"); + world.Dispose(); + } + + [Test] + public void TwoSameTickPicks_FirstWins() + { + var (world, group, dir) = MakeGateWorld(); + var em = world.EntityManager; + MakePlayer(em, 1, RegionId.Expedition); + MakePlayer(em, 2, RegionId.Expedition); + SendPick(em, 1, optionIndex: 0, forSeed: (int)Seed, forLayer: GateLayer); // created first -> wins + SendPick(em, 2, optionIndex: 1, forSeed: (int)Seed, forLayer: GateLayer); + + group.Update(); + + var cmd = em.GetComponentData(dir); + Assert.AreEqual(1, cmd.HasPick, "exactly one commit"); + Assert.AreEqual(0, cmd.OptionIndex, "the FIRST accepted pick wins (in-place latch, DR-014)"); + Assert.AreEqual(0, PendingRequests(em), "both requests consumed"); + world.Dispose(); + } + + [Test] + public void Rejects_WrongSeed_WrongLayer_OutOfRange_BaseSender_ClosedGate() + { + // Wrong run-identity token (a stale pick from the previous run). + var (w1, g1, d1) = MakeGateWorld(); + MakePlayer(w1.EntityManager, 1, RegionId.Expedition); + SendPick(w1.EntityManager, 1, 0, forSeed: (int)Seed + 1, forLayer: GateLayer); + g1.Update(); + Assert.AreEqual(0, w1.EntityManager.GetComponentData(d1).HasPick, "wrong seed rejected"); + Assert.AreEqual(0, PendingRequests(w1.EntityManager)); + w1.Dispose(); + + // Wrong layer (a pick from the previous gate of the SAME run). + var (w2, g2, d2) = MakeGateWorld(); + MakePlayer(w2.EntityManager, 1, RegionId.Expedition); + SendPick(w2.EntityManager, 1, 0, (int)Seed, forLayer: GateLayer - 1); + g2.Update(); + Assert.AreEqual(0, w2.EntityManager.GetComponentData(d2).HasPick, "stale layer rejected"); + w2.Dispose(); + + // Option index out of the published range. + var (w3, g3, d3) = MakeGateWorld(); + MakePlayer(w3.EntityManager, 1, RegionId.Expedition); + SendPick(w3.EntityManager, 1, optionIndex: 2, (int)Seed, GateLayer); // count is 2 -> max index 1 + g3.Update(); + Assert.AreEqual(0, w3.EntityManager.GetComponentData(d3).HasPick, "out-of-range rejected"); + w3.Dispose(); + + // Base-region sender (N3): a home-bound joiner cannot commit the party's route. + var (w4, g4, d4) = MakeGateWorld(); + MakePlayer(w4.EntityManager, 1, RegionId.Base); + SendPick(w4.EntityManager, 1, 0, (int)Seed, GateLayer); + g4.Update(); + Assert.AreEqual(0, w4.EntityManager.GetComponentData(d4).HasPick, "base sender rejected (N3)"); + w4.Dispose(); + + // Gate closed (mid-room): the pick is dropped, never queued. + var (w5, g5, d5) = MakeGateWorld(lifecycle: RunLifecycle.InRoom); + MakePlayer(w5.EntityManager, 1, RegionId.Expedition); + SendPick(w5.EntityManager, 1, 0, (int)Seed, GateLayer); + g5.Update(); + Assert.AreEqual(0, w5.EntityManager.GetComponentData(d5).HasPick, "closed gate rejected"); + Assert.AreEqual(0, PendingRequests(w5.EntityManager), "request still consumed"); + w5.Dispose(); + } + + [Test] + public void AlreadyLatched_LaterPickIgnored() + { + var (world, group, dir) = MakeGateWorld(); + var em = world.EntityManager; + MakePlayer(em, 1, RegionId.Expedition); + em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = 0, ForRunEpoch = 3, ForLayer = GateLayer }); + SendPick(em, 1, optionIndex: 1, (int)Seed, GateLayer); + + group.Update(); + + var cmd = em.GetComponentData(dir); + Assert.AreEqual(0, cmd.OptionIndex, "an already-latched gate ignores later picks"); + world.Dispose(); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs.meta new file mode 100644 index 000000000..069868ae2 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RouteSelectSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c6b6345371289f64188653851f25d8d1 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs new file mode 100644 index 000000000..6ba2f886c --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs @@ -0,0 +1,256 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Core; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for 's Step-7 linear traversal: the objective + /// Cleared edge tears the room down AT RoomReward ENTRY and the next room spawns only on the advance (the + /// teardown-before-spawn empty-tick invariant), the ping-pong sub-slot flip + RoomEpoch bump + teleport, the boss + /// terminal, and the CLEAR-GATED once-per-RunEpoch bank (boss-clear credits Charge/RunsCompleted/retaliation + + /// save; an abort banks ONLY the honest depth high-water — D-F3/F7/C7). + /// + public class RunDirectorTraversalTests + { + const uint Seed = 777u; + const uint T0 = 2000; + + static (World world, SimulationSystemGroup group, Entity dir, Entity player) MakeMidRunWorld( + int currentRoom, out RunMap map) + { + var world = new World("TraversalTest"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + var nt = em.CreateEntity(typeof(NetworkTime)); + em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) }); + + map = RunMapMath.Generate(Seed); + var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective), + typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest)); + em.SetComponentData(dir, new RunInfo + { + Lifecycle = RunLifecycle.InRoom, + CurrentRoom = currentRoom, + RoomCount = map.LayerCount, + RunSeed = Seed, + }); + em.SetComponentData(dir, new RunRuntime + { + RunSeed = Seed, + RunEpoch = 1, + RoomEpoch = currentRoom + 1, + CurrentNodeId = RunMap.NodeId(currentRoom, 0), + ActiveSubSlot = (byte)(currentRoom & 1), + RoomsClearedThisRun = currentRoom, // rooms before this one were cleared + }); + em.SetComponentData(dir, new GoalProgress { Charge = 0, Target = 4 }); + + var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), typeof(LocalTransform)); + em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); + em.SetComponentData(player, LocalTransform.Identity); + return (world, group, dir, player); + } + + static void SetTick(World world, uint tick) + { + var em = world.EntityManager; + var q = em.CreateEntityQuery(typeof(NetworkTime)); + em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) }); + q.Dispose(); + } + + static void MarkCleared(EntityManager em, Entity dir) => + em.SetComponentData(dir, new ExpeditionObjective { State = ExpeditionObjectiveState.Cleared, Remaining = 0 }); + + static int RoomEntities(EntityManager em) + { + var q = em.CreateEntityQuery(typeof(RoomTag)); + int n = q.CalculateEntityCount(); + q.Dispose(); + return n; + } + + [Test] + public void Cleared_TearsDownAtRewardEntry_ThenAdvancesWithSlotFlipAndEpochBump() + { + var (world, group, dir, player) = MakeMidRunWorld(0, out var map); + var em = world.EntityManager; + // Room-0 content that must die at the RoomReward entry. + var node0 = em.CreateEntity(typeof(RoomTag)); + em.SetComponentData(node0, new RoomTag { Room = 0 }); + + MarkCleared(em, dir); + group.Update(); // InRoom -> RoomReward + teardown + + Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData(dir).Lifecycle); + Assert.AreEqual(0, RoomEntities(em), "cleared room torn down AT ENTRY (the empty-tick guarantee)"); + Assert.AreEqual(1, em.GetComponentData(dir).RoomsClearedThisRun, "honest depth counter"); + + group.Update(); // RoomReward -> RouteSelect gate (no boons pending yet) + + var gateInfo = em.GetComponentData(dir); + Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "the branching gate opens (Step 8)"); + Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published"); + // Commit the party's pick directly through the server-only latch (the RPC path is pinned in + // RouteSelectSystemTests): choose the LAST option so a non-lowest pick is exercised when count > 1. + byte pickIdx = (byte)(gateInfo.RouteOptionCount - 1); + byte expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col + : pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col; + em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 }); + + group.Update(); // RouteSelect -> consume the pick -> InRoom room 1 at the PICKED column + + var info = em.GetComponentData(dir); + var run = em.GetComponentData(dir); + Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle); + Assert.AreEqual(1, info.CurrentRoom); + Assert.AreEqual(expectedCol, info.CurrentCol, "entered the PICKED column (non-maskable criterion)"); + Assert.AreEqual(1, run.ActiveSubSlot, "ping-pong sub-slot flipped"); + Assert.AreEqual(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed"); + Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published"); + Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType); + Assert.AreEqual(0, em.GetComponentData(dir).HasPick, "latch consumed"); + Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance"); + Assert.GreaterOrEqual(em.GetComponentData(player).Position.x, 1499f, + "party teleported onto the idle sub-slot (+1500)"); + world.Dispose(); + } + + [Test] + public void BossClear_Returns_AndBanksExactlyOnce_ClearGated() + { + RunMap map0; + var (world, group, dir, player) = MakeMidRunWorld(0, out map0); + var em = world.EntityManager; + int bossLayer = map0.LayerCount - 1; + // Jump the state to the boss room. + var info0 = em.GetComponentData(dir); + info0.CurrentRoom = bossLayer; + em.SetComponentData(dir, info0); + var run0 = em.GetComponentData(dir); + run0.CurrentNodeId = map0.BossNodeId; + run0.ActiveSubSlot = (byte)(bossLayer & 1); + run0.RoomsClearedThisRun = bossLayer; + em.SetComponentData(dir, run0); + + MarkCleared(em, dir); + group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1) + group.Update(); // RoomReward -> Returning + group.Update(); // Returning: bank + teleport home -> Staging + + var info = em.GetComponentData(dir); + Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle); + Assert.AreEqual(RegionId.Base, em.GetComponentData(player).Region, "party home"); + Assert.AreEqual(1, em.GetComponentData(dir).Charge, "win meter +1 on a boss clear"); + var meta = em.GetComponentData(dir); + Assert.AreEqual(1, meta.RunsCompleted, "run completed"); + Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared"); + var threat = em.GetComponentData(dir); + Assert.AreEqual(1, threat.PendingReturns, "retaliation input carried (C7)"); + Assert.AreEqual(1, threat.ExpeditionsCompleted); + Assert.AreEqual(1, em.GetComponentData(dir).Pending, "save checkpoint requested"); + Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated"); + + group.Update(); // extra Staging ticks must not re-bank (once-per-RunEpoch latch) + group.Update(); + Assert.AreEqual(1, em.GetComponentData(dir).Charge, "no double credit (F7)"); + Assert.AreEqual(1, em.GetComponentData(dir).RunsCompleted); + world.Dispose(); + } + + [Test] + public void Abort_BanksDepthOnly_NoWinCredit() + { + var (world, group, dir, player) = MakeMidRunWorld(2, out var map); + var em = world.EntityManager; + // All expedition players gone mid-room-2 (rooms 0-1 cleared) -> abort. + em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); + + group.Update(); // InRoom -> Returning (abort) + group.Update(); // Returning: depth-only bank -> Staging + + Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); + Assert.AreEqual(0, em.GetComponentData(dir).Charge, "no win credit on an abort (D-F3)"); + var meta = em.GetComponentData(dir); + Assert.AreEqual(0, meta.RunsCompleted, "no completed-run credit"); + Assert.AreEqual(2, meta.MaxDepthReached, "honest depth: the 2 rooms actually cleared, not the plan"); + var threat = em.GetComponentData(dir); + Assert.AreEqual(0, threat.PendingReturns, "no retaliation provoked by an abort"); + Assert.AreEqual(0, em.GetComponentData(dir).Pending, "no save spam on abort"); + world.Dispose(); + } + + [Test] + public void RouteGate_PickBeatsSameTickGrace() + { + var (world, group, dir, player) = MakeMidRunWorld(0, out var map); + var em = world.EntityManager; + MarkCleared(em, dir); + group.Update(); // -> RoomReward (teardown) + group.Update(); // -> RouteSelect (gate open, grace armed at T0) + + var gate = em.GetComponentData(dir); + Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle); + byte pickIdx = (byte)(gate.RouteOptionCount - 1); + byte pickedCol = pickIdx == 2 ? gate.RouteOpt2Col : pickIdx == 1 ? gate.RouteOpt1Col : gate.RouteOpt0Col; + + // A pick latches AND the grace expires on the SAME tick -> the pick must win (review F2 precedence). + em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 }); + SetTick(world, T0 + 100000); // way past any grace + group.Update(); + + var info = em.GetComponentData(dir); + Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle); + Assert.AreEqual(pickedCol, info.CurrentCol, "the accepted pick beats the same-tick grace expiry"); + world.Dispose(); + } + + [Test] + public void RouteGate_GraceAutoPicksLowestOption() + { + var (world, group, dir, player) = MakeMidRunWorld(0, out var map); + var em = world.EntityManager; + MarkCleared(em, dir); + group.Update(); // -> RoomReward + group.Update(); // -> RouteSelect + + var gate = em.GetComponentData(dir); + byte lowestCol = gate.RouteOpt0Col; + SetTick(world, T0 + 100000); // grace elapses, nobody picked + group.Update(); + + var info = em.GetComponentData(dir); + Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the AFK backstop advances the run"); + Assert.AreEqual(lowestCol, info.CurrentCol, "deterministic lowest-index reachable auto-pick"); + world.Dispose(); + } + + [Test] + public void RouteGate_Abort_ClosesGateOnTheEdge() + { + var (world, group, dir, player) = MakeMidRunWorld(0, out var map); + var em = world.EntityManager; + MarkCleared(em, dir); + group.Update(); // -> RoomReward + group.Update(); // -> RouteSelect + Assert.Greater((int)em.GetComponentData(dir).RouteOptionCount, 0); + + em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left + group.Update(); // RouteSelect -> Returning (abort) + + var info = em.GetComponentData(dir); + Assert.AreEqual(RunLifecycle.Returning, info.Lifecycle); + Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed ON the abort edge (review F3 — no 1-tick clickable-panel window)"); + world.Dispose(); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs.meta b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs.meta new file mode 100644 index 000000000..4327f15db --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0b3e546718a6f9846a320e56d5b1acb4 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/RunMapMathTests.cs b/Assets/_Project/Tests/EditMode/RunMapMathTests.cs new file mode 100644 index 000000000..16ac9c41c --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RunMapMathTests.cs @@ -0,0 +1,197 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.Collections; + +namespace ProjectM.Tests +{ + /// + /// Pure-function tests for — the deterministic branching run-map generator. Pins + /// determinism (server + client must regenerate the SAME map), the structural invariants the traversal + route + /// choice rely on (run length, single landing, single Boss terminal, an all-Elite gate so every path fights an + /// Elite, full reachability, no all-Reward interior layer), and the reachable-options enumeration. No ECS world. + /// + public class RunMapMathTests + { + // Sweep a spread of seeds so the structural invariants hold generation-wide, not for one lucky map. + static uint[] Seeds() + { + var s = new uint[64]; + for (int i = 0; i < s.Length; i++) s[i] = (uint)(i * 2654435761u + 1u); + return s; + } + + [Test] + public void Generate_Deterministic_SameSeedSameMap() + { + foreach (var seed in Seeds()) + { + var a = RunMapMath.Generate(seed); + var b = RunMapMath.Generate(seed); + Assert.AreEqual(a.LayerCount, b.LayerCount, $"seed {seed}: LayerCount"); + for (int layer = 0; layer < a.LayerCount; layer++) + { + Assert.AreEqual(a.Width(layer), b.Width(layer), $"seed {seed}: width L{layer}"); + for (int col = 0; col < a.Width(layer); col++) + Assert.IsTrue(a.Node(layer, col).Equals(b.Node(layer, col)), + $"seed {seed}: node ({layer},{col}) differs between regenerations"); + } + } + } + + [Test] + public void Generate_RunLength_InSixToTenInclusive() + { + foreach (var seed in Seeds()) + { + int L = RunMapMath.Generate(seed).LayerCount; + Assert.GreaterOrEqual(L, 6, $"seed {seed}"); + Assert.LessOrEqual(L, RunMap.MaxLayers, $"seed {seed}"); + } + } + + [Test] + public void Generate_Layer0_IsSingleCombatLanding() + { + foreach (var seed in Seeds()) + { + var m = RunMapMath.Generate(seed); + Assert.AreEqual(1, m.Width(0), $"seed {seed}: landing width"); + Assert.AreEqual(RoomTypeId.Combat, m.Node(0, 0).RoomType, $"seed {seed}: landing type"); + } + } + + [Test] + public void Generate_LastLayer_IsSingleBossTerminal() + { + foreach (var seed in Seeds()) + { + var m = RunMapMath.Generate(seed); + int last = m.LayerCount - 1; + Assert.AreEqual(1, m.Width(last), $"seed {seed}: boss width"); + Assert.AreEqual(RoomTypeId.Boss, m.Node(last, 0).RoomType, $"seed {seed}: boss type"); + Assert.AreEqual(0, m.Node(last, 0).NextMask, $"seed {seed}: boss is a terminal (NextMask 0)"); + } + } + + [Test] + public void Generate_SecondLastLayer_IsAllElite_GuaranteesElitePerPath() + { + // The only layer feeding the Boss is L-2; every start->boss path traverses it. All-Elite there ⇒ every + // path fights >= 1 Elite before the boss. + foreach (var seed in Seeds()) + { + var m = RunMapMath.Generate(seed); + int gate = m.LayerCount - 2; + for (int col = 0; col < m.Width(gate); col++) + Assert.AreEqual(RoomTypeId.Elite, m.Node(gate, col).RoomType, + $"seed {seed}: gate node ({gate},{col}) must be Elite"); + } + } + + [Test] + public void Generate_ExactlyOneTerminal_IsTheBoss() + { + foreach (var seed in Seeds()) + { + var m = RunMapMath.Generate(seed); + int terminals = 0; + for (int layer = 0; layer < m.LayerCount; layer++) + for (int col = 0; col < m.Width(layer); col++) + if (m.Node(layer, col).NextMask == 0) terminals++; + Assert.AreEqual(1, terminals, $"seed {seed}: exactly one terminal node (the boss)"); + } + } + + [Test] + public void Generate_EveryNonBossNode_HasAnOutEdge() + { + foreach (var seed in Seeds()) + { + var m = RunMapMath.Generate(seed); + for (int layer = 0; layer < m.LayerCount - 1; layer++) + for (int col = 0; col < m.Width(layer); col++) + Assert.AreNotEqual(0, m.Node(layer, col).NextMask, + $"seed {seed}: node ({layer},{col}) has no out-edge"); + } + } + + [Test] + public void Generate_AllNodesReachableFromRoot() + { + foreach (var seed in Seeds()) + Assert.IsTrue(RunMapMath.AllNodesReachable(RunMapMath.Generate(seed)), + $"seed {seed}: a node was stranded (unreachable from the root)"); + } + + [Test] + public void Generate_InteriorLayerWidths_AreTwoOrThree() + { + foreach (var seed in Seeds()) + { + var m = RunMapMath.Generate(seed); + for (int layer = 1; layer < m.LayerCount - 1; layer++) + { + int w = m.Width(layer); + Assert.IsTrue(w == 2 || w == 3, $"seed {seed}: interior layer {layer} width {w} not in {{2,3}}"); + } + } + } + + [Test] + public void Generate_NoInteriorLayerIsAllReward() + { + foreach (var seed in Seeds()) + { + var m = RunMapMath.Generate(seed); + for (int layer = 1; layer < m.LayerCount - 1; layer++) + { + bool anyNonReward = false; + for (int col = 0; col < m.Width(layer); col++) + if (m.Node(layer, col).RoomType != RoomTypeId.Reward) anyNonReward = true; + Assert.IsTrue(anyNonReward, $"seed {seed}: interior layer {layer} is entirely Reward"); + } + } + } + + [Test] + public void ReachableOptions_MatchNextMask_AndStayInNextWidth() + { + foreach (var seed in Seeds()) + { + var m = RunMapMath.Generate(seed); + for (int layer = 0; layer < m.LayerCount - 1; layer++) + for (int col = 0; col < m.Width(layer); col++) + { + int n = RunMapMath.ReachableOptions(m, layer, col, out FixedList32Bytes cols); + Assert.Greater(n, 0, $"seed {seed}: node ({layer},{col}) offered no options"); + Assert.AreEqual(n, cols.Length); + byte mask = m.Node(layer, col).NextMask; + int wn = m.Width(layer + 1); + for (int i = 0; i < cols.Length; i++) + { + Assert.Less(cols[i], (byte)wn, $"seed {seed}: option out of next-layer width"); + Assert.AreNotEqual(0, mask & (1 << cols[i]), $"seed {seed}: option not set in NextMask"); + } + } + } + } + + [Test] + public void ReachableOptions_BossLayer_ReturnsNone() + { + var m = RunMapMath.Generate(12345u); + int n = RunMapMath.ReachableOptions(m, m.LayerCount - 1, 0, out FixedList32Bytes cols); + Assert.AreEqual(0, n); + Assert.AreEqual(0, cols.Length); + } + + [Test] + public void Hash_IsDeterministic_AndSensitiveToInputs() + { + Assert.AreEqual(RunMapMath.Hash(7u, 3u), RunMapMath.Hash(7u, 3u)); + Assert.AreEqual(RunMapMath.Hash(1u, 2u, 3u), RunMapMath.Hash(1u, 2u, 3u)); + Assert.AreNotEqual(RunMapMath.Hash(7u, 3u), RunMapMath.Hash(3u, 7u), "order-sensitive"); + Assert.AreNotEqual(RunMapMath.Hash(1u), RunMapMath.Hash(2u)); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/RunMapMathTests.cs.meta b/Assets/_Project/Tests/EditMode/RunMapMathTests.cs.meta new file mode 100644 index 000000000..df85b0c79 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RunMapMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ed13d85c05c6fc8469ae19c07647e32e \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs index 154d76d31..37e572752 100644 --- a/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs +++ b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs @@ -167,8 +167,8 @@ namespace ProjectM.Tests { var data = new SaveData { GoalCharge = 1, GoalTarget = 4, RunOutcome = RunOutcomeId.Victory }; var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); - Assert.AreEqual(SaveData.CurrentVersion, back.Version, "END-2: new saves write v5."); - Assert.AreEqual(5, SaveData.CurrentVersion, "SaveData is at v5 (END-2 added RunOutcome)."); + Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version."); + Assert.AreEqual(6, SaveData.CurrentVersion, "SaveData is at v6 (permanent meta: tier rows + run counters)."); Assert.AreEqual((int)RunOutcomeId.Victory, back.RunOutcome, "the latched terminal outcome round-trips through JSON."); } @@ -183,6 +183,41 @@ namespace ProjectM.Tests Assert.LessOrEqual(back.Version, SaveData.CurrentVersion); } + [Test] + public void Pre_v6_Save_Missing_Meta_Defaults_To_Empty() + { + // A v5 save JSON lacks MetaUpgrades/RunsCompleted/MaxDepthReached -> the field initializer keeps the + // array EMPTY (never null) and the counters 0-default. Additive: no field, no break; v5 loads. + var back = JsonUtility.FromJson("{\"Version\":5,\"GoalCharge\":3,\"GoalTarget\":4}"); + Assert.IsNotNull(back.MetaUpgrades, "missing MetaUpgrades -> empty array, never null."); + Assert.AreEqual(0, back.MetaUpgrades.Length); + Assert.AreEqual(0, back.RunsCompleted, "missing RunsCompleted -> 0 (StagePendingSave floors it to GoalCharge)."); + Assert.AreEqual(0, back.MaxDepthReached); + Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v5 stays within the additive load floor."); + } + + [Test] + public void MetaUpgrades_And_Counters_RoundTrip() + { + var data = new SaveData + { + RunsCompleted = 7, + MaxDepthReached = 9, + MetaUpgrades = new[] + { + new MetaUpgradeSave { ClassId = 2, UpgradeId = 1, Tier = 3 }, + new MetaUpgradeSave { ClassId = 3, UpgradeId = 200, Tier = 1 }, // unknown id persists verbatim + }, + }; + var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); + Assert.AreEqual(7, back.RunsCompleted); + Assert.AreEqual(9, back.MaxDepthReached); + Assert.AreEqual(2, back.MetaUpgrades.Length, "meta tier rows round-trip through JSON."); + Assert.AreEqual(2, back.MetaUpgrades[0].ClassId); + Assert.AreEqual(1, back.MetaUpgrades[0].UpgradeId); + Assert.AreEqual(3, back.MetaUpgrades[0].Tier); + Assert.AreEqual(200, back.MetaUpgrades[1].UpgradeId, "an unknown upgrade id is preserved on disk, not clamped at save time (preserve-don't-crash)."); + } } } diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs b/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs deleted file mode 100644 index 04f78cbeb..000000000 --- a/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs +++ /dev/null @@ -1,193 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Collections; -using Unity.Core; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the server-only . A bare world is - /// seeded with NetworkTime, a CycleDirector entity (CycleState + CycleRuntime) and a zone-enemy director - /// (ZoneEnemyDirector + ZoneEnemyState + a Prefab-tagged enemy in the ZoneEnemyPrefab buffer). Pins: it spawns - /// only while a player is OUT in the expedition AND the base is Calm; tags spawns RegionTag{Expedition} + - /// ZoneEnemyTag at the deterministic ring origin (Scale preserved); and marks CycleRuntime.ClearedThisEpoch on a - /// real clear. - /// - public class ZoneEnemyDirectorSystemTests - { - static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name, uint serverTick, byte phase, int epoch) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - var nt = em.CreateEntity(typeof(NetworkTime)); - em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) }); - var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime)); - em.SetComponentData(cyc, new CycleState { Phase = phase }); - em.SetComponentData(cyc, new CycleRuntime { ExpeditionEpoch = epoch }); - return (world, group, cyc); - } - - static Entity MakeZonePrefab(EntityManager em) - { - var e = em.CreateEntity(typeof(LocalTransform), typeof(EnemyTag)); - em.SetComponentData(e, LocalTransform.Identity); // Scale = 1 so WithPosition keeps it - em.AddComponent(e); - return e; - } - - static Entity MakeDirector(EntityManager em, Entity grunt, Entity charger, - int maxAlive, int gruntsPerWave, int chargersPerWave, - uint nextSpawnTick, int remainingToSpawn, int seededEpoch, uint spawnCounter) - { - var e = em.CreateEntity(typeof(ZoneEnemyDirector), typeof(ZoneEnemyState)); - em.SetComponentData(e, new ZoneEnemyDirector - { - MaxAlive = maxAlive, RingRadius = 14f, RingSlots = 10, SpawnIntervalTicks = 10, - GruntsPerWave = gruntsPerWave, ChargersPerWave = chargersPerWave, RewardOre = 25, - }); - em.SetComponentData(e, new ZoneEnemyState - { - SpawnCounter = spawnCounter, RemainingToSpawn = remainingToSpawn, - NextSpawnTick = nextSpawnTick, SeededEpoch = seededEpoch, - }); - var buf = em.AddBuffer(e); - buf.Add(new ZoneEnemyPrefab { Prefab = grunt }); - buf.Add(new ZoneEnemyPrefab { Prefab = charger }); - return e; - } - - static Entity MakeExpeditionPlayer(EntityManager em, float3 pos) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition }); - em.AddComponentData(e, LocalTransform.FromPosition(pos)); - em.AddComponent(e); - return e; - } - - static int ZoneCount(EntityManager em) - { - using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag)); - return q.CalculateEntityCount(); - } - - [Test] - public void Spawns_Expedition_Tagged_Enemy_When_Occupied_And_Calm() - { - var (world, group, _) = MakeWorld("ZoneSpawn", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); - using (world) - { - var em = world.EntityManager; - var grunt = MakeZonePrefab(em); - var charger = MakeZonePrefab(em); - var dir = MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, - nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); - MakeExpeditionPlayer(em, new float3(1000, 1, 0)); - - group.Update(); - - Assert.AreEqual(1, ZoneCount(em), "one zone enemy spawns this tick"); - using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(RegionTag)); - var arr = q.ToComponentDataArray(Allocator.Temp); - Assert.AreEqual(RegionId.Expedition, arr[0].Region, "the spawn is tagged RegionTag{Expedition}"); - arr.Dispose(); - - var zs = em.GetComponentData(dir); - Assert.AreEqual(1u, zs.SpawnCounter, "spawn counter advanced"); - Assert.AreEqual(1, zs.RemainingToSpawn, "wave size 2 seeded, 1 spawned -> 1 remaining"); - } - } - - [Test] - public void Spawn_Lands_On_Expedition_Ring_Origin_With_Scale_Preserved() - { - var (world, group, _) = MakeWorld("ZoneRing", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); - using (world) - { - var em = world.EntityManager; - var grunt = MakeZonePrefab(em); - var charger = MakeZonePrefab(em); - MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, - nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); - MakeExpeditionPlayer(em, new float3(1000, 1, 0)); - - group.Update(); - - using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(LocalTransform)); - var arr = q.ToComponentDataArray(Allocator.Temp); - // origin = base(0,1,0) + (1000,0,0); ring slot 0 of a 10-slot radius-14 ring -> +X. - Assert.AreEqual(1014f, arr[0].Position.x, 1e-2f, "deterministic ring slot 0 at the expedition origin (+radius on X)"); - Assert.AreEqual(1f, arr[0].Scale, 1e-3f, "baked Scale preserved (WithPosition, not FromPosition)"); - arr.Dispose(); - } - } - - [Test] - public void Does_Not_Spawn_When_No_Expedition_Player() - { - var (world, group, _) = MakeWorld("ZoneEmpty", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); - using (world) - { - var em = world.EntityManager; - var grunt = MakeZonePrefab(em); - var charger = MakeZonePrefab(em); - MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, - nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); - // no expedition player out there - - group.Update(); - - Assert.AreEqual(0, ZoneCount(em), "nobody out in the expedition -> nothing spawns"); - } - } - - [Test] - public void Does_Not_Spawn_During_Base_Siege() - { - var (world, group, _) = MakeWorld("ZoneSiege", serverTick: 100, phase: CyclePhase.Siege, epoch: 1); - using (world) - { - var em = world.EntityManager; - var grunt = MakeZonePrefab(em); - var charger = MakeZonePrefab(em); - MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, - nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); - MakeExpeditionPlayer(em, new float3(1000, 1, 0)); - - group.Update(); - - Assert.AreEqual(0, ZoneCount(em), "the expedition wave pauses while the base is under siege (Calm-only spawning)"); - } - } - - [Test] - public void Cleared_Wave_Marks_ClearedThisEpoch() - { - var (world, group, cyc) = MakeWorld("ZoneCleared", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); - using (world) - { - var em = world.EntityManager; - var grunt = MakeZonePrefab(em); - var charger = MakeZonePrefab(em); - // already seeded this epoch + fully spawned (RemainingToSpawn 0) + no live zone enemies. - MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, - nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 1, spawnCounter: 2); - MakeExpeditionPlayer(em, new float3(1000, 1, 0)); - - group.Update(); - - Assert.AreEqual((byte)1, em.GetComponentData(cyc).ClearedThisEpoch, - "wave fully spawned + no live zone enemies -> a real clear is marked"); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs.meta deleted file mode 100644 index b0e3cb5fd..000000000 --- a/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f307978f01b668b42ba10eea2029091b \ No newline at end of file diff --git a/Docs/Vault/07_Sessions/2026/2026-06-29_Expedition_Redesign_Build_Spec.md b/Docs/Vault/07_Sessions/2026/2026-06-29_Expedition_Redesign_Build_Spec.md new file mode 100644 index 000000000..0885ec2fa --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-06-29_Expedition_Redesign_Build_Spec.md @@ -0,0 +1,647 @@ +--- +id: SPEC-Expedition-Redesign +title: Expedition Redesign — Build Spec (multi-room co-op runs, ready-check, choice-of-3 boons, expedition-only economy) +status: pending-operator-approval +date: 2026-06-29 +tags: +- spec +- design +- expedition +- roguelite +- netcode +- procgen +- reward +- economy +- north-star +permalink: gamevault/07-sessions/2026/2026-06-29-expedition-redesign-build-spec +--- + +# Expedition Redesign — Build Spec + +> **Provenance.** Synthesized + adversarially reviewed by the design workflow `wf_5fc54784-1dc` (7 ground digests → 3 candidate architectures → merge → netcode/determinism/reuse lens review → adversarial critic → this spec). Every CONFIRMED must-fix from the critique (F1/C8 sort-cycle, N2 cross-arena aggro, N1 boon send-type, F2 launch guard, F7 double-credit latch, F6 seed stability, C7 dropped retaliation writer, F4 tick-wrap, C1/C2 teleport-reuse + coordinate authority) is **already folded into the architecture below** — no known defect remains in-spec. +> +> **Status: PENDING operator approval + the §6 fork answers.** Build one system at a time, each fully validated before the next (operator directive: NO minimal vertical slice — full depth, completeness over speed). Supersedes the DR-040 "defer layout/theme/depth to v2" framing; consistent with [[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]] and [[DR-042_Loop_Reshape_Expedition_Driven]] (the win-spine is reused, only the credit event moves to run completion). A DR locking this will be filed on approval. + +Unity 6.5 DOTS (Entities) + Netcode for Entities · ProjectM. Server-authoritative, input-only clients. + +--- + +## 1. NORTH STAR — the target loop + +The base is a pure SPEND hub (build + upgrade); **no resource nodes spawn at base**. Players gather at a gate and each toggle READY; when **all** players in the session are ready the **whole party launches together** into one shared, seed-generated **multi-room run** (combat / elite / reward / boss rooms; depth and difficulty escalate; shape, layout, and biome vary per run). Clear a room to advance; on each clear every player independently picks **1 of 3** random ability/stat upgrades (Hades-boon shaped, per-run). Resources are **scarce and run-capped** — hauled home and spent at base. Clearing the run's **boss** banks +1 toward the win meter (the existing `GoalProgress`/`GoalReached`/`RunOutcome` win-spine is untouched — only the credit event moves to run completion). The run has a **discrete start and end**; the base never resets. + +--- + +## 2. ARCHITECTURE + +### 2.1 Region model — ONE `RegionId.Expedition`, sequential sub-arenas, teardown-before-spawn (DECISIVE) + +The run is a sequence of rooms inside the **single existing `RegionId.Expedition` region**, not N region ids. Only the **active** room is materialized; the previous room is torn down before the next is spawned. + +**Why (against the five axes the open question named):** +- **Relevancy cost (decisive).** `RegionRelevancySystem` is `O(region-tagged-ghosts × in-game-connections)`, rebuilt fully every tick with no spatial index or dirty-tracking. The cost lever is *how many region-tagged ghosts are alive at once*, not how many region ids exist. Materializing only the active room keeps the live ghost count at one room's `MaxAlive` budget whether the run is 2 rooms or 12. N-region-ids buys nothing: pre-spawning future rooms multiplies cost by depth; lazy-spawning makes the extra ids dead weight while forcing `RegionMath` to grow a per-id origin table that collides past 2. +- **Determinism.** Region id never changes mid-run → no per-region epoch-latch fan-out. One scalar `RunEpoch`/`RoomEpoch` latch, equality-compared. +- **Co-op.** One region = one relevancy bucket = the whole party always sees the same active room (locked direction #2: one shared party run). +- **Save/restore.** Nothing region-shaped is serialized; the run instance is session-scoped and explicitly not persisted. +- **Mid-run join/disconnect.** A client is either in the Expedition bucket or not — no "which of N regions" bookkeeping. Late joiners stay at base; falls out of the existing split with zero new machinery. + +**Sub-arena origins (single coordinate authority — fixes C2).** There is exactly ONE expedition-origin function. `RegionMath.ExpeditionOrigin(baseCenter)` (today's `RegionOrigin(Expedition, ...)`) is **redefined to take the active sub-slot from the global `RunRuntime`**, and every existing call site is repointed to it; no parallel `RoomOrigin` signature is introduced (split-authority hazard). Concretely: +``` +RegionMath.ExpeditionRoomOrigin(baseCenter, subSlot) = baseCenter + (ExpeditionOffsetX + subSlot * RoomStrideX, 0, 0) +ExpeditionOffsetX = 1000f ; RoomStrideX = 500f // >= any sweep/AI range +RegionMath.ExpeditionOrigin(baseCenter) = ExpeditionRoomOrigin(baseCenter, currentActiveSubSlot) // sole call for "where is the expedition now" +``` +Two ping-pong sub-slots (`subSlot = CurrentRoom & 1`) is the minimum that allows a clean handoff. **Ordering is teardown-before-spawn with one empty tick** (fixes N2, dissolves F3, shrinks F11): on a room advance the cleared room is destroyed first; the next room spawns the following tick at the idle slot; the party teleports onto it. Because only one room's enemies ever exist, the cross-arena-aggro window does not occur. + +**Cross-arena AI safety (defense in depth for N2).** Even with teardown-before-spawn, `EnemyAISystem`'s region-aware `PickWeightedNearest` filters only on `RegionTag.Region` and has no distance cap — both sub-arenas are `Expedition`. Because we never let two rooms coexist, the AI cannot see across the gap. The `RoomTag` (server-only) is additionally available to plumb into the AI target snapshot if a future change ever lets rooms overlap; v1 does not rely on it for aggro isolation. + +### 2.2 Run lifecycle + ready-check + +The run lifecycle is a **server-decided, client-observed `byte` FSM** on the existing **global untagged CycleDirector ghost** (the exact precedent of `RunOutcome`/`GoalProgress`/`CoreIntegrity` — untagged ⇒ relevant to every connection cross-region for free). It is **distinct from `CycleState.Phase`** (Calm↔Siege stays the *base* posture machine for retaliation/final sieges). Single writer = new `RunDirectorSystem`, mirroring how `CyclePhaseSystem` is the sole `Phase` writer. + +**State machine** (`RunLifecycle : byte`): +``` +Staging=0 → Launching=1 → InRoom=2 → RoomReward=3 → Returning=4 → Staging +``` +- **Staging** — party in the base hub; ready-check active; no expedition ghosts exist. +- **Launching** — one-tick transient on the all-ready rising edge: chooses `RunSeed`, bumps `RunEpoch`, plans `RoomCount`, sets the launch countdown telegraph; on countdown elapse teleports the party to room-0 and flips `RegionTag` to Expedition. +- **InRoom** — active room populated; party fighting/looting; `CurrentRoom` is the depth. +- **RoomReward** — room cleared; per-player boon offers pushed; room ghosts torn down; waiting on all surviving players to pick (or the grace backstop). +- **Returning** — run ended (boss cleared, party wiped, or all expedition players disconnected): teleport home, flip `RegionTag` to Base, bank the win meter + retaliation inputs + meta **once per RunEpoch**, request save, clear all ready flags → Staging. + +**Cross-FSM launch guard (fixes F2).** The `Staging→Launching` edge is refused while `RunPhase != Normal` OR `RunOutcome != InProgress` — a new run cannot launch while a final siege is arming or after the run outcome has latched. + +**Ready-check derivation (no connection-count singleton exists).** +``` +total = CalculateEntityCount(PlayerTag); ready = count(PlayerReady.Value != 0); +allReady = total > 0 && ready == total; +``` +Computed each Staging tick by `RunDirectorSystem`. On the `allReady` rising edge (latch `wasAllReady` in `RunRuntime`) → `Staging→Launching`, set `LaunchTick = TickUtil.NonZero(now + LaunchCountdownTicks)`. Un-readying during Launching reverts to Staging and clears `LaunchTick`. Single-player (`total==1`) works trivially. The N/M count is load-bearing on the Staging-only co-location invariant — documented in code (fixes N7). + +**Disconnect / join.** +- *Disconnect while staging* — player ghost (+`PlayerReady`) despawns via the connection's `LinkedEntityGroup`; `total` drops; can correctly complete the check. +- *Disconnect mid-run* — in-room ghost despawns; `RunDirectorSystem` recomputes the Expedition-region player count; if zero → force `Returning` (clean abort, per-room meta already banked). Safety teardown fires whenever `Lifecycle` leaves `InRoom`. +- *Join mid-staging* — spawns at base, `Value=0`, bumps `total`, resets `allReady` (a late joiner is not dragged into an in-flight launch). +- *Join mid-run* — closed party (locked #2). Joiner spawns at base in `RegionId.Base`; relevancy hides the run's ghosts; the global `RunInfo` shows a run in progress; HUD shows "Run in progress" and suppresses READY until `Lifecycle==Staging`. The **1-of-N-drops co-op-abandonment case is an explicit OPEN FORK** (§6), not silently "handled". + +### 2.3 Multi-room generation + traversal + +**Per-run seed → per-room layout (the procgen discipline reused verbatim).** `RunRuntime.RunSeed` is chosen once at the `Staging→Launching` edge: `RunSeed = math.max(1u, Hash(RunEpoch, HostSalt))` — never the tick, never 0, monotonic-int + equality-compared. `HostSalt` is a server-incrementing seed source seeded at director spawn from a non-tick source and bumped each run (explicit, fixes C5). Per-room RNG derives with distinct offsets (the `epoch*2+1` trick generalized): +``` +roomSeed = Hash(RunSeed, roomIndex) | 1u // shape/biome/archetype +nodeSeed = Hash(RunSeed, roomIndex, 0x0DE) | 1u // resource scatter +enemySeed = Hash(RunSeed, roomIndex, 0xEEE) | 1u // enemy jitter (composition stays pure-int via ZoneEnemyMath) +boonSeed = Hash(RunSeed, roomIndex, playerSlot)| 1u // per-player boon draw (stable PlayerSlot, NOT NetworkId — fixes F6) +``` + +**`RoomLayoutMath`** (new pure math, the `ZoneEnemyMath` sibling — no RNG state, EditMode-tested): +- `RoomPlan Plan(uint runSeed, int roomIndex, int roomCount, BlobAssetReference table)` → `RoomPlan { byte RoomType; byte Biome; byte ShapeId; float Radius; int NodeCount; int DifficultyEpoch; }`. +- `float3 ScatterInShape(byte shapeId, float3 center, int index, int count, ref Random rng)` — adds rect/polygon variants to today's disk/annulus-only helpers; reuses `EnemyAIMath.RingPosition`/`ClusterOffset` for spawn anchors. +- `void PickBoons(uint offerSeed, byte classId, in BoonCatalogBlob pool, out byte o0, out byte o1, out byte o2)` — 3 distinct, rarity-weighted, class-filtered, rejection-sampled. +- **Room-type arc** is deterministic from `(runSeed, roomIndex, roomCount)`: room 0 = always Combat; last room = always Boss; one Elite at ~⅔ depth; Reward rooms interleaved by hash. +- **Shape/cover/biome** from a baked `BlobAssetReference` (authored `RoomArchetypeDefinition` SOs → config singleton, both worlds, NOT replicated — the `AbilityDatabaseBlob` pattern). + +**Room population (re-point the existing field/wave chassis from `ExpeditionEpoch` to `RoomEpoch`).** +- **`RoomFieldSystem`** (refactor of `ExpeditionFieldSystem`): scatter `RoomPlan.NodeCount` scarce nodes (§2.4) on the `RoomEpoch` change edge (equality compare `LastSpawnedRoomEpoch != RoomEpoch`) at `RegionMath.ExpeditionRoomOrigin(base, ActiveSubSlot)` via `ScatterInShape` + `nodeSeed`. Tag `RegionTag{Expedition}` + `RoomTag{Room}`. Preserve baked Scale with `baked.WithPosition` (never `FromPosition`). Per-`RoomTag` teardown. +- **`RoomEnemyDirectorSystem`** (refactor of `ZoneEnemyDirectorSystem`): reseed on `SeededRoomEpoch != RoomEpoch`; reuse `ZoneEnemyMath.WaveSlots/KindForSlot` verbatim, indexed by `RoomPlan.DifficultyEpoch` (a function of `CurrentRoom` and `RoomType`; Elite/Boss bump the band). Reuse the drip-spawn cadence + the `MaxAlive` "spawn-pack-only-if-fits-else-wait" guard. A Boss room spawns one high-HP boss. `ExpeditionObjective` (replicated, untagged ghost) written **above the early-return** so the HUD never freezes. Tag spawns `RoomTag{Room}`. + +**Per-room teardown tag (the DR-031/DR-040 shared-tag-wipe hazard).** `RoomTag : IComponentData { byte Room; }` — server-only, NOT a `[GhostField]`. Stamped on every room-scoped ghost at spawn (`Room = CurrentRoom & 0xFF`). Teardown of room *i* filters on `RoomTag.Room == i` so a transiently-coexisting room is never wiped. + +**Clear → advance (the loop).** Clear condition reuses the existing "real clear" predicate, surfaced through the existing `ExpeditionObjective.State == Cleared` (the enemy director already computes this edge — no separate `RoomClearSystem`/`RoomCleared` byte; collapse per C4). `RunDirectorSystem` consumes it as the sole lifecycle writer: +1. `InRoom` + objective Cleared → `InRoom→RoomReward`; trigger boon offers; **tear down the cleared room** (`RoomTag`-filtered); set `RewardGraceTick = TickUtil.NonZero(now + RewardGraceTicks)`. +2. `RoomReward` + (all surviving players `BoonOffer.Pending==0` **OR** `RewardGraceTick` elapsed, tested via `IsNewerThan` — fixes F4) → + - if last room (Boss) → `RoomReward→Returning`; + - else advance one tick later (the cleared room is already gone): bump `CurrentRoom`, flip `ActiveSubSlot = CurrentRoom & 1`, set `RunInfo.{CurrentRoom,CurrentRoomType,CurrentBiome}` from the next `RoomPlan`, bump `RoomEpoch` so `RoomFieldSystem`/`RoomEnemyDirectorSystem` spawn the next room at the idle slot, then teleport every Expedition player to `ExpeditionRoomOrigin(base, ActiveSubSlot)` by writing **`LocalTransform.Position =`** (the `RegionTransitSystem` idiom — NEVER `FromPosition`; fixes N4/C1), back to `InRoom`. Teardown-previous → empty tick → spawn-next → teleport ordering guarantees exactly one room alive. +3. `Returning` → teleport party to base, flip `RegionTag{Base}`, **bank once per RunEpoch** guarded by `RunRuntime.LastBankedRunEpoch != RunEpoch` (fixes F7): `GoalProgress.Charge += 1` (clamped to Target) + carry the retaliation inputs `ThreatState.PendingReturns++`/`ExpeditionsCompleted++` (fixes C7) + meta counters, set `LastBankedRunEpoch = RunEpoch`, `SaveRequest.Pending=1`, clear all `PlayerReady`, `Lifecycle→Staging`. + +**Win meter (spine untouched).** When `Charge >= Target`, the existing `GoalReachedSystem` arms the climactic final base siege exactly as today; `CyclePhaseSystem` latches `RunOutcome` Victory/Loss unchanged. HUD shows "Room i/N"; biome re-themes per room via `WorldAtmosphereSystem` generalized to read `RunInfo.CurrentBiome` rather than the camera-X>500 threshold. + +`RunInfo.Lifecycle`/`CurrentRoom` are always-on HUD readouts → written **above any presence early-return** in `RunDirectorSystem` (fixes F12). + +### 2.4 Choice-of-3 boon reward + +**Pool.** Authored `BoonDefinition` SOs → `BlobAssetReference` (config singleton, both worlds, not replicated). Each entry is a thin wrapper over the existing stat pipeline: +``` +BoonDefBlob { byte Id; byte Target /*StatTarget*/; byte Op /*ModOp*/; float Value; byte Rarity; byte ClassMask; FixedString64Bytes Name; FixedString128Bytes Desc; byte IconId; } +``` +`Target/Op/Value` map directly to a `StatModifier` (e.g. `{Damage, PercentAdd, 0.15}`, `{CooldownTicks, PercentMult, -0.10}`, `{Range, Flat, 2}`, `{MoveSpeed, PercentMult, 0.10}`, `{MaxHealth, Flat, 25}`). `ClassMask` filters Warrior-only / Ranger-only / both. + +**Per-ability targeting — DECISION: target GLOBAL per-player stat axes (v1).** Each player has exactly one `AbilityRef` (the Fire slot); `StatModifier` has no ability-id discriminator, and adding one to the `[GhostField] StatModifier` buffer re-bakes the ghost (the documented reason `TimedModifier` was split out). So v1 boons target the global ability/character axes (Damage / Range / −CooldownTicks / ProjectileSpeed / AutoTarget* / MoveSpeed / MaxHealth / Melee*). With a single active ability, "+Damage" *is* "improve your ability" — matches the `AbilityUpgradeSystem` precedent, zero new application infra. True per-ability targeting is deferred behind a future multi-ability holder-entity model. + +**Deterministic per-player offer.** On clear, `BoonOfferSystem` (server) draws each Expedition player's 3 distinct, rarity-weighted, class-filtered boons via `RoomLayoutMath.PickBoons(boonSeed, classId, pool, ...)` where `boonSeed = Hash(RunSeed, CurrentRoom, PlayerSlot) | 1u` — seeded from the **stable server-assigned `PlayerSlot`** (the spawn-ring slot `GoInGameServerSystem` already assigns), not the transport `NetworkId` (fixes F6: reconnect-stable, replay-reproducible). + +**Replicating the offer — `SendToOwnerType.SendToOwner` (fixes N1).** `BoonOffer` is an observe-only HUD read of the local player only; no prediction, no teammate read. Use the literally-correct, traffic-minimal owner-only send: +```csharp +[GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)] +public struct BoonOffer : IComponentData +{ + [GhostField] public byte Pending; // 1 = awaiting this player's pick + [GhostField] public byte Option0; + [GhostField] public byte Option1; + [GhostField] public byte Option2; +} +``` +The HUD reads its local player via `GhostOwnerIsLocal` and raises the modal. `BoonOfferSystem` writes all four fields for each alive Expedition player on the `RoomReward` entry edge. (Validate the `SendToOwner` codegen path in Play; if any issue surfaces it falls back to `SendToOwnerType.All`, which still delivers each player's component only to its owner.) + +**Pick RPC.** +```csharp +public struct BoonPickRequest : IRpcCommand { public byte Index; } // 0/1/2; UNCONDITIONAL wire type +``` +- *Client send* — `BoonSendSystem` (ClientSimulation), static enqueue (`PickBoon(byte idx)`) + drain. HUD card click → `BoonSendSystem.PickBoon(i)` (capture the loop var into a local before the lambda). +- *Server receive* — `BoonApplySystem` (ServerSimulation, plain group). Resolve sender → player. Validate server-authoritatively: `BoonOffer.Pending==1 && Index∈{0,1,2}` (ignore stale/invalid). Map `Index → Option{Index} → BoonDefBlob`, **append a `StatModifier`** with a disjoint SourceId (`Boon = 0x00B00000 + pickCounter`, distinct from the documented `Tuning.cs` map). Set `BoonOffer.Pending=0`. `ecb.DestroyEntity(reqEntity)`. The buffer mutation is non-structural (safe while iterating); it folds through the unchanged `StatRecomputeSystem` → `EffectiveAbilityStats`/`EffectiveCharacterStats` on both worlds, rollback-correct. + +**Co-op independence + stall backstop.** Each player has its own `BoonOffer` + pick RPC. `RunDirectorSystem` leaves `RoomReward` only once **all surviving players** (counted off live `PlayerTag` ghosts so a disconnect can't hold the gate) have `Pending==0` **OR** `RewardGraceTick` elapses. On timeout, the un-picked-offer policy is an OPEN FORK (§6). The modal blocks only the local client; "time slows" is a client-only presentation flourish — the sim never pauses (rollback-safe). The FSM gates on the **server-written `Pending` flag**, not RPC presence (eventually-consistent, benign snapshot lag is acceptable since no predicted logic gates on it). + +### 2.5 Economy reshape + +- **Remove base nodes.** Remove the `BaseFieldSpawner` authoring instance from the gameplay subscene; `BaseFieldSpawnSystem` idles via its `RequireForUpdate`, then retire it + `BaseFieldRuntime` once Play-confirmed empty. Keep the one-time `CycleDirectorSpawnSystem` `StartingOre` grubstake so the first run is launchable. +- **Scarce + capped expedition nodes.** Per-room count = `RoomPlan.NodeCount` (Combat/Elite lean, Reward dense, Boss minimal), one-shot per room (no respawn — already the expedition behavior). Run-wide `RunRuntime.NodeBudgetRemaining` floors each room's spawn count and decrements per node → finite per-run allotment = true scarcity across the run. Replace the flat `i%3` round-robin with rarity weighting (Aether rare, Ore/Biomass common). +- **Resource → use map.** Ore → building (expedition-only now); Biomass → building/fabricator; Charge (ResourceId 4) → turret ammo (unchanged). **Aether freed** — boons replace the 20-Aether `AbilityUpgradeSystem` spend; that purchase path is retired (keep the `StatModifier`-append machinery — boons reuse it). Aether's fate is an OPEN FORK (§6). +- **Win meter** — unchanged shape, re-sourced: `Charge += 1` per completed run, `Target = N runs`; surviving a base siege grants nothing (DR-042 preserved). + +--- + +## 3. INVENTORY + +### 3.1 Components & `[GhostField]`s + +| Component | Status | Where | Replicated | Churn | +|---|---|---|---|---| +| `RunInfo` (byte Lifecycle, int CurrentRoom, int RoomCount, byte CurrentRoomType, byte CurrentBiome, uint LaunchTick) | NEW | CycleDirector ghost (untagged/global), baked | yes — 6 `[GhostField]`s | **director ghost re-bake** | +| `RunRuntime` (uint RunSeed, int RunEpoch, int RoomEpoch, byte ActiveSubSlot, int NodeBudgetRemaining, uint HostSalt, uint RewardGraceTick, byte WasAllReady, int LastBankedRunEpoch) | NEW | CycleDirector, `AddComponent` at spawn | no | none | +| `PlayerReady` (byte Value) | NEW | player ghost, send-to-all `[GhostField]` | yes | **player ghost re-bake** | +| `BoonOffer` (byte Pending, Option0/1/2) | NEW | player ghost, `OwnerSendType.SendToOwner` | yes | **player ghost re-bake (batch with PlayerReady)** | +| `RoomTag` (byte Room) | NEW | room-scoped ghosts, server-only | no | none | +| `RoomArchetypeBlob`, `BoonCatalogBlob` | NEW | config singletons (blob, from SOs) | no | none | +| `RegionTag` | unchanged | — | no | none | +| `StatModifier`, `EffectiveAbilityStats/CharacterStats` | unchanged (boons append; **no new member**) | — | yes | **none** | +| `GoalProgress`, `RunOutcome`, `CoreIntegrity`, `RunPhase`, `ThreatState` | unchanged | — | mixed | none | + +### 3.2 Systems + +| System | Status | Group / ordering | Responsibility | +|---|---|---|---| +| `RoomLayoutMath` | NEW (pure static, no system) | — | deterministic `RoomPlan` + `ScatterInShape` + `PickBoons` | +| `ReadyToggleSystem` | NEW | ServerSimulation, `SimulationSystemGroup`, `[UpdateBefore(RunDirectorSystem)]` | apply ready RPC → `PlayerReady` (Staging only) | +| `RunDirectorSystem` | NEW | ServerSimulation, `SimulationSystemGroup`, `[UpdateAfter(ReadyToggleSystem)]`, `[UpdateBefore(GoalReachedSystem)]`, `[UpdateBefore(CyclePhaseSystem)]` | **sole `RunInfo.Lifecycle` writer**; ready-count, launch edge+guard, room advance, teleport, return, bank Charge+retaliation once-per-RunEpoch | +| `RoomFieldSystem` | CHANGED (from `ExpeditionFieldSystem`) | ServerSimulation, `SimulationSystemGroup`, `[UpdateAfter(RunDirectorSystem)]` — **drop the inherited `[UpdateAfter(CyclePhaseSystem)]`** | scatter scarce nodes on `RoomEpoch` edge; `RoomTag` teardown; node budget | +| `RoomEnemyDirectorSystem` | CHANGED (from `ZoneEnemyDirectorSystem`) | ServerSimulation, `SimulationSystemGroup`, `[UpdateAfter(RunDirectorSystem)]` — **must NOT add `[UpdateBefore(CyclePhaseSystem)]`** | per-room wave/boss on `RoomEpoch` edge; `MaxAlive` guard; surface clear via `ExpeditionObjective.State` | +| `BoonOfferSystem` | NEW | ServerSimulation, `SimulationSystemGroup`, `[UpdateAfter(RunDirectorSystem)]` | on RoomReward entry, compute+write per-player `BoonOffer` from `PlayerSlot` seed | +| `BoonApplySystem` | NEW | ServerSimulation, `SimulationSystemGroup`, `[UpdateAfter(BoonOfferSystem)]` | apply pick RPC → append `StatModifier`; clear Pending | +| `ReadySendSystem`, `BoonSendSystem` | NEW | ClientSimulation, `SystemBase` | static enqueue + drain → RPC entity | +| `CycleDirectorSpawnSystem` | CHANGED | unchanged ordering | stage `RunRuntime` (incl. `HostSalt`, `LastBankedRunEpoch=0`); restore v6 meta born-correct | +| `ExpeditionGateSystem` | RETIRED | — | walk-in launch + Charge credit replaced; **retaliation increments carried to `RunDirectorSystem`** | +| `BaseFieldSpawnSystem` (+`BaseFieldRuntime`) | RETIRED | — | base nodes removed | +| `AbilityUpgradeSystem` | RETIRED | — | Aether-spend upgrade replaced by boons (keep `StatModifier` machinery) | +| `HudSystem` | CHANGED | PresentationSystemGroup | ready panel ("N/M READY" + launch ring), "Room i/N", 3-card boon modal | +| `WorldAtmosphereSystem` | CHANGED | client | biome cross-fade from `RunInfo.CurrentBiome` | +| `RegionRelevancySystem`, `StatRecomputeSystem`, `RegionTransitSystem` | REUSED unchanged | — | relevancy / stat fold / teleport primitive | + +**Sort-cycle audit (fixes F1/C8 — the single highest-risk item; invisible to EditMode, Play-only).** The refactored `RoomFieldSystem`/`RoomEnemyDirectorSystem` **must drop the inherited `[UpdateAfter(CyclePhaseSystem)]`** and instead order `[UpdateAfter(RunDirectorSystem)]`. The resulting linear chain is: +``` +ReadyToggleSystem → RunDirectorSystem → RoomFieldSystem → RoomEnemyDirectorSystem → (CyclePhaseSystem reads later) +``` +`RunDirectorSystem` is strictly **before** `CyclePhaseSystem` and `GoalReachedSystem` (its Charge credit lands before `GoalReached` reads it; one-tick-late is fine), and `ThreatDirectorSystem` (which consumes the `ThreatState` that `RunDirector` now writes) keeps its `[UpdateBefore(CyclePhaseSystem)]` slot, so `RunDirector → ThreatDirector → CyclePhase` is acyclic. **Hard rule: no system in the room chain adds any `[UpdateAfter]`/`[UpdateBefore]` edge to `CyclePhaseSystem`.** The clear signal flows via the data flag `ExpeditionObjective.State` consumed one-tick-late by `RunDirectorSystem`, not via a system-ordering edge (so no `RoomClear→RunDirector` back-edge closes a cycle). Play-validate at Step 2 (where the `RunDirector→CyclePhase` edge first exists) and again at Step 7. + +### 3.3 RPCs + +| RPC | Status | Payload | Notes | +|---|---|---|---| +| `ReadyToggleRequest` | NEW | `byte Ready` | explicit set 0/1, UNCONDITIONAL wire type | +| `BoonPickRequest` | NEW | `byte Index` | 0/1/2, UNCONDITIONAL wire type | +| `AbilityUpgradeRequest` | RETIRED | — | **keep as a dead-but-present unconditional struct** until the single coordinated hash bump (fixes C11/N6) | + +**RpcCollection hash discipline (fixes C11).** All new wire types unconditional (no `#if`); only send/receive **systems** may be `#if`-gated. The hash changes when the new RPCs are introduced. To avoid two intermediate-incompatible bumps across the build sequence, `AbilityUpgradeRequest` stays present (dead, unused) until the **single coordinated step** where the two new RPCs land and the old one is removed together — all peers must share that build. + +### 3.4 Churn line + +- **Ghost re-bakes (2, coordinated):** director ghost (`+RunInfo`); player ghost (`+PlayerReady` and `+BoonOffer` in one re-bake). +- **RpcCollection hash (1 coordinated bump):** +2 RPCs, −1 retired, landed together; `AbilityUpgradeRequest` kept as a dead wire until then. +- **SaveData v5 → v6 (additive; `MinLoadableVersion` stays 2):** new flat fields `int RunsCompleted; int MaxDepthReached;` born-correct on the director via `PendingSave` (exactly like `GoalCharge`/`CoreCurrent`). Optional `CharacterBoon[]` (per-class persisted boons) gated on the persistence OPEN FORK §6. **Must NOT persist:** `RunInfo`/`RunRuntime` (live run instance — re-run from base), `PlayerReady`, `BoonOffer.Pending`; boot always in `Staging`. v2–v5 saves still load (JsonUtility 0-defaults; each restore guard maps 0→baked). + +--- + +## 4. SYSTEM-BY-SYSTEM BUILD SEQUENCE + +Each rung compiles clean, passes EditMode, and survives a Play-smoke before the next. Run the adversarial multi-agent design-review BEFORE each netcode-heavy rung. Re-run the clean netcode Play boot (connect / ghost-sync / player-spawn) after every ghost-hash-affecting step. Edit Assets `.cs` only via MCP (`apply_text_edits`/`create_script`), never raw `Write`. + +### Phase A — run lifecycle skeleton (no rooms) + +**Step 1 — `RoomLayoutMath` + `RoomArchetypeBlob` (pure math + blob, NO netcode).** +- *Create:* `Simulation/World/RoomLayoutMath.cs`, `Simulation/World/RoomPlan.cs`, `Simulation/World/RoomArchetypeBlob.cs`; `Authoring/World/RoomArchetypeDefinition.cs` (SO) + its baker; `Tests/EditMode/RoomLayoutMathTests.cs`. +- *Depends on:* nothing (mirror `ZoneEnemyMath`). +- *Validate (EditMode, `ZoneEnemyMathTests` style — no editor focus needed):* same `(seed,index)` → identical `RoomPlan`; distinct `roomIndex` differ; `Plan` gives room0=Combat, last=Boss, exactly one Elite at ~⅔, Reward interleaved; `ScatterInShape` results stay within `RoomPlan.Radius` for disk/rect/polygon; `PickBoons` always returns 3 distinct ids, all passing the `classId` `ClassMask`, deterministic per `offerSeed`. Assert 368 prior EditMode tests still green. + +**Step 2 — `RunInfo` + `RunRuntime` + bake onto CycleDirector + `RunDirectorSystem` lifecycle skeleton.** +- *Create:* `Simulation/World/RunInfo.cs`, `Simulation/World/RunRuntime.cs`, `Server/World/RunDirectorSystem.cs`; *modify:* `CycleDirector.prefab` (add `RunInfo` authoring → re-bake), `CycleDirectorSpawnSystem.cs` (AddComponent `RunRuntime` with `HostSalt` seeded, `LastBankedRunEpoch=0`, born-correct `RunInfo.Lifecycle=Staging`). +- *Depends on:* Step 1. +- *Implementation note:* stub the room loop (Launching → immediately Returning after the launch countdown), but build the **real sub-slot teleport** here (party teleport to `ExpeditionRoomOrigin(base, ActiveSubSlot)` on launch and back to base on return, via `LocalTransform.Position =`) so Steps 5–7 are validatable on a ping-ponging loop (fixes C3). Write `RunInfo` above any early-return (F12). Include the `RunPhase!=Normal || RunOutcome!=InProgress` launch guard (F2). +- *Validate:* director ghost re-bakes clean (no console errors). **Play-boot:** `execute_code` reads `RunInfo.Lifecycle==Staging` born-correct on BOTH worlds; force `allReady` by writing `PlayerReady` directly (added in Step 3 — for Step 2 use a temporary `RunRuntime.ForceLaunch` debug flag set via `execute_code`), observe Staging→Launching→(stub)→Returning→Staging and the party teleport to the expedition origin and back. **This is the sort-cycle Play-validation point** (the new `[UpdateBefore(CyclePhaseSystem)]`/`[UpdateBefore(GoalReachedSystem)]` edges) — confirm world creation does not throw `ComponentSystemSorter` "circular dependency cycle". 368 EditMode tests green. + +**Step 3 — `PlayerReady` + `ReadyToggleRequest` + `ReadyToggleSystem` + `ReadySendSystem`.** +- *Create:* `Simulation/Player/PlayerReady.cs`, `Simulation/World/ReadyToggleRequest.cs`, `Server/World/ReadyToggleSystem.cs`, `Client/World/ReadySendSystem.cs`; *modify:* player prefab (add `PlayerReady` send-to-all → re-bake), `GoInGameServerSystem.cs` (init `PlayerReady.Value=0` at spawn). +- *Depends on:* Step 2. +- *Validate (EditMode):* tick `ReadyToggleSystem` with a fabricated `ReadyToggleRequest`+`ReceiveRpcCommandRequest`+player; assert `PlayerReady.Value` flips and that a toggle while `Lifecycle!=Staging` is ignored. **Play (MPPM 2 clients):** toggle ready on each; confirm `RunDirectorSystem` launches only when N/M==M; RpcCollection hash matches (no handshake refusal); a staging disconnect recomputes the count and can complete the check. Replace the Step-2 `ForceLaunch` debug flag with the real ready-derivation. + +### Phase B — rooms & traversal + +**Step 4 — `RoomTag` + per-room teardown helper.** +- *Create:* `Simulation/World/RoomTag.cs`, a shared `RoomTeardown` helper (static, `RoomTag`-filtered destroy over `ResourceNode`/`BlightClutter`/`ZoneEnemyTag`-equivalent queries); `Tests/EditMode/RoomTeardownTests.cs`. +- *Depends on:* Step 1. +- *Validate (EditMode):* spawn two rooms' dummy ghosts (`RoomTag.Room` 0 and 1); tear down room 0; assert ONLY room-0 ghosts die and room-1 survive (the cross-room-wipe regression, DR-031/DR-040 hazard). + +**Step 5 — `RoomFieldSystem` (refactor of `ExpeditionFieldSystem`) + `RunRuntime.NodeBudgetRemaining`.** +- *Modify:* `ExpeditionFieldSystem.cs` → `RoomFieldSystem.cs` (rename, re-point to `RoomEpoch`, drop inherited `[UpdateAfter(CyclePhaseSystem)]`, add `[UpdateAfter(RunDirectorSystem)]`, scatter via `ScatterInShape`+`nodeSeed` at `ExpeditionRoomOrigin(base, ActiveSubSlot)`, tag `RoomTag`, node budget); `RunRuntime` already has `NodeBudgetRemaining`. +- *Depends on:* 1, 4. +- *Validate (EditMode):* equality-reseed = exactly one scatter per `RoomEpoch` (a repeat tick with the same epoch scatters nothing); `NodeBudgetRemaining` decrements per node and floors each room's spawn count to the remaining budget; baked Scale preserved (assert spawned `LocalTransform.Scale` equals the prefab's, not 1). **Play-smoke:** room field appears at the active sub-origin, relevancy-scoped to Expedition players; a manual `RoomEpoch` bump reseeds at the idle slot. + +**Step 6 — `RoomEnemyDirectorSystem` (refactor of `ZoneEnemyDirectorSystem`) per-room roster.** +- *Modify:* `ZoneEnemyDirectorSystem.cs` → `RoomEnemyDirectorSystem.cs` (re-point to `RoomEpoch`, index `ZoneEnemyMath` by `RoomPlan.DifficultyEpoch`, drop inherited CyclePhase edge, add `[UpdateAfter(RunDirectorSystem)]`, tag `RoomTag`, Boss-room single-boss branch, keep `MaxAlive` guard + `ExpeditionObjective` above the early-return). +- *Depends on:* 1, 4. +- *Validate (EditMode):* `DifficultyEpoch` → kind counts via `ZoneEnemyMath` (Elite/Boss bands heavier); `MaxAlive` spawn-only-if-fits holds (a pack that wouldn't fit does not consume a slot); `ExpeditionObjective` written even when the early-return path is taken. **Play-smoke:** rooms fight differently by archetype; Boss room spawns the boss. + +**Step 7 — multi-room advance in `RunDirectorSystem` (consume `ExpeditionObjective.State`).** +- *Modify:* `RunDirectorSystem.cs` (consume objective-Cleared one-tick-late → RoomReward; teardown-previous → empty tick → bump `RoomEpoch` + flip `ActiveSubSlot` → teleport via `LocalTransform.Position =`; Boss → Returning; bank once-per-`RunEpoch` guarded by `LastBankedRunEpoch`, incl. `ThreatState` retaliation carries). +- *Depends on:* 5, 6, 2, 4. +- *Validate (EditMode):* fabricated objective-Cleared → RoomReward → (after grace/picks) `CurrentRoom++` + `ActiveSubSlot` flip + `RoomEpoch` bump; bank fires **exactly once** even if `Returning` persists multiple ticks. **Play-validate the full loop:** room0→…→boss→Returning→home → `Charge += 1` once-per-`RunEpoch`; `execute_code` asserts only ONE room's ghosts alive at any tick (teardown-before-spawn invariant) and that no cross-arena enemy targets a player in the other slot; confirm `ThreatState.ExpeditionsCompleted` increments (retaliation input carried); re-audit `[UpdateBefore/After]` — no cycle at world creation. + +### Phase C — the boon reward + +**Step 8 — `BoonCatalogBlob` + `BoonDefinition` SOs + `BoonOffer` (`SendToOwner`) + `BoonOfferSystem`.** +- *Create:* `Simulation/Combat/BoonDefBlob.cs`, `Simulation/Combat/BoonCatalogBlob.cs`, `Authoring/Combat/BoonDefinition.cs` (SO) + baker, `Simulation/Player/BoonOffer.cs`, `Server/Combat/BoonOfferSystem.cs`; *modify:* player prefab (add `BoonOffer` `OwnerSendType.SendToOwner` → re-bake, batch with Step 3's `PlayerReady` add). +- *Depends on:* 1 (picker), 7. +- *Validate:* player ghost re-bakes. **Play 2 clients:** on a clear, `execute_code` reads each client's local `BoonOffer` (via `GhostOwnerIsLocal`) — confirm each owner sees its own three options, options differ per player (seed includes `PlayerSlot`), and a teammate's `BoonOffer` does NOT arrive on the other client (owner-only send). If `SendToOwner` codegen misbehaves, fall back to `SendToOwnerType.All` and re-validate. + +**Step 9 — `BoonPickRequest` + `BoonApplySystem` + `BoonSendSystem`.** +- *Create:* `Simulation/Combat/BoonPickRequest.cs`, `Server/Combat/BoonApplySystem.cs`, `Client/Combat/BoonSendSystem.cs`. +- *Depends on:* 8. +- *Validate (EditMode):* a valid index appends exactly one correct `StatModifier` (distinct `Boon` SourceId) and clears `Pending`; an out-of-range or non-`Pending` pick is rejected (no append). **Play:** pick on each client; `EffectiveAbilityStats.Damage` (or the chosen axis) rises on BOTH worlds for the picker only (co-op independence); `RunDirectorSystem` leaves `RoomReward` only once all surviving `Pending==0` OR `RewardGraceTick` (`IsNewerThan`) elapses. + +### Phase D — economy & presentation (single coordinated churn step + polish) + +**Step 10 — Economy reshape + coordinated RPC retirement.** +- *Modify:* remove `BaseFieldSpawner` from the gameplay subscene; idle then retire `BaseFieldSpawnSystem`+`BaseFieldRuntime`; node-budget cap + rarity weighting in `RoomFieldSystem`; retire `AbilityUpgradeSystem` and remove `AbilityUpgradeRequest` **in this single step** (the one coordinated hash bump — confirm the Aether fork §6 with the operator before deleting the wire). +- *Depends on:* 5, 8, 9. +- *Validate (Play):* zero nodes at base; first run launchable from grubstake; scarce capped nodes that exhaust in later rooms; building still spends Ore/Biomass; no Aether upgrade path remains; RpcCollection hash matches across freshly-built peers. + +**Step 11 — HUD + biome + SaveData v6.** +- *Modify:* `HudSystem.cs` (ready panel "N/M READY" + launch ring, "Room i/N" counter, 3-card boon modal wired to `BoonSendSystem.PickBoon`); `WorldAtmosphereSystem.cs` (biome cross-fade from `RunInfo.CurrentBiome`); `SaveData.cs`→v6 + `SaveWriteSystem.cs` + `PendingSave`/`WorldLauncher.StagePendingSave` + `CycleDirectorSpawnSystem` born-correct restore of `RunsCompleted`/`MaxDepthReached`. +- *Depends on:* all prior. +- *Validate (Play):* screenshot the READY panel ("2/3" + launch ring), the room counter, the 3-card overlay (click commits via Step 9's RPC); biome varies per room deterministically; v6 save→reload restores `RunsCompleted`/`MaxDepthReached` born-correct and boots in `Staging` (no resumed room); old v5 saves still load (0-default). `RoomCoverPropSystem` (deterministic per-room cover props) is **descoped from v1** (fixes C6) — v1 variety ships on biome tint + shape/layout; cover props are a later isolated rung. + +--- + +## 5. RESOLVED RISKS + +| ID | Finding | How the spec now handles it | +|---|---|---| +| **F1/C8** | Sort-cycle from inherited `[UpdateAfter(CyclePhaseSystem)]` on refactored field/zone systems (Play-only, invisible to EditMode) | Refactored `RoomFieldSystem`/`RoomEnemyDirectorSystem` **drop** the inherited CyclePhase edge, order `[UpdateAfter(RunDirectorSystem)]`. Linear chain `ReadyToggle→RunDirector→RoomField→RoomEnemyDirector`; `RunDirector` strictly before `CyclePhase`/`GoalReached`/`ThreatDirector`. Clear flows via the `ExpeditionObjective.State` data flag consumed one-tick-late, not a system edge. Play-validated at Step 2 and Step 7. | +| **N2** | Cross-arena AI aggro: both sub-arenas share `RegionTag{Expedition}`, `PickWeightedNearest` has no range cap | Teardown-before-spawn with one empty tick → two rooms never coexist → AI cannot see across the gap. `RoomTag` available for AI plumbing if rooms ever overlap; v1 does not depend on it. | +| **N1** | `BoonOffer` `SendToOwnerType.All` broadcasts; misreads the API | `BoonOffer` uses `OwnerSendType.SendToOwner` (owner-only, traffic-minimal). `All` is the validated fallback only if `SendToOwner` codegen misbehaves. | +| **F2** | New run can launch while a final siege arms | `Staging→Launching` refused while `RunPhase!=Normal || RunOutcome!=InProgress`. | +| **F7** | Unnamed once-per-`RunEpoch` Charge latch → multi-tick `Returning` double-credit | `RunRuntime.LastBankedRunEpoch`, equality-compared; bank fires once then sets the latch. Validated at Step 7. | +| **F6** | `NetworkId`-seeded boons not reconnect-stable / replayable | `boonSeed` keyed by the stable server-assigned `PlayerSlot`, not the transport `NetworkId`. | +| **C7** | Retiring `ExpeditionGateSystem` drops `ThreatState.PendingReturns`/`ExpeditionsCompleted` (retaliation-siege input) | Both increments carried to `RunDirectorSystem`'s `Returning` edge. | +| **F4** | `RewardGraceTick`/`LaunchTick` raw-`uint` compare → soft-lock on wrap/restore | Stored via `TickUtil.NonZero`; elapsed-test mandated via `new NetworkTick(stored).IsNewerThan(now)`, never raw `uint`. | +| **C1/N4** | Missed reuse of the teleport primitive; `FromPosition` Scale-reset trap | Reuse the `RegionTransitSystem` flip+teleport idiom; teleport writes `LocalTransform.Position =` (never `FromPosition`). Orphan cleanup of `ExpeditionGate`/`ExpeditionGateAuthoring`/`RegionTransitRequest` audited at retirement. | +| **C2** | Parallel `RoomOrigin` splits coordinate authority | Single authority: `RegionMath.ExpeditionRoomOrigin` + `ExpeditionOrigin(base)` reading the active sub-slot; every existing `RegionOrigin(Expedition,…)` call repointed. | +| **C3** | Steps 5–7 not independently validatable (teleport doesn't exist until 7) | Real sub-slot teleport built in Step 2's stub loop (ping-pongs), so the field/enemy rungs are validatable on a moving party. | +| **C4** | Duplicative `RoomClearSystem`/`RoomCleared` byte | Collapsed — clear surfaced through the existing `ExpeditionObjective.State==Cleared`, consumed one-tick-late by `RunDirectorSystem`. | +| **C11/N6** | Two intermediate-incompatible RPC-hash bumps | `AbilityUpgradeRequest` kept as a dead-but-present unconditional struct until Step 10's single coordinated bump (+2 new, −1 old together). | +| **F12** | `RunInfo` must be written above any presence early-return | `RunDirectorSystem` writes `RunInfo.Lifecycle`/`CurrentRoom` above all early-returns. | +| **F3/F11** | "one room alive" invariant actually ≤2×MaxAlive; `ghostId==0` transient relevancy leak amplified per room | Dissolved by teardown-before-spawn (one empty tick → strictly one room). Step 7 asserts exactly one room's ghosts alive at any tick. | +| **N3** | Relevancy "reused unchanged" understates build-count cost scaling | Cost stated honestly: `O(region-tagged-ghosts × connections)`/tick, scales with cumulative base structures; `DefaultRelevancyQuery` flagged as a future optimization to evaluate, not adopted blindly. | +| **N7** | N/M count load-bearing on Staging-only co-location | Documented in code; ready toggles honored only in Staging. | +| **N5** | 1-of-N mid-run disconnect mislabeled "handled" | Moved to OPEN FORKS §6 as a co-op-abandonment product call. | +| **F9/F10** | Snapshot lag / gate-on-`Pending` | Non-defects: FSM gates on the server-written `Pending` flag; the lag is eventually-consistent, no predicted logic depends on it. | + +--- + +## 6. OPEN OPERATOR FORKS (genuine gameplay-design calls — NOT decided here) + +1. **Aether's fate.** Boons replace the 20-Aether in-run upgrade purchase, freeing Aether. (a) Retire Aether entirely (simpler haul); (b) keep it as a premium build material for high-tier base structures; (c) make it a base meta-spend — permanent between-run character upgrades distinct from in-run boons (this path pulls in the `CharacterBoon[]` SaveData v6 field + per-class spawn-seeding). Confirm before Step 10 deletes the Aether-upgrade wire. +2. **Un-picked-boon policy when the reward-grace timer fires** (AFK/disconnected picker). (a) Auto-clear the offer (player forgoes it); (b) auto-pick Option0 (player always gets something). +3. **Do boons persist across runs?** Locked #3 frames boons as per-run (Hades-shaped, reset each run). A meta layer (permanent character growth) is the `CharacterBoon[]` SaveData v6 path + per-class spawn-seeding, overlapping fork #1(c). Per-run-only is the simpler roguelite-pure default. +4. **Launch countdown vs instant launch.** `LaunchTick` supports a telegraphed "all ready → 3-2-1 → go" with an un-ready abort window; instant launch on the all-ready edge is simpler. +5. **Run length (`RoomCount`) — fixed vs seed-varied.** Fixed (e.g. always 8) is predictable; a narrow seed-varied range (e.g. 6–10) adds run-to-run variety (the headline goal) at some pacing-consistency cost. Tunable, not load-bearing. +6. **1-of-N mid-run disconnect / co-op abandonment** (moved from N5). When one of a multi-player party drops mid-run, the remaining players continue (the run is not aborted unless ALL expedition players leave). Should a dropped player be able to rejoin the in-progress run (catch-up/spectate, a v2 deferral today), and should the win-meter credit still bank for the survivors? Currently survivors continue and bank on boss-clear; the dropped player simply misses the run. + + +--- + +# 7. ADDENDUM (2026-07-01) — Branching Route-Map + Permanent Meta-Progression (integrated, review-hardened) + +> **Provenance.** Extends §§1–6 with the four operator locks (branching node-map · permanent meta-progression · Aether = meta currency · seed-varied 6–10 rooms) and realizes the [[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]] two-channel vision. Adversarially reviewed (netcode / determinism-save / reuse-scope lenses). **Every CONFIRMED finding is corrected in the design text below** — D-F1 (ThreatDirector edge), R-F3 (client economy co-strip), D-F2 (unconditional `MetaCounters`), N1/R-F5 (in-place route latch), D-F3 (clear-gated banking), D-F4 (boon-strip ordering), R-F1 (meta upsert), R-F4/N5 (bounded buffer), R-F2 (pre-collected class members), plus the N2/N3/N4/D-F5/R-F6/R-F7/R-F11/R-F12 guards. No baseline CONFIRMED fix (F1/C8, N1-boon-sendtype, N2, F2, F7, F6, C7, F4, C1/C2) is re-opened. **This supersedes §2.4's "append a permanent `StatModifier`" (boons are now run-scoped) and resolves §6 forks #1→(c), #3→per-run, #5→[6,10]; §5 gains the rows below.** + +--- + +## A. THE TWO-CHANNEL MODEL (crisp separation — the DR-037 correction) + +Three storage/write channels that **never cross** (distinct SourceId bands, distinct storage, distinct writer systems). Nothing reads as a roguelike reset: base/gear/meta never reset; only run boons expire. + +### A.1 In-run power channel — choice-of-3 boons, RUN-SCOPED (corrects §2.4) + +Boons remain a `StatModifier` append (§2.4's pipeline is otherwise reused), but on a **bounded, range-strippable band**, appended during the run and **stripped on the `Returning` edge** — they do **not** persist. + +- **Band + provenance.** `RunRuntime.BoonPickCounter` (server-only `uint`, reset on the `Staging→Launching` edge, `++` per applied pick). Each applied pick draws `sid = Tuning.BoonSourceIdBase + (BoonPickCounter++ % Tuning.BoonSourceIdSpan)` → boons occupy the disjoint band `[0x00B00000, 0x00B10000)`. One row per pick preserves provenance and lets boons stack as distinct rows (fork #5a; §G). +- **Strip = `Returning` edge, inside the once-per-`RunEpoch` bank latch.** New `TimedModifierUtil.RemoveBySourceIdRange(DynamicBuffer mods, uint lo, uint hiExclusive)` (3-line sibling of the verified `RemoveBySourceId`) is called over **every** `PlayerTag`'s `StatModifier` buffer inside the existing `LastBankedRunEpoch != RunEpoch` block (§2.3 step 3). It also **zeroes every `BoonOffer.Pending`** in the same pass. Server-only; idempotent (a second `Returning` tick strips an empty range); rollback-safe (the `[GhostField]` buffer removal replicates and `StatRecomputeSystem` reverts `Effective*Stats` on both worlds). Disjoint from the class band (`0x00C1A550`) and meta band (`0x00E7A000`), so class seeds + meta seeds survive. +- **Ordering hardening (D-F4).** `BoonApplySystem` runs `[UpdateAfter(BoonOfferSystem)]` (hence after `RunDirectorSystem`), so a pick RPC drained on the same tick the strip runs would otherwise append *after* the latch consumed it and leak a run. **Fix, folded in: `BoonApplySystem` gates every pick on `RunInfo.Lifecycle == RoomReward`** (rejects any pick once the FSM has left `RoomReward` toward `RouteSelect`/`Returning`), and the strip zeroes all `Pending`. A grace-timeout straggler pick therefore arrives on a `RouteSelect`/`Returning` tick and is rejected — no post-strip append is possible. +- **Defense-in-depth against persistence.** The save writes **director-level state only** — never per-player `StatModifier` buffers — so boons cannot reach disk even if a strip were missed. +- **DR-037 reconciliation (R-F11).** DR-037 said "run-scoped via `TimedModifier`," but `TimedModifier.UntilTick` needs a fixed end tick and a seed-varied run has **no known end tick at pick time**. The `Returning`-edge range-strip is therefore *strictly more correct* and **supersedes** the fixed-duration timer for boons. Recorded so no future reader reunifies boons onto a `TimedModifier` countdown. + +### A.2 Permanent power channel — Aether-bought meta upgrades, PERSISTENT + +Aether (§2.5, kept as a **rare** expedition resource; fork #1→(c)) is spent **at the base hub** on permanent per-class tiers. They persist (SaveData v6) and are re-applied **born-correct** at player spawn as meta-band (`[0x00E7A000, 0x00E7A100)`) `StatModifier`s — identical mechanism to the class-seed path, so rollback-correct via the unchanged `StatRecomputeSystem`. The in-run Aether spend (`AbilityUpgradeSystem`) is **retired** (fork #3→per-run boons already replaced in-run upgrades). Full model in §C. + +### A.3 Navigation channel — the branching route (touches no stat/ledger/save) + +`RunMap`/`RouteSelect` decide *where the party goes*; session-scoped, regenerated from `RunSeed`; never persisted. Full model in §B. + +### A.4 Bounded `StatModifier` buffer (R-F4 / N5 — folded in) + +`StatModifier` is `[InternalBufferCapacity(8)] OwnerSendType.All`. Steady-state rows now = class seeds (≤4) + one meta row per owned upgrade (≤ catalog per class, ~12 in v1) + equip (≤4) + in-run boons (≤ max picks ≈ `RoomCount` ≤ 10) ≈ **30**, which overflows 8 into a heap-allocated, owner-replicated buffer every run. **Fix: raise `InternalBufferCapacity` to `32`.** This is a chunk-layout hint, **not** part of the ghost serializer metadata → **no ghost-hash change, no re-bake**; overflow past 32 spills to heap (a perf note, not a crash), so the common case stays chunk-internal. (We keep per-pick boon rows + the range-strip rather than folding by `(Target,Op)`: op-aware folding is wrong for `PercentMult` and would break strip provenance; fork #5b alone does **not** reduce row count — see §G.) + +--- + +## B. BRANCHING ROUTE-CHOICE MAP (Slay-the-Spire node-map over the §2 FSM) + +**The party is one shared token occupying exactly one node per layer.** Branching = *which* `RoomPlan` comes next, chosen by the party — **never** more materialized rooms. The §2.1 traversal chassis (single sub-arena origin, two ping-pong sub-slots, teardown-before-spawn, `RoomEpoch` reseed, `LocalTransform.Position =` teleport, `RegionRelevancy`) is **reused verbatim**; netcode cost is identical to the linear arc. + +### B.1 Data model (transient — never a ghost buffer) + +New `Simulation/World/RunMap.cs` (pure data + consts, **not** `IComponentData`): + +``` +byte RoomTypeId : Combat=0, Elite=1, Reward=2, Boss=3 // append-only, save/replay-stable +struct RunMapNode { byte RoomType; byte Biome; byte ShapeId; byte NextMask; } // bit j of NextMask ⇒ reaches column j of the next layer +struct RunMap { FixedList512Bytes Nodes; byte LayerCount; ... } // bounded MaxLayers=10 × MaxWidth=3 = 30 nodes +``` + +Stable node key `nodeId = layer*MaxWidth + col`. Layer 0 = single guaranteed-Combat landing node; interior layers width 2–3, typed; layer `L-2` = all-Elite gate; layer `L-1` = single Boss terminal (`NextMask == 0`). `LayerCount == RoomCount ∈ [6,10]` (lock #4). **The room-type arc logic moves here** out of `RoomLayoutMath.Plan` (typing is now a graph-arc property, resolution A of the conflict ledger). + +### B.2 Deterministic generation — `RunMapMath.Generate(uint runSeed) → RunMap` + +New pure static (the `ZoneEnemyMath` integer-hash discipline). **Integer-hash only — no `Unity.Mathematics.Random`** for the map structure (multi-pass edge generation is draw-order fragile, and clients regenerate this — §B.3). (`RoomLayoutMath.ScatterInShape`/`PickBoons` keep their `ref Random` seeds — those are server-only and never client-regenerated.) Passes: depth `L = 6 + Hash(runSeed,0x1A)%5`; per-layer widths; weighted per-node typing (Combat 60 / Reward 25 / Elite 15, guard against an all-Reward layer); primary-edge pass (every source ≥1 out-edge, proportional column map ±1 jitter); coverage pass (every target ≥1 in-edge, forces convergence on the single Boss); branch-widen pass. + +**Invariants (EditMode-asserted — includes the D-F6 "refuted-as-defect, keep-as-test-criteria" items):** same seed → field-identical map; integer-only (no `Random` draw); `LayerCount ∈ [6,10]`; full BFS reachability from `(0,0)`; exactly one Boss = the sole `NextMask==0` terminal; **every start→boss path passes ≥1 Elite** (holds by construction — `L-2` is all-Elite). + +### B.3 Replication decision — regenerate-for-display + authoritative option bytes (justified) + +**Decision: option (b), regenerate-for-display, is chosen over serializing a node buffer.** Only `RunSeed` (constant per run → delta-free after first send) plus the party's `CurrentCol` and the authoritative reachable-option set ride the wire on the **untagged CycleDirector ghost** (a party decision ⇒ global, cross-region-relevant for free — the §2.2 `RunOutcome`/`GoalProgress` precedent). Clients regenerate the full graph *for drawing only* via the identical `RunMapMath.Generate` compiled into both worlds from `ProjectM.Simulation`. + +**Justification.** This mirrors DR-037's decisive principle (server-authored procedural content ⇒ the seed never needs a replicated buffer). Wire cost = 1 `uint` + a handful of bytes changing only at route edges, vs a ~120-byte re-serialized `[GhostField]` node buffer every layer. **Authority never rests on the regen:** the *gameplay* choice uses the server-replicated `RouteOpt*` bytes and the pick is a byte `OptionIndex` the server validates against its own `NextMask`. A divergent client (already blocked by the ghost/RPC hash gates) could only *mis-draw the cosmetic map*, never desync traversal or send an illegal pick. + +New `RunInfo` `[GhostField]`s (all defined + baked at the **single director re-bake**, §D): `uint RunSeed`, `byte CurrentCol`, `byte RouteOptionCount` (0 unless `RouteSelect`), `byte RouteOpt0Col/RouteOpt1Col/RouteOpt2Col`, `byte RouteOpt0Type/RouteOpt1Type/RouteOpt2Type`. The big map panel draws from the regenerated graph (needs `RunSeed`); the clickable option buttons use the authoritative `RouteOpt*` bytes (zero regen dependence for gameplay). (`RunSeed` also lives in server-only `RunRuntime` as the working copy; `RunInfo.RunSeed` is the published `[GhostField]` mirror.) + +### B.4 FSM — distinct `RouteSelect=5` state (append; no byte renumber) + +``` +Staging=0 → Launching=1 → InRoom=2 → RoomReward=3 → RouteSelect=5 → InRoom (loop) + └─(current node is Boss, NextMask==0)──→ Returning=4 → Staging +``` + +`RouteSelect` is **distinct** from `RoomReward` (different actors/predicates): `RoomReward` completes when *all surviving players picked their own boon*; `RouteSelect` completes when *the party committed one shared route*. `RouteSelect` runs with **no room materialized** — it *is* the teardown-before-spawn empty gap; the next room spawns only on the `RouteSelect→InRoom` edge (bump `RoomEpoch` → spawn at the idle slot → teleport), so the one-room-alive invariant holds by construction, strictly cleaner than §2.1's "one empty tick." **`RunDirectorSystem` stays the sole lifecycle + map writer** — `RouteSelect` is just another `Lifecycle` value it writes. **No new `[Update*]` edge to `CyclePhaseSystem`** (the §3.2 hard rule). + +### B.5 Route RPC + systems + +`Simulation/World/RouteSelectRequest.cs`: +```csharp +public struct RouteSelectRequest : IRpcCommand { public byte OptionIndex; public int ForRunEpoch; public int ForLayer; } +// UNCONDITIONAL wire type; blittable scalars; index into replicated RouteOpt*, never a raw col/int2/enum +``` + +- **Client** — `RouteSendSystem` (`Client/World/`, `ClientSimulation`, `SystemBase`): static `PickRoute(byte optionIndex)` enqueue + drain → `SendRpcCommandRequest`. +- **Server** — `RouteSelectSystem` (`Server/World/`, `ServerSimulation`, plain `SimulationSystemGroup`, `[UpdateBefore(RunDirectorSystem)]` — **no CyclePhase edge**). Drains via the `playerByConn` idiom and validates: `RunInfo.Lifecycle==RouteSelect && ForRunEpoch==RunEpoch && ForLayer==CurrentRoom && OptionIndex" telegraph is cosmetic; the commit is instant server-side (sim never pauses, rollback-safe). + +### B.6 `RunDirector` advance integration — single plan authority (risk H) + +`RunDirectorSystem` (sole writer) gains: +- **On `RoomReward→RouteSelect` entry:** `Generate(RunSeed)` → enumerate `NextMask` at `(CurrentRoom, CurrentCol)` → write `RouteOptionCount` + `RouteOptK{Col,Type}` + `RouteGraceTick`. If the current node is Boss (`NextMask==0`) → skip straight to `Returning`. +- **On `RouteSelect→InRoom` advance:** resolve chosen col (`RouteCommand.HasPick ? RouteOptK[OptionIndex].Col : lowest-reachable`); clear `RouteCommand.HasPick=0`; `CurrentRoom++`; `CurrentCol=chosen`; `nodeId = CurrentRoom*MaxWidth + CurrentCol`; `roomType = map.Node(nodeId).RoomType`; `plan = RoomLayoutMath.Plan(RunSeed, nodeId, CurrentRoom, roomType, RoomCount, blob)`; write `RunInfo.{CurrentRoom,CurrentRoomType,CurrentBiome,CurrentCol}` **above all early-returns** (F12); **publish `RunRuntime.{CurrentNodeId, CurrentRoomType}`** (the single plan authority — `RoomFieldSystem`/`RoomEnemyDirectorSystem` read these, never re-derive the node/type; generalizes §2.1's C2 coordinate authority to the room *plan*, risk H); `ActiveSubSlot = CurrentRoom & 1`; bump `RoomEpoch`; teleport via `LocalTransform.Position =` (never `FromPosition`); `Lifecycle→InRoom`. + +`RoomFieldSystem`/`RoomEnemyDirectorSystem` change **only** to read `RunRuntime.CurrentNodeId`/`CurrentRoomType` and the new `Plan(runSeed, nodeId, layer, byte roomType, roomCount, blob)` signature; everything else (scatter, `RoomTag`, `MaxAlive`, `ExpeditionObjective` above the early-return) is verbatim §2.3. + +--- + +## C. PERMANENT META-PROGRESSION + +### C.1 Catalog (distinct blob — do NOT reuse `BoonDefBlob`) + +Boons are run-scoped/single-shot/rarity-drawn; meta upgrades are permanent/tiered/escalating-priced/optionally tree-gated. Overloading `BoonDefBlob` would blur the two DR-037 channels. New `Simulation/Meta/MetaUpgradeDefBlob.cs`: +``` +byte Id; // stable persisted key (append-only) +byte ClassMask; // bit0=Warrior, bit1=Ranger, 3=both +byte Target; byte Op; // maps to StatModifier (byte, NEVER enum — Burst-ICE-safe) +byte MaxTier; float ValuePerTier; +int BaseCost; int CostGrowth; // cost(t) = BaseCost + owned*CostGrowth +byte PrereqId (0xFF=none); byte PrereqTier; +FixedString64Bytes Name; FixedString128Bytes Desc; byte IconId; +``` +→ `MetaUpgradeCatalogBlob` config singleton, **both worlds, NOT replicated** (the `BoonCatalogBlob`/`RoomArchetypeBlob` precedent), authored via `Authoring/Meta/MetaUpgradeDefinition.cs` SO + baker into the gameplay subscene (batched with the RoomArchetype/BoonCatalog authoring — **one** subscene save). + +### C.2 Persisted model — per-class, shared party pool + +The **class (`ClassId`) is the durable identity anchor** (DR-037; lock #3: "a Warrior's permanent upgrades apply to whoever plays Warrior"). The save is single-slot host-local with no stable per-player on-disk identity (`NetworkId` is unstable), and Aether is a **shared party ledger** on the director — the party pools Aether and invests in classes, which is correct for drop-in co-op (R-F13: intended semantics per lock #3, not a defect). + +- **Owned-tier truth** = `MetaTierState { byte ClassId, UpgradeId, Tier }`, a **`[GhostField]` buffer on the untagged director ghost** (co-op party invests together, not secret; rides the ghost already re-baking for `RunInfo`; clients read it for the shop for free). Baked (empty) onto `CycleDirector.prefab` at the single director re-bake. +- **Server-only** on the director: `MetaCounters { int RunsCompleted, MaxDepthReached }` (`AddComponent`). **Server-only** on the player: `PlayerClass { byte ClassId }` (`AddComponent` at spawn — class was previously only wire/`AbilityRef`, so this is genuinely new). + +### C.3 Meta-spend RPC + system + +`Simulation/Meta/MetaSpendRequest.cs`: +```csharp +public struct MetaSpendRequest : IRpcCommand { public byte UpgradeId; } +// UNCONDITIONAL wire; tier is SERVER-COMPUTED (you always buy owned+1) — sending a tier would invite desync/cheat +``` +- **Client** — `MetaSpendSendSystem` (`Client/Meta/`, `ClientSimulation`, `SystemBase`): static `RequestPurchase(byte upgradeId)`. +- **Server** — `MetaSpendSystem` (`Server/Meta/`, `ServerSimulation`, plain `SimulationSystemGroup`, `[BurstCompile]`, **no room-chain / CyclePhase edge**). Mirrors `AbilityUpgradeSystem` with **DR-014 in-loop atomicity**. Per request, all in-loop (no hoist), re-reading ledger + `MetaTierState` each iteration (so two same-tick purchases on barely-enough Aether cannot both pass): + 1. **Phase gate (N4 / R-F9):** reject unless `RunInfo.Lifecycle == Staging` (enforces "base-hub only" server-side, not just via the HUD panel). + 2. resolve sender → `PlayerClass.ClassId`. + 3. catalog lookup by `UpgradeId` (`FindDef < 0` → reject unknown). + 4. `ClassMask` bit gate. + 5. `owned` = scan `MetaTierState` for `(ClassId, UpgradeId)`; **clamp `owned = min(owned, MaxTier)` (D-F5)**; reject if `owned >= MaxTier`. + 6. `PrereqId`/`PrereqTier` gate (skip if `0xFF`). + 7. `cost = BaseCost + owned*CostGrowth`; **soft-fail** (no state change) if ledger Aether `< cost`. + 8. **commit atomically:** `StorageMath.Withdraw(ledger, ResourceId.Aether, cost)` → bump the `MetaTierState` row to `owned+1` (append the row if absent) → **upsert the live meta `StatModifier` on every spawned player of that class (R-F1 + R-F2, below)** → `SaveRequest.Pending=1`. + 9. `ecb.DestroyEntity(req)`. + +**Live application = UPSERT, absolute value (R-F1 — folded in).** A fresh `0→1` purchase on an already-spawned player has **no** existing meta row (born-correct seeding only seeds `Tier>0` rows). So it is **not** "grow-in-place": find the row where `SourceId == MetaSourceIdBase + Id`; if present **SET** `Value = ValuePerTier * newTier` (and `Target/Op`); else **APPEND** it. Absolute value (not accumulate), keyed on the meta SourceId. + +**Pre-collect class members (R-F2 — folded in).** "Every spawned player of that class" must **not** nest a `SystemAPI.Query` inside the Bursted drain `foreach`. Before the drain loop, collect `Entity`s with `PlayerClass.ClassId == classId` into a `NativeList` (via a `BufferLookup`), then upsert each — the `AbilityUpgradeSystem.cs:39-42` `playerByConn` pre-build precedent. + +### C.4 Born-correct spawn seeding (`GoInGameServerSystem`, same ECB as `Instantiate`) + +After the existing `AbilityRef` + `ClassTraits.AppendSeeds`, add `PlayerClass{ClassId}` and seed permanent meta: for each `MetaTierState` row of this class, **skip tier 0, skip unknown catalog id (`FindDef < 0` — preserve-don't-crash, the `BaseRestoreSystem` precedent), clamp `tier = min(saved, MaxTier)` (D-F5)**, then `AppendToBuffer` a `StatModifier { Target, Op, Value = ValuePerTier*tier, SourceId = MetaSourceIdBase + Id }`. Rides the `OwnerSendType.All` `StatModifier` buffer → rollback-correct via the unchanged `StatRecomputeSystem`, identical to class seeds. + +**Availability guard wraps the WHOLE iteration (N2 — folded in).** `GoInGameServerSystem.cs:53,68` instantiates the player and destroys the request in one iteration; a guard scoped to only the seeding block would spawn a meta-less player *and* eat the request. **Fix: if the meta singletons (`MetaUpgradeCatalogBlob`, director `MetaTierState`) are absent the tick a `GoInGameRequest` is processed, `continue` without instantiating or destroying — retry next tick.** A no-op in practice (director + catalog come up at subscene-stream, before any `GoInGame` round-trip, per commit `86575dd5b`), which makes the born-correct guarantee unconditional. + +### C.5 SaveData v6 — additive; `MinLoadableVersion` stays 2 + +- **New fields (array *field*, not a root array):** `MetaUpgradeSave { byte ClassId, UpgradeId, Tier }[] MetaUpgrades` (sparse — absent row = tier 0), `int RunsCompleted`, `int MaxDepthReached`. `SaveData.CurrentVersion → 6`. +- **`SaveService.Load` gains `data.MetaUpgrades ??= Array.Empty();`** (the `Ledger ??=` precedent — else a NullRef at staging). Counters 0-default; **unknown `UpgradeId` rows are preserved** (forward-compat) but skipped for seed/shop/spend. +- **Baseline's optional `CharacterBoon[]` is DROPPED** (fork #3 → boons don't persist). Two crisply-distinct save shapes, no channel bleed. +- **Born-correct restore, `MetaCounters` added UNCONDITIONALLY (D-F2 — folded in, resolves the §1.2↔§2.1 contradiction).** `CycleDirectorSpawnSystem`: `AddComponent(dir, default(MetaCounters))` **unconditionally at spawn** (mirroring the unconditional `CycleRuntime`/`ThreatState`/`RunPhase`/`SaveRequest` adds), so New-Game / no-`PendingSave` boots have the component the bank block reads. Only **inside** the existing `HasData != 0` block: `ecb.SetComponent(dir, MetaCounters{RunsCompleted, MaxDepthReached})` and `ecb.SetBuffer(dir)` from a new staged `PendingMetaRow { byte ClassId, UpgradeId, Tier }` buffer (sibling of `PendingSaveLedgerRow`) — **before Playback, same ECB as `Instantiate`** (no default-empty `[GhostField]`-buffer flicker). New game → empty `MetaTierState` + zeroed `MetaCounters`. +- **`WorldLauncher.StagePendingSave`** copies `SaveData.MetaUpgrades → PendingMetaRow` + the two counters (null-guarded). **`PendingSave` gains `RunsCompleted, MaxDepthReached`.** +- **`RunDirector` credits `MetaCounters` in the once-per-`RunEpoch` bank block, CLEAR-GATED (D-F3 — folded in).** The `Returning` bank block (§2.3 step 3) is reached on boss-clear, party-wipe, or all-disconnect. The credit is split: + - **Always (any terminal):** set `LastBankedRunEpoch`, `SaveRequest.Pending=1`, clear `PlayerReady`, strip boons (§A.1), and `MaxDepthReached = max(MaxDepthReached, actualRoomsCleared)` — **actual depth, never the planned `RoomCount`** (a 1-room abort must not record the planned length). + - **Boss-clear only (`RunRuntime.LastTerminalCleared == 1`, set on the `RoomReward→Returning` Boss edge, 0 on abort edges):** `GoalProgress.Charge += 1` (win meter), `RunsCompleted++`, and the retaliation carries `ThreatState.PendingReturns++`/`ExpeditionsCompleted++` (C7). An aborted/wiped run banks no win credit and provokes no retaliation — only the depth high-water is recorded. + +- **Must NOT persist:** run boons (per-player `StatModifier` buffers — never touched, stripped on `Returning`); `RunInfo`/`RunRuntime`/`RunMap` (session-scoped; boot always in `Staging`); `PlayerReady`; `BoonOffer`; `RouteCommand`. + +--- + +## D. CONSOLIDATED INVENTORY DELTA (plugs into §3) + +### D.1 Components & `[GhostField]`s (extends §3.1) + +| Component | Status | Where / replication | Re-bake | +|---|---|---|---| +| `RunMapNode`, `RunMap`, `RoomTypeId` | NEW (`RunMap.cs`, pure data/consts) | transient (regenerated); not `IComponentData` | none | +| `RunInfo` | CHANGED (§3.1 NEW) | director ghost; §3.1's 6 `[GhostField]`s **+9 branching** (`RunSeed`,`CurrentCol`,`RouteOptionCount`,`RouteOpt0/1/2Col`,`RouteOpt0/1/2Type`) **+2 HUD mirror** (`RunsCompleted`,`MaxDepthReached`) = **17 total** | folds into the **one** director re-bake | +| `MetaTierState {byte ClassId,UpgradeId,Tier}` | NEW | director ghost, `[GhostField]` buffer (untagged/global → all clients); baked empty | folds into the **one** director re-bake | +| `RunRuntime` | CHANGED (§3.1 NEW) | director, server-only; **+`int CurrentNodeId`,`byte CurrentCol`,`byte CurrentRoomType`,`uint RouteGraceTick`,`byte LastTerminalCleared`** (branching+clear-gate) **+`uint BoonPickCounter`** (boons) | none | +| `RouteCommand {byte HasPick,OptionIndex; int ForRunEpoch,ForLayer}` | NEW | director, server-only singleton, `AddComponent` at spawn | none | +| `MetaCounters {int RunsCompleted,MaxDepthReached}` | NEW | director, server-only, **`AddComponent` UNCONDITIONALLY** at spawn (D-F2) | none | +| `PlayerClass {byte ClassId}` | NEW | player, server-only, `AddComponent` at spawn | none | +| `PlayerReady`, `BoonOffer` | NEW (§3.1) | player ghost; both added at the **one** player re-bake | player re-bake | +| `RoomTag {byte Room}` | NEW (§3.1) | room-scoped ghosts, server-only | none | +| `MetaUpgradeDefBlob`, `MetaUpgradeCatalogBlob` | NEW | config singleton, both worlds, **not replicated** | none | +| `RoomArchetypeBlob`, `BoonCatalogBlob` | NEW (§3.1) | config singletons, not replicated | none | +| `PendingSave` (+`RunsCompleted`,+`MaxDepthReached`) · `PendingMetaRow` buffer | CHANGED/NEW | staged in ServerWorld pre-stream, server-only | none | +| `StatModifier` | CHANGED (attr only) | `[InternalBufferCapacity(8→32)]` (R-F4/N5); **no `[GhostField]` change → no re-bake** | none | +| `Effective*Stats`, `GoalProgress`, `RunOutcome`, `CoreIntegrity`, `RunPhase`, `ThreatState`, `RegionTag` | unchanged | boons/meta append rows; no new member | none | + +### D.2 Pure math (extends §3.2) + +| Item | Status | Note | +|---|---|---| +| `RunMapMath` (`Generate`, `ReachableOptions`, BFS) | NEW | integer-hash only; EditMode-tested (§B.2 invariants) | +| `RoomLayoutMath.Plan` | CHANGED signature | `Plan(runSeed, nodeId, layer, byte roomType, roomCount, blob)`; room-type arc **moved to `RunMapMath`**; `PickBoons`/`ScatterInShape` keep their `ref Random` seeds | +| `TimedModifierUtil.RemoveBySourceIdRange(mods, lo, hiExclusive)` | NEW | 3-line sibling of `RemoveBySourceId` | + +### D.3 Systems (ordering obeys the §3.2 no-CyclePhase-edge hard rule) + +| System | Status | Group / ordering | +|---|---|---| +| `ReadyToggleSystem` | NEW (§3.2) | ServerSim, `[UpdateBefore(RunDirectorSystem)]` | +| `RouteSelectSystem` | NEW | ServerSim, `[UpdateBefore(RunDirectorSystem)]` — drain, validate (+region gate N3), **in-place `RouteCommand` latch (N1)**; no CyclePhase edge | +| `RunDirectorSystem` | CHANGED (§3.2 NEW) | ServerSim; keeps `[UpdateAfter(ReadyToggleSystem)]`,`[UpdateBefore(GoalReachedSystem)]`,`[UpdateBefore(CyclePhaseSystem)]`. **+`RouteSelect` state + branching advance + publish `RunSeed`/`CurrentNodeId`/`CurrentRoomType` + boon-strip + clear-gated `MetaCounters`/`Charge`/retaliation bank** — ordering unchanged | +| `RoomFieldSystem` | CHANGED (from `ExpeditionFieldSystem`) | ServerSim, `[UpdateAfter(RunDirectorSystem)]`, **drop inherited `[UpdateAfter(CyclePhaseSystem)]`**; read `RunRuntime.CurrentNodeId` + new `Plan` | +| `RoomEnemyDirectorSystem` | CHANGED (from `ZoneEnemyDirectorSystem`) | ServerSim, `[UpdateAfter(RunDirectorSystem)]`, **must NOT add `[UpdateBefore(CyclePhaseSystem)]`**; read `RunRuntime.CurrentNodeId`/`CurrentRoomType` + new `Plan` | +| `BoonOfferSystem` | NEW (§3.2) | ServerSim, `[UpdateAfter(RunDirectorSystem)]` | +| `BoonApplySystem` | CHANGED (§3.2 NEW) | ServerSim, `[UpdateAfter(BoonOfferSystem)]`; **gate `Lifecycle==RoomReward` (D-F4)** + SourceId band + `BoonPickCounter` | +| `MetaSpendSystem` | NEW | ServerSim, plain `SimulationSystemGroup`, `[BurstCompile]`, **no room-chain/CyclePhase edge**; phase gate (N4) + DR-014 in-loop atomicity + pre-collected class members (R-F2) + upsert (R-F1) + clamp (D-F5) | +| `ThreatDirectorSystem` | CHANGED (edge only) | **repoint `[UpdateAfter(typeof(ExpeditionGateSystem))]` → `[UpdateAfter(typeof(RunDirectorSystem))]` at Step 11 (D-F1)** — fixes the compile break on `ExpeditionGateSystem` deletion *and* the undefined RunDirector→ThreatDirector consume order | +| `ReadySendSystem`, `BoonSendSystem`, `RouteSendSystem`, `MetaSpendSendSystem` | NEW (2 §3.2 + 2 new) | ClientSim, `SystemBase`, static enqueue + drain | +| `CycleDirectorSpawnSystem` | CHANGED | born-correct `RunRuntime` + `RouteCommand` + `MetaTierState` + **unconditional `MetaCounters`** (D-F2); ordering unchanged | +| `GoInGameServerSystem` | CHANGED | +`PlayerReady=0` +`PlayerClass` + born-correct meta seeding (whole-iteration guard N2, clamp D-F5, skip-unknown) | +| `HudSystem` | CHANGED | ready panel · Room i/N · boon modal · **branching map panel** (regen from `RunInfo.RunSeed` + clickable `RouteOpt*`) · **base meta-shop panel** (Staging-only) · **`AbilityUpgrade` button removed (R-F3)** | +| `BuildSendSystem` | CHANGED (R-F3 — **added to CHANGED**) | strip `UpgradeAbility`/`SendUpgrade`/`uKey` at Step 11 or `ProjectM.Client` fails to compile | +| `WorldAtmosphereSystem` | CHANGED (§3.2) | biome cross-fade from `RunInfo.CurrentBiome` | +| `SaveService`/`SaveWriteSystem`/`WorldLauncher` | CHANGED | v6 stage + write + `??= Array.Empty` | +| `ExpeditionGateSystem`, `BaseFieldSpawnSystem`(+`BaseFieldRuntime`), `AbilityUpgradeSystem` | RETIRED (§3.2) | retaliation carried to `RunDirector`; base nodes removed; in-run Aether spend removed (machinery kept) | +| `RegionRelevancySystem`, `StatRecomputeSystem`, `RegionTransitSystem` | REUSED unchanged | relevancy / stat-fold / teleport | + +**Resulting acyclic server chain (no CyclePhase edge):** `ReadyToggleSystem, RouteSelectSystem → RunDirectorSystem → {RoomFieldSystem, RoomEnemyDirectorSystem, BoonOfferSystem → BoonApplySystem}`; `RunDirector → ThreatDirector → CyclePhase` (after the Step-11 repoint); `MetaSpendSystem` unordered/plain. + +### D.4 RPCs (extends §3.3) + +| RPC | Status | Payload | Notes | +|---|---|---|---| +| `ReadyToggleRequest` | NEW (§3.3) | `byte Ready` | unconditional wire | +| `BoonPickRequest` | NEW (§3.3) | `byte Index` | unconditional wire | +| `RouteSelectRequest` | NEW | `byte OptionIndex; int ForRunEpoch; int ForLayer` | unconditional; index into replicated `RouteOpt*` | +| `MetaSpendRequest` | NEW | `byte UpgradeId` | unconditional; tier server-computed | +| `AbilityUpgradeRequest` | RETIRED | — | dead-but-present unconditional struct until the single coordinated bump (Step 11) | + +### D.5 Combined churn line (unchanged envelope vs §3.4) + +- **Ghost re-bakes — exactly 2.** (1) **Director** — `+RunInfo` (17 `[GhostField]`s) **+ `MetaTierState` `[GhostField]` buffer**, one re-bake at Step 2. (2) **Player** — `+PlayerReady` + `+BoonOffer`, one re-bake at Step 3. (`RunRuntime`, `RouteCommand`, `MetaCounters`, `PlayerClass`, `PendingSave`/`PendingMetaRow` server-only → no re-bake; the `StatModifier` `InternalBufferCapacity` bump is chunk-internal → no re-bake.) +- **RpcCollection hash — exactly 1 coordinated bump at the release boundary: net +4 (`ReadyToggle`, `BoonPick`, `RouteSelect`, `MetaSpend`) / −1 (`AbilityUpgradeRequest`).** All four new structs unconditional + declared at Step 3; `AbilityUpgradeRequest` stays a dead wire until Step 11 removes it (dev-time intermediate hashes are harmless — all local/MPPM peers rebuild together). +- **Subscene save — 1:** the `MetaUpgradeCatalogBlob` config singleton, batched with the RoomArchetype/BoonCatalog authoring. +- **SaveData v5 → v6, additive, `MinLoadableVersion` stays 2:** `+MetaUpgradeSave[] MetaUpgrades` (array *field*), `+int RunsCompleted`, `+int MaxDepthReached`; `CharacterBoon[]` dropped. + +--- + +## E. REVISED BUILD SEQUENCE (how §4's 11 steps change; +3 inserted; each dependency-ordered + validatable in isolation) + +**Re-bake discipline (integration invariant, risk G):** finalize both ghost layouts at their single re-bake point — **director @ Step 2** (`RunInfo` full 17-field set + `MetaTierState` buffer), **player @ Step 3** (`PlayerReady` + `BoonOffer`). Declare all four new RPC wire structs at Step 3. Writer systems arrive later; the inert dead state is harmless. **Adversarial design-review BEFORE Step 8 and before the Step 12–13 meta block.** Re-run the clean netcode Play boot after every ghost-hash-affecting step. Edit Assets `.cs` only via MCP. + +### Phase A — lifecycle skeleton (no rooms) +- **Step 1** — *(edits §4 Step 1)* **+ `RunMap.cs`, `RunMapMath.cs`, `RunMapMathTests.cs`; `RoomLayoutMath.Plan` gets the new signature (room-type arc moved to `RunMapMath`).** *Validate (EditMode):* baseline `Plan` determinism + all §B.2 `RunMapMath` invariants (integer-only, reachability, single Boss, ≥1 Elite per path, `LayerCount∈[6,10]`); 368 prior tests green. +- **Step 2** — *(edits §4 Step 2 — the sort-cycle Play point)* CREATE `RunInfo.cs` (**all 17 `[GhostField]`s**), `RunRuntime.cs` (all fields incl. `CurrentNodeId`/`LastTerminalCleared`/`BoonPickCounter`), `Simulation/Meta/MetaComponents.cs` (define `MetaTierState`, `RouteCommand`, `MetaCounters`, `PlayerClass`), `RunDirectorSystem.cs` (stub loop with **real sub-slot teleport**, land on `(0,0)`, publish `RunSeed` at Launch, F2 launch guard, F12 write-above-early-return). MODIFY `CycleDirector.prefab` (`+RunInfo` + `MetaTierState` buffer → **the one director re-bake**), `CycleDirectorSpawnSystem` (stage `RunRuntime`+`RouteCommand`+empty `MetaTierState`; **`AddComponent` `MetaCounters` UNCONDITIONALLY** (D-F2); born-correct `Lifecycle=Staging`). *Validate:* re-bake clean; Play-boot both worlds `Lifecycle==Staging`; force-launch → Staging→Launching→(stub)→Returning→Staging with teleport; **confirm world creation throws no `ComponentSystemSorter` cycle** (the `[UpdateBefore(CyclePhase/GoalReached)]` edges first exist here). +- **Step 3** — *(edits §4 Step 3 — front-load wire + player layout)* CREATE `PlayerReady.cs`, `BoonOffer.cs`, **all four RPC structs** (`ReadyToggleRequest`, `BoonPickRequest`, `RouteSelectRequest`, `MetaSpendRequest`), `ReadyToggleSystem.cs`, `ReadySendSystem.cs`. MODIFY player prefab (**`+PlayerReady` + `+BoonOffer` → the one player re-bake**; `BoonOffer` inert until Step 9), `GoInGameServerSystem` (`PlayerReady=0` **+ `PlayerClass`**). Keep `AbilityUpgradeRequest` (dead). *Validate:* EditMode ready-toggle flip + Staging-gate; Play (MPPM 2c) launches only at N/M==M; RPC hash matches; staging disconnect recomputes count. + +### Phase B — rooms & linear traversal +- **Step 4** — *(= §4 Step 4)* `RoomTag` + teardown helper + `RoomTeardownTests` (cross-room-wipe regression). +- **Step 5** — *(edits §4 Step 5)* `RoomFieldSystem` reads `RunRuntime.CurrentNodeId` + new `Plan`. *Validate:* equality-reseed once per `RoomEpoch`; budget floor; baked Scale preserved. +- **Step 6** — *(edits §4 Step 6)* `RoomEnemyDirectorSystem` reads `RunRuntime.CurrentNodeId`/`CurrentRoomType` + new `Plan`; Boss branch; `MaxAlive`; `ExpeditionObjective` above early-return. +- **Step 7** — *(edits §4 Step 7)* multi-room **LINEAR** advance (col fixed at 0 — validate the traversal chassis before branching); consume `ExpeditionObjective.State`; teardown→empty→bump `RoomEpoch`→teleport; Boss→Returning; **clear-gated bank (D-F3):** always-once-per-epoch bookkeeping + `MaxDepthReached`=actual depth; boss-clear-only `Charge`/`RunsCompleted`/`ThreatState` carries; publish `RunRuntime.CurrentNodeId`/`CurrentRoomType`. *Validate (Play):* full linear loop; exactly one room's ghosts alive; no cross-arena aggro; bank fires once; a fabricated 1-room abort records actual depth + no `Charge`/`RunsCompleted` credit. + +### Phase B2 — branching (netcode-heavy — REVIEW FIRST) +- **Step 8** — *(NEW)* CREATE `RouteSelectSystem.cs`, `RouteSendSystem.cs`. MODIFY `RunDirector` (add `RouteSelect` state; consume `RouteCommand`; branching advance replaces linear col-0; publish `CurrentNodeId`/`CurrentRoomType`; `RouteGraceTick`). **In-place `RouteCommand` latch (N1); sender region gate (N3).** *Validate:* adversarial review before; Play-boot after — no sort-cycle; exactly one room alive across a branch; **client-regen map at `(CurrentRoom,CurrentCol)` matches replicated `CurrentRoomType`, integer-only, buttons driven by `RouteOpt*` (D-F6 criteria)**; first-commit latch + `ForRunEpoch`/`ForLayer` reject stale picks; grace auto-pick = lowest-index reachable, deterministic; a base-bound joiner's route pick is rejected. + +### Phase C — run-scoped boons +- **Step 9** — *(edits §4 Step 8)* `BoonCatalogBlob` + SOs + `BoonOfferSystem` (`BoonOffer` already on the prefab). *Validate (Play 2c):* owner-only offer delivery, distinct per player (seed = `Hash(RunSeed, CurrentRoom, NetworkId.Value)` — see R-F7 in §F). +- **Step 10** — *(edits §4 Step 9)* `BoonApplySystem` + `BoonSendSystem` + the two-channel correction. ADD `Tuning.BoonSourceIdBase/Span`, `RunRuntime.BoonPickCounter`, `TimedModifierUtil.RemoveBySourceIdRange`, the `Returning`-edge strip in `RunDirector`; **gate `BoonApplySystem` on `Lifecycle==RoomReward` (D-F4)**; **raise `StatModifier` `InternalBufferCapacity` 8→32 (R-F4/N5).** *Validate (EditMode):* a pick appends a boon-band mod; a fabricated `Returning` bank tick strips **all** boon-band rows, **leaves** class (`0x00C1A550`) + meta (`0x00E7A000`) rows, and zeroes `BoonOffer.Pending`; a pick on a non-`RoomReward` tick is rejected; strip fires **once** across a multi-tick `Returning`. Play: picker's `Effective*Stats` rise, revert on return. + +### Phase D — economy + the single coordinated churn +- **Step 11** — *(edits §4 Step 10)* Remove `BaseFieldSpawner` from the subscene; idle→retire `BaseFieldSpawnSystem`/`BaseFieldRuntime`; node-budget cap + rarity; retire `AbilityUpgradeSystem`, **delete `ExpeditionGateSystem` and repoint `ThreatDirectorSystem` `[UpdateAfter]` → `RunDirectorSystem` (D-F1)**, and **remove `AbilityUpgradeRequest`** (the **one** coordinated hash bump, net +4/−1). **Co-strip `BuildSendSystem.UpgradeAbility`/`SendUpgrade`/`uKey` + `HudSystem` Aether-upgrade button + affordability tint + HowToPlay copy (R-F3)** or `ProjectM.Client` won't compile. **Keep Aether** as a rare expedition resource (fork #1c). *Validate (Play):* **no `ComponentSystemSorter` cycle after the ThreatDirector repoint**; zero base nodes; grubstake-launchable; scarce capped nodes; building still spends Ore/Biomass; no Aether *upgrade* path; RPC hash matches across freshly-built peers; client compiles. + +### Phase E — permanent meta (REVIEW the block first; R-F6 split for isolation) +- **Step 12a** — *(NEW)* catalog + born-correct seeding + SaveData v6 plumbing (no wire, no disk yet). CREATE `MetaUpgradeDefBlob.cs`, `MetaUpgradeCatalogBlob.cs`, `Authoring/Meta/MetaUpgradeDefinition.cs`+baker (author the catalog singleton into the subscene, batched). MODIFY `GoInGameServerSystem` (`PlayerClass` + meta seeding: whole-iteration guard N2, clamp D-F5, skip-unknown), `RunDirector` (`MetaCounters` bump in the clear-gated bank block). *Validate (EditMode):* fabricate a director with `MetaTierState` rows → a spawned Warrior gains correct meta `StatModifier`s (`Value=ValuePerTier*tier`, `SourceId=base+id`); a Ranger-masked upgrade is skipped for a Warrior; an unknown id is skipped; a saved tier above a lowered `MaxTier` is clamped. +- **Step 12b** — *(NEW)* disk round-trip. MODIFY `SaveData`→v6 (`MetaUpgradeSave[]`,`RunsCompleted`,`MaxDepthReached`), `SaveService` (`??= Array.Empty`), `PendingSave`/`PendingMetaRow`, `WorldLauncher.StagePendingSave`, `SaveWriteSystem`, `CycleDirectorSpawnSystem` (born-correct `MetaTierState` + `SetComponent MetaCounters` inside `HasData`; preserve-unknown-id). *Validate (Play):* `MetaTierState` born-correct on both worlds after a Continue load; boot in `Staging`; old v5 saves 0-default-load. +- **Step 13** — *(NEW)* meta-spend RPC systems. CREATE `MetaSpendSystem.cs` (phase gate N4, DR-014 in-loop atomicity, pre-collected class members R-F2, upsert R-F1, clamp D-F5), `MetaSpendSendSystem.cs` (`MetaSpendRequest` declared Step 3). *Validate (EditMode):* valid purchase withdraws the exact escalating Aether, bumps `MetaTierState`, upserts the live meta `StatModifier` (append on 0→1, set on n→n+1); past-`MaxTier`/unaffordable/wrong-class/unmet-prereq/non-Staging soft-fail with no withdraw; two same-tick requests on barely-enough Aether → only one succeeds. Play (2c): purchase updates both clients' `MetaTierState`, buyer's `Effective*Stats` rise immediately, save→reload restores tiers + re-seeds a fresh player. + +### Phase F — presentation & save polish +- **Step 14** — *(edits §4 Step 11)* MODIFY `HudSystem` (ready panel · Room i/N · 3-card boon modal → `BoonSendSystem.PickBoon` · **branching map panel** regen from `RunInfo.RunSeed` with clickable `RouteOpt*` → `RouteSendSystem.PickRoute` · **base meta-shop panel** Staging-only: catalog for the local class + owned tier from `MetaTierState` + Aether from ledger + next-tier cost → `MetaSpendSendSystem.RequestPurchase`); `WorldAtmosphereSystem` biome cross-fade. *Validate (Play):* screenshot each panel; route click commits; biome varies per room; win-spine still fires. (`RoomCoverPropSystem` descoped from v1 per §5 C6.) + +--- + +## F. RESOLVED RISKS (extends §5 — every CONFIRMED finding + how the design handles it) + +| ID | Finding | Resolution in this addendum | +|---|---|---| +| **D-F1** *(blocker)* | Retiring `ExpeditionGateSystem` dangles `ThreatDirectorSystem`'s `[UpdateAfter(typeof(ExpeditionGateSystem))]` (hard compile error) + leaves RunDirector→ThreatDirector consume order undefined | Repoint to `[UpdateAfter(typeof(RunDirectorSystem))]` **at Step 11** (the deletion step); interim (Steps 7–10) `ExpeditionGateSystem` idles (walk-in trigger never fires under ready-check launch, so no double-write of `PendingReturns`); the one-tick-late consume tolerates the interim. Re-Play-validate for a sort cycle. | +| **R-F3** *(blocker)* | Removing `AbilityUpgradeRequest` breaks `ProjectM.Client` — `BuildSendSystem.cs:51,121,129,312` still enqueues it, `HudSystem.cs:851/258-260` wires the button | Step 11 co-strips `BuildSendSystem.UpgradeAbility`/`SendUpgrade`/`uKey`, the `HudSystem` Aether-upgrade button + affordability tint, + HowToPlay copy; `BuildSendSystem.cs` added to CHANGED (§D.3). | +| **D-F2** *(blocker)* | `MetaCounters` added only inside `HasData` → New-Game NRE / Continue double-add; §1.2↔§2.1 contradiction | `AddComponent(MetaCounters)` **unconditionally at director spawn** (mirrors `ThreatState`/`RunPhase`); `SetComponent` restored values only inside `HasData` (§C.5, Step 2). | +| **N1 / R-F5** *(major)* | Route first-commit latch is receipt-order-arbitrary if `RouteCommand.HasPick` is written via a hoisted read / deferred ECB | **In-place `SystemAPI.SetComponent(RouteCommand)` inside the drain loop** (DR-014); only the request `DestroyEntity` on the ECB (§B.5). | +| **D-F3** *(medium)* | Unconditional `Returning` bank credits `Charge`/`RunsCompleted`/`MaxDepthReached` on an abort/wipe; records planned `RoomCount` | Split bank: always-once bookkeeping + `MaxDepthReached`=**actual depth**; boss-clear-only (`RunRuntime.LastTerminalCleared==1`) `Charge`/`RunsCompleted`/retaliation (§C.5, Step 7). | +| **D-F4** *(medium)* | A grace-timeout straggler `BoonPickRequest` drained after the `Returning` strip leaks a run | `BoonApplySystem` gates on `Lifecycle==RoomReward`; strip zeroes all `BoonOffer.Pending` (§A.1, Step 10). | +| **R-F1** *(medium)* | Live meta apply mis-specified as "grow-in-place"; 0→1 has no existing row | **Upsert** keyed on `MetaSourceIdBase+Id`: append on 0→1, **set** absolute `Value=ValuePerTier*tier` otherwise (§C.3). | +| **R-F4 / N5** *(medium)* | Per-owned-upgrade + per-pick rows overflow `[InternalBufferCapacity(8)]` into a heap-allocated owner-replicated buffer | Raise to **32** (chunk-internal, **no re-bake**); overflow spills to heap (perf, not correctness). Keep per-pick boons + range-strip (op-folding is `PercentMult`-wrong); fork #5b alone does not reduce rows (§A.4, §G). | +| **R-F2** *(low-med)* | "Grow every player of that class" naively nests a query in a Bursted drain | Pre-collect class members into a `NativeList` before the drain (the `playerByConn` precedent) (§C.3). | +| **N2** *(minor)* | Born-correct meta guard must precede `Instantiate`+`Destroy`, not just the seeding block | The availability guard wraps the **whole** `GoInGame` iteration — `continue` without instantiating/destroying; retry next tick (§C.4). | +| **N3** *(minor)* | Route pick lacks a sender-region gate → a base-bound joiner could commit the party's route | `RouteSelectSystem` requires the sender's `RegionTag.Region == RegionId.Expedition` (§B.5). | +| **N4 / R-F9** *(minor)* | `MetaSpendSystem` has no server phase gate ("base-hub only" was HUD-only) | Reject unless `RunInfo.Lifecycle==Staging`, server-side (§C.3). | +| **D-F5** *(low-med)* | A rebalance lowering `MaxTier`/`ValuePerTier` over-applies a saved higher tier | `tier = min(saved, MaxTier)` at seed/shop/spend; `UpgradeId`/`ClassId` documented **append-only** (§C.3–C.4). | +| **R-F6** *(process)* | Step 12 bundled ~9 co-validated files, breaking one-system-at-a-time | Split into **12a** (seed from fabricated `MetaTierState`) / **12b** (disk round-trip) (§E). | +| **R-F7** *(low, accuracy)* | No stable `PlayerSlot` component exists — `GoInGameServerSystem` uses `NetworkId.Value` | Boon-offer seed = `Hash(RunSeed, CurrentRoom, NetworkId.Value)` (corrects §2.4's "PlayerSlot"); reconnect-stable offers need a real slot component (deferred). Meta keys on `ClassId` → unaffected. | +| **R-F11** *(low, doc)* | DR-037 said "run-scoped via `TimedModifier`"; a seed-varied run has no fixed end tick | The `Returning`-edge range-strip **supersedes** the fixed-duration timer for boons; recorded so no reader reunifies onto a countdown (§A.1). | +| **R-F12** *(low, guardrail)* | New folders must not spawn new asmdefs | `Simulation/Meta`, `Server/Meta`, `Client/Meta`, `Authoring/Meta`, `Client/World` are `.cs`-only inside the four existing asmdefs — never a `.asmdef` (CLAUDE.md). | +| **D-F6** | Client map-regen integer-only + clickable-from-`RouteOpt*` | REFUTED as a defect — it is the design's own constraint; **kept as Step-8 validation criteria** (§B.2, §E Step 8). | +| **R-F8** | Four near-identical client send systems vs consolidation | REFUTED as a defect — functionally correct; baseline already specifies separate send systems. No change. | +| **R-F13** | Two same-class players share one meta pool | REFUTED as a defect — intended drop-in-co-op semantics per lock #3; one-line operator confirm only (§G). | + +--- + +## G. OPEN OPERATOR FORKS (genuine gameplay calls — NOT decided here) + +**Resolved by the four locks (recorded, not re-opened):** §6 #1 → Aether = base meta-spend (kept as a rare resource; in-run spend retired); §6 #3 → boons per-run + strip (meta is the permanent channel); §6 #5 → run length seed-varied `[6,10]`. Meta key granularity → per-class (lock #3), not a fork. + +1. **WHO chooses the route in co-op** *(the headline branching call).* (a) **any-player-first-commits** *(recommended — lowest friction, netcode-simplest, degrades to solo; the in-place first-accepted latch is what §B.5 builds by default)*; (b) majority-vote (needs a tally + tie-break + timeout UI); (c) host-decides. The chassis (in-place latch + `ForRunEpoch`/`ForLayer` reject + grace backstop) supports any. +2. **Route grace-timeout auto-pick policy** *(minor).* (a) **lowest-index reachable** *(recommended — deterministic, built by default)*; (b) hash-random reachable; (c) type-priority. +3. **Un-picked-boon policy on reward-grace** *(§6 #2, still open, minor).* (a) auto-clear (forgo); (b) **auto-pick Option0** *(recommended — player always gets something; symmetric with #2a)*. +4. **Launch countdown vs instant launch** *(§6 #4, tunable).* `LaunchTick` supports a telegraphed 3-2-1 with an un-ready abort window; instant is simpler. Recommend the **countdown**. +5. **Boon SourceId provenance** *(minor engineering fork; does NOT affect the R-F4/N5 buffer bound — that is fixed independently by `InternalBufferCapacity=32`).* (a) **per-pick distinct ids + range-strip** *(recommended — preserves provenance, boons stack as rows; built by default)*; (b) single `BoonSourceId` + one-call `RemoveBySourceId` (drops the counter/span/`…Range` helper). Fully reversible. +6. **Meta tree-gating in v1** *(design fork; no code change either way — purely how the SOs are authored).* Ship a **flat catalog** (all `PrereqId=0xFF`) *(recommended for the first meta pass — validate the economy before gates)* or author real prereq trees now. +7. **Shared per-class meta pool confirm** *(R-F13 — a one-line confirm, not a build fork).* Two players on the same class draw from and invest in one shared per-class tier record (lock #3). Confirm this is the intended drop-in-co-op semantic (recommended: yes). +8. **1-of-N mid-run disconnect / co-op abandonment** *(§6 #6, product call).* Survivors continue; the dropped player misses the run; only a boss-clear terminal banks (D-F3). Should a dropped player rejoin the in-progress run (catch-up/spectate — a v2 deferral today)? + +--- + +*Files.* **NEW:** `Simulation/World/{RunMap,RunMapMath,RouteSelectRequest}.cs`, `Server/World/RouteSelectSystem.cs`, `Client/World/RouteSendSystem.cs`, `Tests/EditMode/RunMapMathTests.cs`; `Simulation/Meta/{MetaUpgradeDefBlob,MetaUpgradeCatalogBlob,MetaComponents,MetaSpendRequest}.cs`, `Authoring/Meta/MetaUpgradeDefinition.cs`, `Server/Meta/MetaSpendSystem.cs`, `Client/Meta/MetaSpendSendSystem.cs`. **CHANGED:** `Simulation/World/{RunInfo,RunRuntime,RoomLayoutMath}.cs`, `Server/World/{RunDirectorSystem,CycleDirectorSpawnSystem}.cs`, `Server/AI/ThreatDirectorSystem.cs` *(edge repoint, D-F1)*, `Server/Combat/BoonApplySystem.cs`, `Simulation/Combat/{TimedModifier,StatModifier}.cs` *(`…Range` helper; capacity 8→32)*, `Simulation/Tuning.cs`, `Simulation/Persistence/{SaveData,SaveComponents,SaveService}.cs`, `Server/Persistence/SaveWriteSystem.cs`, `Client/UI/{WorldLauncher,HudSystem,BuildSendSystem}.cs` *(R-F3)*, `Server/Connection/GoInGameServerSystem.cs`, `Client/World/WorldAtmosphereSystem.cs`. diff --git a/Docs/Vault/07_Sessions/2026/2026-07-01_Asset_Pipeline_Fabricator_Core.md b/Docs/Vault/07_Sessions/2026/2026-07-01_Asset_Pipeline_Fabricator_Core.md new file mode 100644 index 000000000..966b21bff --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-07-01_Asset_Pipeline_Fabricator_Core.md @@ -0,0 +1,57 @@ +--- +title: 2026-07-01_Asset_Pipeline_Fabricator_Core +type: note +permalink: gamevault/07-sessions/2026/2026-07-01-asset-pipeline-fabricator-core +--- + +# 2026-07-01 — Blender↔Unity asset pipeline + Fabricator & Awakening Core hero models + +**Context:** New tooling landed this session: Blender MCP (live Python/bpy control of Blender, addon socket 9876) + MCP-for-Unity v10 (HTTP 127.0.0.1:8080, new `generate_model`/`import_model`/asset-gen tools) + `com.unity.cloud.gltfast` 6.19.0. Proved a full Synty-kitbash round-trip, then shipped two real assets with it. + +## Proven pipeline (Blender ⇄ Unity, Synty round-trip) + +1. `bpy.ops.import_scene.fbx` on pack `Models/*.fbx` — UV layer `map1` + atlas mapping survive. +2. Kitbash freely. **New geometry** gets Synty-styled by pinning all UVs to a flat-color texel on the pack atlas (scan `image.pixels` for a uniform region — the Core's plinth did this). +3. **Before export:** unparent (keep world matrix) + `transform_apply(rotation=True, scale=True)`. Blender keeps FBX-imported objects at scale 0.01 — exporting without applying writes per-node 0.01 scales, and *nested* ones multiply (a child hit 0.0001 = invisible in Unity). +4. Export: `use_selection, object_types={'MESH'}, apply_unit_scale=True, apply_scale_options='FBX_SCALE_NONE', bake_space_transform=True, path_mode='STRIP', add_leaf_bones=False` → arrives exactly like a Synty original (`fileScale=0.01`, nodes at 1, meter-true bounds). `path_mode='COPY'` duplicates the atlas into `.fbm/` — don't. +5. Unity import: `materialImportMode=None`, `addCollider=false` (a Default-layer collider would join the baked PhysicsWorld), assign the pack `.mat` (all packs share ONE ShaderGraph: `PolygonGeneric/Shaders/Generic_Basic` — URP-native, EG-verified). +6. Blender viewport screenshots are BLACK when the window isn't drawing — render EEVEE to a file instead. Unity-side visual check: `AssetPreview.GetAssetPreview` kicked in one `execute_code` call, polled + Blit + `EncodeToPNG` in the next. + +Pipeline reference demo: `Assets/_Project/Art/Experiments/` (gattling-gun twin kitbash). + +## Shipped assets (both in `Assets/_Project/Art/Structures/`) + +**`SM_Fabricator_01.fbx`** — 1-cell industrial converter, 1426 verts, ONE material slot (PolygonSciFiSpace atlas). Kitbash: Detail_Machine hull + half-sunk Detail_Vent drum + Detail_Tank (ore in) + Battery pack w/ orange cells (Charge out) + Screen_Small + AirVent. ~1.34×1.04×1.10 m. +- **Wired into `Fabricator.prefab` in place** (root mesh+material swap, root scale 2.5→**1.0**; GUID preserved, GhostAuthoring/FabricatorAuthoring untouched — recipe 1 Ore→3 Charge/30t intact). Replaces the BefourStudios battery placeholder that overhung ~2.5 cells and sank 0.75 m underground. Placed visual now matches the 1 m preview-cube footprint. + +**`SM_AwakeningCore_01.fbx`** — the base centerpiece, TWO meshes so materials stay independent: +- `SM_AwakeningCore_Machine` (2808 v, SciFiSpace atlas — `PolygonScifiSpace_01_A.mat`): octagonal plinth (custom mesh, UV-pinned grey) + 3 upright Veh_Part_Engine exhaust stacks + shrunken Engine_Construction "awakening rig" facing outward. +- `SM_AwakeningCore_Crystals` (355 v): DungeonRealms crystal clusters + accents → assigned existing `Mat_EngineCore_Aether` (keeps the established cyan-HDR-glow hero read; independently tintable for future CoreIntegrity feedback). +- **Wired into `Game.unity`**: old `EngineCore` PrefabInstance (bare crystal) replaced by an FBX-linked instance `AwakeningEngineCore` under `BaseBiome` at (0,0,0); `CoreBeaconLight` untouched; static flags copied. Footprint r≈1.6 m / height ≈4.3 m — inside the 2.5 m spawn ring and reads at the 3 m `CoreReachRadius`; collider-free on purpose (returning players teleport to exactly PlotCenter). + +## Validation + +Play boot (subscene re-baked after the prefab edit): director/player/catalog up; Fabricator ghost spawned server-side via catalog-entry instantiate (BuildPlaceSystem's position-only override pattern) → **replicated + rendered client-side** at cell (3.5, 2.5) beside Storage; Core renders with crystal bloom + visible machine plinth/stacks. Zero console errors/warnings for the whole session. + +## Round 2 (same day): Turret + Wall models + ACCURATE placement ghost + +**`SM_Turret_01.fbx`** (3914 v, 2 slots): PolygonMech gattling gun (body+barrels+ammo, handheld bits dropped) on a SciFiSpace machine-slab + finned-drum joint (family language with the Fabricator). 0.67×2.13×1.32 m — barrels overhang the cell like the old ballista did. Wired into `Turret.prefab`: old 6-part ballista "Model" subtree replaced by one MeshFilter/MeshRenderer on the `Model` child (scale 0.8→1), mats `[PolygonScifiSpace_01_A, PolygonMech_01_A]`; root BoxCollider 0.8×1.2×0.8 / layer 9 / Health/Destructible untouched. + +**`SM_Wall_01.fbx`** (42 v, 2 slots): two mirrored PolygonGeneric `SM_Bld_Base_Pillar_Half_01` end caps + custom energy panel + base rail (UV-pinned to a Generic-atlas grey texel). Panel slot gets **`Mat_StructureOwned_Cyan`** → the wall reads as a deployable cyan energy barrier, keeping the owned-structure identity. Long axis Z matches the old collider (0.59×0.6×0.98, untouched). Wired into `Wall.prefab` the same way (Model scale 1.2→1). + +**Accurate build ghost** (`BuildSendSystem` + `HudTheme`): the palette ghost now renders the SELECTED structure's real mesh tinted translucent green/red instead of the generic cube. +- `HudTheme` gained `TurretGhostMesh/WallGhostMesh/FabricatorGhostMesh` (+ `StructureGhostMesh(byte)`) — serialized mesh refs in `HudTheme.asset` (build-safe, DR-024 pattern). +- `ShowGhost(center, cellSize, valid, type)` + `ApplyGhostMesh`: preview meshes are authored real-size/ground-pivot → position = cell center, scale 1; one `_ghostMat` per submesh so multi-slot meshes tint whole; **cube fallback preserved** for types without a mesh (no selection = 0 → cube). Rotation is a non-issue: `Direction` only ever mattered for conveyors (palette-trimmed). +- Edits via MCP `apply_text_edits` (sha-chained, one edit per call, bottom-first). + +**★ NEW GOTCHA — scene PrefabInstance of a raw FBX is reimport-fragile:** the AwakeningCore was first placed as an FBX PrefabInstance; a later `refresh_unity force` shifted the FBX's internal hierarchy fileIDs and the instance **silently dropped its Crystals child** (machine survived, crystal invisible in Play — caught only by the second Play screenshot). Scene YAML + FBX asset both looked intact; the binding was the casualty. **Fix + rule: reference mesh SUB-ASSETS directly** (fileID 4300000-series, name-stable across reimports — exactly what the structure prefabs do) from plain GameObjects or project prefabs; don't scene-instance raw multi-object FBX assets. Core rebuilt as plain `AwakeningEngineCore` GO + `CoreMachine`/`CoreCrystals` children with direct mesh refs. + +**Validation round 2:** Play boot → spawned Turret/Wall/Fabricator row via catalog + teleport; all three render client-side; Core crystal back. Ghost preview forced on-screen (system disabled + reflection-invoked `ShowGhost`): green turret mesh at a free cell, red wall mesh on an occupied cell — both captured. **410/410 EditMode tests pass**; console clean (only unfocused-editor tick-batching warnings). + +## Open follow-ups + +- Fabricator bakes **no Health/Destructible** (pre-existing divergence from DR-032 "machines can die") — flagged, not changed. +- Core visual is still static — a client observe-only system could dim/flicker the crystal from `CoreIntegrity` (the two-mesh split was designed for this). +- ~~Build-preview ghost stays a generic cube~~ **DONE (round 2)** — real meshes for Turret/Wall/Fabricator, cube fallback for the rest. +- Old ballista/spike-wall looks live only in these prefabs' git history; `HudTheme` icons (`TurretIcon` etc.) still show the old Synty sprite glyphs — fine, but a re-curation pass could match the new silhouettes. +- Blender asset integrations (PolyHaven/Sketchfab/Rodin/Hunyuan) still disabled in the addon N-panel; MCP-for-Unity v10 `generate_model`/`generate_image` (BYOK) untested. \ No newline at end of file diff --git a/Docs/Vault/07_Sessions/2026/2026-07-02_Expedition_Redesign_Build_Steps1-11.md b/Docs/Vault/07_Sessions/2026/2026-07-02_Expedition_Redesign_Build_Steps1-11.md new file mode 100644 index 000000000..f3eb5173e --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-07-02_Expedition_Redesign_Build_Steps1-11.md @@ -0,0 +1,46 @@ +--- +title: Expedition Redesign — Build session, Steps 1–11 of 14 (interim log) +date: 2026-07-02 +tags: +- session +- build +- expedition +- roguelite +- netcode +permalink: gamevault/07-sessions/2026/2026-07-02-expedition-redesign-build-steps1-11 +--- + +# Expedition Redesign — Build Steps 1–11 (interim session log) + +Building the [[2026-06-29_Expedition_Redesign_Build_Spec]] (§§1–6 core + §7 addendum) one system at a time, each validated (compile → EditMode → Play-smoke) before the next, per the operator's full-depth directive. **Steps 1–11 of 14 are BUILT + GREEN.** Suite: 444/444 EditMode (from 429 pre-session; 10 superseded tests retired with their systems). Zero console errors across every Play session. + +## What shipped (in build order) + +1. **`RunMap`/`RunMapMath`/`RoomPlan`/`RoomLayoutMath`** — deterministic branching DAG generator (integer-hash only, client-regenerable), room plans, shape scatter. 19 tests incl. reachability/single-boss/Elite-gate invariants. +2. **`RunInfo` (17 `[GhostField]`s) + `RunRuntime` + `RunDirectorSystem`** — the run FSM on the CycleDirector ghost (the ONE director re-bake, front-loaded with `MetaTierState`). `RegionMath.ExpeditionRoomOrigin(base, subSlot)` = the single coordinate authority (ping-pong slots, stride 500). Sort-cycle Play-validated. +3. **Ready-check** — `PlayerReady` (send-to-all) + `BoonOffer` (`SendToOwner`) on the player ghost (the ONE player re-bake); all 4 RPC wire structs declared up front; `ReadyToggleSystem`/`ReadySendSystem`; real all-ready derivation + un-ready countdown abort. Live wire path validated. +4. **`RoomTag` + `RoomTeardown`** — the type-agnostic room-scoped destroy (cross-room-wipe regression pinned). +5. **`RoomFieldSystem`** (replaced `ExpeditionFieldSystem`) — per-RoomEpoch scarce scatter, `NodeBudgetRemaining` spend-down (`Tuning.ExpeditionNodeBudget`=12/run), clutter cap, Staging sweep. Relevancy-hiding Play-validated. +6. **`RoomEnemyDirectorSystem`** (replaced `ZoneEnemyDirectorSystem`) — waves sized by `RoomPlan.DifficultyEpoch`, single scaled boss (HP×8, scale×1.6), objective Cleared latch above early-returns, Calm-gate deliberately dropped. +7. **Linear traversal + clear-gated bank** — objective consumed one-tick-late; teardown-at-RoomReward-entry (one-room-alive invariant, Play-asserted); boss-only Charge/RunsCompleted/retaliation credit; honest `MaxDepthReached`. Full loop Play-validated end-to-end (incl. an accidental wipe-abort self-validation: idle player died → clean no-credit abort). +8. **Branching route gate** *(review-gated: `wf_22770994-8e7`, GO with 4 must-fixes — all folded)* — `RouteSelect=5` state, `RouteSelectSystem` (in-place first-commit latch, N3 region gate), `RouteSendSystem`. **Key fix: `RouteSelectRequest.ForRunEpoch` RE-MEANED to carry `(int)RunInfo.RunSeed`** (the server-only RunEpoch is not client-knowable; the replicated seed is the run-identity token — zero wire churn). Predicate order abort→pick→grace. Live non-maskable proof: a client pick of option 2 entered col 2 (`NodeId(1,2)`). +9. **Boon catalog + offers** — `BoonCatalog` blob (code-default 12-boon table, designer-overridable rows; `BoonCatalogAuthoring` in the Gameplay subscene), `BoonMath.PickBoons` (deterministic weighted distinct class-filtered), `BoonOfferSystem` (per-player seeds fold NetworkId). Live: owner-only `SendToOwner` delivery confirmed working. +10. **Boon apply + the two-channel strip** — `BoonApplySystem` **`[UpdateBefore(RunDirectorSystem)]`** (deviation from the spec's after-ordering: all RPC receivers sit before the director; closes the D-F4 straggler race structurally) + grace auto-pick Option0; boon SourceId band `[0x00B00000, +0x10000)`; `TimedModifierUtil.RemoveBySourceIdRange`; the Returning-edge strip; `StatModifier` capacity 8→32 (no re-bake). Live: pick → CD 22→19 on BOTH worlds → return → 0 boon rows + CD 22 on both. +11. **Economy reshape + the coordinated churn** — BaseGate kept as a mesh-only staging landmark (authoring stripped); ReturnGate + BaseFieldSpawner GOs deleted; `ExpeditionGateSystem`/`ExpeditionGate`/`ExpeditionGateAuthoring`/`BaseFieldSpawnSystem`/`BaseFieldSpawner`/`BaseFieldSpawnerAuthoring`/`AbilityUpgradeSystem`/`AbilityUpgradeRequest` all deleted; **`ThreatDirectorSystem` repointed `[UpdateAfter(RunDirectorSystem)]`** (D-F1); `BuildSendSystem`/`HudSystem`/`HowToPlayPanel` co-stripped (R-F3); onboarding gate-pointer hidden (re-pointed at READY in Step 14); node rarity Ore45/Biomass40/**Aether15**. Live: 0 base nodes, grubstake `ledger[Ore]=50`, no sort-cycle. + +## Ordering map (server, all plain group — no CyclePhase edge anywhere in the room chain) +`ReadyToggle / RouteSelect / BoonApply → RunDirector → RoomField / RoomEnemyDirector / BoonOffer` · `RunDirector → ThreatDirector → CyclePhase → GoalReached`. + +## Churn ledger +Director ghost re-bake (Step 2, incl. `MetaTierState`) · player ghost re-bake (Step 3) · RpcCollection +4/−1 (declared Step 3, retired Step 11) · Gameplay subscene edits (BoonCatalog added; gates/spawner removed) · SaveData still v5 (v6 lands Step 12b). + +## Environment fix +Unfocused-editor stalls killed: **Interaction Mode → No Throttling** (EditorPref, set programmatically) + `runInBackground` already on. Editor now fully responsive unfocused; only Burst-heavy recompiles still prefer focus. + +## Remaining +- **12a/12b/13 (permanent meta)** — review-gated; pre-code review running (`wf_f920d50c-abb`). +- **14 (HUD)** — ready panel, Room i/N, boon modal, branching map panel, meta shop, biome cross-fade, onboarding re-point. +- Post-impl diff review + DR + final doc bookend + commit offer after 14. + +## Open operator items (standing defaults in play) +Route authority = any-player-first-commits · un-picked boon = auto-Option0 · launch = 3-2-1 countdown · run length seed-varied [6,10] · meta = shared per-class pool, flat catalog v1 · dead-respawned members can't route-pick but are re-conscripted on advance (documented; surface after the fun-gate playtest). diff --git a/Packages/manifest.json b/Packages/manifest.json index 9dd490706..282c7a006 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -6,6 +6,7 @@ "com.unity.ai.navigation": "2.0.13", "com.unity.charactercontroller": "1.4.2", "com.unity.cinemachine": "3.1.7", + "com.unity.cloud.gltfast": "6.19.0", "com.unity.collab-proxy": "2.12.4", "com.unity.entities": "6.5.0", "com.unity.entities.graphics": "6.5.0", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 373174328..829d4da53 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -15,7 +15,7 @@ "com.unity.nuget.newtonsoft-json": "3.0.2", "com.unity.test-framework": "1.1.31" }, - "hash": "85c101f5329ec1b0c6f70cba44614166dd78f53c" + "hash": "11836003a5e2ffcb7715ecec7e1fbb9d9cdb5bb8" }, "com.rukhanka.animation": { "version": "file:com.rukhanka.animation", @@ -113,6 +113,19 @@ }, "url": "https://packages.unity.com" }, + "com.unity.cloud.gltfast": { + "version": "6.19.0", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.burst": "1.8.29", + "com.unity.collections": "2.6.6", + "com.unity.mathematics": "1.3.3", + "com.unity.modules.jsonserialize": "1.0.0", + "com.unity.modules.unitywebrequest": "1.0.0" + }, + "url": "https://packages.unity.com" + }, "com.unity.collab-proxy": { "version": "2.12.4", "depth": 0, diff --git a/ProjectSettings/ShaderGraphSettings.asset b/ProjectSettings/ShaderGraphSettings.asset index ce8c24328..e33ca2483 100644 --- a/ProjectSettings/ShaderGraphSettings.asset +++ b/ProjectSettings/ShaderGraphSettings.asset @@ -2,7 +2,7 @@ %TAG !u! tag:unity3d.com,2011: --- !u!114 &1 MonoBehaviour: - m_ObjectHideFlags: 61 + m_ObjectHideFlags: 53 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} @@ -11,8 +11,8 @@ MonoBehaviour: m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: de02f9e1d18f588468e474319d09a723, type: 3} m_Name: - m_EditorClassIdentifier: - shaderVariantLimit: 128 + m_EditorClassIdentifier: Unity.ShaderGraph.Editor::UnityEditor.ShaderGraph.ShaderGraphProjectSettings + shaderVariantLimit: 2048 overrideShaderVariantLimit: 0 customInterpolatorErrorThreshold: 32 customInterpolatorWarningThreshold: 16