diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc index 168f00ca4..91f465eca 100644 Binary files a/.voidbot/state/aetheria.cc and b/.voidbot/state/aetheria.cc differ diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index c4bd71fcf..1ef50e0ea 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -436,6 +436,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -457,9 +459,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &23420360 MeshFilter: @@ -701,6 +705,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -722,9 +728,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &45636168 MeshFilter: @@ -1137,6 +1145,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -1597,6 +1606,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -1821,6 +1831,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -2497,6 +2508,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -2518,9 +2531,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &195655953 MeshFilter: @@ -2885,6 +2900,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -3410,6 +3426,7 @@ MonoBehaviour: InfiniteAmmoIcon: {fileID: 2044077503} CooldownFill: {fileID: 757740693} HeatFill: {fileID: 2060807771} + SelectedIndicator: {fileID: 0} --- !u!114 &232470601 MonoBehaviour: m_ObjectHideFlags: 0 @@ -3835,6 +3852,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -4117,6 +4135,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -4138,9 +4158,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &259676750 MeshFilter: @@ -4843,6 +4865,7 @@ RectTransform: m_Children: - {fileID: 2352149240727418666} - {fileID: 410701128} + - {fileID: 568886950} - {fileID: 1558520822} - {fileID: 1396909587} - {fileID: 1556666354} @@ -5153,6 +5176,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -5289,6 +5313,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -5695,6 +5720,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -7714,6 +7740,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -8358,6 +8385,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -8732,6 +8760,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -8753,9 +8783,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &542243549 MeshFilter: @@ -9080,6 +9112,82 @@ RectTransform: type: 3} m_PrefabInstance: {fileID: 2279630454421103832} m_PrefabAsset: {fileID: 0} +--- !u!1 &568886949 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 568886950} + - component: {fileID: 568886952} + - component: {fileID: 568886951} + m_Layer: 5 + m_Name: Debug Info Panel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &568886950 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + 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: 1731988778} + m_Father: {fileID: 346000247} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 16, y: -800} + m_SizeDelta: {x: 220, y: 120} + m_Pivot: {x: 0, y: 0} +--- !u!114 &568886951 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 2100000, guid: 46238d909c2092c4eb873278d84c6ce8, type: 2} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 0 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 84d126f984de5fd42ac66361c00f830c, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 0.5 +--- !u!222 &568886952 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_CullTransparentMesh: 0 --- !u!1 &569895571 GameObject: m_ObjectHideFlags: 0 @@ -10233,9 +10341,6 @@ MonoBehaviour: TintDisplay: {fileID: 218215088} InfluenceDisplay: {fileID: 1390179643} Scale: 1 - Position: - x: 0 - y: 0 IconSize: 32 --- !u!1001 &642590526 PrefabInstance: @@ -11058,6 +11163,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -11079,9 +11186,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &677960094 MeshFilter: @@ -11394,6 +11503,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -11512,6 +11622,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -11533,9 +11645,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &708399897 MeshFilter: @@ -12046,6 +12160,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13167,6 +13282,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13434,6 +13550,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13738,6 +13855,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14206,6 +14324,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14343,6 +14462,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14510,6 +14630,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -14531,9 +14653,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &926083405 MeshFilter: @@ -14699,6 +14823,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14927,6 +15052,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -15094,6 +15220,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -15115,9 +15243,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &936330464 MeshFilter: @@ -15244,6 +15374,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -15265,9 +15397,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &945347194 MeshFilter: @@ -16545,9 +16679,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Settings: {fileID: 11400000, guid: cd2344e2001a37544aab0960c164d4ff, type: 2} - Sensitivity: - x: -0.001 - y: 0.001 Credits: 100000000 TargetSpottedBlinkFrequency: 20 TargetSpottedBlinkOffset: -0.25 @@ -16594,6 +16725,7 @@ MonoBehaviour: HitMarkerDuration: 0.25 SchematicDisplay: {fileID: 7628189704109037584} TargetSchematicDisplay: {fileID: 410701129} + DebugInfoText: {fileID: 1731988779} TargetIndicator: {fileID: 1556666357} TargetHitpointsFill: {fileID: 1691348884} TargetVisibilityFill: {fileID: 1715434759} @@ -16997,6 +17129,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -17431,6 +17564,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -17567,6 +17701,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -18347,6 +18482,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -18759,6 +18895,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -19045,6 +19182,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -19066,9 +19205,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1163671429 MeshFilter: @@ -21015,6 +21156,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -21635,6 +21777,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -21656,9 +21800,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1390575965 MeshFilter: @@ -21855,6 +22001,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22054,6 +22201,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22240,6 +22388,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22746,6 +22895,7 @@ MonoBehaviour: InfiniteAmmoIcon: {fileID: 0} CooldownFill: {fileID: 0} HeatFill: {fileID: 0} + SelectedIndicator: {fileID: 0} --- !u!114 &1462704894 MonoBehaviour: m_ObjectHideFlags: 0 @@ -23011,6 +23161,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -23237,6 +23388,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -23258,9 +23411,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1476765551 MeshFilter: @@ -23428,6 +23583,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -23824,6 +23980,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -24490,6 +24647,11 @@ PrefabInstance: propertyPath: m_firstOverflowCharacterIndex value: 0 objectReference: {fileID: 0} + - target: {fileID: 1713457305180270767, guid: 2758dfa2744d7ad4d8877545189d116e, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 2297648055057363568, guid: 2758dfa2744d7ad4d8877545189d116e, type: 3} propertyPath: m_Pivot.x @@ -24931,6 +25093,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -25211,6 +25374,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -25232,9 +25397,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1566772338 MeshFilter: @@ -25567,6 +25734,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 50 m_lineSpacingMax: 0 @@ -25710,6 +25878,11 @@ PrefabInstance: propertyPath: m_firstOverflowCharacterIndex value: 0 objectReference: {fileID: 0} + - target: {fileID: 1713457305180270767, guid: 2758dfa2744d7ad4d8877545189d116e, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 2297648055057363568, guid: 2758dfa2744d7ad4d8877545189d116e, type: 3} propertyPath: m_Pivot.x @@ -26204,6 +26377,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -26715,6 +26889,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -26962,6 +27137,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -26983,9 +27160,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1713745167 MeshFilter: @@ -27473,6 +27652,151 @@ MonoBehaviour: TabButtons: {fileID: 1783772015} ActiveTabColor: {r: 1, g: 0.44583336, b: 0.050000012, a: 1} InactiveTabColor: {r: 0.6981132, g: 0.6981132, b: 0.6981132, a: 1} +--- !u!1 &1731988777 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1731988778} + - component: {fileID: 1731988780} + - component: {fileID: 1731988779} + m_Layer: 5 + m_Name: Debug Info Text + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1731988778 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + 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: 568886950} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 2, y: 2} + m_SizeDelta: {x: -20, y: -20} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1731988779 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: 'New TextNew TextNew Text + + New TextNew TextNew TextNew Text + + New + TextNew Text + + New TextNew Text' + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 53555f7a2d5e745428813f1b1118734f, type: 2} + m_sharedMaterial: {fileID: -2804126062856497447, guid: 53555f7a2d5e745428813f1b1118734f, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 1, g: 1, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 15.85 + m_fontSizeBase: 12 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 8 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 1 + m_VerticalAlignment: 1024 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_characterHorizontalScale: 1 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!222 &1731988780 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_CullTransparentMesh: 1 --- !u!1 &1733988258 GameObject: m_ObjectHideFlags: 0 @@ -27569,6 +27893,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -27928,6 +28253,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -28979,6 +29305,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -29000,9 +29328,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1857285688 MeshFilter: @@ -30080,6 +30410,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31056,6 +31387,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31112,6 +31444,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -31133,9 +31467,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!1 &2095699056 GameObject: @@ -31368,6 +31704,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31708,6 +32045,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -31729,9 +32068,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &2117796868 MeshFilter: @@ -31892,6 +32233,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -32028,6 +32370,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33170,6 +33513,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33270,6 +33614,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33590,6 +33935,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33719,6 +34065,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33819,6 +34166,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34030,6 +34378,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34369,6 +34718,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34506,6 +34856,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34625,6 +34976,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34780,6 +35132,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34917,6 +35270,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35222,6 +35576,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35358,6 +35713,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35457,6 +35813,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35631,6 +35988,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35787,6 +36145,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37131,6 +37490,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37577,6 +37937,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37888,6 +38249,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 diff --git a/Assets/Scenes/FieldShieldTest.unity b/Assets/Scenes/FieldShieldTest.unity index b1b2e50b9..51c915b7f 100644 --- a/Assets/Scenes/FieldShieldTest.unity +++ b/Assets/Scenes/FieldShieldTest.unity @@ -13,7 +13,7 @@ OcclusionCullingSettings: --- !u!104 &2 RenderSettings: m_ObjectHideFlags: 0 - serializedVersion: 9 + serializedVersion: 10 m_Fog: 0 m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} m_FogMode: 3 @@ -38,13 +38,12 @@ RenderSettings: m_ReflectionIntensity: 1 m_CustomReflection: {fileID: 0} m_Sun: {fileID: 0} - m_IndirectSpecularColor: {r: 0.023674645, g: 0.04639011, b: 0.122535154, a: 1} m_UseRadianceAmbientProbe: 0 --- !u!157 &3 LightmapSettings: m_ObjectHideFlags: 0 - serializedVersion: 12 - m_GIWorkflowMode: 1 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 m_GISettings: serializedVersion: 2 m_BounceScale: 1 @@ -67,9 +66,6 @@ LightmapSettings: m_LightmapParameters: {fileID: 0} m_LightmapsBakeMode: 1 m_TextureCompression: 1 - m_FinalGather: 0 - m_FinalGatherFiltering: 1 - m_FinalGatherRayCount: 256 m_ReflectionCompression: 2 m_MixedBakeMode: 2 m_BakeBackend: 1 @@ -104,7 +100,7 @@ NavMeshSettings: serializedVersion: 2 m_ObjectHideFlags: 0 m_BuildSettings: - serializedVersion: 2 + serializedVersion: 3 agentTypeID: 0 agentRadius: 0.5 agentHeight: 2 @@ -117,7 +113,7 @@ NavMeshSettings: cellSize: 0.16666667 manualTileSize: 0 tileSize: 256 - accuratePlacement: 0 + buildHeightMesh: 0 maxJobWorkers: 0 preserveTilesOutsideBounds: 0 debug: @@ -155,7 +151,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 5 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} @@ -296,7 +291,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 3 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -308,6 +302,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 815358592} m_Modifications: - target: {fileID: 257019179550429270, guid: 426f9dcb510530d4dbfa678c9f727ec1, @@ -360,6 +355,11 @@ PrefabInstance: propertyPath: m_text value: Aetheric Field Tester objectReference: {fileID: 0} + - target: {fileID: 7131821417072393581, guid: 426f9dcb510530d4dbfa678c9f727ec1, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 7131821417165215474, guid: 426f9dcb510530d4dbfa678c9f727ec1, type: 3} propertyPath: m_Enabled @@ -481,6 +481,9 @@ PrefabInstance: value: Properties objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 426f9dcb510530d4dbfa678c9f727ec1, type: 3} --- !u!114 &401779935 stripped MonoBehaviour: @@ -505,6 +508,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 815358592} m_Modifications: - target: {fileID: 9194050151789067659, guid: 5c3a7b786419db74a8c1d17039fd4f67, @@ -663,6 +667,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 5c3a7b786419db74a8c1d17039fd4f67, type: 3} --- !u!224 &415327856 stripped RectTransform: @@ -713,7 +720,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1112533349} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 180} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -812,13 +818,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 699508825} + 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_RootOrder: 5 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &815358588 GameObject: @@ -896,7 +902,9 @@ Canvas: m_OverrideSorting: 0 m_OverridePixelPerfect: 0 m_SortingBucketNormalizedSize: 0 + m_VertexColorAlwaysGammaSpace: 0 m_AdditionalShaderChannelsFlag: 25 + m_UpdateRectTransformForStandalone: 0 m_SortingLayerID: 0 m_SortingOrder: 0 m_TargetDisplay: 0 @@ -919,7 +927,6 @@ RectTransform: - {fileID: 415327856} - {fileID: 87891009} m_Father: {fileID: 0} - m_RootOrder: 3 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} @@ -931,6 +938,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 0} m_Modifications: - target: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, @@ -1008,7 +1016,39 @@ PrefabInstance: propertyPath: m_LocalEulerAnglesHint.z value: 0 objectReference: {fileID: 0} + - target: {fileID: 2212592724187910978, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + propertyPath: m_Enabled + value: 0 + objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026487} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026488} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026489} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026490} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026492} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026491} m_SourcePrefab: {fileID: 100100000, guid: fe47fadcdb5ae5c4caca516b4af6fde6, type: 3} --- !u!1 &909026486 stripped GameObject: @@ -1024,9 +1064,17 @@ MeshCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 909026486} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 4 + serializedVersion: 5 m_Convex: 1 m_CookingOptions: 30 m_Mesh: {fileID: -5495902117074765545, guid: dd6644c1aa6c16c47bb9fea9822ce805, type: 3} @@ -1043,11 +1091,9 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Camera: {fileID: 1153356615} + FrontTwist: 0 + RearTwist: 0 TestMagnitude: 0.5 - Throttle: - x: 0 - y: 0 - ThrottleDecay: 5 FlowSpeed: 2 FlowSpeedThrottleExponent: 1 WaveThrottleExponent: 0 @@ -1084,8 +1130,8 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 52538a3309c36fc4583c964a1c0b705f, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: --- !u!114 &909026490 MonoBehaviour: m_ObjectHideFlags: 0 @@ -1096,8 +1142,40 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: d8f4aa77146e46a2a4b28d9fefb98496, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: +--- !u!114 &909026491 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 909026486} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fdca6a832d764b6e89fc83fdcf7b42d7, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldInterceptor + Envelope: {fileID: 909026492} + PanelPrototype: {fileID: 1087039186} + MaxLivePanels: 12 + PanelRadius: 2 + EnergyScale: 1 + ReuseFraction: 1 + DeathMargin: 0.25 + PatternByDamageType: +--- !u!114 &909026492 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 909026486} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e25c67ee83c01364fb121caeda952606, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldEnvelope --- !u!1 &1012179276 GameObject: m_ObjectHideFlags: 0 @@ -1138,13 +1216,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1012179276} + 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_RootOrder: 4 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1012179280 MonoBehaviour: @@ -1186,11 +1264,14 @@ MonoBehaviour: type: 3} m_DeselectOnBackgroundClick: 1 m_PointerBehavior: 0 + m_CursorLockBehavior: 0 + m_ScrollDeltaPerTick: 6 --- !u!1001 &1080184620 PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 0} m_Modifications: - target: {fileID: 105552333169325610, guid: 4a3db6090789ca24cb57c43666754017, @@ -1275,7 +1356,101 @@ PrefabInstance: objectReference: {fileID: 0} m_RemovedComponents: - {fileID: 7577392600424441103, guid: 4a3db6090789ca24cb57c43666754017, type: 3} + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 4a3db6090789ca24cb57c43666754017, type: 3} +--- !u!1 &1087039185 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1087039188} + - component: {fileID: 1087039187} + - component: {fileID: 1087039186} + m_Layer: 0 + m_Name: Panel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!114 &1087039186 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 1281fe44325f9b548aa42dc331b17266, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::Prototype +--- !u!114 &1087039187 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c1b180b85c234a4488cf0662b75ac77f, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldField.ShieldPanel + tiling: 3 + panelRadius: 5 + cellSize: 0.35 + mergeRhombs: 1 + takeDual: 0 + seed: 1337 + sim: {fileID: 7200000, guid: 1d17c100121b5ba47af9c75de451a2f1, type: 3} + panelShader: {fileID: 4800000, guid: 6a7f034931432c2418a0f0f0b9af0b93, type: 3} + growDuration: 0.35 + wavefrontSpeed: 8 + delayJitter: 0.06 + waveSpeed2: 26 + damping: 1.6 + stressClamp: 4 + substeps: 6 + fixedSubstepDt: 0.0025 + echoDelaySteps: 5 + echoReflect: -0.65 + tensileStrength: 0.9 + temperInit: 0.55 + temperErosion: 2.2 + storedEnergyGain: 0.8 + damageGain: 6 + breakAt: 0.75 + crackStart: 0.18 + breakBudgetFraction: 0.02 + radialKick: 1.6 + lateralKick: 2.2 + normalKick: 1.1 + spinKick: 7 + shardLife: 2.5 + shardDrag: 0.7 + maxShards: 6 + conduitGain: 2.5 +--- !u!4 &1087039188 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + 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 &1112013867 GameObject: m_ObjectHideFlags: 0 @@ -1300,6 +1475,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1112013867} + serializedVersion: 2 m_LocalRotation: {x: 0.05948526, y: -0.910067, z: 0.14047916, w: 0.38536373} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -1307,7 +1483,6 @@ Transform: m_Children: - {fileID: 1153356611} m_Father: {fileID: 0} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 17.55, y: -134.1, z: 0} --- !u!114 &1112013869 MonoBehaviour: @@ -1353,7 +1528,6 @@ RectTransform: m_Children: - {fileID: 602735389} m_Father: {fileID: 815358592} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 1, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1387,13 +1561,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1153356610} + serializedVersion: 2 m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} m_LocalPosition: {x: 0, y: 0, z: -50} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1112013868} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1153356612 MonoBehaviour: @@ -1492,9 +1666,17 @@ Camera: m_projectionMatrixMode: 1 m_GateFitMode: 2 m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 m_SensorSize: {x: 36, y: 24} m_LensShift: {x: 0, y: 0} - m_FocalLength: 50 m_NormalizedViewPortRect: serializedVersion: 2 x: 0 @@ -1564,9 +1746,17 @@ BoxCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1424996955} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 1 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Size: {x: 1, y: 1, z: 1} m_Center: {x: 0, y: 0, z: 0} --- !u!4 &1424996958 @@ -1576,13 +1766,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1424996955} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: -1.4908371, y: -143.55145, z: 0.8193531} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1618381683 GameObject: @@ -1617,7 +1807,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1691,3 +1880,15 @@ CanvasRenderer: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1618381683} m_CullTransparentMesh: 1 +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 909026485} + - {fileID: 1424996958} + - {fileID: 1112013868} + - {fileID: 815358592} + - {fileID: 1012179279} + - {fileID: 699508827} + - {fileID: 1080184620} + - {fileID: 1087039188} diff --git a/Assets/Scripts/AetheriaInput.cs b/Assets/Scripts/AetheriaInput.cs index c6eb618c2..938444fef 100644 --- a/Assets/Scripts/AetheriaInput.cs +++ b/Assets/Scripts/AetheriaInput.cs @@ -162,6 +162,16 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable ""initialStateCheck"": false, ""priority"": 0 }, + { + ""name"": ""Cycle Target Item"", + ""type"": ""Button"", + ""id"": ""3f9a2f0a-6e3f-4a53-9f5e-1a9c1e6d9f7b"", + ""expectedControlType"": ""Button"", + ""processors"": """", + ""interactions"": """", + ""initialStateCheck"": false, + ""priority"": 0 + }, { ""name"": ""Toggle Heatsinks"", ""type"": ""Button"", @@ -409,6 +419,17 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable ""isComposite"": false, ""isPartOfComposite"": false }, + { + ""name"": """", + ""id"": ""8c2d5e77-4b18-4f0b-9a2e-6d3f7c1a5e90"", + ""path"": ""/j"", + ""interactions"": """", + ""processors"": """", + ""groups"": ""Keyboard&Mouse"", + ""action"": ""Cycle Target Item"", + ""isComposite"": false, + ""isPartOfComposite"": false + }, { ""name"": """", ""id"": ""71ac6088-1e18-4810-86be-1179918dd4a2"", @@ -1178,6 +1199,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable m_Player_TargetPrevious = m_Player.FindAction("Target Previous", throwIfNotFound: true); m_Player_TargetNext = m_Player.FindAction("Target Next", throwIfNotFound: true); m_Player_TargetNearest = m_Player.FindAction("Target Nearest", throwIfNotFound: true); + m_Player_CycleTargetItem = m_Player.FindAction("Cycle Target Item", throwIfNotFound: true); m_Player_ToggleHeatsinks = m_Player.FindAction("Toggle Heatsinks", throwIfNotFound: true); m_Player_ToggleShield = m_Player.FindAction("Toggle Shield", throwIfNotFound: true); m_Player_ToggleStance = m_Player.FindAction("Toggle Stance", throwIfNotFound: true); @@ -1294,6 +1316,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable private readonly InputAction m_Player_TargetPrevious; private readonly InputAction m_Player_TargetNext; private readonly InputAction m_Player_TargetNearest; + private readonly InputAction m_Player_CycleTargetItem; private readonly InputAction m_Player_ToggleHeatsinks; private readonly InputAction m_Player_ToggleShield; private readonly InputAction m_Player_ToggleStance; @@ -1342,6 +1365,10 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// public InputAction @TargetNearest => m_Wrapper.m_Player_TargetNearest; /// + /// Provides access to the underlying input action "Player/CycleTargetItem". + /// + public InputAction @CycleTargetItem => m_Wrapper.m_Player_CycleTargetItem; + /// /// Provides access to the underlying input action "Player/ToggleHeatsinks". /// public InputAction @ToggleHeatsinks => m_Wrapper.m_Player_ToggleHeatsinks; @@ -1420,6 +1447,9 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable @TargetNearest.started += instance.OnTargetNearest; @TargetNearest.performed += instance.OnTargetNearest; @TargetNearest.canceled += instance.OnTargetNearest; + @CycleTargetItem.started += instance.OnCycleTargetItem; + @CycleTargetItem.performed += instance.OnCycleTargetItem; + @CycleTargetItem.canceled += instance.OnCycleTargetItem; @ToggleHeatsinks.started += instance.OnToggleHeatsinks; @ToggleHeatsinks.performed += instance.OnToggleHeatsinks; @ToggleHeatsinks.canceled += instance.OnToggleHeatsinks; @@ -1476,6 +1506,9 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable @TargetNearest.started -= instance.OnTargetNearest; @TargetNearest.performed -= instance.OnTargetNearest; @TargetNearest.canceled -= instance.OnTargetNearest; + @CycleTargetItem.started -= instance.OnCycleTargetItem; + @CycleTargetItem.performed -= instance.OnCycleTargetItem; + @CycleTargetItem.canceled -= instance.OnCycleTargetItem; @ToggleHeatsinks.started -= instance.OnToggleHeatsinks; @ToggleHeatsinks.performed -= instance.OnToggleHeatsinks; @ToggleHeatsinks.canceled -= instance.OnToggleHeatsinks; @@ -1964,6 +1997,13 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// void OnTargetNearest(InputAction.CallbackContext context); /// + /// Method invoked when associated input action "Cycle Target Item" is either , or . + /// + /// + /// + /// + void OnCycleTargetItem(InputAction.CallbackContext context); + /// /// Method invoked when associated input action "Toggle Heatsinks" is either , or . /// /// diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs new file mode 100644 index 000000000..988fe15e7 --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +// New ship models own their presentation anchors. The catalog still owns the schematic and gameplay rules. +public static class ShipPrefabAuthoring +{ + public const string ModelDirectory = "Assets/Models/Ships/"; + public const string PrefabDirectory = "Assets/Content/Prefabs/Ships/"; + + private const string ShieldPath = "Assets/Content/Prefabs/Shield.prefab"; + private const string TractorPath = "Assets/Content/Prefabs/Tractor Beam.prefab"; + private const string PingPath = "Assets/Content/Prefabs/PingEffect.prefab"; + private const string ExplosionPath = "Assets/Prefabs/Fire & Explosion Effects/Prefabs/BigExplosion.prefab"; + private const string InvisiblePath = "Assets/Materials/Invisible.mat"; + private const string MapMaterialPath = "Assets/Materials/UI/Map Icons.mat"; + + [MenuItem("Aetheria/Build Ship Prefab From Selected Model")] + private static void BuildSelected() + { + var path = AssetDatabase.GetAssetPath(Selection.activeObject); + try { Debug.Log($"Ship prefab built: {Build(path)}"); } + catch (Exception ex) { Debug.LogError($"Ship prefab build failed for {path}: {ex.Message}"); } + } + + [MenuItem("Aetheria/Build Ship Prefab From Selected Model", true)] + private static bool CanBuildSelected() => IsShipModel(AssetDatabase.GetAssetPath(Selection.activeObject)); + + public static bool IsShipModel(string path) => + !string.IsNullOrEmpty(path) && path.StartsWith(ModelDirectory, StringComparison.Ordinal) && + path.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase); + + public static string Build(string modelPath) + { + if (!IsShipModel(modelPath)) throw new InvalidOperationException($"Expected an FBX in {ModelDirectory}"); + var model = AssetDatabase.LoadAssetAtPath(modelPath); + if (model == null) throw new InvalidOperationException($"Could not load model {modelPath}"); + + var shipName = System.IO.Path.GetFileNameWithoutExtension(modelPath); + var outputPath = PrefabDirectory + shipName + ".prefab"; + var instance = (GameObject)PrefabUtility.InstantiatePrefab(model); + if (instance == null) throw new InvalidOperationException($"Could not instantiate model {modelPath}"); + try + { + instance.name = shipName; + instance.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + Configure(instance); + PrefabUtility.SaveAsPrefabAsset(instance, outputPath, out var saved); + if (!saved) throw new InvalidOperationException($"Unity did not save {outputPath}"); + return outputPath; + } + finally { Object.DestroyImmediate(instance); } + } + + // Takes an imported model instance. Every ship-specific reference comes from its named hierarchy. + public static void Configure(GameObject root) + { + var nodes = root.GetComponentsInChildren(true); + var named = new Dictionary(StringComparer.Ordinal); + foreach (var node in nodes) + { + if (!Reserved(node.name)) continue; + if (!named.TryAdd(node.name, node)) + throw new InvalidOperationException($"Duplicate reserved name {node.name}"); + } + + Transform Required(string name) => named.TryGetValue(name, out var found) + ? found : throw new InvalidOperationException($"Missing {name}"); + + var mapIcon = Required("SHIP.MapIcon").GetComponent(); + if (mapIcon == null) throw new InvalidOperationException("SHIP.MapIcon needs a mesh renderer"); + var hull = Required("SHIP.HullCollider"); + var hullFilter = hull.GetComponent(); + var hullMesh = hullFilter != null ? hullFilter.sharedMesh : null; + if (hullMesh == null) throw new InvalidOperationException("SHIP.HullCollider needs a mesh"); + var shieldMarker = Required("SHIP.Shield"); + var tractorMarker = Required("SHIP.TractorBeam"); + if (!PositiveScale(shieldMarker.localScale)) + throw new InvalidOperationException("SHIP.Shield scale must have three positive radii"); + + var shieldPrefab = LoadComponent(ShieldPath); + var tractorPrefab = LoadComponent(TractorPath); + var ping = LoadComponent(PingPath); + var explosion = LoadAsset(ExplosionPath); + var invisible = LoadAsset(InvisiblePath); + var mapMaterial = LoadAsset(MapMaterialPath); + var minimapLayer = LayerMask.NameToLayer("Minimap"); + var combatLayer = LayerMask.NameToLayer("Combat"); + if (minimapLayer < 0 || combatLayer < 0) throw new InvalidOperationException("Minimap or Combat layer is missing"); + + var equipment = new List(); + var thrusters = new List(); + var weapons = new List(); + var radiators = new List(); + var pivots = new List(); + foreach (var pair in named.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + var name = pair.Key; + var node = pair.Value; + if (name.StartsWith("HP.", StringComparison.Ordinal)) + { + var parts = name.Split('.'); + if (parts.Length != 3 || string.IsNullOrWhiteSpace(parts[2])) + throw new InvalidOperationException($"Invalid hardpoint name {name}"); + equipment.Add(node); + switch (parts[1]) + { + case "Equipment": break; + case "Thruster": + var emitter = DirectChild(node, "Emitter")?.GetComponent(); + if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child"); + AddOrGet(node.gameObject).Emitter = emitter; + thrusters.Add(node.GetComponent()); + break; + case "Weapon": + var muzzles = node.Cast() + .Where(x => x.name.StartsWith("Muzzle.", StringComparison.Ordinal)) + .Select(x => (node: x, number: MuzzleNumber(x.name))) + .OrderBy(x => x.number).ToArray(); + if (muzzles.Length == 0 || muzzles.Select(x => x.number).Distinct().Count() != muzzles.Length) + throw new InvalidOperationException($"{name} needs uniquely numbered Muzzle. children"); + AddOrGet(node.gameObject).FiringPoint = muzzles.Select(x => x.node).ToArray(); + weapons.Add(node.GetComponent()); + break; + case "Radiator": + var mesh = DirectChild(node, "RadiatorMesh")?.GetComponent(); + if (mesh == null) throw new InvalidOperationException($"{name} needs a RadiatorMesh child"); + AddOrGet(node.gameObject).Mesh = mesh; + radiators.Add(node.GetComponent()); + break; + default: throw new InvalidOperationException($"Unknown hardpoint role in {name}"); + } + } + else if (name.StartsWith("Pivot.", StringComparison.Ordinal)) + { + var values = name.Split('.'); + if (values.Length != 7) throw new InvalidOperationException($"Invalid pivot name {name}"); + var pivot = AddOrGet(node.gameObject); + pivot.Group = ParseInt(values[1], name); + pivot.YawMin = ParseInt(values[2], name); + pivot.YawMax = ParseInt(values[3], name); + pivot.PitchMin = ParseInt(values[4], name); + pivot.PitchMax = ParseInt(values[5], name); + pivot.Speed = ParseInt(values[6], name); + if (pivot.YawMin > pivot.YawMax || pivot.PitchMin > pivot.PitchMax || pivot.Speed <= 0) + throw new InvalidOperationException($"Invalid articulation range or speed in {name}"); + pivots.Add(pivot); + } + else if (name.StartsWith("SHIP.", StringComparison.Ordinal) && + name != "SHIP.MapIcon" && name != "SHIP.HullCollider" && + name != "SHIP.Shield" && name != "SHIP.TractorBeam") + throw new InvalidOperationException($"Unknown ship anchor {name}"); + } + if (equipment.Count == 0) throw new InvalidOperationException("Ship has no HP.* hardpoints"); + + mapIcon.sharedMaterial = mapMaterial; + mapIcon.gameObject.layer = minimapLayer; + hull.gameObject.layer = combatLayer; + var hullRenderer = hull.GetComponent(); + if (hullRenderer != null) hullRenderer.forceRenderingOff = true; + var collider = AddOrGet(hull.gameObject); + collider.sharedMesh = hullMesh; + collider.convex = true; + var hullSurface = AddOrGet(hull.gameObject); + + var shield = (ShieldManager)PrefabUtility.InstantiatePrefab(shieldPrefab, shieldMarker); + shield.transform.localPosition = Vector3.zero; + shield.transform.localRotation = Quaternion.identity; + if (shield.GetComponent() == null) shield.gameObject.AddComponent(); + var tractor = (TractorBeam)PrefabUtility.InstantiatePrefab(tractorPrefab, tractorMarker); + tractor.transform.localPosition = Vector3.zero; + tractor.transform.localRotation = Quaternion.identity; + + var ship = AddOrGet(root); + ship.MapIcon = mapIcon; + ship.InfluencePrefab = null; + ship.PingPrefab = ping; + ship.InvisibleMaterial = invisible; + ship.Shield = shield; + ship.HullColliders = new[] { hullSurface }; + ship.EquipmentHardpoints = equipment.ToArray(); + ship.ThrusterHardpoints = thrusters.ToArray(); + ship.WeaponHardpoints = weapons.ToArray(); + ship.RadiatorHardpoints = radiators.ToArray(); + ship.ArticulationPoints = pivots.ToArray(); + ship.DestroyEffect = explosion; + ship.TractorBeam = tractor; + } + + private static bool Reserved(string name) => name.StartsWith("HP.", StringComparison.Ordinal) || + name.StartsWith("Pivot.", StringComparison.Ordinal) || name.StartsWith("SHIP.", StringComparison.Ordinal); + + private static bool PositiveScale(Vector3 scale) => scale.x > 0 && scale.y > 0 && scale.z > 0; + + private static Transform DirectChild(Transform parent, string name) => + parent.Cast().FirstOrDefault(x => x.name == name); + + private static int MuzzleNumber(string name) + { + if (!int.TryParse(name.Substring("Muzzle.".Length), NumberStyles.None, CultureInfo.InvariantCulture, out var n)) + throw new InvalidOperationException($"Invalid muzzle name {name}"); + return n; + } + + private static int ParseInt(string part, string name) + { + if (!int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + throw new InvalidOperationException($"Invalid numeric field in {name}"); + return value; + } + + private static T AddOrGet(GameObject gameObject) where T : Component + { + var component = gameObject.GetComponent(); + return component != null ? component : gameObject.AddComponent(); + } + + private static T LoadAsset(string path) where T : Object + { + var asset = AssetDatabase.LoadAssetAtPath(path); + return asset != null ? asset : throw new InvalidOperationException($"Missing shared asset {path}"); + } + + private static T LoadComponent(string path) where T : Component + { + var prefab = LoadAsset(path); + var component = prefab.GetComponent(); + return component != null ? component : throw new InvalidOperationException($"{path} needs {typeof(T).Name}"); + } +} + +// Model import finishes before generated prefabs read its hierarchy. Invalid exports leave the last valid prefab. +public sealed class ShipModelImport : AssetPostprocessor +{ + private static void OnPostprocessAllAssets(string[] imported, string[] deleted, string[] moved, string[] movedFrom) + { + var models = imported.Concat(moved).Where(ShipPrefabAuthoring.IsShipModel).Distinct().ToArray(); + if (models.Length == 0) return; + EditorApplication.delayCall += () => + { + foreach (var model in models) + { + try { Debug.Log($"Ship prefab built: {ShipPrefabAuthoring.Build(model)}"); } + catch (Exception ex) { Debug.LogError($"Ship prefab build failed for {model}: {ex.Message}"); } + } + }; + } +} diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta new file mode 100644 index 000000000..8256ec660 --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59713c487d73ab3418750d958e24d777 \ No newline at end of file diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs new file mode 100644 index 000000000..adda9ff7d --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs @@ -0,0 +1,80 @@ +using System; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +// Focused Editor witness for the naming contract. Run with -executeMethod ShipPrefabAuthoringSmoke.Run. +public static class ShipPrefabAuthoringSmoke +{ + public static void Run() + { + GameObject root = null; + try + { + root = new GameObject("Fixture"); + Primitive(root, "SHIP.MapIcon"); + Primitive(root, "SHIP.HullCollider"); + Child(root, "SHIP.Shield").localScale = new Vector3(2, 3, 4); + Child(root, "SHIP.TractorBeam"); + var thruster = Child(root, "HP.Thruster.PortAft"); + Primitive(thruster.gameObject, "Emitter"); + var weapon = Child(root, "HP.Weapon.PortGun"); + Child(weapon.gameObject, "Muzzle.1"); + Child(weapon.gameObject, "Muzzle.0"); + var radiator = Child(root, "HP.Radiator.Port"); + Primitive(radiator.gameObject, "RadiatorMesh"); + Child(root, "HP.Equipment.Reactor"); + Child(root, "Pivot.2.-90.90.-80.80.60"); + + ShipPrefabAuthoring.Configure(root); + var ship = root.GetComponent(); + Require(ship != null && ship.EquipmentHardpoints.Length == 4, "equipment references"); + Require(ship.ThrusterHardpoints.Length == 1 && ship.ThrusterHardpoints[0].Emitter != null, "thruster emitter"); + Require(ship.WeaponHardpoints.Length == 1 && ship.WeaponHardpoints[0].FiringPoint.Length == 2, "weapon muzzles"); + Require(ship.WeaponHardpoints[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order"); + Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh"); + Require(ship.ArticulationPoints.Length == 1 && ship.ArticulationPoints[0].Group == 2, "articulation"); + Require(ship.HullColliders.Length == 1 && ship.HullColliders[0].GetComponent().convex, "hull collision"); + Require(ship.Shield != null && ship.Shield.GetComponent() != null, "shield envelope"); + Require(ship.TractorBeam != null && ship.PingPrefab != null && ship.DestroyEffect != null, "shared assets"); + Require(ship.MapIcon != null && ship.MapIcon.gameObject.layer == LayerMask.NameToLayer("Minimap"), "map icon"); + + var invalid = Child(root, "HP.Weapon.Broken"); + var refused = false; + try { ShipPrefabAuthoring.Configure(root); } + catch (InvalidOperationException ex) { refused = ex.Message.Contains("Muzzle"); } + Require(refused, "invalid hierarchy refusal"); + Object.DestroyImmediate(invalid.gameObject); + + Debug.Log("ShipPrefabAuthoringSmoke: named hierarchy and refusal passed"); + if (Application.isBatchMode) EditorApplication.Exit(0); + } + catch (Exception ex) + { + Debug.LogException(ex); + if (Application.isBatchMode) EditorApplication.Exit(1); + else throw; + } + finally { if (root != null) Object.DestroyImmediate(root); } + } + + private static Transform Child(GameObject parent, string name) + { + var child = new GameObject(name).transform; + child.SetParent(parent.transform, false); + return child; + } + + private static GameObject Primitive(GameObject parent, string name) + { + var mesh = GameObject.CreatePrimitive(PrimitiveType.Cube); + mesh.name = name; + mesh.transform.SetParent(parent.transform, false); + return mesh; + } + + private static void Require(bool condition, string claim) + { + if (!condition) throw new InvalidOperationException($"ShipPrefabAuthoringSmoke failed: {claim}"); + } +} diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta new file mode 100644 index 000000000..751fd4e8e --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67d84765dc5ea3243b0ab09bc7f1015e \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 6d3c0491c..c62d980e4 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -405,11 +405,8 @@ public class ActionGameManager : MonoBehaviour // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's revealed // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), same - // predicate (FireControl.IsRevealed) the AI path uses. No generated typed accessor exists for this - // action yet (it was hand-authored into Aetheria.inputactions rather than regenerated through the - // Unity Input Actions editor, unavailable here), so it is looked up by name instead of through - // Input.Player. - Input.asset.FindAction("Player/Cycle Target Item").performed += context => + // predicate (FireControl.IsRevealed) the AI path uses. + Input.Player.CycleTargetItem.performed += context => { var target = CurrentEntity.Target.Value; if (target == null) return; @@ -521,10 +518,29 @@ public class ActionGameManager : MonoBehaviour var itemName = string.Join(" ", args); var item = ItemManager.ItemData.GetAll() .FirstOrDefault(itemData => string.Equals(itemData.Name, itemName, StringComparison.InvariantCultureIgnoreCase)); - if (item != null) + if (item == null) { - _currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f))); + ConsoleController.Instance.AppendLogLine($"No equippable item named \"{itemName}\""); + return; } + if (item is HullData hull) + { + if (hull.HullType != HullType.Ship) + { + ConsoleController.Instance.AppendLogLine($"Refused: {item.Name} is a {hull.HullType} hull, not a ship"); + return; + } + if (DockedEntity == null) + { + ConsoleController.Instance.AppendLogLine($"Refused: dock to take delivery of a {item.Name}"); + return; + } + CommissionShip(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f)) as EquippableItem); + ConsoleController.Instance.AppendLogLine($"{item.Name} moored at {DockedEntity.Name}"); + return; + } + if (!_currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f)))) + ConsoleController.Instance.AppendLogLine($"Refused: no cargo space for {item.Name}"); }); ConsoleController.AddCommand("trackmissile", @@ -535,7 +551,7 @@ public class ActionGameManager : MonoBehaviour missileManager.OnFireGuided.Where(x => x.source == _currentEntity).Take(1).Subscribe(x => { FollowCamera.Follow = x.missile.transform; - FollowCamera.LookAt = x.target; + FollowCamera.LookAt = x.target ? x.target : x.missile.transform; x.missile.OnKill += () => { FollowCamera.LookAt = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint; @@ -877,6 +893,15 @@ public class ActionGameManager : MonoBehaviour } } + // A ship hull acquired while docked becomes a bare player ship moored at the docked entity. It takes no docking + // bay: mothballed ships do not need one. Buying from the trade menu and the give command both land here. + public Ship CommissionShip(EquippableItem hull) + { + var ship = new Ship(ItemManager, Zone, hull, ItemManager.GameplaySettings.DefaultEntitySettings) { IsPlayerShip = true }; + ship.SetParent(DockedEntity); + return ship; + } + private void DoDock(Entity entity, EquippedDockingBay dockingBay) { TradeMenu.Inventory = entity.CargoBays.First(); @@ -1282,6 +1307,13 @@ public class ActionGameManager : MonoBehaviour } } + private static string ResultLabel(ShotResult result) => result switch + { + ShotResult.Hit => "HIT", + ShotResult.Burst => "BURST", + _ => "MISS" + }; + private void UpdateFireControlDebug(Entity target) { if (DebugInfoText == null) return; @@ -1310,7 +1342,7 @@ public class ActionGameManager : MonoBehaviour var shot = CurrentEntity.Zone.PendingShots[i]; if (shot.Source != CurrentEntity || shot.Weapon != selectedItem) continue; if (shot.Committed) - pendingLine = $"shot {shot.ShotId}: committed {(shot.Outcome.Hit ? "HIT" : "MISS")}"; + pendingLine = $"shot {shot.ShotId}: committed {ResultLabel(shot.Outcome.Result)}"; else { var pDeviation = FireControl.DeviationProbability(shot, CurrentEntity.Zone.Time, out var deviation); @@ -1324,11 +1356,16 @@ public class ActionGameManager : MonoBehaviour } var lastLine = _debugLastShots.TryGetValue(selectedItem, out var lastShot) - ? $"last {lastShot.ShotId}: {(lastShot.Hit ? "HIT" : "MISS")} cell {lastShot.Cell.x},{lastShot.Cell.y}" + ? $"last {lastShot.ShotId}: {ResultLabel(lastShot.Result)} cell {lastShot.Cell.x},{lastShot.Cell.y}" : "last: none"; var gates = target == null ? "target: none" - : $"gates vis {d.Visible} range {d.InRange} arc {d.InArc} lock {d.Locked}"; + : $"gates designated {d.Designated} arc {d.InArc}"; + + // A fused weapon's forecast is its outcome, not a hit chance: the burst point or the refusal. + var outcomeLine = d.Outcome == FireOutcome.Burst ? $"burst at {d.BurstReach:F0}" + : d.Outcome == FireOutcome.Refused ? "refused: arming distance exceeds range" + : $"base {d.PBase:P1}"; DebugInfoText.text = $"FIRE CONTROL - {selectedItem.Data.Name}\n" + @@ -1337,7 +1374,7 @@ public class ActionGameManager : MonoBehaviour $"info {d.Info:F3}/{d.InfoDemandCeiling:F3} sensor {d.PSensor:F3}\n" + $"accuracy {d.Accuracy:F3} spread {d.PSpread:F3} hull {d.POnHull:F3}\n" + $"precision {d.Precision:F3} tracking {d.Tracking:F1}\n" + - $"base {d.PBase:P1}\n" + + $"{outcomeLine}\n" + $"{pendingLine}\n" + lastLine; } diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 0e721d733..f3d977c8c 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -203,8 +203,11 @@ public class EntityInstance : MonoBehaviour // Cut 3 (docs/fire-control-cut.md): carries the ShotId FireControl.Fire assigned so the // effect manager (and whatever it spawns) can bind its presentation to Zone.ShotCommitted / // ShotResolved instead of deciding or applying anything itself. + // The target handed to the effect is the shot's own, not the shooter's selection: a fused round + // whose selected target was invalid, or out of arc, flies at none (FireControl.Fire) and the + // effect must not chase one. instantWeapon.OnFire += shotId => - _instantWeaponManagers[data].Fire(instantWeapon, item, this, entity.Target.Value != null && ZoneRenderer.EntityInstances.ContainsKey(entity.Target.Value) ? ZoneRenderer.EntityInstances[entity.Target.Value] : null, shotId); + _instantWeaponManagers[data].Fire(instantWeapon, item, this, ShotTarget(shotId), shotId); if (behavior is ChargedWeapon chargedWeapon) { @@ -277,10 +280,11 @@ public class EntityInstance : MonoBehaviour } } - // Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (Splash, Hit) - // are deleted -- FireControl decides hits now and Entity.ApplyHit/DamageSchematic (ServerShared) apply - // them, the single owner in place of what used to exist once per Unity effect. HullColliders no longer - // publish Hit/Splash at all (R8). + // Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (blast, Hit) + // are deleted -- FireControl decides hits now and applies them through Entity.ArmorAbsorb/ItemAbsorb/ + // DamageHull (ServerShared, Cut 12.3/12.4(b); DamageSchematic, the old per-cell single-lane absorb path + // and ApplyHit are all gone), the single owner in place of what used to exist once per Unity effect. + // HullColliders no longer publish Hit or a blast event at all (R8). LookAtPoint = new GameObject($"{entity.Name} Look Point").transform; @@ -335,6 +339,10 @@ public class EntityInstance : MonoBehaviour } } + private EntityInstance ShotTarget(int shotId) => + Entity.Zone.TryGetShot(shotId, out var shot) && shot.Target != null && + ZoneRenderer.EntityInstances.TryGetValue(shot.Target, out var instance) ? instance : null; + private void OnSensorPingEnd() { Destroy(_currentPing.transform.gameObject); diff --git a/Assets/Scripts/Gameplay/HullCollider.cs b/Assets/Scripts/Gameplay/HullCollider.cs index bdd417e8e..e5dc4ad14 100644 --- a/Assets/Scripts/Gameplay/HullCollider.cs +++ b/Assets/Scripts/Gameplay/HullCollider.cs @@ -3,7 +3,8 @@ using CultMath.UnityBridge; // Cut 4 (docs/fire-control-cut.md): Hit, Splash, SendHit and SendSplash are deleted -- their last callers // (ConstantLaser, ConstantLightning, ConstantParticleWeapon, HitscanEffect, Mine) are gone too (see those -// files and FireControl.Splash). Nothing decides or applies damage through a Unity collider any more (R8): +// files and FireControl.Detonate, 12.4(b)'s renamed Splash). Nothing decides or applies damage through a Unity +// collider any more (R8): // this component is now purely a physics surface for ship-on-ship collision impulse and Mine's own proximity // arming query (Q7), neither of which is hit detection. public class HullCollider : MonoBehaviour diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index 9d9b02070..26a6db650 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -35,6 +35,7 @@ public class GuidedProjectile : MonoBehaviour private float _prevDist; private bool _active; private bool _alive; + private float _spawnTime; private Vector3 _targetVelocity; private Vector3 _previousTargetPosition; @@ -46,11 +47,74 @@ public class GuidedProjectile : MonoBehaviour public Vector3 Velocity { get; set; } public Entity SourceEntity { get; set; } + // How long after spawn the round keeps its binding to the simulation's verdict: the shot's own flight time (the + // manager sets it from the committed shot's ArrivalTime), so a round that faded out early still lives to show + // the detonation the simulation resolves at arrival. 0 (the default) kills the round when its fade ends. + public float BindingLifetime { get; set; } + public event Action OnKill; + // The simulation's verdict on this round, bound by GuidedProjectileManager to Zone.ShotCommitted and + // ShotResolved for its ShotId: this object flies and looks, and neither decides where the round bursts nor when. + // The one owner of the subscription's lifetime: a new binding disposes the one it replaces, Kill disposes it, + // and so does destruction, so a projectile torn down with its scene never leaves an observer on the zone's + // subjects to touch a destroyed transform. + public IDisposable Binding + { + get => _binding; + set + { + _binding?.Dispose(); + _binding = value; + } + } + + private IDisposable _binding; + + void OnDestroy() => Binding = null; + + // The commit named a burst point: fly to it, whatever this round was homing on (a target it would have + // passed, or a Range clamp it would have overshot). The point is a static target, so the old target's motion + // is dropped with it: the jump to the new point must not feed the guidance a target velocity. + public void BurstAt(Vector3 point) + { + Target = null; + TargetPosition = () => point; + _previousTargetPosition = point; + _targetVelocity = Vector3.zero; + } + + // The resolution. A round that detonated (a hit, or a burst) bursts now, at the simulation's burst point when + // it has one; a round the simulation resolved without a detonation (a contact or delayed round that missed, + // one whose target is gone) just falls away, with no explosion to show. + public void Resolve(bool detonated, Vector3? point) + { + // A round that already faded out (overshot, out of flight) still shows the simulation's detonation: only a + // round that has been killed is done. The fade is stopped so it cannot Kill the round a second time. + if (!_alive) return; + if (!detonated) + { + if (_active) StartCoroutine(FadeOut()); + return; + } + StopAllCoroutines(); + // A hit carries no burst point: the effect plays where the round was homing (a round that faded out has stopped + // short of it), and is skipped when the round never knew where its target was. + Vector3? effectAt = point ?? (TargetPosition != null ? TargetPosition() : Target ? Target.position : (Vector3?) null); + if (point.HasValue) transform.position = point.Value; + if (HitEffect != null && effectAt.HasValue) + { + var ht = HitEffect.Instantiate(); + ht.position = effectAt.Value; + } + StartCoroutine(Kill()); + } + void OnEnable() { _active = _alive = true; + _spawnTime = Time.time; + BindingLifetime = 0f; // a pooled round starts unbound; the manager binds it after enabling _phase = Random.value * 100; _prevDist = Single.MaxValue; Particles.startColor = Color.white; @@ -84,14 +148,11 @@ public class GuidedProjectile : MonoBehaviour var targetDist = diff.magnitude; var sourceDist = length(StartPosition.xz - position.xz); + // Out of range, or flown past its target: the round falls away. Only the simulation's detonation + // (Resolve) shows an explosion; a round that merely ran out of flight has none. if (sourceDist > Range || dot(diff.ToCultMath(), Velocity.ToCultMath()) < 0) { StartCoroutine(FadeOut()); - if (HitEffect != null) - { - var ht = HitEffect.Instantiate(); - ht.position = t.position; - } return; } _prevDist = targetDist; @@ -165,11 +226,16 @@ public class GuidedProjectile : MonoBehaviour yield return null; } + // The wait holds still: a round waiting on the verdict must not drift off its last position. + Velocity = Vector3.zero; + // Faded, but the simulation's verdict has not necessarily arrived: wait for it until the shot's flight is over. + while (Time.time - _spawnTime < BindingLifetime) yield return null; StartCoroutine(Kill()); } IEnumerator Kill() { + Binding = null; _active = false; _alive = false; Particles.Stop(true, ParticleSystemStopBehavior.StopEmitting); diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index 51b62969e..e5eb74b79 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -9,6 +9,22 @@ using static CultMath.math; public class GuidedProjectileManager : InstantWeaponEffectManager { + // Presentation follows the committed shot (FireControl): the round retargets to the burst point the commit + // names and bursts when the shot resolves, rather than predicting either from its own homing. + private static void Bind(GuidedProjectile p, Zone zone, int shotId) + { + Vector3 At(ShotOutcome o) => new Vector3(o.BurstPoint.x, p.transform.position.y, o.BurstPoint.y); + var bindings = new CompositeDisposable(); + zone.ShotCommitted.Where(o => o.ShotId == shotId && o.HasBurstPoint).Subscribe(o => p.BurstAt(At(o))).AddTo(bindings); + zone.ShotResolved.Where(o => o.ShotId == shotId) + .Subscribe(o => p.Resolve(o.Result == ShotResult.Hit || o.Result == ShotResult.Burst, o.HasBurstPoint ? At(o) : (Vector3?) null)) + .AddTo(bindings); + // The binding outlives a fade-out until the shot's own arrival (the moment the simulation resolves it), plus a + // second of margin; a shot no longer pending has nothing left to resolve. + p.BindingLifetime = zone.TryGetShot(shotId, out var shot) ? max(0f, shot.ArrivalTime - zone.Time) + 1f : 0f; + p.Binding = bindings; // replaces, and so disposes, any binding the projectile already had + } + public Prototype ProjectilePrototype; public Subject<(Entity source, Transform target, GuidedProjectile missile)> OnFireGuided = new Subject<(Entity source, Transform target, GuidedProjectile missile)>(); @@ -17,12 +33,17 @@ public class GuidedProjectileManager : InstantWeaponEffectManager { if(weapon.Data is LauncherData launcher) { - if (target == null) return; + // A round the simulation fired at no target (FireControl.Fire: a fused round with no valid targeting + // data, or out of arc) flies to the point it bursts at; one with no target and no burst is not a shot. + PendingShot shot = default; + if (target == null && !(source.Entity.Zone.TryGetShot(shotId, out shot) && shot.Fuse != null)) return; + var burst = shot.BurstPosition.ToUnity(); var p = ProjectilePrototype.Instantiate(); p.ShotId = shotId; p.Source = source.transform; p.SourceEntity = source.Entity; - p.Target = target.transform; + if (target != null) p.Target = target.transform; + else p.TargetPosition = () => burst; p.Frequency = launcher.DodgeFrequency; var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); @@ -34,7 +55,9 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.Velocity = barrel.forward * weapon.Velocity; p.Thrust = item.Evaluate(launcher.Thrust); p.TopSpeed = item.Evaluate(launcher.MissileVelocity); - OnFireGuided.OnNext((source.Entity, target.transform, p)); + Bind(p, source.Entity.Zone, shotId); + // A round with no target (a fused round fired at none) has no transform to hand a listener. + OnFireGuided.OnNext((source.Entity, target != null ? target.transform : null, p)); } else if(weapon.Data is GuidedWeaponData guidance) { @@ -54,6 +77,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.Thrust = item.Evaluate(guidance.Thrust); p.TopSpeed = item.Evaluate(guidance.MissileVelocity); p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.LookDirection).ToUnity(); + Bind(p, source.Entity.Zone, shotId); } else Debug.LogError($"Weapon {item.Data.Name} linked to {name} effect, but is not a Launcher!"); } diff --git a/Assets/Scripts/Gameplay/Weapons/Mine.cs b/Assets/Scripts/Gameplay/Weapons/Mine.cs index db0f82141..57d56f68e 100644 --- a/Assets/Scripts/Gameplay/Weapons/Mine.cs +++ b/Assets/Scripts/Gameplay/Weapons/Mine.cs @@ -88,15 +88,16 @@ public class Mine : MonoBehaviour } // Cut 4 (docs/fire-control-cut.md, Q7): the arming OverlapSphere above stays -- that is contact detection, - // not hit detection. Blast damage is not: it goes through the same FireControl.Splash every splash-shaped - // weapon uses now, over the zone's own planar entities, not a Unity collider query. Presentation (ShowHit) - // goes with the query it depended on -- nothing here decides who was hit any more, so nothing here can - // point a hit effect at them; a future pass can rebuild that off FireControl-published state if the mine - // ever ships (R9 keeps it regardless). + // not hit detection. Blast damage is not: it goes through the same FireControl.Detonate every blast-shaped + // weapon uses now (12.4(b) renamed Splash), over the zone's own planar entities, not a Unity collider + // query. Presentation (ShowHit) goes with the query it depended on -- nothing here decides who was hit any + // more, so nothing here can point a hit effect at them; a future pass can rebuild that off + // FireControl-published state if the mine ever ships (R9 keeps it regardless). F12-6: BlastRange stays a + // presentation-side input -- Mine has no catalog weapon record carrying Fuse/BlastRadius. public void Explode() { var position = transform.position; - FireControl.Splash(Source.Entity.Zone, position.ToCultMath(), BlastRange, Damage, DamageType); + FireControl.Detonate(Source.Entity.Zone, position.ToCultMath().xz, BlastRange, Damage, DamageType); var ht = HitEffect.Instantiate(); ht.position = position; diff --git a/Assets/Scripts/Gameplay/Weapons/Projectile.cs b/Assets/Scripts/Gameplay/Weapons/Projectile.cs index dd83d0228..70b591649 100644 --- a/Assets/Scripts/Gameplay/Weapons/Projectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/Projectile.cs @@ -5,10 +5,10 @@ using static CultMath.math; // Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already // decided this shot's fate before this object was ever spawned (R8). What is left is pure flight-path // presentation: fly, and disappear at Range or ShotId's resolution, whichever comes first. -// Cut 6b, 6.2: the flak-round blast now resolves in the simulation (FireControl.Step calls Splash instead of -// Apply at arrival) -- a MonoBehaviour calling Splash would be exactly the authority Cut 3 spent itself -// deleting, so the blast-distance/blast-radius fields this object used to carry for that are gone. This -// object's job stays fly-and-disappear. +// Cut 6b, 6.2 (12.4(b): Apply's own switch on the frozen Fuse decides this now, not Step): the flak-round +// blast resolves in the simulation, through FireControl.Detonate -- a MonoBehaviour calling it would be +// exactly the authority Cut 3 spent itself deleting, so the blast-distance/blast-radius fields this object +// used to carry for that are gone. This object's job stays fly-and-disappear. public class Projectile : MonoBehaviour { public TrailRenderer Trail; diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index cd2589dc0..bee8a4c9f 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -29,22 +29,10 @@ public static class AetheriaStores cache.AllStoredDocuments.All(stored => stored.Descriptor != descriptor)); if (missing != null) throw new InvalidOperationException($"Catalog {catalogPath} has no {missing.SchemaName} record; catalog globals are authored, never invented."); - // R-heat (docs/stats-and-power-cut.md): every equippable design's heat response must describe a - // coherent range before anything reads it. Fails loudly, naming the item. - foreach (var data in cache.GetAll()) - { - StatValidation.ValidateHeatResponse(data); - StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - // Cut 7 (docs/stats-and-power-cut.md): a stat naming a role its design lacks is refused. - StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); - } - // Cut 2 (docs/stats-and-power-cut.md): a consumable's own StatModifierData needs the same check; - // consumables carry no heat response, so this is not folded into the loop above. - foreach (var data in cache.GetAll()) - { - StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); - } + // Every catalog document is held to the one check Upsert applies on write: R-heat (docs/stats-and-power- + // cut.md), stat modifiers, role usage (Cut 7). Fails loudly, naming the item. + foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); + foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); return cache; } catch diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 019198f36..d605ff8a5 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -57,10 +57,12 @@ public class CombatState : BaseState var group = _agent.Ship.WeaponGroups[i]; var dps = 0f; foreach (var weapon in group.weapons) - if (weapon.Item.Online.Value && - weapon.MinRange < targetDistance && - targetDistance < weapon.Range && - (weapon is ConstantWeapon || + // The range test is FireControl's (Designated), and a weapon Solve refuses adds nothing to a group's + // damage. + if (weapon.Item.Online.Value && + FireControl.Designated(weapon, _agent.Ship, target, out _) && + !FireControl.Refuses(weapon, _agent.Ship) && + (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) { if(weapon is LockWeapon lockWeapon) @@ -111,13 +113,12 @@ public class CombatState : BaseState toTarget = normalize(predictedPosition - _agent.Ship.Position); } - // Cut 3: replaces Cut 1's bare InArc, which HitProbability already subsumes (it returns zero out - // of arc) -- this is the AI's own "worth it" heuristic layered on top of the shared gate, not a - // second gate. - var shouldFire = FireControl.HitProbability(testWeapon, _agent.Ship, target) >= _agent.Settings.AgentMinHitProbability; + // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a + // verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it + // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (shouldFire) + if (FireControl.AgentFires(weapon, _agent.Ship, target)) weapon.Activate(); else if (weapon.Firing) weapon.Deactivate(); diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index f3363ac23..bc80bc1e5 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -160,7 +160,10 @@ public class ChargedWeapon : InstantWeapon public override void Activate() { - if(!_charging && !_coolingDown) + // CanFire also holds a weapon whose released charge has a burst pending: its cooldown starts at its first round, + // so a charge started before then would overlap it. + // A refused weapon never starts charging: it would pay charge heat and audio for a shot Trigger drops. + if(!_charging && CanFire && !FireControl.Refuses(this, Entity)) { OnStartCharging?.Invoke(); Item.FireAudioEvent(ChargedWeaponAudioEvent.Start); diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 37da4fe06..e9dab4ce6 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -91,7 +91,13 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // Cut 5 (docs/fire-control-cut.md, 5.3, Soul finding 9): StanceAllowsFire alone let a side-mounted beam // fire forward -- ArcAllowsFire joins it here, matching InstantWeapon.cs's player arc gate (Q2: manual and // programmatic are one truth, and a continuous weapon is no exception). - public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; + // Operator ruling 2026-09-30: a refused round is free, and a beam is a stream of rounds, so a fused beam whose + // arming distance exceeds its Range fires nothing and draws nothing -- no power, ammo, wear, heat or + // visibility. FireControl.Refuses is the one arming test; it reads the Range Execute last refreshed, the same + // one-tick lag every PowerRequest reads its stats with. + private bool MayFire => StanceAllowsFire && ArcAllowsFire && !FireControl.Refuses(this, Entity); + + public float PowerRequest(float dt) => _firing && MayFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon. public int DefaultPowerTier => PowerTiers.Low; @@ -99,10 +105,11 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override bool Execute(float dt) { base.Execute(dt); - if (_firing && !(StanceAllowsFire && ArcAllowsFire)) + if (_firing && !MayFire) { // Safed: shooter has a target and hasn't declared hostility toward it, or the target no longer - // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does). + // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does), or a fused round is + // refused. _firing = false; OnStopFiring?.Invoke(); return false; @@ -187,7 +194,9 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override void Activate() { - if(!_firing && !_reloading) + // A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and + // audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing. + if(!_firing && !_reloading && !FireControl.Refuses(this, Entity)) { _firing = true; OnStartFiring?.Invoke(); diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 6b9b14fc8..2801110c9 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -41,6 +41,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo protected readonly InputCapacitor _capacitor = new InputCapacitor(); protected int _burstRemaining; + private bool _burstPaid; + private bool _burstStarted; private float _burstTimer; private float _burstInterval; protected float _cooldown; // Normalized @@ -52,7 +54,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public float Cooldown { get; protected set; } public virtual bool CanFire { - get => !_coolingDown; + // A triggered burst that has not yet fired a round is not ready either: its cooldown has not started. + get => !_coolingDown && (_burstStarted || _burstRemaining == 0); } public override float DamagePerSecond => Damage / Cooldown; @@ -103,16 +106,20 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // through -- a player's action-bar Activate() reaches this exactly the same way Combat.cs's and // TurretController.cs's Activate() calls do. if (!ArcAllowsFire) return; + // A refused round is free (operator ruling 2026-09-30): no ammo, energy or cooldown is spent on a burst + // whose first round FireControl would refuse. + if (FireControl.Refuses(this, Entity)) return; - // If 1 ammo is consumed per burst, perform ammo and energy consumption here - // UseAmmo returns false when triggering reload; cancel firing if that is the case - if(_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) return; - + // A burst that pays once (SingleAmmoBurst) pays at its first round that is not refused, in Execute, judged + // with the Range that round flies with: paying here would judge with the Range of the previous tick, and a + // Range that fell in between would spend the ammo on rounds Fire then refuses. + _burstPaid = false; _burstRemaining = (int) BurstCount; _burstInterval = BurstTime / _burstRemaining; _burstTimer = 0; - _cooldown = 1; - _coolingDown = true; + // The cooldown starts with the burst's first round that is not refused (Execute), not here: a burst whose + // every round is refused fires nothing and leaves no cooldown behind. + _burstStarted = false; } protected override void UpdateStats() @@ -227,16 +234,32 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _burstTimer += dt; while (_burstRemaining > 0 && _burstTimer > 0) { - // If multiple ammo is consumed per burst, perform ammo and energy consumption here - // UseAmmo returns false when triggering reload; cancel firing if that is the case - if (!_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) + // A fused round whose arming distance Range cannot reach is refused, per round, before anything is + // spent (operator ruling 2026-09-30): it costs no ammo, energy, sound, heat, wear or visibility. + // The decision is FireControl's; Fire makes the same one. + var refused = FireControl.Refuses(this, Entity); + // Ammo and energy are consumed here, per round, or once per burst at its first unrefused round + // (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case + if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo())) { _burstRemaining = 0; return false; } - + if (!refused) + { + _burstPaid = _data.SingleAmmoBurst; + if (!_burstStarted) + { + _burstStarted = true; + _cooldown = 1 - dt / Cooldown; + // The round starts the cooldown after this tick's decrement above: charge the tick to it, so the cadence stays Cooldown + _coolingDown = true; + } + } + _burstRemaining--; _burstTimer -= _burstInterval; + if (refused) continue; // Cut 3: this is fire authority's one entry point. FireControl.Fire freezes the payload snapshot // (Q6 -- the gun's own stats at this exact instant, base.Execute(dt) above already refreshed them // this tick) and queues a PendingShot; nothing downstream re-evaluates a stat. diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 4a2fdab63..48b4c6c4e 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -73,16 +73,12 @@ public class TurretController : Behavior, IInitializableBehavior } else Entity.LookDirection = normalize(diff); - var dist = length(diff); foreach (var x in _weapons) { - var data = x.Data as WeaponData; - // Cut 3 (docs/fire-control-cut.md): the same gate and threshold Combat.cs uses -- HitProbability - // already subsumes the bare arc test (zero out of arc), so this is the "worth it" heuristic, not - // a parallel arc check. - var fire = FireControl.HitProbability(x, Entity, Entity.Target.Value) >= Entity.ItemManager.GameplaySettings.AgentMinHitProbability; - if (x.Evaluate(data.Range) > dist && fire) + // The same per-weapon decision Combat.cs makes; the range test is part of it (a shot out of range + // is not designated and prices at zero). + if (FireControl.AgentFires(x, Entity, Entity.Target.Value)) { x.Activate(); } diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 350d63500..47f2acf11 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -107,16 +107,8 @@ public abstract class Weapon : Behavior, IActivatedBehavior // gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on). // Cut 5, 5.4 (Soul finding 11): the point-blank special case this used to carry moved into InArc itself -- // one bearing test, one owner, so every caller (not just this one) gets a point-blank shot that bears. - public bool ArcAllowsFire - { - get - { - var target = Entity.Target.Value; - if (target == null) return true; - var toTarget = target.Position - Entity.Position; - return FireControl.InArc(Item, toTarget); - } - } + // FireControl owns the gate, including its one exemption: a fused weapon fires out of arc. + public bool ArcAllowsFire => FireControl.ArcPermitsFire(this, Entity); public Weapon(WeaponData data, EquippedItem item) : base(data, item) { diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index b30177b98..6ad751e01 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -329,54 +329,78 @@ public abstract class Entity ? TargetItem.Value : null; - // Cut 3 (docs/fire-control-cut.md): the damage rule moved verbatim from EntityInstance.cs's Unity - // HullCollider subscription (DamageSchematic) -- same arithmetic, same order, same thresholds. The only - // thing that changed is the owner: this entity's own schematic, armor and hull, not a Unity component - // reacting to a physics collision. - public void DamageSchematic(float damage, Shape hitShape) + // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of + // what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so + // Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per + // cell -- always local to one lane, since armour never spans cells -- while deferring the item phase to + // ItemAbsorb's own pool for EVERY occupied cell, not only ones shared with another lane: a lane that turns + // out to be an item's only contributor still pools through a list of one (ItemAbsorb's own degenerate + // case), never a separate solo path. ArmorDamage fires only for incoming > 0. + public float ArmorAbsorb(int2 cell, float damage) { - var hullData = ItemManager.GetData(Hull) as HullData; - foreach (var v in hitShape.Coordinates) - hitShape[v] = hitShape[v] && hullData.Shape[v]; + var d = damage; - float hullDamage = 0; - var damagePerCell = damage / hitShape.Coordinates.Length; - foreach (var v in hitShape.Coordinates) + if (d > 0f) { - var d = damagePerCell; - - // Subtract surface damage from armor, passing on the remainder to the item and then to the hull - var prev = Armor[v.x, v.y]; - Armor[v.x, v.y] = max(prev - d, 0); - ArmorDamage.OnNext((v, d)); - d = max(d - prev, 0); - - if (d > 0.1f) - { - var item = GearOccupancy[v.x, v.y]; - if (item != null) - { - prev = item.EquippableItem.Durability; - item.EquippableItem.Durability = max(prev - d, 0); - ItemDamage.OnNext((item, d)); - d = max(d - prev, 0); - } - } - - hullDamage += d; + var prevArmor = Armor[cell.x, cell.y]; + Armor[cell.x, cell.y] = max(prevArmor - d, 0); + ArmorDamage.OnNext((cell, d)); + d = max(d - prevArmor, 0); } - if (hullDamage > .1f) + return d; + } + + // Cut 12.3 fix batch (one apply path, operator ruling 2026-09-25 "do not expect items taking up multiple + // cells to be an exception, this should be one code path"): the ONE function that decides an item's own + // absorption, whatever the item's shape or however many lanes of one shot reach it. `incoming` holds every + // contributing lane's own post-armour remainder for this resolve, one lane's worth included -- a + // single-lane item is this rule's own degenerate case, not a separate function or a separate threshold. + // The existing .1f threshold is decided once, on the POOLED total, never per contribution (a pooled path + // that gated each contribution separately would let two shares under .1f each slip past an item that a + // single .08f solo hit would already have stopped at). Absorbed durability is split back across `incoming` + // in place, pro-rata to what each lane brought -- the divide below never sees total <= 0.1f, so it never + // sees zero (the guard above already returned). ItemDamage fires once per contributing lane, reporting its + // own INCOMING share -- not the post-clamp amount, the same convention ArmorAbsorb already uses -- and only + // when that lane's own share is itself > 0, so a zero-deposit contributor (a lane whose armour ate its + // whole share before reaching this item) never fires a phantom event. `item` is never null here: both + // callers (`FireControl.ApplyPooled`'s `Resolve`, one lane's worth of contributions included, and + // `FireControl.Detonate`'s own per-entity item-pool resolution, one covered cell's worth included) already + // guarantee it -- a pool is only ever opened under a non-null `GearOccupancy` cell. + public void ItemAbsorb(EquippedItem item, Span incoming) + { + var total = 0f; + for (var i = 0; i < incoming.Length; i++) total += incoming[i]; + if (total <= 0.1f) return; + + var before = item.EquippableItem.Durability; + var absorbed = min(total, before); + item.EquippableItem.Durability = max(before - absorbed, 0f); + var fraction = absorbed / total; + for (var i = 0; i < incoming.Length; i++) { - Hull.Durability -= hullDamage; - HullDamage.OnNext(hullDamage); + if (incoming[i] > 0f) ItemDamage.OnNext((item, incoming[i])); + incoming[i] -= incoming[i] * fraction; + } + } + + // Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one + // HullDamage event are unchanged. FireControl.Apply calls this once per resolved hit, with the summed + // remainder every lane's own walk left over (Q12-3 = A: a lane's remainder goes to the hull wherever the + // lane ends -- penetration exhausted, a gap, or the far side). + public void DamageHull(float damage) + { + if (damage > .1f) + { + Hull.Durability -= damage; + HullDamage.OnNext(damage); } } // Cut 12.1 (docs/fire-control-cut.md): the one schematic-frame owner. Maps a world-planar vector into this - // entity's own schematic frame -- x = starboard, y = bow -- so ApplyHit's penetration march and - // FireControl.Splash's directional half both read the same transform instead of each carrying their own - // copy of forward/right. + // entity's own schematic frame -- x = starboard, y = bow -- so FireControl.Apply's lane walk and + // FireControl.Detonate's per-entity point conversion both read the same transform instead of each carrying + // their own copy of forward/right. public float2 ToSchematic(float2 worldPlanar) { var forward = normalize(Direction); @@ -384,38 +408,29 @@ public abstract class Entity return float2(dot(worldPlanar, right), dot(worldPlanar, forward)); } - // Cut 3: the shape construction moved from EntityInstance.cs's Unity HullCollider subscription -- the hit - // cell is the rolled Cell FireControl already chose, not a UV texture coordinate. - // Cut 12.2 (docs/fire-control-cut.md): `bearing` is already in this entity's own schematic frame -- the - // committed ShotOutcome.Bearing FireControl.Commit computed once, at the commit tick -- so the ToSchematic - // call this used to make internally (12.1) is gone; ApplyHit no longer touches a world direction or this - // entity's own Direction at all. - public void ApplyHit(Entity source, int2 cell, float spread, float penetration, float damage, float2 bearing) + // Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the world<->schematic POINT owners, built on + // ToSchematic's frame -- the entity's own position is the schematic's centre of mass. FireControl.Detonate + // uses ToSchematicPoint to find where a blast sits in each candidate's own schematic (the host of a contact + // or delayed blast included: it is treated like any other entity in the radius, its cells found again + // here, not carried over from the commit). ToWorldPoint is the inverse, used once, to convert a contact or + // delayed fuse point (found on the committed lane, in the host's schematic frame at commit) into the one + // world point Detonate's single input shape takes -- the round trip through the host's own pose at arrival + // is what fixes the host's damage to the commit (R4) while every bystander is judged live. + public float2 ToSchematicPoint(float2 worldPlanar) { - IncomingHit.OnNext(source); - var hullData = ItemManager.GetData(Hull) as HullData; - var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); - hitShape[cell] = true; + var cellSize = ItemManager.GameplaySettings.SchematicCellSize; + return ToSchematic(worldPlanar - Position.xz) / cellSize + hullData.Shape.CenterOfMass; + } - for (var i = 0; i < (int) floor(spread + .5f); i++) - hitShape = hitShape.Expand(); - - if (penetration > .5f) - { - var penetrationVector = normalize(bearing); - - var penetrationPoint = (float2) cell + float2(.5f); - var penetrationDistance = 0f; - while (penetrationDistance < penetration && hullData.Shape[int2(penetrationPoint)]) - { - penetrationDistance += .5f; - hitShape[int2(penetrationPoint)] = true; - penetrationPoint += penetrationVector * .5f; - } - } - - DamageSchematic(damage, hitShape); + public float2 ToWorldPoint(float2 schematicPoint) + { + var hullData = ItemManager.GetData(Hull) as HullData; + var cellSize = ItemManager.GameplaySettings.SchematicCellSize; + var local = (schematicPoint - hullData.Shape.CenterOfMass) * cellSize; + var forward = normalize(Direction); + var right = float2(forward.y, -forward.x); + return Position.xz + local.x * right + local.y * forward; } // Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null) @@ -501,7 +516,10 @@ public abstract class Entity Death = HullDamage.Where(_ => Hull.Durability < .01f).Select(_ => CauseOfDeath.HullDestroyed) .Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke)) .Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia)) - .Merge(ItemDestroyed.Where(i=>i.GetBehavior()!=null).Select(_ => CauseOfDeath.CockpitDestroyed)); + .Merge(ItemDestroyed.Where(i=>i.GetBehavior()!=null).Select(_ => CauseOfDeath.CockpitDestroyed)) + // Death is terminal: the first cause ends the stream, so no damage path (a blast whose cockpit kill is + // followed by its own hull damage, a second item, a heat tick) can raise it twice to any subscriber. + .Take(1); //CurrentSecurityLevel.Value = SecurityLevel.Open; } diff --git a/Assets/Scripts/ServerShared/Enums.cs b/Assets/Scripts/ServerShared/Enums.cs index e556ba656..2b5a9637b 100644 --- a/Assets/Scripts/ServerShared/Enums.cs +++ b/Assets/Scripts/ServerShared/Enums.cs @@ -107,6 +107,15 @@ public enum WeaponModifiers Cluster = 1 << 6 } +// Cut 12.4(a) (docs/fire-control-cut.md): what a weapon's blast radius (WeaponItemData.BlastRadius) detonates +// as. Null means no blast at all -- WeaponItemData.Fuse is frozen null unless BlastRadius > 0 (FireControl.Fire). +public enum WeaponFuse +{ + Contact, + Proximity, + Delayed +} + public enum DamageType { Kinetic, diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 63d20a4da..06c9d3ca5 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -57,6 +57,67 @@ public static class FireControl return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f)); } + // The one place a weapon's payload is read: its blast radius, and the fuse it detonates with. A weapon + // detonates only with both a fuse and a radius above zero; either alone is inert and resolves as a direct + // hit (nothing polices that), so a null return means "not fused". + public static WeaponFuse? FuseOf(EquippedItem item, out float blastRadius) + { + var data = item?.Data as WeaponItemData; + blastRadius = data?.BlastRadius > 0f ? data.BlastRadius.Value : 0f; + return blastRadius > 0f ? data.Fuse : null; + } + + // The one arc gate on the trigger. A fused weapon fires whatever the arc says: a target it cannot bear on is + // no reason to keep it silent, the round flies along the arc-clamped aim and bursts at the target's range + // (Fire; operator ruling 2026-09-30, "the user is hoping that the arc gets the weapon close enough to still + // splash some damage"). A weapon that only hits what it fires at still refuses. Fire reads InArc itself to + // choose that flight, so the two share the one bearing test and differ only in this exemption. + public static bool ArcPermitsFire(Weapon weapon, Entity shooter) + { + var target = shooter.Target.Value; + if (target == null) return true; + return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position); + } + + // Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so + // a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The + // answer is Solve's own -- there is no second copy of the arming test. + public static bool Refuses(Weapon weapon, Entity shooter) => + Solve(weapon, weapon.Item, shooter, shooter.Target.Value).Outcome == FireOutcome.Refused; + + // The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30). + // A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its + // target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never + // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and + // never one Solve refuses: a refused weapon is not a weapon it can use. + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) + { + if (FuseOf(weapon.Item, out _) == null) + return HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + var solution = Solve(weapon, weapon.Item, shooter, target); + return solution.Designated && solution.Outcome != FireOutcome.Refused; + } + + // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming + // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The + // arc is InArc's own cone: an aim inside it is used as is, one outside it is turned to the nearest edge of + // the same cone. There is no second bearing test here; the edge is where InArc stops passing. A shooter + // with no aim set (LookDirection zero) aims down the mount, the same fallback InArc takes for a zero bearing. + // The simulation had no aim clamp before this: the Unity barrel's ArticulationPoint clamps the picture, + // and a target outside the arc merely priced the shot at zero. + public static float2 AimDirection(EquippedItem item, Entity shooter) + { + var mount = MountDirection(item).xz; + var look = shooter.LookDirection.xz; + if (lengthsq(look) < 1e-6f) return mount; + look = normalize(look); + if (InArc(item, float3(look.x, 0, look.y))) return look; + + var half = radians(ArcFor(item) / 2f); + var turn = mount.x * look.y - mount.y * look.x >= 0f ? half : -half; + return float2(mount.x * cos(turn) - mount.y * sin(turn), mount.x * sin(turn) + mount.y * cos(turn)); + } + // Cut 2, R5: whether `observer` has resolved `item` (which must belong to some entity in the zone) well // enough to aim at it. Derived fresh from EntityInfoGathered on every call -- nothing caches a reveal // result, so decay drops it the moment info falls back below tier, with no clearing loop anywhere. @@ -152,28 +213,47 @@ public static class FireControl // freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette // enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again, // live, by Commit at the commit tick. 0 the moment any gate closes. - private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _); + private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _, out _); // The `out range` overload is HitProbability's own gate call -- one computation of `target.Position - // source.Position`, not two. HitProbability used to recompute its own copy for PSpread, which is exactly // the kind of split authority Cut 3 named as the risk (two places computing the same vector, free to // drift or to have their subtraction order flipped in only one of them). - private static float PFire(Weapon weapon, Entity source, Entity target, out float range) + private static float PFire(Weapon weapon, Entity source, Entity target, out float range) => + PFire(weapon, source, target, out range, out _, out _); + + // The one answer to "does the shooter hold valid targeting data on this target": visible, in range and locked + // (for a LockWeapon). PFire, Solve and the agents' trigger decision all read it here, and nowhere else. + // F8 correction (Soul's fix batch, 2026-09-24): `range` is computed for any non-null target, before any + // gate -- Fire reads it for flightTime, and flightTime must reflect the real distance even when a gate (not + // visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the + // burst reach and the range clamp all measure the same distance, so a target's height never moves one of + // them without the others. + public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) { range = 0f; + if (target == null) return false; + range = length((target.Position - source.Position).xz); + return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range && + !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); + } + + // `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated. + // PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at + // (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but + // out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the + // arc from Solve and Inspect and decides nothing. + private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc) + { + range = 0f; + designated = false; + inArc = false; if (target == null) return 0f; - // F8 correction (Soul's fix batch, 2026-09-24): range is computed for any non-null target, before any - // gate -- Fire reads it through this same overload for flightTime, and flightTime must reflect the - // real distance even when a gate (not visible, out of arc) already prices the shot at PFire 0. The - // gates below still short-circuit the rest of the shooter-side probability in the same order as before. - var toTarget = target.Position - source.Position; - range = length(toTarget); - - if (!source.VisibleEntities.Contains(target)) return 0f; - if (range < weapon.MinRange || range > weapon.Range) return 0f; - if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; - if (!InArc(weapon.Item, toTarget)) return 0f; + designated = Designated(weapon, source, target, out range); + if (!designated) return 0f; + inArc = InArc(weapon.Item, target.Position - source.Position); + if (!inArc) return 0f; var settings = source.ItemManager.GameplaySettings; var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; @@ -188,9 +268,21 @@ public static class FireControl // the same thing are two places for the same bug to hide and only be found once. // Cost fix batch (Self, 2026-09-24): Forecast reads only sil.POnHull and sil.Span, never sil.Intervals, so // it rents the Silhouette's own scratch array from ArrayPool.Shared and returns it before - // returning -- the forecast path (HitProbability, Inspect) no longer allocates on a gated-in call. + // returning -- Silhouette itself (given this pooled buffer) allocates nothing, measured directly. // Commit's own path (CommitProbability -> Silhouette, uncalled from here) still needs sil.Intervals alive // for the lateral draw that follows it, so it keeps its own owned array; only the forecast pools. + // S3 fix batch (Hands, 2026-09-25) correction: "the forecast path no longer allocates on a gated-in call" + // overclaimed -- a gated-in HitProbability call still allocates roughly 120 bytes (measured, Release; ~280 + // for Inspect, which reads one more gated stat). 12.3 fix batch correction: that cost is exactly three + // GetBehavior calls (Accuracy, Resolution via PSensor, Precision), at ~40 B each -- each + // one walks Entity.Equipment, a ReactiveCollection whose enumerator is heap-allocated per foreach. It is + // NOT `Behaviors` (the earlier wording here was wrong): EquippedItem.Behaviors is a plain array, allocates + // nothing to enumerate. With an item aimed at, ResolveAimPoint -> CellsOf also allocates (a List, then + // ToArray) -- about 944 B per HitProbability, on top of the three GetBehavior calls. None of this is + // Silhouette's or this pooling's cost; it predates Cut 12.2 and belongs to Entity's own collection choice + // and to CellsOf, out of this cut's scope. Cut 10's own guard is what actually matters + // operationally: HitProbability and CommitProbability both bail before Forecast is ever called for the + // common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0). private static (Silhouette Silhouette, float PSpread) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range) { var bearing = Bearing(target, TravelDirection(weapon, source, target)); @@ -241,13 +333,14 @@ public static class FireControl MinRange = weapon.MinRange, MaxRange = weapon.Range }; + var solution = Solve(weapon, weapon.Item, source, target); + diagnostic.Outcome = solution.Outcome; + diagnostic.BurstReach = solution.BurstReach; if (target == null) return diagnostic; var toTarget = target.Position - source.Position; - diagnostic.Range = length(toTarget); - diagnostic.Visible = source.VisibleEntities.Contains(target); - diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range; - diagnostic.Locked = !(weapon is LockWeapon lockWeapon) || lockWeapon.IsLocked; + diagnostic.Range = length(toTarget.xz); + diagnostic.Designated = solution.Designated; diagnostic.InArc = InArc(weapon.Item, toTarget); var targetHull = source.ItemManager.GetData(target.Hull) as HullData; @@ -307,6 +400,20 @@ public static class FireControl // of each carrying their own copy of the sign. private static float2 Lateral(float2 bearing) => float2(-bearing.y, bearing.x); + // Cut 12.2 fix batch (S1, Soul's second pass): the one function that projects a unit cell onto an axis -- + // the exact support of the axis-aligned unit square centred on c along `axis`, half-width + // h = (|axis.x| + |axis.y|) / 2. Silhouette's shadow (axis = ell) and Lane's own admission test (axis = + // ell) now read this SAME arithmetic instead of Lane re-deriving its own copy of centre/h -- the two used + // to differ only in that Lane's copy was a plain re-derivation, not a second formula, but "not a second + // formula" is exactly why they never drifted apart in exact arithmetic and exactly why the real defect was + // never here: it was the second, independent SlabAlongB gate below, deleted with this fix. + private static Interval Extent(float2 c, float2 axis) + { + var h = (abs(axis.x) + abs(axis.y)) / 2f; + var centre = dot(c, axis); + return new Interval { Lo = centre - h, Hi = centre + h }; + } + public static float DeviationProbability(PendingShot shot, float now, out float deviation) { var elapsed = now - shot.FireTime; @@ -315,6 +422,92 @@ public static class FireControl return shot.Target == null ? 1f : saturate(1f - deviation / shot.Tracking); } + // The one decision about what a round does, made from the shooter's state and a target and nothing else: + // no draw, no side effect. Fire freezes it into a PendingShot; Inspect shows it to the HUD; the weapons ask + // it whether a round is refused before they spend anything -- so a refusal is decided once, by the same + // code that would have flown the round, and the HUD cannot forecast a burst the trigger will not fire. + // Direct: a round that resolves against its target (a weapon without a fuse). Burst: a fused round, with + // BurstReach the planar distance from the shooter at which it bursts. Refused: a fused round whose blast + // radius (its arming distance) exceeds the weapon's Range; it cannot be fired. + public static FireSolution Solve(Weapon weapon, EquippedItem item, Entity source, Entity target) + { + var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc); + + // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the + // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided + // later by a Unity projectile, and never by a label. Solve decides "detonates" once: a fuse is frozen + // only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius + // is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below + // still reads as "resolve this shot with Apply." + var fuse = FuseOf(item, out var blastRadius); + + // Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the + // mount's arc) and carries no target -- so nothing at resolution asks whether one is still around -- + // unless its target is designated (visible, in range, locked) and in arc: + // * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no + // lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing; + // * valid data but out of arc bursts at the target's range, which the range gate has already held to + // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. + // Either way the point frozen here is the farthest the round can burst: whatever its fuse, it detonates at the + // first hull on its aim line short of it (CommitBurst; operator ruling 2026-09-30, "a no-lock round stops at + // the first hull"), and bomb fishing bursts at this point only when the line is clear. + // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot + // without a fuse keeps its target as before. + // Arming distance (operator ruling 2026-09-30): the blast radius, measured from the shooter's position and + // blind to hull size -- a large hull can take splash from its own close burst. A round never bursts nearer + // than that, so a burst that would land nearer is pushed out along its flight line to it. A round fired at a + // valid target inside the radius is pushed out but stays a targeted round: it keeps its target, so a target + // that leaves the zone cancels it like any other. A round whose Range is shorter than the arming distance + // cannot be fired at all: it is refused, per round, where Range is read after Execute refreshed it (a + // burst's later rounds and a power-starved Range included). + var engaged = fuse != null && !(designated && inArc) ? null : target; + var origin = source.Position; + var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); + + var arming = fuse != null ? blastRadius : 0f; + var burstPosition = engaged != null ? engaged.Position : origin; + var flightDistance = 0f; + if (fuse != null && engaged != null) + { + // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is + // pulled back along its own line to Range. + var intercept = PredictedIntercept(weapon, source, engaged); + var interceptReach = length((intercept - origin).xz); + var point = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + burstPosition = Armed(origin, travelDirection, arming, point); + flightDistance = max(fireRange, arming); + } + else if (fuse != null) + { + var reach = designated ? fireRange : weapon.Range; + burstPosition = Armed(origin, travelDirection, arming, origin + float3(travelDirection.x, 0, travelDirection.y) * reach); + flightDistance = max(reach, arming); + } + else if (engaged != null) flightDistance = fireRange; + + var solution = new FireSolution + { + Outcome = weapon.Range < arming ? FireOutcome.Refused : fuse != null ? FireOutcome.Burst : FireOutcome.Direct, + PFire = pFire, + FireRange = fireRange, + Designated = designated, + Engaged = engaged, + TravelDirection = travelDirection, + BurstPosition = burstPosition, + BurstReach = length((burstPosition - origin).xz), + FlightDistance = flightDistance, + Fuse = fuse, + BlastRadius = blastRadius + }; + return solution; + } + + // The arming rule, once: a burst point nearer to `origin` than `arming` (planar, blind to hull size) is pushed + // out along the unit planar `direction` to it. Solve applies it to the point it plans; the detonation of a + // contact or delayed blast, whose point is found on the hull only at resolution, applies it to that point. + private static float3 Armed(float3 origin, float2 direction, float arming, float3 point) => + length((point - origin).xz) < arming ? origin + float3(direction.x, 0, direction.y) * arming : point; + // Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes flight time, freezes the // payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and queues a PendingShot for // Zone.Step to age and eventually resolve. Returns the ShotId so the caller's OnFire event can carry it to @@ -335,37 +528,34 @@ public static class FireControl // R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time // re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share -- // those are priced live at Commit, against the target's facing then, not now (Bearing timing, R10). - // F8 (Soul's fix batch, 2026-09-24): the `out range` overload is the one range HitProbability now - // reads too -- Fire used to recompute its own second copy of `target.Position - source.Position` for - // fireRange, right beside the copy PFire's gate already computed. - var pFire = PFire(weapon, source, target, out var fireRange); - var travelDirection = target != null ? TravelDirection(weapon, source, target) : float2(0, 1); + // Solve is the one range and the one burst decision Inspect and the weapons' refusal check read too. + var solution = Solve(weapon, item, source, target); + if (solution.Outcome == FireOutcome.Refused) return 0; + var pFire = solution.PFire; + var fireRange = solution.FireRange; + var engaged = solution.Engaged; + var travelDirection = solution.TravelDirection; + var burstPosition = solution.BurstPosition; + var fuse = solution.Fuse; + var blastRadius = solution.BlastRadius; + var origin = source.Position; var targetVelocity = float3.zero; - var targetPosition = source.Position; - var flightTime = 0f; - if (target != null) + var targetPosition = origin; + if (engaged != null) { - targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); - targetPosition = target.Position; - flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f; + targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y); + targetPosition = engaged.Position; } + var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f; var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; - // Cut 6b, 6.2 (Soul finding 5): airburst is a property of the weapon, resolved and frozen here like - // every other payload field -- never decided later by a Unity projectile. A weapon without the - // Airburst flag freezes a zero radius, which Step below reads as "resolve this shot with Apply." - var weaponItemData = item.Data as WeaponItemData; - var isAirburst = weaponItemData != null && weaponItemData.WeaponModifiers.HasFlag(WeaponModifiers.Airburst); - var burstPosition = isAirburst && target != null ? PredictedIntercept(weapon, source, target) : targetPosition; - var burstRadius = isAirburst ? weaponItemData.AirburstRange ?? 0f : 0f; - var shot = new PendingShot { ShotId = zone.NextShotId(), Source = source, - Target = target, + Target = engaged, Weapon = item, Aimed = source.ResolvedTargetItem, Damage = damageOverride ?? weapon.Damage, @@ -379,12 +569,17 @@ public static class FireControl Spread = weapon.Spread, FireRange = fireRange, FireTime = now, + FireOrigin = origin, FireTargetPosition = targetPosition, FireTargetVelocity = targetVelocity, ArrivalTime = now + flightTime, - CommitTime = now + max(0f, flightTime - commitHorizon), + // The commit horizon exists so the target's facing can be read late (Bearing timing). A round fired at + // nothing has no facing to read, so it commits at Fire: its burst point is decided from the poses of + // the moment it leaves, and a Contact round short of max range can arrive as early as it truly does. + CommitTime = fuse != null && engaged == null ? now : now + max(0f, flightTime - commitHorizon), BurstPosition = burstPosition, - BurstRadius = burstRadius, + Fuse = fuse, + BlastRadius = blastRadius, Committed = false }; @@ -393,8 +588,15 @@ public static class FireControl } // Cut 3, R4: ages every shot in the zone, commits the ones that have reached their horizon and resolves - // the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose source or target - // has left the zone resolves as a miss and is removed outright (0b table), whichever stage it is at. + // the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose target has left + // the zone resolves as a miss and is removed outright (0b table), whichever stage it is at. A round outlives + // its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked + // for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot. + // Resolution reads the zone and the target; there is no credit or loot for a shooter to receive. + // A shot's queue state is settled before anything that can run user code: it is committed in the list before + // ShotCommitted fires and removed before its damage is applied (damage observers) or ShotResolved fires, so an + // observer that throws costs that one notification, or the rest of that blast, and cannot leave the shot to be + // committed, resolved or applied again on every later tick. public static void Step(Zone zone, float dt) { var shots = zone.PendingShots; @@ -405,19 +607,18 @@ public static class FireControl { var shot = shots[i]; - var sourceGone = !zone.Entities.Contains(shot.Source); var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target); - if (sourceGone || targetGone) + if (targetGone) { - // Cut 11 (Soul C4): a shot whose source or target has left the zone resolves as a miss whichever + // Cut 11 (Soul C4): a shot whose target has left the zone resolves as a miss whichever // stage it is at (0b table). The resolution is published as a fresh miss rather than by // rewriting shot.Outcome: a committed outcome is immutable (R4) and stays a true record of what // the commit decided, while ShotResolved reports what actually happened -- nothing, because the // target is gone. Republishing the committed outcome here used to put a hit marker on a corpse. - var miss = MakeOutcome(shot, false, false, false, int2.zero, float2.zero, 0f, now); + var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); + shots.RemoveAt(i); if (!shot.Committed) zone.ShotCommitted.OnNext(miss); zone.ShotResolved.OnNext(miss); - shots.RemoveAt(i); continue; } @@ -425,19 +626,24 @@ public static class FireControl { shot.Outcome = Commit(zone, shot, now); shot.Committed = true; - zone.ShotCommitted.OnNext(shot.Outcome); + // A contact burst short of max range arrives when the round has flown that far, not at the frozen + // max-range time (CommitBurst). Never later than the frozen time. + if (shot.Outcome.Result == ShotResult.Burst) shot.ArrivalTime = min(shot.ArrivalTime, now + shot.Outcome.ArrivalIn); shots[i] = shot; + zone.ShotCommitted.OnNext(shot.Outcome); } if (shot.Committed && now >= shot.ArrivalTime) { - // Cut 6b, 6.2: an airburst shot (frozen BurstRadius > 0) resolves as an area effect instead of - // a discrete hit -- Splash instead of Apply, never both, which is exactly the double- - // application Soul was told to hunt for. - if (shot.BurstRadius > 0f) Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType); - else Apply(shot); - zone.ShotResolved.OnNext(shot.Outcome); + // Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of + // which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage + // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here + // the same way; only Apply decides what that means. + // Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one + // that throws would resolve, and hurt, again on every later tick. shots.RemoveAt(i); + Apply(zone, shot); + zone.ShotResolved.OnNext(shot.Outcome); } } } @@ -483,14 +689,18 @@ public static class FireControl if (p <= 0f) return 0f; var bearing = Bearing(shot.Target, shot.TravelDirection); - var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData; + var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision); - var settings = shot.Source.ItemManager.GameplaySettings; + var settings = shot.Target.ItemManager.GameplaySettings; return p * PSpread(shot.Spread, sil.Span, shot.FireRange, settings.SchematicCellSize) * sil.POnHull; } private static ShotOutcome Commit(Zone zone, PendingShot shot, float now) { + // A fused round with no target rolls nothing: it bursts. Where is decided here, at the commit, from the + // poses of this tick (R4); Apply performs it. + if (shot.Fuse != null && shot.Target == null) return CommitBurst(zone, shot, now); + // Cut 6b, 6.1 (Soul finding 6): a shot's dice belong to the shot, not to whatever else happened to // draw from the engine-wide shared generator first. A pure function of (zone identity, shot id) -- // the `| 1u` guards @@ -528,7 +738,7 @@ public static class FireControl bearing = Bearing(shot.Target, shot.TravelDirection); lateral = LateralDraw(sil, random.NextFloat()); - var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData; + var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); try { @@ -549,19 +759,189 @@ public static class FireControl ArrayPool.Shared.Return(buffer); } - var shield = shot.Target.Shield; - var shieldActive = shield != null && shield.Item.Active.Value; - if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true; - else if (shieldActive) shieldBroken = true; + // Q12-9 = A (docs/fire-control-cut.md): Commit decides no shield for a shot that carries a fuse -- + // Detonate decides every blast's shields live, at detonation, the host included, charged the + // entity's own covered share. A direct hit (Fuse == null) is unchanged: Commit still decides it here, + // on the whole frozen Damage. + if (shot.Fuse == null) + { + var shield = shot.Target.Shield; + var shieldActive = shield != null && shield.Item.Active.Value; + if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true; + else if (shieldActive) shieldBroken = true; + } } - return MakeOutcome(shot, hit, shielded, shieldBroken, cell, bearing, lateral, now); + // A proximity round detonates at its burst point whether or not the roll hit, so a roll that missed is a + // Burst, not a Miss: the outcome types what happened, and the HUD must not call a real explosion a miss. A + // contact or delayed round detonates only on a hit, so its miss stays a Miss. + var proximity = shot.Fuse == WeaponFuse.Proximity; + var outcome = MakeOutcome(shot, hit ? ShotResult.Hit : proximity ? ShotResult.Burst : ShotResult.Miss, + shielded, shieldBroken, cell, bearing, lateral, now); + if (proximity) + { + outcome.BurstPoint = shot.BurstPosition.xz; + outcome.HasBurstPoint = true; + } + return outcome; + } + + // Operator rulings 2026-09-30. A fused round with no target bursts at the first hull its aim line crosses + // before the max-range point Fire froze, whatever its fuse (contact, proximity and delayed alike: a round with + // nothing locked stops on what is in its way), and at the max-range point when the line is clear. The point is + // read from this tick's poses and frozen into the outcome (R4, the same timing Commit reads facing at). + // Nothing is rolled or drawn, so determinism needs nothing beyond the frozen inputs. + private static ShotOutcome CommitBurst(Zone zone, PendingShot shot, float now) + { + var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now); + outcome.HasBurstPoint = true; + var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); + var travelled = min(reach, max(HullDistance(zone, shot, reach), shot.BlastRadius)); + if (travelled < reach) + { + outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; + // The round arrives once it has flown that far, which is sooner than the frozen max-range arrival. + outcome.ArrivalIn = max(0f, shot.FireTime + (shot.ArrivalTime - shot.FireTime) * travelled / reach - now); + } + else outcome.BurstPoint = shot.BurstPosition.xz; + return outcome; + } + + // The distance along a no-lock round's aim line to the first hull it crosses, or `reach` (the + // max-range point) when it meets nothing first. The shooter's own hull is never a candidate. + // SEAM FOR THE MINING CAMPAIGN: mining chunks are not Entities and are not on this branch. When they land, + // whatever they are contributes its own entry distance to this minimum, and nothing else in the file changes. + private static float HullDistance(Zone zone, PendingShot shot, float reach) + { + var nearest = reach; + foreach (var entity in zone.Entities) + { + if (entity == shot.Source) continue; + nearest = min(nearest, HullEntry(entity, shot.FireOrigin.xz, shot.TravelDirection)); + } + return nearest; + } + + // Where the line from `origin` along the unit planar `direction` meets `entity`'s hull: the world distance from + // `origin` to the first metal cell ahead, +infinity when the line meets none, and negative when the origin is + // already inside the hull (the blast radius floors every caller's use of it). Read through Lane's own cell + // admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset is its own, and cells + // wholly behind the origin are skipped. Only a no-lock round asks. + private static float HullEntry(Entity entity, float2 origin, float2 direction) + { + var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; + var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; + var start = entity.ToSchematicPoint(origin); + var bearing = Bearing(entity, direction); + var lateral = dot(start, Lateral(bearing)); + var along = dot(start, bearing); + + var entry = float.PositiveInfinity; + var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + try + { + var count = CellsOnLine(hullData, bearing, lateral, buffer); + for (var i = 0; i < count; i++) + { + if (!(buffer[i].Exit > along)) continue; + entry = (buffer[i].Entry - along) * cellSize; + break; + } + return entry; + } + finally + { + ArrayPool.Shared.Return(buffer); + } } // R4: the commit is authoritative and immutable from here on -- this only performs what Commit already // decided. The one owner of the shield-absorbs-or-hull-takes-it branch (it used to exist seven times, // once per Unity effect). - private static void Apply(PendingShot shot) + // Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step + // march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A): + // 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the + // centre lane, at the committed Lateral, always does (Commit already proved it, R3). Each lane is Lane's + // own contiguous cell run from its own facing cell, clipped to the penetration depth (a cell is reached + // when entry - impact.entry < penetration; the impact cell is always reached, replacing the deleted + // `> .5f` threshold at zero cost). Whatever a lane does not spend -- penetration exhausted, a gap, or the + // far side -- goes to the hull where the lane ends (Q12-3 = A), summed across every lane into one + // DamageHull call. + // F2 fix batch (Soul, 2026-09-25; operator: "I would prefer if the overlapping damage cells were diffused + // sideways to thicken the line rather than doubling up"): lanes are spaced by the shadow width a cell + // casts at this bearing -- Extent's own h, doubled -- not by one cell. At axis-aligned bearings that width + // is exactly 1 (today's spacing, unchanged); at 45 degrees it is sqrt(2). Reusing Extent (the one function + // that owns h, Silhouette's shadow and Lane's own admission test) instead of re-deriving |bx|+|by| a + // second way is the same discipline Cut 12.2's own fix batch already applied to Lane -- two derivations of + // one geometric quantity is exactly how that cut's crash happened. Because each cell's admitted lateral + // interval is exactly this width wide and half-open, lanes this far apart can never both admit the same + // cell: no cell is struck twice, and the footprint widens at angles instead of doubling up. + // F1 fix batch (Soul's second pass, 2026-09-25): the F2 spacing above is correct in exact arithmetic, but + // the loop that used to sit here still asked the wrong question in float: it called Lane() once per lane + // index, each call re-deriving that lane's own lateral s(li) = Lateral + (li - n) * laneSpacing by forward + // float addition, then testing s(li) against a cell's Extent(c, ell) interval independently of every other + // lane's test. Two lane lines are only ever exactly laneSpacing apart in real arithmetic; accumulating that + // offset by repeated addition can shift a side lane by an ulp or two, and a shifted s(li) can land inside a + // NEIGHBOURING cell's half-open interval that its own, unshifted s(li) was never meant to reach -- the same + // "two derivations of one quantity" defect Cut 12.2's own fix batch found and removed from Lane itself + // (S1 above), now recurring one layer up. + // Self's review (2026-09-25): the first fix for this replaced the per-lane Lane() loop with a ceil-based + // index formula that computed lane membership from a cell's own Extent directly -- but that formula and + // Lane's own half-open test (Lo <= s < Hi) were still two separate computations, agreeing only when a + // cell's own (Hi - Lo) equalled laneSpacing exactly in float. That is the identical trap one level removed, + // not removed. `Lanes` below is the actual single owner: it calls Lane() itself once per lane index, so + // Commit's placement (Lane, the n = 0 case) and Apply's damage split (Lanes, every case) read one function, + // and resolves the resulting rare cross-lane collision by ownership rather than by a competing formula -- + // see `Lanes`. + // Cut 12.3 fix batch (2026-09-25, operator ruling "one code path" -- docs/fire-control-cut.md, Cut 12.3 + // status): the earlier fix batch's own survey-then-branch (ApplySequential vs. ApplyWithSharedItems) was a + // split authority -- a second reachability derivation for "is this item shared" alongside Lanes' own + // membership rule, the identical shape of failure as 12.2's empty lane and 12.3's own double strike. There + // is now one walk, `ApplyPooled`, in which every item pools the deposits of whatever lanes reach it, a + // pool of one lane included -- a single-cell item is this rule's own degenerate case, not a fork. + // + // Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of which model + // a shot uses, through the switch on the frozen shot.Fuse below. Step has no damage branch left -- every + // arrived shot, direct hit or blast, comes through here. A null fuse keeps 12.3's lane path unchanged; a + // proximity fuse detonates at arrival with no roll, whether the shot hit or missed + // (ProximityDetonatesEvenOnAGuaranteedMiss keeps today's AirburstSplashesEvenOnAGuaranteedMiss rule); a + // contact or delayed fuse detonates only on a committed hit, at a point 12.3's own Lane/Reach machinery + // locates. This is a default, not a ruling: a blast shot's DamageSpread is not read, and P sits on the + // centre lane (see the fuse-point comment on the Contact/Delayed branch below). + private static void Apply(Zone zone, PendingShot shot) + { + // A Burst outcome detonates at the point Commit decided (its BurstPoint): a fused round with no target, + // which has nothing to hit or miss, and a proximity round, which detonates whether its roll hit or not. + // Detonate is still the one blast path. + if (shot.Outcome.Result == ShotResult.Burst || shot.Fuse == WeaponFuse.Proximity) + { + Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType); + return; + } + + switch (shot.Fuse) + { + case null: + ApplyDirectHit(shot); + return; + case WeaponFuse.Contact: + case WeaponFuse.Delayed: + ApplyBlastHit(shot); + return; + } + } + + // Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step + // march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A): + // 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the + // centre lane, at the committed Lateral, always does (Commit already proved it, R3). Each lane is Lane's + // own contiguous cell run from its own facing cell, clipped to the penetration depth by Reach (the impact + // cell is always reached, replacing the deleted `> .5f` threshold at zero cost). Whatever a lane does not + // spend -- penetration exhausted, a gap, or the far side -- goes to the hull where the lane ends (Q12-3 = + // A), summed across every lane into one DamageHull call. + // Cut 12.4(b): unchanged from 12.3 except that the inline penetration clip is now Reach, the shared owner + // the delayed fuse point also reads (see "The penetrator"). + private static void ApplyDirectHit(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -575,13 +955,344 @@ public static class FireControl return; } + var target = shot.Target; + // Cut 12.3: moved here from Entity.ApplyHit's own first line -- the shield branches above still return + // before this runs, unchanged from 12.2. Cut 12.4(b): fires only for a direct hit; a proximity burst + // and every bystander in any blast never fire it (AContactBlastReportsTheHit covers the blast half). + target.IncomingHit.OnNext(shot.Source); + // Cut 12.2 (R4): the committed geometry, frozen. Apply reads no position or facing of its own -- // shot.Outcome.Bearing is already the schematic-frame bearing Commit computed at the commit tick, so // turning the target after commit changes nothing here (TurningAfterCommitChangesNothing). - shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, shot.Damage, shot.Outcome.Bearing); + var hullData = target.ItemManager.GetData(target.Hull) as HullData; + var bearing = shot.Outcome.Bearing; + var n = (int) floor(shot.DamageSpread + .5f); // today's rounding, Entity.cs:389 before this cut + var laneCount = 2 * n + 1; + // F2: the shadow width at this bearing, from Extent's own h (the cell argument does not affect h). + var shadowExtent = Extent(float2.zero, Lateral(bearing)); + var laneSpacing = shadowExtent.Hi - shadowExtent.Lo; + + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) + buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers); + + // Cut 12.4(b) ("The penetrator", Q12-8): Reach is now the one penetration-reach owner, shared with the + // delayed fuse point below -- no second reach derivation exists anywhere in this file. + for (var li = 0; li < laneCount; li++) + walked[li] = Reach(buffers[li], walked[li], shot.Penetration); + + var metalLanes = 0; + for (var li = 0; li < laneCount; li++) + if (walked[li] > 0) metalLanes++; + + // The centre lane (li == n) always meets metal: it walks from the committed Lateral, and Commit's own + // guard already proved Lane finds an occupied cell there (R3) -- metalLanes is never 0. + var damagePerLane = shot.Damage / metalLanes; + + var hullDamage = ApplyPooled(target, buffers, walked, laneCount, damagePerLane); + + for (var li = 0; li < laneCount; li++) ArrayPool.Shared.Return(buffers[li]); + + target.DamageHull(hullDamage); } - private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) + // Cut 12.4(b) (docs/fire-control-cut.md, "Where P is"): a contact or delayed blast needs a committed hit -- + // on a miss nothing happens, exactly like a direct hit's own gate. IncomingHit fires once, for the host, + // before the blast is handed to Detonate (which then treats the host like any other entity in the radius: + // Detonate never reads a host parameter, and finds the host's own cells again by ToSchematicPoint). + private static void ApplyBlastHit(PendingShot shot) + { + if (!shot.Outcome.Hit) return; + + var target = shot.Target; + target.IncomingHit.OnNext(shot.Source); + + // "Where P is": the lane's own parametrisation gives the point directly, point(t) = t*b + s*ell, on the + // committed lane (the committed Bearing and Lateral, which Commit's own guard already proved non-empty, + // R3). Contact: t is the impact cell's own entry, lane[0].Entry. Delayed: t_fuse is where the frozen + // Penetration runs out or the metal does, whichever comes first -- Reach (the same owner the direct-hit + // walk reads) decides how deep the lane reaches, and t_fuse clamps to the last reached cell's exit so + // the disc is always centred on metal. + var hullData = target.ItemManager.GetData(target.Hull) as HullData; + var bearing = shot.Outcome.Bearing; + var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + float2 schematicPoint; + try + { + var walked = Lane(hullData, bearing, shot.Outcome.Lateral, buffer); + var t = buffer[0].Entry; + if (shot.Fuse == WeaponFuse.Delayed) + { + var reach = Reach(buffer, walked, shot.Penetration); + t = min(buffer[0].Entry + shot.Penetration, buffer[reach - 1].Exit); + } + schematicPoint = t * bearing + shot.Outcome.Lateral * Lateral(bearing); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + // "Conversion to the world": the round trip through the host's own ToWorldPoint/ToSchematicPoint + // cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while + // every bystander is judged against where it actually is at arrival -- the same live rule a proximity + // burst already applies to everyone. + // The arming rule applies to this point as it does to every other burst (Solve's Armed): a hull met nearer + // the muzzle than the blast radius is detonated on at the radius, along the round's own flight line. + var hullPoint = target.ToWorldPoint(schematicPoint); + var worldPoint = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(hullPoint.x, shot.FireOrigin.y, hullPoint.y)).xz; + Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType); + } + + // Cut 12.4(b) (docs/fire-control-cut.md, "The penetrator", Q12-8): the one penetration-reach owner, moved + // verbatim from Apply's own inline clip (Cut 12.3 fix batch) so the delayed fuse point above can read the + // identical rule the direct-hit walk already used. The rule is unchanged: the impact cell is always + // reached, and a later cell is reached while entry - impact.entry < penetration. Two callers, and no third: + // a null-fuse shot spends damage along the walk (ApplyDirectHit), a contact-or-delayed shot only locates P + // (ApplyBlastHit) -- no shot ever takes both branches of Apply's switch. + private static int Reach(LaneCell[] cells, int walked, float penetration) + { + if (walked == 0) return 0; + var impactEntry = cells[0].Entry; + var reach = 1; + while (reach < walked && cells[reach].Entry - impactEntry < penetration) reach++; + return reach; + } + + // Cut 12.3 fix batch (2026-09-25, operator rulings "proportional absorption; go" and "one code path" -- + // docs/fire-control-cut.md, Cut 12.3 status): every lane walks its own already-clipped cells near to far. + // Armour is never shared between lanes (Lanes' own disjointness rule), so Entity.ArmorAbsorb runs the + // instant a lane reaches a cell, exactly as before. A lane that reaches an ITEM cell instead deposits its + // own post-armour remainder into that item's pool and blocks there -- a pool of one lane included, so a + // single-cell item is this rule's own degenerate case, not a separate fast path. A pool resolves the + // moment no other unfinished lane can still reach that item down its own remaining (already-clipped) walk + // -- for an item only one lane can ever touch, that is immediately, the same instant it is deposited. Once + // resolved the item's pool is gone, so a non-convex item a lane crosses a second time (or a second lane + // arriving after a first pool already resolved) opens a brand new pool under the identical rule -- there is + // no separate "solo absorb after a forced resolve" behaviour anywhere in this function. + // + // Cycle rule (Self's default, recorded as such in the map): two lanes can cross two different shared + // (multi-cell, non-convex) items in reversed relative order -- lane A reaches item1 before item2, lane B + // reaches item2 before item1 -- so neither pool can ever become ready in each lane's own causal order and + // every remaining lane ends up blocked. When that happens (no lane can advance and no pool is + // independently ready), every CURRENTLY OPEN pool resolves at once, together, from whatever deposits it + // already holds. Each blocked lane sits in exactly one open pool, so this batch always clears every blocked + // lane in one pass, needs no ordering key, and is mirror-symmetric by construction (nothing about which + // lane is "left" or "right" ever enters the tie-break). If somehow no pool is open when every lane is + // blocked, that is a real defect -- rem would otherwise be silently dropped -- so this throws instead of + // breaking out of the loop. + private static float ApplyPooled(Entity target, LaneCell[][] buffers, int[] walked, int laneCount, float damagePerLane) + { + // Pooled bookkeeping (this file's own established convention -- ArrayPool is already how Apply rents + // its own per-lane cell buffers): a shot that never crosses an item is the overwhelmingly common case, + // and must not pay a per-shot heap allocation for lane state it never needs to keep past this call. + var pointer = ArrayPool.Shared.Rent(laneCount); + var rem = ArrayPool.Shared.Rent(laneCount); + var finished = ArrayPool.Shared.Rent(laneCount); + var blocked = ArrayPool.Shared.Rent(laneCount); + try + { + for (var li = 0; li < laneCount; li++) + { + pointer[li] = 0; + blocked[li] = false; + finished[li] = walked[li] == 0; + rem[li] = finished[li] ? 0f : damagePerLane; + } + + // Lazily allocated: a shot that never crosses an item (the overwhelmingly common case) never allocates + // a pool at all. + Dictionary> pools = null; + var hullDamage = 0f; + + void FinishLane(int li) + { + finished[li] = true; + hullDamage += rem[li]; + } + + // "No other unfinished lane still has that item among its remaining clipped cells" -- read directly off + // each other lane's own remaining walk (from just past wherever it currently sits), never from a + // precomputed reachability table. A lane already IN this item's own pool has already arrived and does + // not need to arrive again. + bool StillReachableByAnotherLane(EquippedItem item, List<(int lane, float amount)> contributors) + { + for (var li2 = 0; li2 < laneCount; li2++) + { + if (finished[li2]) continue; + var alreadyContributed = false; + for (var k = 0; k < contributors.Count; k++) + if (contributors[k].lane == li2) { alreadyContributed = true; break; } + if (alreadyContributed) continue; + + // No +1 needed when li2 is blocked at this same item's cell: `alreadyContributed` above already + // caught that case (a blocked lane is always a contributor to the item it is blocked on), so + // `pointer[li2]` itself is never this item's own cell by the time this line runs. + for (var i = pointer[li2]; i < walked[li2]; i++) + if (target.GearOccupancy[buffers[li2][i].Cell.x, buffers[li2][i].Cell.y] == item) return true; + } + return false; + } + + void Resolve(EquippedItem item) + { + var contributors = pools[item]; + pools.Remove(item); // a later visit to this same item opens a fresh pool under the same rule + var amounts = ArrayPool.Shared.Rent(contributors.Count); + try + { + for (var k = 0; k < contributors.Count; k++) amounts[k] = contributors[k].amount; + target.ItemAbsorb(item, new Span(amounts, 0, contributors.Count)); + for (var k = 0; k < contributors.Count; k++) + { + var lane = contributors[k].lane; + rem[lane] = amounts[k]; + blocked[lane] = false; + pointer[lane]++; + // One writer for lane completion: AdvanceLane, not here. Unblocking and advancing the + // pointer is enough -- the next call to AdvanceLane(lane), later this pass or the next while + // iteration, is the one place that checks `pointer >= walked` and calls FinishLane. + } + } + finally + { + ArrayPool.Shared.Return(amounts); + } + } + + void Deposit(int li, EquippedItem item) + { + pools ??= new Dictionary>(); + if (!pools.TryGetValue(item, out var list)) pools[item] = list = new List<(int, float)>(); + list.Add((li, rem[li])); + blocked[li] = true; + if (!StillReachableByAnotherLane(item, list)) Resolve(item); + } + + bool AdvanceLane(int li) + { + var moved = false; + while (!finished[li] && !blocked[li]) + { + if (pointer[li] >= walked[li]) + { + FinishLane(li); + moved = true; + break; + } + var cell = buffers[li][pointer[li]].Cell; + rem[li] = target.ArmorAbsorb(cell, rem[li]); + moved = true; + var item = target.GearOccupancy[cell.x, cell.y]; + if (item != null) Deposit(li, item); + else pointer[li]++; + } + return moved; + } + + bool AllFinished() + { + for (var li = 0; li < laneCount; li++) + if (!finished[li]) return false; + return true; + } + + while (!AllFinished()) + { + var progressed = false; + for (var li = 0; li < laneCount; li++) + if (!finished[li] && !blocked[li] && AdvanceLane(li)) progressed = true; + + if (!progressed) + { + if (pools == null || pools.Count == 0) + throw new InvalidOperationException( + "FireControl.ApplyPooled: every remaining lane is blocked but no item pool is open -- " + + "a lane's own damage would be lost rather than reaching an item or the hull."); + // Cycle rule: resolve every currently open pool at once, from whatever it already holds -- a + // snapshot of the keys, since Resolve mutates `pools` as it goes. + foreach (var item in new List(pools.Keys)) Resolve(item); + } + } + + return hullDamage; + } + finally + { + ArrayPool.Shared.Return(pointer); + ArrayPool.Shared.Return(rem); + ArrayPool.Shared.Return(finished); + ArrayPool.Shared.Return(blocked); + } + } + + // Self's fix (2026-09-25): the ONE public membership query behind both Commit's placement and Apply's + // damage split. Lane(s) is exactly this function's n = 0 case -- Lanes calls Lane(hull, b, centreLateral + + // k * laneSpacing, ...) once per lane index k, so a lane's own cell set can never disagree with what + // Lane(s) alone returns for that s: there is no second formula for "does this cell belong to this lane", + // only Lane's own half-open Extent test, called at 2n+1 different lines. This is what removes the trap a + // ceil-based index formula reintroduced one level up from Cut 12.2's own crash (see Apply's comment above): + // that formula and Lane's own test were still two computations, agreeing only when a cell's own (Hi - Lo) + // equalled the externally supplied laneSpacing exactly in float. + // What is left, once every lane's own result is individually exact, is a DIFFERENT and much narrower + // question: can two separate calls to Lane(), at two different (and individually correct) lines, still walk + // the SAME cell? Yes, rarely -- laneSpacing (F2's shadow width, one value shared by every lane) can differ + // by a few ulps from one specific cell's own computed (Hi - Lo), the same float drift Soul's second pass + // found. Lanes resolves that by ownership, not by a competing membership rule: a cell walked by more than + // one lane keeps the lane closest to the centre (ties are impossible -- equal |k| means the same lane), and + // the centre lane (k = 0) never loses a cell to a side lane. Its own final cell set is therefore always + // exactly Lane(centreLateral)'s own output, unmodified -- R3 (the centre lane always meets metal, Commit's + // own guard) and the 12.2 placement contract (HitsLandOnTheFacingEdge) hold structurally, not by chance. A + // side lane that cedes a cell simply omits it from its own walk; the walk's existing contiguity rule (a + // cell more than 1e-4 past the previous SURVIVING cell's exit ends the lane) then decides on its own whether + // the lane continues past the gap that leaves -- no new rule, the same one `Lane` already applies at a + // genuine gap. laneSpacing itself is untouched: it is still the one F2 value that places every lane's own + // query line. + public static int[] Lanes(HullData hull, float2 b, float centreLateral, int n, float laneSpacing, LaneCell[][] buffers) + { + var laneCount = 2 * n + 1; + var raw = new LaneCell[laneCount][]; + var rawCount = new int[laneCount]; + for (var li = 0; li < laneCount; li++) + { + var k = li - n; + var s = centreLateral + k * laneSpacing; + raw[li] = ArrayPool.Shared.Rent(hull.Shape.Coordinates.Length); + rawCount[li] = Lane(hull, b, s, raw[li]); + } + + // Ownership: whichever lane is closest to the centre keeps a cell two lanes both walked; the centre + // lane (k = 0, |k| = 0) can never be outranked. + var ownerLane = new Dictionary(); + for (var li = 0; li < laneCount; li++) + { + var k = Math.Abs(li - n); + for (var i = 0; i < rawCount[li]; i++) + { + var cell = raw[li][i].Cell; + if (!ownerLane.TryGetValue(cell, out var curLi) || Math.Abs(curLi - n) > k) + ownerLane[cell] = li; + } + } + + var walked = new int[laneCount]; + for (var li = 0; li < laneCount; li++) + { + var w = 0; + for (var i = 0; i < rawCount[li]; i++) + { + if (ownerLane[raw[li][i].Cell] != li) continue; // ceded to a closer lane -- this lane's own gap + if (w > 0 && raw[li][i].Entry > buffers[li][w - 1].Exit + 1e-4f) break; + buffers[li][w++] = raw[li][i]; + } + walked[li] = w; + ArrayPool.Shared.Return(raw[li]); + } + return walked; + } + + private static ShotOutcome MakeOutcome(PendingShot shot, ShotResult result, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) { return new ShotOutcome { @@ -589,7 +1300,7 @@ public static class FireControl Source = shot.Source, Target = shot.Target, Weapon = shot.Weapon, - Hit = hit, + Result = result, Shielded = shielded, ShieldBroken = shieldBroken, Cell = cell, @@ -600,47 +1311,202 @@ public static class FireControl }; } - // Cut 4 (docs/fire-control-cut.md): the one splash rule -- an unconditional area effect, not a rolled - // shot. Nothing here draws: a mine or an airburst round always damages everything it catches, the same - // as the Physics.OverlapSphere queries this replaces always did. Applies the shield-absorb-or-hull-takes- - // it branch (F5, docs/stats-and-power-cut.md) per target, then -- for a target the shield didn't fully - // absorb -- DamageSchematic over the directional half of that target's own hull that faces the blast, the - // rule moved verbatim from the Splash subscription EntityInstance.cs carried before Cut 3 deleted it - // (git show b7743789^:Assets/Scripts/Gameplay/EntityInstance.cs), made planar (R7): the blast-to-target - // direction is measured in the zone's (x,z) plane and rotated into each target's own facing by its - // Direction, the same rotation Entity.ApplyHit's penetration march uses, in place of a Unity - // InverseTransformDirection. - public static void Splash(Zone zone, float3 position, float radius, float damage, DamageType damageType) + // Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the one owner of blast damage -- a blast is + // an area, not a bundle of rays (Q12-7 dissolved the ray model's remainder question along with the rays + // themselves). Splash's signature with the point made planar (R7). Callers: Apply's three fuse branches and + // Mine.Explode. Nothing draws: every candidate entity in radius takes its share, unconditionally. + // + // For each candidate (an entity whose centre lies within radius plus half its own hull diagonal -- + // CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius, which Splash's centre-distance cull could not pass): + // take Ps = ToSchematicPoint(P) and rCells = radius / SchematicCellSize once, then for every occupied hull + // cell whose unit square meets the disc, share_c = damage * overlap(c) / (pi * rCells^2) -- the exact area + // of the disc inside that cell's unit square, over the disc's own area. Every share that falls on an + // unoccupied cell, or outside the schematic, is lost (TheShareOffTheHullIsLost); nothing renormalises over + // the covered cells. + // + // Every shield decision for a blast is made here, live, charged the entity's summed shares under today's + // CanTakeHit/Break/TakeHit rules (Q12-9 = A: the host of a contact or delayed blast decides its shield here + // too, never at Commit). If the shield absorbs, no cell of that entity takes anything; if it cannot, it + // breaks and the cells take their shares. + // + // Armour protects only its own cell's share (ArmorProtectsOnlyItsOwnCellsShare) -- "armour absorbs first" + // runs per cell, in hull Coordinates order. Only once every covered cell has deposited does an item resolve + // its own pool, once (AMultiCellItemAbsorbsItsCoveredCellsAsOnePool): no per-cell item threshold survives + // anywhere, because a disc's schedule has no walk -- no cell's share depends on another + // cell's leftover, so every deposit is known before any item resolves and there is nothing for a + // reachability test or a cycle rule to decide (unlike ApplyPooled's lane walk, which this function does not + // call: doing so would buy no behaviour, per ProbeC in the cut doc). Leftovers -- unoccupied-cell shares, + // and whatever an item's pool does not absorb -- join one entity-wide hull total, applied through one + // DamageHull call. + public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType) { - foreach (var target in zone.Entities) + // No distance prefilter: RectDiskOverlap is the one decider of whether the disc touches a hull. A + // prefilter measured from Position (the centre of mass) with the bounding box's half-diagonal is not a + // bound on how far a hull's cells reach from it -- it culled real overlap on an L-shaped hull and on the + // shipped Longinus corners. A nonpositive radius needs no guard either: the overlap of a disc with no + // area is zero for every cell, so nothing is covered and nothing is delivered. + // + // The pass runs over a snapshot: a lethal blast fires an entity's Death, which removes it from + // Zone.Entities, and the live enumeration threw on the next MoveNext (the same idiom Zone.Update uses). + foreach (var entity in zone.Entities.ToArray()) { - var toTarget = (target.Position - position).xz; - if (length(toTarget) > radius) continue; + var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; + var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; + var shape = hullData.Shape; - var shield = target.Shield; - var shieldActive = shield != null && shield.Item.Active.Value; - var shieldAbsorbs = shieldActive && shield.CanTakeHit(damageType, damage); - if (shieldActive && !shieldAbsorbs) shield.Break(); - if (shieldAbsorbs) + var centre = entity.ToSchematicPoint(worldPlanar); + var rCells = radius / cellSize; + var normaliser = PI * rCells * rCells; + + // Shares, computed once, in hull Coordinates order -- the same order armour absorbs in below and + // an item's pool deposits in. + var coords = shape.Coordinates; + List<(int2 Cell, float Share)> covered = null; + var totalShare = 0f; + for (var i = 0; i < coords.Length; i++) { - shield.TakeHit(damageType, damage); - continue; + var overlap = CircleSquareOverlap(centre, rCells, coords[i]); + if (overlap <= 0f) continue; + var share = damage * overlap / normaliser; + (covered ??= new List<(int2 Cell, float Share)>()).Add((coords[i], share)); + totalShare += share; + } + // An entity the disc does not touch has nothing to decide: no shield is consulted, nothing is dealt. + if (covered == null) continue; + + var shield = entity.Shield; + var shieldActive = shield != null && shield.Item.Active.Value; + if (shieldActive) + { + if (shield.CanTakeHit(damageType, totalShare)) + { + shield.TakeHit(damageType, totalShare); + continue; + } + shield.Break(); } - var hullData = target.ItemManager.GetData(target.Hull) as HullData; - var localDirection = lengthsq(toTarget) > 1e-6f - ? normalize(target.ToSchematic(toTarget)) - : float2(0, 1); + // Armour first, per cell; an item's covered cells pool their post-armour deposits and resolve once, + // from the pooled sum -- ItemAbsorb's own degenerate case for a pool of one covers a single-cell + // item, exactly as it does in ApplyPooled. + Dictionary> itemPools = null; + var hullTotal = 0f; + foreach (var (cell, share) in covered) + { + var postArmor = entity.ArmorAbsorb(cell, share); + var item = entity.GearOccupancy[cell.x, cell.y]; + if (item == null) { hullTotal += postArmor; continue; } + itemPools ??= new Dictionary>(); + if (!itemPools.TryGetValue(item, out var pool)) itemPools[item] = pool = new List(); + pool.Add(postArmor); + } - var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); - foreach (var v in hullData.Shape.Coordinates) - if (dot(normalize((float2) v - hullData.Shape.CenterOfMass), localDirection) < 0) - hitShape[v] = true; + if (itemPools != null) + foreach (var (item, pool) in itemPools) + { + var amounts = pool.ToArray(); + entity.ItemAbsorb(item, amounts); + for (var i = 0; i < amounts.Length; i++) hullTotal += amounts[i]; + } - target.DamageSchematic(damage, hitShape); + entity.DamageHull(hullTotal); } } + // Cut 12.4(b) (docs/fire-control-cut.md, "Exact overlap, in float"): the exact area of a unit square, + // centred on `cell` (the unit square centred on its integer coordinate, the same convention every other + // frame rule uses), that lies inside a disc of radius `r` centred at `diskCentre` -- both in the same + // (schematic-cell) units. Allocates nothing. + private static float CircleSquareOverlap(float2 diskCentre, float r, int2 cell) + { + var xlo = cell.x - .5f - diskCentre.x; + var xhi = cell.x + .5f - diskCentre.x; + var ylo = cell.y - .5f - diskCentre.y; + var yhi = cell.y + .5f - diskCentre.y; + return RectDiskOverlap(xlo, xhi, ylo, yhi, r); + } + + // The area of intersection of a disc of radius r centred at the origin with the axis-aligned rectangle + // [xlo,xhi] x [ylo,yhi]. Integrates the disc's chord height over the rectangle's x-span: for a fixed x, the + // disc spans y in [-h,h] with h = sqrt(r^2 - x^2), and the covered length there is + // min(yhi,h) - max(ylo,-h), clamped to zero. That covered length changes formula only where h(x) crosses + // ylo or yhi (or at the rectangle's own edges), so the span is cut at those breakpoints and each piece is + // integrated in closed form with AntiderivSqrt, the antiderivative of sqrt(r^2 - x^2). + private static float RectDiskOverlap(float xlo, float xhi, float ylo, float yhi, float r) + { + if (r <= 0f) return 0f; + xlo = max(xlo, -r); + xhi = min(xhi, r); + if (xlo >= xhi) return 0f; + + Span bp = stackalloc float[6]; + var n = 0; + bp[n++] = xlo; + bp[n++] = xhi; + + // The breakpoints where h(x) = sqrt(r^2 - x^2) crosses ylo or yhi -- the only places the covered + // length's formula (below) can change. No local function: a stackalloc Span cannot be captured by one. + Span ys = stackalloc float[2] { ylo, yhi }; + for (var k = 0; k < 2; k++) + { + var y = ys[k]; + if (abs(y) >= r) continue; + var xc = sqrt(r * r - y * y); + if (-xc > xlo && -xc < xhi) bp[n++] = -xc; + if (xc > xlo && xc < xhi) bp[n++] = xc; + } + + // Insertion sort: n is at most 6. + for (var i = 1; i < n; i++) + { + var key = bp[i]; + var j = i - 1; + while (j >= 0 && bp[j] > key) { bp[j + 1] = bp[j]; j--; } + bp[j + 1] = key; + } + + var area = 0f; + for (var i = 0; i < n - 1; i++) + { + var a = bp[i]; + var b = bp[i + 1]; + if (b - a < 1e-9f) continue; + + var mid = .5f * (a + b); + var h = sqrt(max(0f, r * r - mid * mid)); + // Inclusive: a rectangle edge exactly tangent to the disc (h == yhi at a piece whose midpoint is 0) + // is the disc's own boundary, so the chord is the true bound there. A strict test sent that piece to + // the whole-rectangle branch and counted the square in full -- a boundary flip that creates damage. + var upperIsDisc = h <= yhi; + var lowerIsDisc = -h >= ylo; + var upper = upperIsDisc ? h : yhi; + var lower = lowerIsDisc ? -h : ylo; + if (upper <= lower) continue; + + float piece; + if (upperIsDisc && lowerIsDisc) + piece = 2f * (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)); + else if (upperIsDisc) + piece = (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)) - ylo * (b - a); + else if (lowerIsDisc) + piece = yhi * (b - a) + (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)); + else + piece = (yhi - ylo) * (b - a); + + area += piece; + } + return max(0f, area); + } + + // The antiderivative of sqrt(r^2 - x^2): x/2 * sqrt(r^2 - x^2) + r^2/2 * asin(x/r). Clamped into [-r,r] + // first -- every caller here already clips its integration bounds into that range, so this is a guard + // against float rounding at the boundary, not a second clipping rule. + private static float AntiderivSqrt(float x, float r) + { + var xc = clamp(x, -r, r); + return .5f * (xc * sqrt(max(0f, r * r - xc * xc)) + r * r * asin(xc / r)); + } + // Cut 12.2 (docs/fire-control-cut.md): Sigma keeps its Cut 6d/9.2 rule unchanged -- the frozen Precision's // reciprocal, in hull-schematic cell units, floored at half a cell. The 9.2 floor's old rationale ("the // discrete sum undershoots the continuous integral") is retired: Silhouette below integrates the Gaussian @@ -680,15 +1546,11 @@ public static class FireControl { var ell = Lateral(bearing); var coords = hull.Shape.Coordinates; - var h = (abs(ell.x) + abs(ell.y)) / 2f; var intervals = buffer ?? new Interval[coords.Length]; for (var i = 0; i < coords.Length; i++) - { - var centre = dot((float2) coords[i], ell); - intervals[i] = new Interval { Lo = centre - h, Hi = centre + h }; - } - Array.Sort(intervals, 0, coords.Length, IntervalByLoComparer.Instance); + intervals[i] = Extent((float2) coords[i], ell); + Array.Sort(intervals, 0, coords.Length); // Merge in place: sorted ascending by Lo, so an interval overlaps (or exactly abuts) the last kept // interval whenever its own Lo does not exceed that interval's Hi. @@ -769,6 +1631,34 @@ public static class FireControl return sil.A; // sil.Count == 0: no metal in the shadow at all -- unreachable while POnHull > 0 gated Commit's roll } + // Every occupied cell the line meets, sorted by entry, in the pooled buffer; returns how many. Lane below + // walks the contiguous run from the first; a caller that needs the whole line (a contact fuse looking for + // the first cell ahead of its origin) reads all of them. One admission test either way. + private static int CellsOnLine(HullData hull, float2 b, float s, LaneCell[] buffer) + { + var ell = Lateral(b); + var coords = hull.Shape.Coordinates; + + var n = 0; + for (var i = 0; i < coords.Length; i++) + { + var c = (float2) coords[i]; + var lateral = Extent(c, ell); + if (s < lateral.Lo || s >= lateral.Hi) continue; // half-open, the same admission Silhouette sums over + + // F9 (Soul's fix batch, 2026-09-24): Projection (dot(cell, b)) is precomputed here, into the + // struct, so the sort below needs no closure over `b`. S3 fix batch: the sort itself now uses + // LaneCell's own IComparable, not a per-call Comparer.Create allocation the "never + // allocates" comment used to contradict. Projection is also the "along-bearing projection" the cut + // doc orders entry by. + AlongBearing(c, b, ell, s, out var entry, out var exit); + buffer[n++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit, Projection = dot(c, b) }; + } + + Array.Sort(buffer, 0, n); + return n; + } + // Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): exact slab traversal along the // bearing at a fixed lateral offset s, replacing the old 0.5-step sampling march. Collects the occupied // cells whose shadow interval contains s, each with its own entry/exit parameter along b, ordered by @@ -778,30 +1668,17 @@ public static class FireControl // walk's first element is the impact cell. Only that first element is read in 12.2; the rest is 12.3's // armour-first absorption march. Writes into the caller-supplied pooled buffer and returns the walked // count; never allocates on its own. + // Cut 12.2 fix batch (S1, Soul's second pass): admission is now decided ONCE, by the exact same shadow + // interval Silhouette sums over (Extent(c, ell), the shared function above) -- not by a re-derivation and + // not by a second, independent geometric test. The old code ran two tests: this shadow prefilter (a + // "re-derivation" of Silhouette's own centre/h, inline) AND SlabAlongB, an independent 2-axis ray-box + // intersection that could reject a cell the shadow test had just admitted, purely on float rounding at an + // interval edge -- an empty lane for a shot that had already passed its roll (R3 violation, the crash Soul + // reproduced through Zone.Update). Deleting the second gate removes the split authority: a cell the shadow + // sum counted metal for can no longer be un-counted here. public static int Lane(HullData hull, float2 b, float s, LaneCell[] buffer) { - var ell = Lateral(b); - var h = (abs(ell.x) + abs(ell.y)) / 2f; - var coords = hull.Shape.Coordinates; - - var n = 0; - for (var i = 0; i < coords.Length; i++) - { - var c = (float2) coords[i]; - var centre = dot(c, ell); - if (s < centre - h || s >= centre + h) continue; // outside this cell's own lateral shadow - - if (!SlabAlongB(c, b, ell, s, out var entry, out var exit)) continue; // the shadow test is a conservative bound; the exact slab can still miss - - // F9 (Soul's fix batch, 2026-09-24): Projection (dot(cell, b)) is precomputed here, into the - // struct, so the sort below needs no closure over `b` -- LaneCellComparer.Instance is a stateless - // singleton, not a per-call Comparer.Create allocation the "never allocates" comment used to - // contradict. - buffer[n++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit, Projection = dot(c, b) }; - } - - Array.Sort(buffer, 0, n, LaneCellComparer.Instance); - + var n = CellsOnLine(hull, b, s, buffer); var walked = n > 0 ? 1 : 0; for (var i = 1; i < n; i++) { @@ -811,33 +1688,39 @@ public static class FireControl return walked; } - // The slab (ray-box) intersection of point(t) = t*b + s*ell against the unit square centred on cell c, one - // axis at a time: solving bAxis*t + val in [cLo, cHi] for each of x and y, then entry = the later of the - // two lower bounds, exit = the earlier of the two upper bounds. A near-zero bAxis component means the ray - // does not move along that axis at all, so it either always satisfies that axis's bound (no constraint on - // t) or never does (no intersection). - private static bool SlabAlongB(float2 c, float2 b, float2 ell, float s, out float entry, out float exit) + // Cut 12.2 fix batch (S1): the along-bearing entry/exit parameters for a cell the shadow test above has + // ALREADY admitted -- unconditional, never a gate. The doc's own proof ("s lies in the shadow is exactly + // the same event as this lane has metal") makes the intersection this computes a certainty, not a + // hypothesis to re-test: for a convex shape, a value inside its projection onto ell is achieved by a + // nonempty segment of the line ell=s, so the box-slab solve below cannot come back empty in exact + // arithmetic. It only ever disagreed with the shadow test by float rounding at the interval's own edge, + // which is exactly where SlabAlongB used to throw the metal away; here that same rounding is absorbed by + // clamping entry <= exit instead of rejecting. + private static void AlongBearing(float2 c, float2 b, float2 ell, float s, out float entry, out float exit) { entry = float.NegativeInfinity; exit = float.PositiveInfinity; - return SlabAxis(b.x, c.x - .5f, c.x + .5f, s * ell.x, ref entry, ref exit) - && SlabAxis(b.y, c.y - .5f, c.y + .5f, s * ell.y, ref entry, ref exit); + SlabAxis(b.x, c.x - .5f, c.x + .5f, s * ell.x, ref entry, ref exit); + SlabAxis(b.y, c.y - .5f, c.y + .5f, s * ell.y, ref entry, ref exit); + if (entry > exit) exit = entry; // rounding only -- the shadow test already proved a real intersection } - private static bool SlabAxis(float bAxis, float lo, float hi, float val, ref float entry, ref float exit) + // The slab (ray-box) bound of point(t) = t*b + s*ell against the unit square centred on cell c, one axis at + // a time: solving bAxis*t + val in [lo, hi], then folding the result into the running (entry, exit) bound. + // A near-zero bAxis component means the ray does not move along that axis at all, so it adds no constraint + // on t (the shadow test above already established this axis's bound holds). + private static void SlabAxis(float bAxis, float lo, float hi, float val, ref float entry, ref float exit) { - if (abs(bAxis) < 1e-9f) - return val >= lo && val <= hi; // no motion along this axis: in bounds forever, or never + if (abs(bAxis) < 1e-9f) return; // no motion along this axis: already covered by the shadow admission test var t1 = (lo - val) / bAxis; var t2 = (hi - val) / bAxis; entry = max(entry, min(t1, t2)); exit = min(exit, max(t1, t2)); - return entry <= exit; } // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source - // of truth for where an equipped item's footprint lands, the same table Entity.DamageSchematic reads. + // of truth for where an equipped item's footprint lands, the same table Detonate and ApplyPooled read. private static int2[] CellsOf(Entity target, EquippedItem item) { var hullData = target.ItemManager.GetData(target.Hull) as HullData; @@ -882,12 +1765,17 @@ public struct PendingShot public float Spread; public float FireRange; - // Cut 6b, 6.2: the airburst payload, frozen at fire alongside everything else above. BurstRadius is zero - // for a weapon without the Airburst flag (WeaponModifiers, Enums.cs) -- Step reads that zero as "resolve - // with Apply," not a separate bool. + // Cut 12.4(a): the blast payload, frozen at Fire alongside everything else above. BlastRadius is zero for + // a weapon whose data does not carry one -- Step reads that zero as "resolve with Apply," not a separate + // bool. Fuse is frozen only when BlastRadius > 0 (Solve decides "detonates" once); it is unread until + // Apply's fuse switch (12.4(b)). public float3 BurstPosition; - public float BurstRadius; + public WeaponFuse? Fuse; + public float BlastRadius; + // Frozen with the rest of the shooter's decision: where the round left from. A round outlives its shooter, so + // nothing at resolution reads the shooter's position. + public float3 FireOrigin; public float3 FireTargetPosition; public float3 FireTargetVelocity; public float FireTime; @@ -898,12 +1786,38 @@ public struct PendingShot public ShotOutcome Outcome; } +// What a round does when fired (FireControl.Solve): Direct resolves against its target, Burst detonates at a +// point, Refused cannot be fired -- its blast radius exceeds the weapon's Range. +public enum FireOutcome +{ + Direct, + Burst, + Refused +} + +// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point, read for Burst +// only) are what the HUD shows; the rest is what Fire freezes into the PendingShot. +public struct FireSolution +{ + public FireOutcome Outcome; + public float BurstReach; + // How far the round flies: to its burst point, or for a direct round to its target. + public float FlightDistance; + public float PFire; + public float FireRange; + // Whether the shooter holds valid targeting data on the target (Designated); the HUD and the agents read it here. + public bool Designated; + public Entity Engaged; + public float2 TravelDirection; + public float3 BurstPosition; + public WeaponFuse? Fuse; + public float BlastRadius; +} + public struct FireControlDiagnostic { public bool HasTarget; - public bool Visible; - public bool InRange; - public bool Locked; + public bool Designated; public bool InArc; public float Range; public float MinRange; @@ -921,24 +1835,23 @@ public struct FireControlDiagnostic public float PSpread; public float POnHull; public float PBase; + // What a round from this weapon does (Solve): the forecast for a fused weapon, whose percentages above do + // not describe it. BurstReach is read only when Outcome is Burst. + public FireOutcome Outcome; + public float BurstReach; } // Cut 12.2 (docs/fire-control-cut.md): a merged interval of a hull's lateral shadow, in the schematic frame's // lateral (ell) coordinate. -public struct Interval +// S3 fix batch (Hands, 2026-09-25): IComparable (sorted by Lo) instead of a separate IComparer -- +// Array.Sort(T[], int, int, IComparer) boxes/wraps the comparer into a fresh delegate on CoreCLR every call +// even when the comparer instance itself is a cached singleton; Array.Sort(T[], int, int) resolves T's own +// IComparable at JIT time and allocates nothing per call. Same order as the deleted IntervalByLoComparer. +public struct Interval : IComparable { public float Lo; public float Hi; -} - -// Cost fix batch (Self, 2026-09-24): a stateless singleton, not a per-call Comparer.Create -- Silhouette's -// own sort no longer allocates a wrapper on every call, matching the ArrayPool pooling this batch added on the -// forecast path. -internal sealed class IntervalByLoComparer : IComparer -{ - public static readonly IntervalByLoComparer Instance = new IntervalByLoComparer(); - private IntervalByLoComparer() { } - public int Compare(Interval x, Interval y) => x.Lo.CompareTo(y.Lo); + public int CompareTo(Interval other) => Lo.CompareTo(other.Lo); } // Cut 12.2: the hull's lateral shadow at one bearing, and the exact Gaussian mass it carries. Intervals holds @@ -958,7 +1871,11 @@ public struct Silhouette // Cut 12.2: one occupied cell FireControl.Lane crossed at a fixed lateral offset, with its own slab entry/exit // parameter along the bearing. Entry ascending is nearest-to-farthest along the shot's own travel direction. -public struct LaneCell +// S3 fix batch (Hands, 2026-09-25): IComparable replaces the separate LaneCellComparer, for the same +// reason Interval's own comparer was deleted -- the (T[], int, int, IComparer) sort overload allocates a +// wrapper delegate per call even for a cached singleton comparer; (T[], int, int) does not. Same order as the +// deleted comparer: entry ascending, ties by projection along b, then cell index for full determinism. +public struct LaneCell : IComparable { public int2 Cell; public float Entry; @@ -966,24 +1883,15 @@ public struct LaneCell // F9 (Soul's fix batch, 2026-09-24): dot(Cell, b), precomputed once per candidate so Lane's sort tie-break // needs no closure over the bearing. public float Projection; -} -// F9: Lane's own sort order (entry ascending, ties by projection along b, then cell index for full -// determinism) as a stateless singleton -- Array.Sort's IComparer overload needs an instance, and this one -// is allocated exactly once, ever, not per Lane() call. -internal sealed class LaneCellComparer : IComparer -{ - public static readonly LaneCellComparer Instance = new LaneCellComparer(); - private LaneCellComparer() { } - - public int Compare(LaneCell x, LaneCell y) + public int CompareTo(LaneCell other) { - var byEntry = x.Entry.CompareTo(y.Entry); + var byEntry = Entry.CompareTo(other.Entry); if (byEntry != 0) return byEntry; - var byProjection = x.Projection.CompareTo(y.Projection); + var byProjection = Projection.CompareTo(other.Projection); if (byProjection != 0) return byProjection; - var byX = x.Cell.x.CompareTo(y.Cell.x); - return byX != 0 ? byX : x.Cell.y.CompareTo(y.Cell.y); + var byX = Cell.x.CompareTo(other.Cell.x); + return byX != 0 ? byX : Cell.y.CompareTo(other.Cell.y); } } @@ -997,7 +1905,15 @@ public sealed class ShotOutcome public Entity Source; public Entity Target; public EquippedItem Weapon; - public bool Hit; + // What the commit decided, typed: a shot rolled and missed, rolled and hit, or was a fused round with no + // target and so burst without rolling, or a proximity round whose roll missed but which detonates anyway + // (Burst, at BurstPoint). Hit is derived from it, not stored beside it. + public ShotResult Result; + public bool Hit => Result == ShotResult.Hit; + // World planar point a proximity or no-target round detonates at, frozen by the commit; HasBurstPoint says + // whether this round has one (a contact or delayed round detonates where it hits, not at a frozen point). + public float2 BurstPoint; + public bool HasBurstPoint; public bool Shielded; // Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active, // and unable to CanTakeHit this shot is decided broken right here, so Apply performs Break() rather than @@ -1012,3 +1928,13 @@ public sealed class ShotOutcome public float ArrivalIn; public DamageType DamageType; } + +// What a committed shot came to. A Burst is a detonation that is not a hit on a target: a fused round that had no +// valid target and rolled nothing, or a proximity round whose roll missed but which bursts anyway. It is neither +// a hit nor a miss, and the presentation must not call it one. +public enum ShotResult +{ + Miss, + Hit, + Burst +} diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 1cec438cc..59d93c1ba 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -490,12 +490,20 @@ public class WeaponItemData : GearData [Inspectable, JsonProperty("modifiers"), Key(28)] public WeaponModifiers WeaponModifiers; - // Cut 6b (docs/fire-control-cut.md, 6.2): the authored blast radius an Airburst-flagged weapon's shot - // splashes with at arrival, frozen into PendingShot.BurstRadius at fire. Zero (and unread) for a weapon - // without the Airburst flag. No authored value exists yet -- the flag is wired and unexercised until Cut - // 6a or a later authoring pass sets it. - [Inspectable, JsonProperty("airburstRange"), Key(29)] - public float? AirburstRange; + // Cut 12.4(a) (docs/fire-control-cut.md): the authored blast radius a weapon's shot detonates with, in + // world units. Renamed from slot 29's old airburst-range field -- the slot and type are unchanged, so + // CultCache opens the catalog as compatible drift. Null (every shipped record) means no blast; WeaponModifiers + // no longer decides this (Q12-6, "WeaponModifiers are labels, not behaviour"). + [Inspectable, JsonProperty("blastRadius"), Key(29)] + public float? BlastRadius; + + // Cut 12.4(a): what kind of blast BlastRadius describes. Nullable under the 7.4 rule -- null means no + // blast, and every shipped record reads null. FireControl.Fire freezes this from the weapon only when + // BlastRadius > 0; a fuse without a radius, or a radius without a fuse, is inert and resolves as a direct + // hit (nothing polices that, per Q12-6). Key 30 and 31 belong to EquippableItemData's OptimalTemperature + // and PlateauWidth, inherited here -- the next free key across WeaponItemData's own ancestry is 32. + [Inspectable, JsonProperty("fuse"), Key(32)] + public WeaponFuse? Fuse; } [CultDocument("aetheria.hulldata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d37d6b650..418a55f79 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -43,6 +43,21 @@ public class Zone private int _nextShotId; public int NextShotId() => ++_nextShotId; + // The committed shot a presentation was handed the id of by OnFire. It carries what the simulation decided + // -- the target it flies at (null for a round that carries none) and where it bursts -- so a presentation + // reads that rather than the shooter's current selection. + public bool TryGetShot(int shotId, out PendingShot shot) + { + foreach (var pending in PendingShots) + if (pending.ShotId == shotId) + { + shot = pending; + return true; + } + shot = default; + return false; + } + public float Time { get => (float) _time; diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c36d5e9f6..2144f718c 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -207,17 +207,39 @@ public static class ZoneGenerator var storyStations = galaxyZone.Locations.Where(story => story.Type == LocationType.Station).ToArray(); var stationCount = (int)(random.NextFloat() * (factionPresence + 1)) + storyStations.Length; + // Enemy count below is based on this pre-override value, so the tutorial entrance doesn't gain an enemy + // just because its station got forced. + var baseStationCount = stationCount; + // Operator ruling, 2026-09-25 (docs/locomotion-cut.md, "Tutorial station"): the entrance zone always + // gets a generated faction station with a docking bay, deterministically. Galaxy.cs halves influence and + // picks the lowest-influence unowned zone for Entrance, which otherwise rolls stationCount 0 on every + // seed. galaxy.Entrance is reference-identity, not a name comparison. + var isTutorialEntrance = isTutorial && galaxyZone == galaxy.Entrance; + if (isTutorialEntrance && stationCount < 1) stationCount = 1; var potentialLagrangePoints = planets .Where(p => p.Parent != null && p.Parent.Children .TrueForAll(c => !(c != p && abs(c.Distance - p.Distance) < .1f))) // Filter Rosettes .OrderBy(p => p.Distance) .ToArray(); + // The entrance's own station count is the floor above; its orbit always comes from this same selection. + // Ordinarily that selection is potentialLagrangePoints (non-rosette planet orbits). If the entrance zone + // has none of those (a boring single-body system, or a system that is entirely one rosette), widen to + // every planet orbit ordinary placement's own candidate pool is drawn from before the rosette filter -- + // still a real, parented planet orbit with a positive Distance -- rather than inventing an unparented, + // zero-distance orbit (that gave PlaceTurret's phase = 20*multiplier/orbit.Distance a division by zero, + // and left an orbit record with no planet behind it). + var lagrangeCandidates = potentialLagrangePoints; + if (isTutorialEntrance && lagrangeCandidates.Length == 0) + lagrangeCandidates = planets.Where(p => p.Parent != null).OrderBy(p => p.Distance).ToArray(); // Pick a selection from the middle of the distribution - var selectedStationOrbits = potentialLagrangePoints - .Skip(potentialLagrangePoints.Length / 2) + var selectedStationOrbits = lagrangeCandidates + .Skip(lagrangeCandidates.Length / 2) .Take(stationCount) .Select(p=>orbitMap[p]) .ToArray(); + if (isTutorialEntrance && lagrangeCandidates.Length == 0) + throw new InvalidOperationException( + "The tutorial entrance zone must always get a station, but it has no candidate orbit at all -- every planet in the zone is a parentless root."); var loadoutGenerators = new Dictionary(); @@ -234,7 +256,7 @@ public static class ZoneGenerator { Parent = baseOrbit.Parent, Distance = baseOrbit.Distance, - Phase = baseOrbit.Phase + PI / 3 * sign(random.NextFloat() - .5f) + Phase = baseOrbit.Phase + PI / 3 * sign(random.NextFloat() - .5f), }; pack.Orbits.Add(cache.Upsert(lagrangeOrbit)); return lagrangeOrbit; @@ -294,7 +316,13 @@ public static class ZoneGenerator var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout(); - if (station == null) continue; + if (station == null) + { + if (isTutorialEntrance) + throw new InvalidOperationException( + $"The tutorial entrance zone must always get a station, but {nearestFaction?.Name} has no station loadout to generate."); + continue; + } station.Orbit = cache.RefOf(lagrangeOrbit); station.SecurityLevel = security; station.SecurityRadius = pack.Radius; @@ -303,7 +331,7 @@ public static class ZoneGenerator PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2); } - var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + stationCount; + var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount; for (int i = 0; i < enemyCount; i++) { var ship = GetLoadoutGenerator(nearestFaction).GenerateShipLoadout(); diff --git a/Assets/Scripts/UI/ConsoleView.cs b/Assets/Scripts/UI/ConsoleView.cs index fcde87e29..64e0a4597 100644 --- a/Assets/Scripts/UI/ConsoleView.cs +++ b/Assets/Scripts/UI/ConsoleView.cs @@ -50,7 +50,9 @@ public class ConsoleView : MonoBehaviour { Keyboard.current.onTextInput += c => { - if (!Visible || c == '`') return; + // Text input also delivers control characters (backspace as '\b', enter as '\r'); editing keys are + // handled in Update, so only printable characters are text. + if (!Visible || c == '`' || char.IsControl(c)) return; InputString += c; }; InputString = ""; @@ -81,7 +83,7 @@ public class ConsoleView : MonoBehaviour { if (!Visible) return; - if(keyboard.deleteKey.wasPressedThisFrame && InputString.Length>0) + if((keyboard.backspaceKey.wasPressedThisFrame || keyboard.deleteKey.wasPressedThisFrame) && InputString.Length>0) { InputString = InputString.Substring(0, InputString.Length - 1); } diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 9bc285b75..3b1643dab 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -374,8 +374,7 @@ public class TradeMenu : MonoBehaviour { if (hullData.HullType != HullType.Ship) throw new ArgumentException("Attempted to buy non-ship hull from station, WTF are you doing?!"); - var ship = new Ship(GameManager.ItemManager, GameManager.Zone, item as EquippableItem, GameManager.ItemManager.GameplaySettings.DefaultEntitySettings) { IsPlayerShip = true }; - ship.SetParent(GameManager.DockedEntity); + GameManager.CommissionShip(item as EquippableItem); GameManager.Credits -= data.Price; UpdateCreditsLabel(); diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..16fe9b214 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# Aetheria agent doctrine + +## Data tooling: AetherDb + +`tools/AetherDb` is the agent's tool for reading and writing Aetheria's CultCache stores (`GameData/Aetheria.cc`, +`run.cc`, `player.cc`). The simulation lives in `Aetheria.Shared` (the headless build of +`Assets/Scripts/ServerShared`) and needs no Unity; Unity is only the shell for graphics and input. + +- When a task needs a catalog or save capability AetherDb lacks (authoring or cloning designs, setting fields, + fixtures for a play smoke, a query), add it to AetherDb as a command. Do not write a scratch console project, + a loose `.cs` script, a Python or PowerShell reader of the `.cc` bytes, or a Unity editor script instead. + Loose scripts were retired in `9f9149bd` because they ran against stale schemas. +- A command dry-runs by default and writes only with `apply`, validates with `CultRecordRefs.Validate` and + commits in one `CultCache.Commit` batch, like the existing migrations in `tools/AetherDb/Program.cs`. +- Deep copies of records use CultCache's own options, + `CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly)`; MessagePack's standard options + cannot serialise `CultMath` types or `CultRecordRef`. +- The headless build refuses to compile unless CultLib is a clean checkout at the revisions pinned in + `Directory.Build.props` (`Directory.Build.targets` enforces it). When `F:\Projects\CultLib` is elsewhere, point + `CULTLIB_ROOT` and `CULTMATH_ROOT` at detached worktrees of those revisions; never move the shared checkout. +- CultCache Studio is the operator's GUI over the same files. It is not an agent path. diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index d2a0b4982..200c93e22 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1862ddc46be55e3ae574d214afd1be5aa917d3e9ff5c13d6c38d48a1652e13c1 -size 12691535 +oid sha256:2aa79c0adca4e50b608f1c7154ad847777d62af83b918b6de19eb881e8fdd7e9 +size 12698108 diff --git a/GameData/Narrative/HeroOrZero.json b/GameData/Narrative/HeroOrZero.json deleted file mode 100644 index e63bd875c..000000000 --- a/GameData/Narrative/HeroOrZero.json +++ /dev/null @@ -1 +0,0 @@ -{"inkVersion":20,"root":[["^As you wander the docking area, swaying a little as you readjust to the inertial compensation aboard the station, a shout stops you in your tracks, and a quick about face reveals a frantic young man wearing what appears to be a dazzling, star-splattered cape.","\n","^“Oh, there you are,” he says, grabbing your hand and pulling you into the shadow of a rather dilapidated looking ship. “Thank credits I’ve found you. I’ve been looking all over for you.”","\n",["ev",{"^->":"0.4.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":"0.c-0","flg":18},{"s":["^You blink. “Uh…have we met? And can you let go of me? You’re kind of hurting my arm.”",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":"0.4.s"},[{"#n":"$r2"}],"\n","^The man seems surprised to hear this.","\n","^“Oh, come on, old buddy,” he says. “It’s Bertie! From Potoo The gala? I was bartending for extra credits, and we were just chatting, and then everything was all, ‘boom! Bang!’ and everyone was running around all ‘oh, no, we’re going to die!”’ and you were all ‘just part of the human condition, am I right?’ and just straight up did a death drop into the carnivorous sand dunes after that thing with the spines! Bam! Just like that. And we all thought you were going to get eaten but you totally didn’t and I said, I said, ‘that was amazing,’ and you said ‘I know!’ And then you ordered a Calpraxian moonshine popper with ‘extra moons, please’ and a fondant umbrella and drank the whole thing? The clip got like, ten thousand hits. I’m your biggest fan!”","\n","^You…don’t remember any of that. But you do really love Calpraxian moonshine poppers, and doubly so when they’re doubled. These two things are probably related. “Oh,” you say. “Thanks, I think.”","\n","^“Oh, this is the best!” Bertie says. “I’ve been looking all over the galaxy for you, and here you are! Come on, come on, I want to show you something.”","\n","^“Show me what?” you say, not entirely sure where this is headed.","\n","^“This,” he says, whipping out a phaser and firing a stun beam directly between your eyes.","\n",{"->":"0.g-0"},{"#f":5}],"g-0":[{"->":"wakeywakeyeggsandfakey"},["ev",{"^->":"0.g-0.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"Really? Why?\"",{"->":"$r","var":true},null]}],["ev",{"^->":"0.g-0.2.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"Oh, hey Dave! Nice ship.\" ",{"->":"$r","var":true},null]}],"ev","str","^-kick him in the shins and run-","/str","/ev",{"*":".^.c-3","flg":20},{"c-1":["ev",{"^->":"0.g-0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.1.s"},[{"#n":"$r2"}],"\n","^“Why?” the man says. “Why? I can’t believe you’re asking me that. And me your biggest fan, too.”","\n","^“Really?” you say. “You grab me out of nowhere, say you’ve been looking all over for me, start dragging me to your junky, rat-trap ship, and are somehow surprised when I want to know your motives?”","\n",[[{"->":"ship"},{"->":"0.g-1"},{"#f":5,"#n":"g-0"}],null],{"#f":5}],"c-2":["ev",{"^->":"0.g-0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.2.s"},[{"#n":"$r2"}],"\n","^He stops.","\n","^“Dave?” he says, looking deeply offended. “Who’s Dave? Dave is a debtor’s name. I’m Humblebert Maxibillion Goiterwold. The Twenty-Second. I’d think you of all people would know that.”","\n","^“Not good enough to be Humblebrag Monstermillion Goiterwold the first, eh?” you say. “Well, we’ve all been there.”","\n","^“Humblebert Maxibillion Goiterwold,” he insists, cape swirling around him and catching the light with an incredible sense of narrative drama.","\n","^You’ve never killed a man for his clothes before, but you’re definitely starting to consider it.","\n","^“And you, ,” he continues, oblivious to your thoughts, “Have been chosen. By me! Out of all the best fighters in the galaxy—oh, yes, I’ve been watching you, . The sand dunes on Potoo 6—the thing with the jelly—the battle on the Moons of Alcorion! Amazing! I need your heroic skills. You see,” he says, gesturing at the half falling apart ship behind him, “my manor has been infested with Iltorian venom beetles. Oh, horrible things; twice the size of a man and three times as deadly! I need you to exterminate them.”","\n",[["ev",{"^->":"0.g-0.c-2.19.0.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^“Fighting gigantic killer insects with my pal Dave? It’s just like old times! Consider me hired.” ",{"->":"$r","var":true},null]}],["ev",{"^->":"0.g-0.c-2.19.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^“Uh…what manor? That’s a spaceship.” ",{"->":"$r","var":true},null]}],["ev",{"^->":"0.g-0.c-2.19.2.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^“Well, this is obviously a trap. Bye!” ",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"0.g-0.c-2.19.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.0.s"},[{"#n":"$r2"}],"\n","^“Humblebert,” the young man says, and with a swirl of his cape motions you up the steps to his ‘manor’. You, long since ready for an adventure, follow. As the door slides shut behind you with a loud screech and a faint scent of scorched rubber, you turn to look for Humblebert, only to find him gone and your vision slowly going black. As you slip into unconsciousness, you swear you can hear someone whisper, slowly, incredulously, “Who the debt is Dave?”","\n",{"->":"wakeywakeyeggsandfakey"},{"->":"0.g-1"},{"#f":5}],"c-1":["ev",{"^->":"0.g-0.c-2.19.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.1.s"},[{"#n":"$r2"}],"\n",{"->":"ship"},{"->":"0.g-1"},{"#f":5}],"c-2":["ev",{"^->":"0.g-0.c-2.19.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.2.s"},[{"#n":"$r2"}],"\n",{"->":"Exitone_thepeaceout"},{"->":"0.g-1"},{"#f":5}]}],{"#f":5}],"c-3":["^ ",{"->":"Exitone_thepeaceout"},"\n",{"->":"0.g-1"},{"#f":5}],"#f":5}],"g-1":["done",{"#f":5}]}],"done",{"ship":["^“It’s not a ship,” he snaps, turning slightly red with outrage. You seriously begin to debate the merits of just knocking him out and running for it. “It’s a battleground! An arena. A castle of unspeakable horrors, to be conquered by only the bravest of heroes! It is your destiny.”","\n","^You look at the grav-dynamic, be-thrustered, battered but still very obviously ship-like space-faring transport behind him.","\n","^“No,” you say. “I’m pretty sure it’s a ship.”","\n","^You turn to leave the madman to his destiny, and then, with a sudden, sharp crackle of electricity along the labyrinth of your nerves, darkness closes in.","\n",[[{"->":"wakeywakeyeggsandfakey"},{"#f":5,"#n":"g-0"}],null],{"#f":1}],"wakeywakeyeggsandfakey":[["^You awaken, gear missing and a faint prickle between your eyes, to a cascade of brilliant light, which eventually clears to reveal the ominously decorated interior of a ship, and the staticky blare of a catchy jingle played over monitors that are not built for quite that level of *peppy*.","\n","^This is nothing, of course, to the voice which follows it. It’s the voice of a being who has somehow attained their designated 8 hours of restful sleep, and followed it up with a heaping bowl of sunbeams and three different kinds of protein. It is, you recognize resentfully, the voice of someone who proudly identifies as a morning person.","\n","^You make a mental note to strangle them should the opportunity ever arise.","\n","^“Gooooood morning, Lucent Employees and Copyrighted Genotype Creations!” the voice cheers. “It’s 5am studio time, and you know what that means! Oh yes. It’s time for the number one reality show hit of the season, the reason you all wake up bright eyed and bushy tailed these workday mornings--and not just for my dashing good looks, either! It’s that wonderful time of day we all gather by the X-CAff bot to watch one randomly chosen citizen, plucked out of their daily life by yours truly, strive to prove they have the mettle to survive the Gauntlet…and become our new Hero! I’m your host, Humblebert Maxibillion, and this is…HERO or ZERO!”","\n","^Whoops and cheers suddenly surround you. Whether real or manufactured, you can’t tell, and you don’t know which would be more comforting.","\n","^You take a closer look around. For a supposed number one hit, the scenic design is a bit…lackluster. The broad outlines are obviously there—the idea of an old Terran castle traced in stonework and lanternlight—but you can clearly see through the facade the sleek metallic gleam of a spacefaring world.","\n","^You don’t have much time to consider, because the voice is speaking again, loud and tinny:","\n","^“Today’s would-be hero is a rough and tumble contract pilot pulled straight outta X station! (under license agreement with Finch Cybernetics, of course!) [he/she/it select pilot’s gender]’s just awoken here in our ancient Castle Victory, hand hewn from the stone by bold warriors in the halcyon days of yore--”","\n",["ev",{"^->":"wakeywakeyeggsandfakey.0.16.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^He takes a breath, and you take the opportunity to cut in. “Hey! Hey! I think there’s been a mistake. I didn’t sign up for this.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.17.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^”Oh, for—it’s not a castle; it’s a spaceship! Look—I can see the wing struts—” ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.18.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^The curiousity is too much. You burst into the continuous torrent of his speech with an excited, “Oh, this sounds fun! Is there a prize?” ",{"->":"$r","var":true},null]}],"ev","str","^-Search for an exit-","/str","/ev",{"*":".^.c-3","flg":20},"ev","str","^-Do nothing-","/str","/ev",{"*":".^.c-4","flg":20},{"c-0":["ev",{"^->":"wakeywakeyeggsandfakey.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.16.s"},[{"#n":"$r2"}],{"->":".^.^.deathandorglory"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"c-1":["ev",{"^->":"wakeywakeyeggsandfakey.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.17.s"},[{"#n":"$r2"}],"\n","^The voice—Humblebert Maxabillion?—sounds annoyed as he continues over you. “The, ah, ancient and very real Castle Victory, home of…the GAUNTLET!”","\n","^“I don’t need any more armour, thanks,” you say, feeling around for the escape hatch you know from extensive perusal of the fine print of all your ship purchases that Lucent Industries safety regulations requires to be no more than nine metres away at all given times. The ‘stonework’ gives slightly beneath your fingers. “Besides, I’m pretty sure this is just papier mache—”","\n","^“Papier mache is an ancient and respected art form,” Humblebert snaps, before gathering himself and continuing cheerily in his role of host.","\n","^At your feet, the ‘stones’ begin to glow, illuminating a path into the bowels of the ship. \"I want to state that at no point did I sign up for, agree to, or otherwise consent to any of this,\" you say, because there are limits and, more importantly, liabilities.","\n",{"->":".^.^.deathandorglory"},{"->":".^.^.deathandorglory"},{"#f":5}],"c-2":["ev",{"^->":"wakeywakeyeggsandfakey.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.18.s"},[{"#n":"$r2"}],"\n","^“Oh, the prize is greater than you could ever imagine,” Humblebert says. “And it looks like our contestant is raring to fight for it! So without further ado…”","\n","^The stones beneath your feet kindle with some soft white light.","\n","^“March forth, you fount of potential,” Humblebert says. “Glory awaits.”","\n","^You march. ",{"->":"TheGauntlet"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"c-3":["^ ","\n","^Despite your best efforts, no exit is to be found. The only way, it seems, is forward. As if to underscore this inevitability, the stones beneath your feet begin to glow, leading you on to whatever strangeness awaits. ",{"->":"TheGauntlet"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"c-4":["^ ",{"->":"TheGauntlet"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"deathandorglory":["^“Oh, nobody signs up,” your host—Humblebert?—says. “This is Hero or Zero! A challenge of daring—of thinking on your feet—of courage in the face of unexpected adversity! There’s no glory in preparation.”","\n","^“This is kidnapping and I’m leaving,” you say.","\n","^Humblebert Maxabillion laughs. It is…not a nice laugh. “Oh, no, my friend. No one leaves. Not til they’ve either passed through the trials—or perished. You can opt for immediate death, of course, but it won’t make you popular with the viewers.”","\n","^“Yeah,” you say. “That’s incredibly illegal.”","\n","^“Not in Castle Victory!” Humblebert trills. The stones at your feet begin to glow softly, forming a path leading into the darkness ahead. His voice becomes stentorian. “Now, contestant. Choose. Death or glory.”","\n","^Slowly, familiarly, and then gaining frantic speed in a contagious mob, the studio audience picks up the chant.","\n","^Death or glory.","\n","^Death or glory.","\n","^Death or glory.","\n","^Death or glory. Death or glory. Deathorglory.Deathorglory.deathorglorydeathorglorydeathorglorydeathorglordeathor","\n","^“Well?” Humblebert says.","\n",["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.22.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-5","flg":18},{"s":["^Uh, glory, I guess. ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.23.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-6","flg":18},{"s":["^GLORY! RAH! ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.24.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-7","flg":18},{"s":["^Death. I pick death. ",{"->":"$r","var":true},null]}],{"c-5":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-5.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.22.s"},[{"#n":"$r2"}],{"->":"TheGauntlet"},"\n",{"#f":5}],"c-6":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-6.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.23.s"},[{"#n":"$r2"}],{"->":"TheGauntlet"},"\n",{"#f":5}],"c-7":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.24.s"},[{"#n":"$r2"}],"\n","^The crowd howls.","\n","^“Uh, really?” Humblebert says. “No one’s ever picked death before.”","\n","^“What can I say, I’m a trendsetter,” you say. “I’d be obliged if you could deliver my remains to my employers; they’re going to want to know who to sue for property damage.”","\n","^The crowd screams.","\n","^Humblebert’s voice drops from manic announcer to annoyed show-biz veteran, dropping off air and wrangling the talent. “Look, Captain Fatalism, this is a reality show. We don’t actually…I don’t come into work to kill people, okay? It’s all smoke and mirrors, right? And we have ratings to think about! So if you don’t mind just trying the first trial and then taking a dive there, if you really feel like you have to, it’d make my life a lot easier. And yours too. You don’t want to upset the channel execs. You really don’t.”","\n",[["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.0.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^You smile. \"They'll live. And so will I. I'm leaving.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^You sigh. \"Well, alright. But if you’re going to go around kidnapping people you may as well really commit to the whole ‘death or glory’ thing. Make the stakes matter. Otherwise your audience won’t care what happens.\"",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.0.s"},[{"#n":"$r2"}],{"->":"Exitone_thepeaceout"},"\n",{"#f":5}],"c-1":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.1.s"},[{"#n":"$r2"}],"\n","^“Great, great. Wonderful!” Humblebert says. “Now if you could just say “Glory” on camera, that’d be…that’d be perfect.","\n","^“Uh, glory?” you say.","\n","^Humblebert sighs. “Good enough.” ",{"->":"TheGauntlet"},"\n",{"#f":5}]}],{"#f":5}],"#f":5}]}],{"#f":1}],"TheGauntlet":[["^Humblebert Maxibillion’s voice echoes sonorously as you follow the gently glowing path. “Our hero-to-be must face the Gauntlet, and its three onerous challenges: first, the Trial of Wit! Quick thought and cleverness will lead to victory—no brainless barbarians will be *our* heroes.","\n","^“Secondly, they will face the Trial of Daring! Courage and nerves of synthetic sapphire are required for this task—only the bravest survive.”","\n","^“And third, the most perilous of all, and the perennial favourite of our loyal fans—the Trial of Heroically-Avoiding-Being-Agonizingly-Disintegrated-While-Dodging-Plasma-Bombs! A very necessary quality for anyone who endeavors to attain the coveted title of Official Lucent Hero (tm).”","\n","^“And, needless to say, it’s hero or zero, so any failure on the part of our adventurer means—that’s right! THE OUBLIETTE!”","\n","^Excited cheers ring through the speakers.","\n","^“Oubliette,” you whisper to yourself. It’s fun to say. You doubt it’ll be fun to experience.","\n","^“So, contestant,” Maxibillion roars. “Are you ready to brave the Gauntlet and prove yourself…our HERO?”","\n",["ev",{"^->":"TheGauntlet.0.14.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^\"Bring it on!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"TheGauntlet.0.15.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"Fine. I guess. If I have to.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"TheGauntlet.0.16.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"...Don't I get a weapon or anything?\"",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"TheGauntlet.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.14.s"},[{"#n":"$r2"}],{"->":"Trial_of_Wit"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"TheGauntlet.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.15.s"},[{"#n":"$r2"}],{"->":"Trial_of_Wit"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["ev",{"^->":"TheGauntlet.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.16.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^“Nope!” Humblebert says. “A true hero requires no weapon but their heart.”","\n","^“I’d rather be a live coward with a mini railgun, thanks,” you say, but it’s clear that you don’t really have the choice.","\n",{"->":"Trial_of_Wit"},{"#f":5}]}],{"#f":1}],"Trial_of_Wit":[["^The path opens out into a large room, which would be empty if it weren’t mostly taken up by the enormous cybernetic beast blocking the clearly marked exit forward. Two giant paws, folded with incongruous daintiness below a human face, one clearly printed from an artist’s collection, flex, catching the light like knife blades.","\n","^“Welcome,” she says, voice low and purring, “to the Trial of Wit. I am the Snynx, Mistress of Riddles.”","\n","^“Don’t you mean the sphynx?” you say, before you can stop yourself.","\n","^The beast sighs loudly. “No. That name is a Miss Terri’s copyright. I’m off brand. Stop asking.”","\n","^You nod understandingly.","\n","^“I am the arbiter of the Trial of Wit,” the Snynx continues, long, rapier like claws tapping on the stone. “And you, my nosy hero to be, must answer me these riddles three, if you another day would see.”","\n","^“That’s not a riddle, is it?” you ask.","\n","^“No,” she says. “But this is:","\n","^I eat and eat,","\n","^But never sated,","\n","^A hunger here","\n","^From death created,","\n","^I have no mind,","\n","^I cannot feel,","\n","^Still, spaghetti is","\n","^my favourite meal.","\n","^What am I?”","\n","^“Ooh, that’s a tough one!” Humblebert crows. “Let’s hope it doesn’t make spaghetti out of them. It’s done in plenty in the past-a!”","\n","^An audible groan can be heard from the studio audience.","\n","^You shake your head, scrunch up your eyes, and blurt out an answer.","\n",["ev",{"^->":"Trial_of_Wit.0.40.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^\"Uh, um…a puppy? No! Wait…\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.41.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"A black hole.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.42.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"A fungus.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.43.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-3","flg":18},{"s":["^\"Oh, that's easy. It's me!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.44.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-4","flg":18},{"s":["^\"A virus!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.45.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-5","flg":18},{"s":["^\"I don't know that!\" ",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"Trial_of_Wit.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.40.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"Trial_of_Wit.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.41.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["ev",{"^->":"Trial_of_Wit.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.42.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-3":["ev",{"^->":"Trial_of_Wit.0.c-3.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.43.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-4":["ev",{"^->":"Trial_of_Wit.0.c-4.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.44.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-5":["ev",{"^->":"Trial_of_Wit.0.c-5.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.45.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^The snynx smiles.","\n","^“You are…correct.”","\n","^“And that’s one riddle down, two to go. Will our intrepid contestant survive this test of wits? Tune in after the break and find out! I’m your host, Humblebert Maximillion, and this is…HERO or ZERO!”","\n","^“So…can I go get an xcaff or something, or…” you begin.","\n","^Laughter rings out of over the speakers. “No break for you, contestant! No, it’s life or death in Castle Victory! And you’ve got two and a half more trials to complete.”","\n","ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"!","/ev",{"*":".^.c-6","flg":9},"ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-7","flg":9},{"c-6":[{"->":".^.^.^.^.Riddletwo"},"\n",{"->":".^.^.^.bit"},{"#f":5}],"c-7":[{"->":".^.^.^.bit"},"\n",{"->":".^.^.^.bit"},{"#f":5}],"#f":5}],"bit":["^You grin. \"Oh, come on. If you’re not going to kill me you can at least get me a drink.\"","\n","^\"Fine,\" Humblebert hisses. \"But only if you stop being a pain in my--\"","\n",{"->":".^.^.^.Riddletwo"},{"#f":5}]}],{"Riddletwo":[["^“The second riddle,” the snynx says imperturbably, “is as follows:","\n","^An atom? NAh.","\n","^It’s not my all,","\n","^Though crystalline,","\n","^And white, and small,","\n","^But sight’s not the sense,","\n","^Of who I am,","\n","^My taste—unmatched,","\n","^My match—a CLam!","\n","^What am I?”","\n",["ev",{"^->":"Trial_of_Wit.Riddletwo.0.20.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^\"Now that has to be cocaine.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.21.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"Bacon!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.22.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"Salt?\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.23.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-3","flg":18},{"s":["^\"Star dust.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.24.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-4","flg":18},{"s":["^\"Elbruxian sticking powder?\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.25.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-5","flg":18},{"s":["^\"...can I take a mulligan?\"",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.20.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.21.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.22.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"c-3":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-3.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.23.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-4":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-4.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.24.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-5":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-5.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.25.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^“Amazing! Our contestant has beat the first odds kindly provided by Diceroll Statistics Inc., proving their cleverness two for nil against the devious snynx! Will their wit be enough to see them through to the next challenge, or will our brave pilot perish in ignominy? Tune in after these words from our sponsors to find out!”","\n","ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"!","/ev",{"*":".^.c-6","flg":9},"ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-7","flg":9},{"c-6":[{"->":".^.^.^.^.^.RiddleThree"},"\n",{"->":".^.^.^.bitsy"},{"#f":5}],"c-7":[{"->":".^.^.^.bitsy"},"\n",{"->":".^.^.^.bitsy"},{"#f":5}],"#f":5}],"bitsy":["^“Honestly, I’m a little offended that my potential death is being televised and you’re not even showing it live.”","\n","^“Don't be ridiculous. Real death is never cinematic enough for TV. That’s why we have editors.” ",{"->":".^.^.^.^.RiddleThree"},"\n",{"#f":5}]}],{"#f":1}],"RiddleThree":[[["^“So, who exactly are these sponsors?” you ask, and are, unsurprisingly, ignored.","\n","^“The third riddle is as follows,” the snynx says.","\n","^“Without a doubt, you’ve challenged me,","\n","^You’ve got a wit, or two, or three,","\n","^You think you’re smart—you’ve got it made,","\n","^Your thoughts are quick; your mind’s a blade,","\n","^You think this test will be a rout—","\n","^But answer this:","\n","^Which way is out?”","\n","^And as the snynx speaks, the room spins disorientingly, rolls and whirls, walls and floor and ceiling switching place like crossed roulette wheels, the clearly marked exit now decidely unclear.","\n","^“Oh, they look puzzled now! What’ll it be, contestant? Only one route leads forward. The rest are just a sudden sharp drop down five stories, to the very end of the story.”","\n","^You sit down, on the cold stone (ish) floor, and come up with a plan.","\n","ev","str","^-guess at random-","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^-take one last sip before splashing your XCAFF on the walls to see where it seeps through the gaps around the door-","/str",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-1","flg":21},"ev","str","^-redefine the idea of ‘inside’ as being everything contained beyond the walls. You are now outside. The way ‘out’ is now...here.-","/str","/ev",{"*":".^.c-2","flg":20},"ev","str","^-Give up-","/str","/ev",{"*":".^.c-3","flg":20},{"c-0":["^ ","\n",["ev","visit",3,"seq","/ev","ev","du",0,"==","/ev",{"->":".^.s0","c":true},"ev","du",1,"==","/ev",{"->":".^.s1","c":true},"ev","du",2,"==","/ev",{"->":".^.s2","c":true},"nop",{"s0":["pop","^ ",{"->":"ExitThree_oublietteroulette"},{"->":".^.^.23"},null],"s1":["pop",{"->":"Trial_of_Wit.forth"},{"->":".^.^.23"},null],"s2":["pop",{"->":"ExitThree_oublietteroulette"},{"->":".^.^.23"},null],"#f":5}],"\n",{"#f":5}],"c-1":["^ ",{"->":".^.^.^.^.^.forth"},"\n",{"#f":5}],"c-2":["^ ",{"->":".^.^.^.^.^.forth"},"\n",{"#f":5}],"c-3":["^ ",{"->":"ExitThree_oublietteroulette"},"\n",{"#f":5}],"#f":5,"#n":"g-0"}],null],{"#f":1}],"forth":[["^To your eternal shock, the snynx listens to you answer…and nods.","\n","^“Congratulations,” she says, and removes two of her claws. She offers them to you. “You have chosen…wisely. Good luck, hero.”","\n","^The audience cheers.","\n","^“You’ve passed the Trial of Wit. You may continue.”","\n","^The door opens. You continue, snynx claws in hand.","\n","^“Incredible! Look at that! Our hero to be continues to defy all bets on their grizzly demise. I’ve got to say, I didn’t see that one coming. We’ve got a crafty one on our hands, eh? Alright. But cunning will only get you so far. Now, contestant, gird your loins and line your girdles, and get ready to face…the Trial of Daring!”","\n","^You enter a new chamber, a long, high hall lit only by the gently swaying illumination of very atmospheric biolanterns. You step forward gingerly, only to jump back as your foot meets nothing but empty air. Something whistles by in the space it had just been.","\n","^In the shifting dimness, you can just make out the dark lines of rope hanging above the pit.","\n","^“Daring. Courage. Bravery. Nerve,” Humblebert says, voice hushed and almost reverent. “The very essence of a hero. Now, contestant, your task lies before you. Cross the Chasm. Brave the void. If you dare.”","\n","^Someone in the studio audience begins a low chant, and it catches like brushfire in a parched forest.","\n","^Jump. Jump. Jump. Jump.","\n","^Jump.","\n","ev","str","^Attempt to cross using the available system of ropes and handholds","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^if variable geckogirl is true, as yet undefined:","/str","/ev",{"*":".^.c-1","flg":20},"ev","str","^Attempt to cross using the snynx claws as climbing tools.","/str","/ev",{"*":".^.c-2","flg":20},["ev",{"^->":"Trial_of_Wit.forth.0.42.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-3","flg":18},{"s":["^\"Brave the void,\" you whisper, square your shoulders, and leap straight off the edge into the darkness below. ",{"->":"$r","var":true},null]}],{"c-0":["\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["^ You look down at your fingers, splayed and not quite human, remembering high walls and falling and genomes in beautiful sequence and the kind of hope that kills just as fast as it saves, and smile. ",{"->":".^.^.c-3.8.geckotime"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["\n",{"->":".^.^.g-0"},{"#f":5}],"c-3":["ev",{"^->":"Trial_of_Wit.forth.0.c-3.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.42.s"},[{"#n":"$r2"}],{"->":"ExitFour_bouncehousestageleft"},"\n",[["^“I love you, gecko hands,” you whisper, as you crawl easily along the wall to the gasps and cheers of the studio audience.","\n","^“And our contestant has…lizard genes! Apparently!” Humblebert chirps. “Oh, that’s a twist I didn’t see coming. I guess you could say it’s…the anolest trick in the book! Well, I don’t suppose they’re going to have any problem with this challenge. But there’s still one more trial to go—and the most dangerous of all. Like they say in the pleasure houses of Anthrax 9—the best is yet to come!”","\n",{"->":".^.^.^.^.g-0"},{"#f":5,"#n":"geckotime"}],null],{"#f":5}],"g-0":["^It’s a struggle, but it works. Sweaty, scratched, and exhausted, you survive the Trial of Daring and make it to the other side.","\n","^“Oohh, we nearly lost ‘em a couple of times there, but look at that! They made it. What a heartstopping scene that was. But, of course, the show’s not over yet! One trial remains.”","\n","^Tentatively, you follow the glowing path as it leads you once more forward, into another large room, likewise decked out like the main hall of a castle.","\n","^Humblebert’s voice rings out once more.","\n","^“So far our would be hero has proved their wit, and their courage—but now it’s time for them to prove…their resistance to superheated plasma!”","\n","^An earthshattering scream of excitement erupts from the speakers. You look around for an exit, but it’s too late, the doors have sealed behind you and there’s no way forward.","\n","ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"!","/ev",{"*":".^.c-4","flg":9},"ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-5","flg":9},{"c-4":[{"->":".^.^.^.bob.kill"},"\n",{"->":".^.^.^.bob"},{"#f":5}],"c-5":[{"->":".^.^.^.bob"},"\n",{"->":".^.^.^.bob"},{"#f":5}],"#f":5}],"bob":["^\"I thought you said you weren’t in the business of killing people!”","\n","^“I mean, we’re not in the business of killing people before they’ve provided full entertainment value,” Humblebert says. “I thought that was implied? Now don’t be a baby and a take a blast of superheated plasma to the face like true hero of the people. And don’t forget to smile!”","\n",{"->":".^.kill"},["^Slots in the walls open up, revealing the death machines within, and suddenly you’re running and dodging for your life.","\n","ev","str","^Keep dodging. Surely they can’t keep firing forever?","/str","/ev",{"*":".^.c-6","flg":20},"ev","str","^Find shelter","/str","/ev",{"*":".^.c-7","flg":20},["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.14.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str",{"CNT?":".^.^.^.^.c-3.8.geckotime"},"/ev",{"*":".^.^.c-8","flg":19},{"s":["^Forward...is on fire. Left is also on fire. What was once right--extremely on fire. But up...up is safe. And you know you can make it up there, you mutant wonder, you. ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.15.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str",{"CNT?":"ship"},"/ev",{"*":".^.^.c-9","flg":19},{"s":["^This, as you quite firmly told Humblebarf-Maximurderous-Soon-To-Be-Maximurdered on your abduction, is not a castle. It's a debting spaceship. It's designed to protect your frail meatball body. You grin, and activate the personnel shields. ",{"->":"$r","var":true},null]}],{"c-6":["^ ",{"->":"Exit_DeathMKI"},"\n",{"#f":5}],"c-7":["^ ",{"->":"Exit_DeathMKII"},"\n",{"#f":5}],"c-8":["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.c-8.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.14.s"},[{"#n":"$r2"}],"\n","^“I love you, gecko hands,” you whisper, as you hang by your mutant grip cells from the high ceilings, safely away from any stray plasma blasts. Below you, everything burns. ",{"->":"ExitFive_Victory"},"\n",{"#f":5}],"c-9":["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.c-9.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.15.s"},[{"#n":"$r2"}],{"->":"ExitFive_Victory"},"\n",{"#f":5}],"#f":5,"#n":"kill"}],{"#f":5}]}],{"#f":1}],"#f":1}],"Exitone_thepeaceout":["^By dint of your swift feet, the complete lack of pursuit, and a loud warning cry of ‘stranger danger!’, you escape the odd young man, and go on with your adventurer’s life.","\n","^The only lasting impact of the encounter is the occasional passing thought on a sleepless night: “I wonder where the weirdo got that amazing cape?”","\n","^Just another little mystery for the filing cabinet. ","done","\n",{"#f":1}],"ExitTwo_givemedeathorgivemedeath":["^Humblebert pauses. “Well, I can’t really—oh, forget it. Just leave! You’re terrible on camera anyway. I’ve got better prospects three spreadsheets deep!”","\n","^A door opens to your left, leading back out into the busy spaceport you were abducted from. As you duck through it and head down the ladder, you can hear Humblebert yelling after you, “And don’t think I’m not showing this tape to the execs, you, you…aggressively independent person! I will! And they’re not going to be happy…”","\n","^You don’t think much of it. You have *real* trials to face, out there in the galactic maelstrom. And you really should be getting to them. ","done","\n",{"#f":1}],"ExitThree_oublietteroulette":["^“You are…incorrect,” the snynx says.","\n","^“INCORRECT,” Humblebert half-screams. The speakers pop and crackle. “You know what that means, folks! It’s time for…THE OUBLIETTE!”","\n","^And with that, the floor opens beneath you and all of a sudden you’re falling, all your life saving gadgets long gone and years of your past flashing past before your eyes, heart pounding to break your ribs and lungs gasping for the last air you’ll ever get before—","\n","^Everything stops. You stare, amazed to find yourself here, now, and wonderfully, gloriously alive. Even the scruffy, dark clothed man standing over you looks beautiful to your miraculously saved eyes.","\n","^“The snynx stumped you, eh?” he says, scratching his chin. “Well, it’s alright. She gets most of you. I told HB that he should make the riddles easier, you know, for the show, but who listens to me? I’m just a techie.” He looks at you. “Don’t look so scared, kid. The big bad Oubliette is just an airlock with a holofilter to make it feel like you’re falling fifty feet instead of two. [laughs] Got you good, didn’t it? Bit rough on the ol’ equilibrium, but the screaming always goes over well with the fans. I mean, for all they know, you’re bleeding out on a pit of spikes right now. Bloodthirsty, the public is. Absolutely bloodthirsty. But there it is. Good try. Oh, and if you want your compensation, you need to go visit the Unexpected Employee Liason office on Durant 3. Fill out forms 2a, 6x, and they’ll need a blood sample or three.”","\n","^“Thanks?” you manage, and scramble up and out into the spaceport before anyone else can decide you need to be part of their show. ","done","\n",{"#f":1}],"ExitFour_bouncehousestageleft":["^After falling for what seems like lightyears, you hit the ground with a light thump and nothing bruised but your pride.","\n","^“That was incredibly stupid,” a grizzled man in a dusty black shirt says, helping you to your feet. “I’ve never seen anything that stupid before. Really, I’m actually kind of impressed. Anyway, you lost! Too bad, very sad, you can claim your compensation at the Reality Conscription Offices on Datrik 6. Have a fantastic day!”","\n","^You wander off, slightly dazed, into the bustling spaceport from whence you began, not entirely sure what just happened and determined not to think too much about it.","\n","^You really, really, really need a drink. ","done","\n",{"#f":1}],"ExitFive_Victory":["^“Brilliant! Marvelous! Wonderful! Let’s have a hand for our new HERO, [Pilot’s name],” Humblebert Maxibillion’s voice announces, and then the man himself appears at last, dropping from above like an enormous bejeweled bat.","\n","^He beams, the expression so dazzling you can’t help but blink, and whips off his cloak. It flutters there, in a nonexistent breeze, catching the light like diamonds.","\n","^“Here,” he says. “I present to you, the Cape of Heroism, and the Official Irrevocable Title of Genuine Lucent Hero (tm). Use it wisely. And congratulations.”","\n","^As you tie the cape around your shoulders, the sound of cheering reaches you from worlds away, the people of Lucent’s empire sharing their adulation.","\n","^“Congratulations,” Humblebert says once more, shaking your hand. “And now…we send our hero back to [pronoun] life, secure in the knowledge that if we are ever in need…our tried and tested saviour will be ready.”","\n","^You open your mouth to speak, only to find yourself standing back in the spaceport from whence you began, your own trusty ship beside you and no Humblebert Maxibillion or dressed up castle ship in sight.","\n","^You turn, and as you do, your new cape catches the light like a swatch full of stars.","\n","^Hero, you think. Yeah. Yeah, I am. obtainspacecape ","done","\n",{"#f":1}],"Exit_DeathMKI":["^As it turns out, they can keep firing forever. Or, at least, much longer than you can keep running.","\n","^The last thing you remember is a strange sense of peace as your nerves burn away and then the world becomes just...nothing.","\n","^You wake up three days later in a Finch Medical regen-pod, all limbs and nerves attached and really wishing you didn’t remember how you ended up there.","\n","^You make a note in your journal. If the opportunity ever arises, Humblebert Maxibillion is toast. ","done","\n",{"#f":1}],"Exit_DeathMKII":["^Shockingly enough, reality game show sets don’t involve a lot of hiding places that are resistant to plasma arcing around at 28000K. You are positively incandescent to discover this. Luckily, the surprise doesn’t last long.","\n","^You wake up three days later in a Finch Medical regen-pod, all limbs and nerves attached and really wishing you didn’t remember how you ended up there.","\n","^You make a note in your journal. If the opportunity ever arises, Humblebert Maxibillion is toast. ","done","\n",{"#f":1}],"#f":1}],"listDefs":{}} \ No newline at end of file diff --git a/GameData/Narrative/Locations/TestA.json b/GameData/Narrative/Locations/TestA.json deleted file mode 100644 index dd822d57f..000000000 --- a/GameData/Narrative/Locations/TestA.json +++ /dev/null @@ -1 +0,0 @@ -{"inkVersion":20,"root":[[{"#":"constraint: DistanceFrom start = 0"},{"#":"type: Station"},{"#":"security: Open"},{"#":"faction: Miss Terri"},{"#":"name: Test Location 1"},{"#":"nameZone: Start Zone"},{"->":"start"},["done",{"#f":5,"#n":"g-0"}],null],"done",{"start":[["^This is the start of the location story.","\n","ev","str","^Go to location A1","/str","/ev",{"*":".^.c-0","flg":4},["ev",{"^->":"start.0.8.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^This will end the story.",{"->":"$r","var":true},null]}],{"c-0":["^ ",{"->":"A1"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"start.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.8.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^They lived happily ever after.","\n","end",{"#f":5}]}],{"#f":1}],"A1":[["^This content is in location A1","\n","ev","str","^Return to start","/str","/ev",{"*":".^.c-0","flg":4},["ev",{"^->":"A1.0.8.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":"A1.0.c-1","flg":2},{"s":["^Choice in location A1 ",{"->":"$r","var":true},null]}],{"c-0":["^ ",{"->":"start"},"\n",{"#f":5}],"c-1":["ev",{"^->":"A1.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":"A1.0.8.s"},[{"#n":"$r2"}],"end","\n",{"#f":5}]}],{"#f":1}],"#f":1}],"listDefs":{}} \ No newline at end of file diff --git a/GameData/Narrative/Quests/TestQuest.json b/GameData/Narrative/Quests/TestQuest.json deleted file mode 100644 index 41095e5f9..000000000 --- a/GameData/Narrative/Quests/TestQuest.json +++ /dev/null @@ -1 +0,0 @@ -{"inkVersion":20,"root":[[["done",{"#f":5,"#n":"g-0"}],null],"done",{"A1":[[{"#":"location: TestA"},{"#":"required"},"ev","str","^This choice is being injected from TestQuest","/str","/ev",{"*":".^.c-0","flg":4},{"c-0":["^ ",{"->":"A1A"},"\n",{"#f":5}]}],{"#f":1}],"A1A":[["^This is a branch thread being injected by TestQuest","\n","ev","str","^Exit the injected thread","/str","/ev",{"*":".^.c-0","flg":20},{"c-0":["^ ","end","\n",{"#f":5}]}],{"#f":1}],"#f":1}],"listDefs":{}} \ No newline at end of file diff --git a/Vault/Aetheria/scratch.md b/Vault/Aetheria/scratch.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/content-batch-one.md b/docs/content-batch-one.md index 4b38e4b30..1e14a5a69 100644 --- a/docs/content-batch-one.md +++ b/docs/content-batch-one.md @@ -738,7 +738,8 @@ is ready to write the moment the mint path is. ## 9. Blockers, mechanism gaps, and operator forks -**F1 — Only one spare ship prefab exists, and two hulls are specified.** +**F1 — Resolved by the 2026-09-26 hull restore.** The following was the authoring baseline when this batch was +mapped. Both Longinus and Djinni prefabs are now referenced by live hull records in `GameData/Aetheria.cc`. `ZoneRenderer.cs:295` instantiates `EngineAssets.Load(hullData.Prefab)` and calls `GetComponent()`. A hull with no prefab GUID renders nothing and null-refs. The project holds exactly two ship prefabs: `Assets/Content/Prefabs/Ships/Longinus.prefab` (used by diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index dbaee6897..00d783dc6 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1916,6 +1916,15 @@ Second round: from its own facing cell. Operator: "Spread is width makes sense for shotguns, but what about explosive penetrators? Burrowing in before splashing radially would be a satisfying way to damage vulnerable internals." That remark became sub-cut 12.4. + - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at + angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the + shared armour and mirror-image shots did different damage in ~30% of angled spread hits. + Operator: "I would prefer if the overlapping damage cells were diffused sideways to + thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): + **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` + width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly + that wide and half-open, so no two lanes can strike the same cell; the footprint widens at + angles instead of doubling up, and axis-aligned shots are unchanged. - **Q12-3 = A (direct hits):** a lane's remainder goes into the hull wherever the lane ends. - **Q12-4 = A:** delete `ShotOutcome.Aimed`. @@ -1929,7 +1938,8 @@ Second round: takes the share of the disc that lies inside it. The share of the disc that falls outside the hull's occupied cells is lost. Each covered cell absorbs independently, in the existing order (armour, then item, then hull). Nothing travels, nothing is occluded, and - no remainder is routed anywhere. + no remainder is routed anywhere. (Superseded for items by the 2026-09-25 one-path + ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) **A shot has a direction; a blast does not.** That sentence is the rule behind the split: direct hits keep the lane model of 12.2 and 12.3, and blasts are areas. Where P sits: @@ -1994,15 +2004,16 @@ Second round: embeds each type's schema descriptor, including `{"slot":29,"name":"AirburstRange", "type":"System.Nullable"}`, and the wire format is positional (`Probe5_Wire`: `[…,12.5,50,20]`, with no field names on the wire). On a content-hash - mismatch, `CultCache.ResolveSchema` (`CultCache.cs:590-670`) runs `CompareSchemaShapes` - (`:672-744`), which compares slot type, reference, cardinality, target schema, name + mismatch, `CultCache.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` + (`:672-752`), which compares slot type, reference, cardinality, target schema, name semantics and index alias, **not the member name**. So renaming slot 29 opens as `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` warning. The warnings persist until the catalog is next written with the new descriptor. Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: - no C# field of that name exists outside `ItemData.cs:498`. + no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as + `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. ### Frame, geometry and the bearing (the rules every sub-cut reads) @@ -2371,6 +2382,47 @@ a no-behaviour cut this size folds into 12.2's. factor should read higher from the beam. - Turn your armoured face into a slow missile and watch where it lands. +**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: +`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` +(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. + +What the Soul passes found, in order, because two of them are lessons for 12.3: +- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, + and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so + every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause + found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; + `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration + could follow a turn made after commit; multi-cell aimed items were untested. +- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. + The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result + into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root + cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, + `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the + two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an + `IComparer` in a delegate per sort). +- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the + slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all + cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and + float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration + found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact + arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. + The `Commit` invariant throw is unreachable. + +Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX +from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside +fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an +item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per +`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. + +**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at +b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant +survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of +their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in +`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make +`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through +visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` +import. + ### Cut 12.3. How a direct hit travels - **Repo/branch:** Aetheria, on top of 12.2. @@ -2390,8 +2442,11 @@ a no-behaviour cut this size folds into 12.2's. - **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches unchanged: 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. - 2. The lanes are `Lateral + k` for `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + - .5f)` (today's rounding, `Entity.cs:389`), spaced one cell apart along ℓ (Q12-2 = A). + 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's + rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and + **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per + cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two + derivations disagreed at cell edges and struck a cell twice). 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. 4. Each lane walks from its own facing cell, clipped to the penetration depth, with `rem = target.Absorb(cell, rem)` at each cell. @@ -2399,7 +2454,7 @@ a no-behaviour cut this size folds into 12.2's. exhausted, a gap, or the far side (Q12-3 = A). The remainders are summed and passed to `DamageHull` once. 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead - (12.4), never both. + (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. **Where 12.3 and 12.4 meet.** Which model a shot uses is decided by the blast radius, not by its penetration: @@ -2464,162 +2519,698 @@ a no-behaviour cut this size folds into 12.2's. - **Operator:** a smoke with a GT 3K or pswarm launcher into a LonginusX bow, then from a flank. The schematic display should pulse the facing edge only. +**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: +- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. +- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. +- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. +- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. +- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. +- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. + +297 tests. Unity batchmode is clean at `cccf3a2a`. + +Proportional absorption, passes 4 and 5: +- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` + was already ruled out by the operator. Soul found three more defects: + - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. + - The pooled resolve skipped the item's `.1f` threshold. + - The model sweep still passed with proportionality deleted. +- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. + `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is + Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. +- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled + bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. + Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and + stale comments. All are fixed, and each mutant dies to a committed test. + +Recorded, not fixed: +- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, + which only a private seam offers. The operator rule forbids reflection, and no test seam is added + for them. + - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. + - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). + - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). +- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. + +What the three Soul passes found: +- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of + 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an + angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. + It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on + (Q12-2 amendment: no doubling up, the line thickens). +- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits + struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first + structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the + centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit + with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float + drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two + derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, + the `ceil` empty centre); the cure each time was one owner. +- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two + lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by + construction. Residual float effects (a side lane starting one cell deeper, a column falling + between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage + is never lost. + +**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike +exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% +of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing +ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since +it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. + +**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the +first-processed lane drains its durability and the second carries the leftover into its own deeper +cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on +a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs +from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. +Alternative: keep the processing order and pin it. + +**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell +item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane +carries on with a leftover in proportion to what it brought. The result must not depend on the order +lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. + +**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an +exception, this should be one code path." The first implementation (`56757913`) surveyed each shot +for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split +authority, and the second reachability derivation it needed is the same failure as the 12.2 empty +lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of +the lanes that reach it, a pool of one lane included. The survey and the sequential path die. + +**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. + ### Cut 12.4. The detonation primitive +**Status (2026-09-29, Self): closed.** 12.4 landed at `2625768f`, `7c99909d` and `7e63d744`. + +Soul pass 1 (Opus) found three defects: +- **F1:** a tangent disc created damage, 127 of 100. +- **F2:** a candidate cull measured from the centre of mass missed hull corners. +- **F3:** a lethal blast threw mid-loop and detonated again. + +It also found four rules pinned only by tests that could not fail. + +The fixes landed at `8b4bf01e`, `76e98598` and `fd792013`: +- the tangent comparisons now include equality; +- the cull and the `radius <= 0` guard are deleted, so `RectDiskOverlap` is the only decider; +- the blast loop iterates a snapshot. + +Soul pass 2 closed it. Conservation held over 1,944 exact-lattice discs, with a worst error +of 2e-5. A blast costs 0.3-0.7 ms in a zone of 40-100 entities. + +Pins landed at `4629bc67` and `c7e20f08`. `Death` fires once per entity (`Entity.cs`, `.Take(1)`), +which also fixed a direct hit on a corpse raising `Death` again. A three-kill blast test was added. +362 tests pass. + +**Ruled (operator, 2026-09-30): "fused weapons without a lock explode at max range."** This is no longer +open. Landed at `8c002637` (fix) and `3e8ee022` (tests), and Soul confirmed it. That Soul pass raised four +follow-up rulings, all given on 2026-09-30: +- **The aim is `LookDirection`, restricted by the mount's arc.** The operator notes this was already the case; + the fix had used the hull-fixed mount. +- **An invalid target counts as no lock.** A target that is selected but out of range, out of arc, not + visible, or not locked takes the no-lock path. Nothing bursts beyond max range. +- **A no-lock round of any fuse stops on the first hull on its aim line**, computed at commit, and detonates + there. This covers contact, proximity and delayed fuses (operator, 2026-09-30, extending the contact-only + ruling). A round fired along a clear line still bursts at max range (bomb fishing). Mining adds chunks to + this through a named seam. +- **Airburst follows player intent (operator, 2026-09-30):** "If I don't have targeting data but decide + to shoot anyway, my intent is likely to be bomb fishing. If I fire with a target but that target warps + away, I likely to not want to explode whatever was behind it, and the projectile shouldn't burst." + - A round fired without valid targeting data bursts at max range. + - A round fired at a valid target that later leaves or becomes invalid does not burst. It resolves + with no detonation. + - Validity is judged on the targeting data at fire time. +- **Out of arc (operator, 2026-09-30):** a fused weapon whose selected target is out of arc still fires + (bomb fishing). It bursts at the target's range, clamped to max range, along the arc-clamped aim. + Non-fused weapons refuse. +- **Arming distance (operator, 2026-09-30; refined the same day):** it is literal. A round never bursts + closer than `BlastRadius` to the ship's centre at fire time (`FireOrigin`), whatever the hull size or the + aim. Close bursts are pushed out to that distance, and fire is refused when `Range < BlastRadius`. A large + hull can take splash from its own close bursts, and that is accepted. +- **Demoted rounds keep their target (operator, 2026-09-30):** a round fired at a valid target closer than + the blast radius flies to the arming point but stays targeted. If the target leaves before resolution, + the round does not burst. A pilot who flies into their own distant burst + takes the damage. Fire is refused when the live Range cannot clear BlastRadius. +- **Turret arc ownership (operator, 2026-09-30):** the simulation's arc is authoritative, and Unity barrels + derive their aim from it. The true owner of an arc is the **pivot**, not the hardpoint. The arc stays on + the hardpoint until the moddable-ships campaign adds pivots to `HullData`. Until then, a pivot carrying + several hardpoints turns within the **intersection** of their arcs. +- **AI and turrets (operator, 2026-09-30):** each weapon decides for itself, not the group's first weapon. + A fused weapon fires whenever its target is designated (visible, in range, locked), whatever the arc. A + non-fused weapon keeps the hit-chance threshold. The AI never fires at nothing, so AI does not bomb-fish. +- **HUD forecast (operator, 2026-09-30):** for a fused weapon, the forecast shows the outcome (Direct, Burst + at N m, Refused). `Fire` and `Inspect` compute it with the same helper. Direct-hit weapons keep the + percentage. +- **A refused round is free (operator, 2026-09-30):** it spends no ammo or energy, and makes no sound, heat, + wear or visibility. **It does not start the cooldown either** (operator, same day: "triggering cooldown on + refusal would be mostly neutral but punishing for long cooldown weapons, worth the code to fix"). +- **Batch 5 Soul pass, 2026-09-30: fix first.** `Solve` is the one decision, and `Fire`, `Inspect` and + `Refuses` all read it. Findings in fix batch 6 (Hands, `hands/fuse-fix2`): + - Contact and delayed rounds detonate on the hull and ignore the arming push-out. + - A refused beam blinks start and stop. + - A throwing damage observer re-applies a blast every tick. + - The AI selects refused fused weapons. + - A single-ammo burst pays, then refuses. + - The guided projectile's overshoot explosion ignores the simulation. + - Two copies of the range and designation predicates remain. + + An out-of-arc round with a selected target bursts after that target leaves. That is ruled behaviour: an + out-of-arc target is invalid, so the round takes the no-lock path. +- **Landed, 2026-09-29: batches 5-8 merged at `06001b57`.** 476 tests pass on the merged tree, after Soul + passes 5-7. Recorded follow-ups: + - The push-out follows the flight line, not the ray to the hull point. + - The HUD's burst range is an upper bound when a no-lock round stops at an earlier hull. + - The first-hull stop reads poses at fire time. + - A burst whose first round fails its energy or ammo spend retries every tick, bounded by capacitor refill. + - The first-round cooldown divides by `Cooldown` even when the magazine empties (a reload quirk that + predates this work). +- **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go + nowhere. + +Soul also found two more defects: +- A fused weapon with `Range <= BlastRadius` bursts on its shooter. The fix is to refuse it when the catalog + opens. +- A burst without a target reads as MISS in the debug HUD. + +Fix batch 2 covers all six, in Hands on `hands/fuse-fix2`. The stale lines about "`BurstPosition` is read +only for a proximity fuse" and the Apply rules below it are superseded by these rulings. +Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship +still takes blast damage. + +Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path +rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. +Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see +Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. + - **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: - - (a) the data fields and the label demotion. This is behaviour-neutral on shipped - content, because no weapon carries the label or a radius **(probe)**. - - (b) `Detonate`, with Splash deleted. -- **Deletes first:** - - The label read `isAirburst = … WeaponModifiers.HasFlag(WeaponModifiers.Airburst)` - (`FireControl.cs:281-284`), in (a). Afterwards `WeaponModifiers` has no simulation - reader. `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing (today - it returns exactly `FireControl.cs:282`). - - `FireControl.Splash` (`:488-529`) and its half-hull footprint, in (b). This absorbs - F12-1. - - `PendingShot.BurstRadius` (`:648-652`) becomes `BlastRadius`, and `BurstPosition` stays. -- **Data (smallest field set, both on `WeaponItemData`, `ItemData.cs:476-499`):** - - Slot 29, `float? AirburstRange`, is renamed `float? BlastRadius` (JSON `"blastRadius"`), - in world units, meaning the same thing it meant for airburst. Same slot, same type: - CultCache resolves it as compatible drift (see the probes). - - New slot 30: `WeaponFuse? Fuse` (JSON `"fuse"`), with `enum WeaponFuse { Contact, - Proximity, Delayed }` in `Enums.cs`. It is nullable per the 7.4 rule. Null means no - blast, which is what every shipped record reads as. - - A shot detonates iff `Fuse != null && BlastRadius > 0`. Fire freezes both - (`PendingShot.Fuse`, `BlastRadius`). A fuse without a radius (or a radius without a - fuse) is inert, and nothing reports it: Q12-6 ruled that the catalog is not policed. - - The catalog needs one write through `tools/AetherDb` or Studio to clear the drift - warnings. That write is content, with no data change, and is recorded as part of the - retune follow-up. - - Readers to update: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, - 279`, which author `WeaponModifiers.Airburst`. Those fixtures move to - `Fuse = Proximity`. Whether those fixtures keep the label is free: Q12-6 ruled that - nothing checks labels against behaviour. -- **Adds:** `FireControl.Detonate(Zone zone, float2 worldPoint, float radius, float damage, - DamageType type)`, the one owner of blast damage, plus `Entity.ToSchematicPoint` and - `ToWorldPoint` (see the frame rules). - - **Where P is:** - - Proximity: Step calls it at arrival with the frozen `BurstPosition` - (`PredictedIntercept`, `:283`). There is no roll, as today (`AirburstSplashesEvenOnA - GuaranteedMiss` stands). - - Contact: on a committed, unshielded hit, P is the entry point of the impact cell on - the committed lane. - - Delayed: on a committed, unshielded hit, P is the end of the penetration walk on that - lane (penetration exhausted or first gap). The walk only locates P; nothing is - absorbed along it (Q12-8), and that is true only because this weapon carries a blast. - A weapon with no blast radius never reaches this path and spends its damage down the - lane instead (12.3). + - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no + weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has + not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). + - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. - Both contact and delayed are converted with `target.ToWorldPoint`, so every entity in - the radius (the host included) is treated alike. A shielded contact or delayed hit is - absorbed whole, as a direct hit is, and does not detonate. `Mine.cs:99` calls - `Detonate`. - - **Area, per entity:** - 1. Candidates are the entities with `|Position.xz - P| ≤ radius + boundingRadius`, - where `boundingRadius = ½·√(W²+H²)·cellSize`. - 2. For each candidate, `Ps = ToSchematicPoint(P)` and `rCells = radius / - SchematicCellSize`. - 3. For every cell of the candidate's schematic inside the bounding box - `[Ps ± (rCells + .5)]`, compute `overlap(c)`, the exact area of the disc of radius - `rCells` centred on `Ps` intersected with that cell's unit square. - 4. An occupied cell takes `damage × overlap(c) / (π · rCells²)` through - `Absorb(c, …)`, and the remainder of that share goes to `DamageHull`. An unoccupied - cell's share is lost, and so is every part of the disc outside the schematic. - 5. Entities are independent. Two ships caught by one blast each cover their own cells, - and neither shadows the other. - - **Why exact overlap and not sampling.** The overlap is the standard circle-rectangle - intersection, evaluated per cell from the antiderivative `½(x√(r²-x²) + r²·asin(x/r))` - over the cell's clipped x-span, combined across the rectangle's edges. It is closed - form, allocation-free, and exactly conservative: the overlaps over the bounding box sum - to `π·rCells²`, so shares sum to 1 and the only loss is geometric. Supersampling would - be cheaper to write and would break that: a blast smaller than one cell, or one - straddling a cell boundary, would deliver the wrong total, and the conservation test - below could only be written with a tolerance wide enough to hide real errors. - - **Shields:** an entity with an active shield is charged the summed share of the disc - that its occupied cells cover, under the same `CanTakeHit`/`Break`/`TakeHit` rules as - today. - - If the shield absorbs, no cell of that entity takes anything. - - If not, it breaks and the cells take their shares. +#### Deletes first - Today every shield in radius pays the full damage (`:505-513`). Charging the covered - share makes the shield pay only for what reaches the ship, which is consistent with - "omnidirectional": the reserve has no facings, but it is not charged for energy that - went past the hull. - - **Cost:** `candidates × (2·rCells + 2)²` overlap evaluations, about 3 × 27² ≈ 2k for a - 25-unit blast at the default cell size, once per detonation and never per tick. No - allocation: the loop writes nothing but the per-cell absorption. - - **Accepted loss.** Interior armour no longer shadows the cells behind it, because - nothing is occluded within an area. A bulkhead protects its own cell and no other. The - ray model bought that shadowing, and the operator cut the ray model. Falloff toward the - rim is F12-7, not this cut. - - **HitProbability and Inspect** are unchanged: - - A contact or delayed blast still needs the shot to hit, so the same forecast - applies. - - A proximity blast has no roll and delivers whatever geometry gives. The forecast - still reports the direct-hit number for it. That is pre-existing, and AI gating on it - is recorded as F12-5. -- **Authority map:** - - Owner: `FireControl.Detonate` owns blast damage and the disc's coverage. - `Entity.Absorb` owns per-cell absorption, shared with 12.3. `Lane` owns traversal and - is a direct-hit function only; a blast calls it just once, for the delayed fuse's - point, on the walk 12.3 already performs. - - Inputs: - - world P, radius, damage and type; - - each entity's position, facing, hull, armour and occupancy at the detonation tick; - - the fuse and radius, frozen from weapon data at Fire. - - Outputs: the same mutations and events as 12.3. - - Derived state: `WeaponModifiers` is display-only: `SchematicListElement.cs:54` icons, - `GameSettings.cs:67`, and `GameplaySettingsEditor.cs:44`. - - Forbidden writers: - - No label decides behaviour. - - Nothing but `Detonate` applies area damage. - - Mine's Unity `BlastRange` and arming query remain presentation-side inputs (R8 - residue, Cut 4 Q7) and decide no damage themselves. - - Shared paths: the airburst resolution in `Step` (`:356`), Mine (`Mine.cs:99`), and - contact and delayed hits from `Apply`. - - Deletion line: `Splash` and the label read are deleted before `Detonate` is called. -- **Verification:** - - `TheDiscIsConservedOverTheGrid`: over a solid hull large enough to contain the disc, - the total damage delivered equals `damage` within float tolerance, at several radii - including one smaller than a cell and one centred on a cell corner. It kills a - sampling approximation, a wrong normalizer, and a bounding box that clips the disc. - - `EachCellTakesItsShareOfTheDisc`: on a solid hull, the damage on a named cell equals - `damage × overlap / (π r²)`, with the overlap computed independently by the test - (fine numeric integration). It kills an even split over the covered cells, which is - what today's Splash does. - - `ExternalBlastWastesMostOfItsEnergy`: a proximity blast beside a hull delivers total - armour + item + hull damage well below `damage`. The expected share is the disc's - overlap with the schematic, computed by the test. - - `InternalBlastDeliversIt`: the same blast deep inside the hull delivers nearly all of - `damage`, and damages interior items an external blast of the same radius cannot reach. - - `PenetratorBurrowsBeforeItBursts`: a delayed-fuse hit with penetration 2 into the - enclosed-cockpit fixture damages the cockpit, while a contact-fuse hit with the same - stats does not. It also pins Q12-8's scope, in two halves: - - With a blast radius, the armour the burrow passes through absorbs nothing, so the - cockpit's share is the full area share of its own cell. - - With the radius removed and everything else held, the same shot is a dumb AP round: - it damages the cells along its lane in order, and the cockpit gets only what the - armour and items in front of it left (12.3). It kills a build that routes every - penetrating shot through the fuse path. - - `ABlastDamagesTheCellsNearestIt`: a blast to port damages port cells and leaves - starboard cells untouched, at facings including |fx| > |fy|. It is the successor of - `SplashDamagesTheSideTheBlastCameFrom` and `SplashIsDirectional`, and it is what pins - `ToSchematicPoint`'s handedness. - - `ShieldPaysForWhatReachesIt`: a shield in radius is charged the covered share, not - the whole blast. `SplashShieldAbsorptionDrainsTheReserve` and - `SplashBreaksUnabsorbedShield` are rewritten to the covered share, with the expected - amounts recomputed rather than loosened. - - `LabelsDoNotDecideBehaviour`: a weapon labelled `Airburst` with no fuse resolves as a - direct hit, and a weapon with `Fuse = Proximity` and no label detonates. - - Kept green: `AirburstSplashesEvenOnAGuaranteedMiss` and its no-double-application twin, - re-fixtured to `Fuse`. - - Negative: `rg -n "Splash\(|HasFlag\(WeaponModifiers" Assets/Scripts tests` returns - nothing. Today it matches `FireControl.cs`, `Mine.cs` and `FireControlCut4/5/6/11Tests.cs`. - - Stryker: `--since:<12.3 head>`. -- **Operator:** author one proximity weapon and one delayed-fuse weapon in a scratch - catalog. Watch an airburst beside a LonginusX and a penetrator into its nose, and check - that the damage lands where the model shows the blast. That also verifies - `ToSchematicPoint`'s centre-of-mass anchor. +In (a): + +- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), + `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. + `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. +- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The + initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a + proximity fuse. +- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. + +In (b): + +- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. +- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. + `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). +- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell + separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. + **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` + the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. + 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. +- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended + answer. It is not deleted: it is gated on `shot.Fuse == null`. +- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one + reach function (see **The penetrator**). +- Stale comments that name a deleted owner as live: + - `Entity.cs:391` and `:417`; + - `FireControl.cs:1201`; + - `Gameplay/EntityInstance.cs:280-283`; + - `Gameplay/HullCollider.cs:4-6`; + - `Gameplay/Weapons/Projectile.cs:8-9`; + - `Gameplay/Weapons/Mine.cs:90-95`. + +#### Data + +The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). + +- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name + `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache + opens the catalog as compatible drift (see 0b). +- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, + Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: + null means no blast, and every shipped record reads as null. +- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when + `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing + re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius + without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is + not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on + `BlastRadius` alone (Soul, `2625768f`). +- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That + write is content and changes no data. It belongs to F12-2. +- **Readers to update:** + - `FireControl.cs:386-388`. + - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` + parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, + because Q12-6 ruled that nothing checks labels against behaviour. + +#### Which model a shot uses + +`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: + +- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. +- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which + `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, + `AirburstSplashesEvenOnAGuaranteedMiss`. +- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P + is**). On a miss nothing happens. + +Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse +into Apply's switch, so `Step` has no damage branch left. + +`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and +only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one +consumer is the player's hit marker (`ActionGameManager.cs:1070`). + +This is a default, not a ruling. + +#### Where P is + +The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as +`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). + +**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: + +- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 + from its centre; +- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. + +So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. + +- **Proximity:** `BurstPosition.xz`, a world point. +- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and + `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. +- **Delayed:** the schematic point `point(t_fuse)`, with + `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that + 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, + whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last + reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. +- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already + proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not + read. The disc's radius is its width. This is a default, not a ruling. +- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the + host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like + every other entity in the radius: its cells are found again by `ToSchematicPoint`. + - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for + it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. + - Bystanders are judged against where the host actually is at arrival. That is the same live rule a + proximity burst already applies to everyone. + + There is exactly one input shape, a world point. There is no host-only schematic entry. + +#### The penetrator (Q12-8) + +A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` +the shot's whole `Damage`**. + +That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the +explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. + +The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at +`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → +int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while +`entry - impact.entry < penetration`. + +Two callers read it: + +- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; +- the delayed fuse point, which calls it on the centre lane. + +No second "lane damage with a blast" path exists, because no shot does both: + +- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; +- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. + +A shot takes one branch of Apply's switch or the other, never both. + +The mutant this must kill is a second reach derivation: for example, a delayed P computed as +`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. + +#### Area, per entity: `FireControl.Detonate` + +The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, +public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). + +- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes + `position.ToCultMath().xz`). +- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / + SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the + frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and + `SchematicCellSize` is `Settings.cs:255`. + +For each entity in the zone: + +1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. + Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a + long hull whose end lies inside the blast. +2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For + every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × + overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a + cell being the unit square centred on its integer coordinate. + - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing + computes an overlap twice. + - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. +3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, + under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact + or delayed blast, under Q12-9's recommended answer. + - If the shield absorbs, no cell of that entity takes anything. + - If it cannot, it breaks, and the cells take their shares. +4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects + its own cell's share and nothing else. +5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in + hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has + deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join + the hull total. +6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). + +Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the +other (F12-8). + +**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, +and no operator question is needed: + +- "Armor absorbs first", from the first round of rulings; +- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; +- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where + incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after + `ArmorAbsorb`; +- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." + +The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: +an item's cells deposit into one pool, and they do not absorb independently. + +**One owner, and why there is no walk.** + +- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on + the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. +- `ArmorAbsorb` is the one owner of what armour does. +- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, + `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the + item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no + cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the + reachability test and the cycle rule have nothing to decide. + +The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull +with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell +with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 +discs. Its hull total differed only by float summation order, at most 3.8e-6. + +`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a +starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. + +**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same +per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only +order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. + +**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the +disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` +between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it +conserves exactly up to float rounding. + +- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at + radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double + precision is not needed. +- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. +- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc + smaller than a cell, or for one straddling a cell boundary. + +**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than +128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not +the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. + +**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an +area is occluded. A bulkhead protects its own cell's share and no other. Falloff toward the rim is F12-7, not +this cut. + +**HitProbability and Inspect are unchanged.** A contact or delayed blast still needs the shot to hit, so the +forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. +That is pre-existing, and it is F12-5. + +#### Authority map + +**Owners:** + +- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. +- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the + per-entity armour→pool→hull pass. +- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with + 12.3's walk. +- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. +- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. +- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. + +**Inputs:** + +- P, the radius, the damage and the type; +- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; +- the fuse and the radius, frozen at Fire; +- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. + +**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, +`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. + +**Derived state:** + +- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, + `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. +- `BurstPosition` is read only for a proximity fuse. +- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. + Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` + finds only `FireControl.cs`). + +**Forbidden writers:** + +- No label decides behaviour. +- Nothing but `Detonate` applies area damage. +- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. +- Nothing but `Reach` decides how deep a lane reaches. +- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). +- `Step` has no damage branch. +- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they + decide no damage themselves. + +**Shared paths:** + +- the proximity burst: Apply at arrival, with no roll; +- the contact and delayed blasts: Apply on a committed hit; +- `Mine.Explode`; +- the tests, which call `Detonate` directly (see **Test seam**). + +**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a +caller. + +#### Test seam + +`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen +intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production +interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from +`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is +committable with no seam and no reflection. + +It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at +an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes +a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and +exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: + +- angled direct-hit correctness is covered by the model sweep and + `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; +- P1 and P2 stay uncommitted. + +#### Verification + +These fixture rules apply to every blast test that touches the frame: + +- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a + deleted `/ SchematicCellSize` survives. +- The target is off the origin. +- The facings include one with |fx| > |fy|. +- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. + +Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 +absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. + +**Geometry.** + +- `TheDiscIsConservedOverTheGrid`. + - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius + under one cell, one centred on a cell corner, and one off the grid. + - Pass: the total equals `damage`. + - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box + that clips the rim cells; a dropped chord breakpoint. +- `EachCellTakesItsShareOfTheDisc`. + - Setup: a solid hull with no armour and no items. + - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. + - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the + cell's centre. +- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). + - Setup: a blast straddling the hull's edge. + - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The + shares sum to the blast's damage minus what falls off the hull. + - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. +- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. + - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. + - Pass: the near end takes damage. + - Kills: Splash's centre-distance cull. + +**Absorption order.** + +- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` + (`FireControlCut123Tests.cs:750`). + - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage + levels of the old test: below the armour, between the armour and armour plus durability, and above both. + - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. + - Kills: the item absorbing before the armour, and armour being skipped. +- `ArmourProtectsOnlyItsOwnCellsShare`. + - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. + - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. + - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat + cell B's share. +- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. + - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but + more than `.1` on the two together (the `ProbeC2` geometry). + - Pass: the item absorbs the sum. + - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or + a pool that resolves before every covered cell has deposited. + + This test is also what pins that the result does not depend on which cell is considered first. As shown + above, the threshold is the only order-sensitive rule a blast has left. +- `ItemDamageFiresOncePerCoveredCell`. + - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive + post-armour deposit, and each event carries that cell's deposit. + - Kills: one event per item, and events for zero-deposit cells. + +**Symmetry and frame.** + +- `MirroredBlastsDoMirroredDamage`. + - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of + world points mirrored across the target's bow axis, with the mirror computed by the test from the facing + and not through `ToSchematic`. + - Pass: per-cell armour, item durability and hull damage mirror. + - Kills: + - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); + - a missing or wrong `CenterOfMass` anchor; + - any scheduling rule that depends on enumeration order. + + It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test + does. +- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` + (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). + - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's + width. + - Pass: port cells are damaged, and starboard cells are untouched. + - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. + +**Shields** (under Q12-9 = A). + +- `ShieldPaysForWhatReachesIt`. + - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. + - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` + (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the + covered share, never loosened. + - Kills: charging the full damage. +- `ABlastShotsShieldIsDecidedAtDetonation`. + - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at + arrival. + - Pass: the shield is charged the covered share. + - Second setup: a shield that could absorb the whole shot at commit. + - Pass: the shot still detonates, and the shield pays its covered share. + - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the + host. + +**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). + +- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit + behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. + - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour + of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. + - A contact fuse with the same stats leaves the cockpit untouched. + - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in + order, and the cockpit takes only what the ring left (12.3). Damage in a line. + - Kills: + - absorbing along the burrow; + - a contact fuse reading penetration; + - routing every penetrating shot through the fuse path; + - a fuse without a radius detonating. +- `TheFusePointStaysOnMetal`. + - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap + behind the first run. + - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the + gap's near edge). + - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. +- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. + - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. + - Pass: the host's per-cell damage equals that of the same shot with no turn. + - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. +- `LabelsDoNotDecideBehaviour`. + - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with + `Fuse = Proximity` and no label detonates. +- `AFuseWithoutARadiusIsADirectHit`. + - Pass: `Fuse = Contact` with a null or zero radius does lane damage. + - Kills: Fire freezing `Fuse` without the radius check. +- Re-fixtured to `Fuse`, with their behaviour unchanged: + - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes + `ProximityDetonatesEvenOnAGuaranteedMiss`; + - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); + - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; + - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes + `ABlastHitsEveryEntityItCovers`. +- `AContactBlastReportsTheHit`. + - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. + - Kills: `IncomingHit` moved inside the direct-hit branch. + +**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: + +- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes + `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with + a remainder of 0. +- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool + of `{ .1f }`. + +**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the +clip rule are otherwise untouched. + +**Negative greps:** + +- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` + returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and + `FireControlCut4/5/6/11/123Tests.cs`. + - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are + unrelated. + - Exclude `StrykerOutput`, `bin` and `obj`. +- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the + stale-comment list above. + +**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. +`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). + +#### Operator + +- Author one proximity weapon and one delayed-fuse weapon in a scratch catalog. +- Watch an airburst beside a LonginusX and a penetrator into its nose, and check that the damage lands where + the model shows the blast. That verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of + the frame that only a running blast can show. + +#### History (superseded 2026-09-25, kept for the record) + +The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: + +- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** + Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour + stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. +- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with + `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. +- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** + Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then + `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit + had judged unshielded: two authorities over one shield. +- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and + `Step` has no damage branch. +- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one + `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. +- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was + `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. ### 0b. Identity, lifecycle, authority @@ -2627,8 +3218,8 @@ Two persisted changes, both on `aetheria.weaponitemdata` v1 and both in 12.4: | Field | Slot | Change | Lifecycle | Reader risk | |---|---|---|---|---| -| `BlastRadius` (was `AirburstRange`) | 29 | rename, same `float?` type | authored catalog | compatible drift: `CompareSchemaShapes` ignores member names (`CultCache.cs:672-744`); drift warnings until the next catalog write | -| `Fuse` | 30 | new, `WeaponFuse?` | authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule | +| `BlastRadius` (was `AirburstRange`) | 29 | rename, same `float?` type | authored catalog | compatible drift: `CompareSchemaShapes` ignores member names (`CultCache.cs:672-752`, CultLib `45c2f40`); drift warnings until the next catalog write | +| `Fuse` | 32 | new, `WeaponFuse?` | authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule | Runtime-only and never serialised (0b table, `FireControl.cs:626-629`, `:686-689`): @@ -2673,6 +3264,50 @@ Ruled 2026-09-22 (the words are recorded under **Rulings**): Open: +**Q12-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. + +**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a +blast that needs a hit?** + +The current map lets two places decide it: + +- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. +- `Detonate` then decides every shield in the radius live, and that includes the host. + +**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so +the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can +absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split +this campaign has cut three times. + +- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield + for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the + radius, live at detonation. + - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no + direction", applied to shields. + - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that + reaches the hull. For a contact detonation on the hull's face, that is roughly half. + - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads + them. +- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole + and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" + parameter: a compensator that keeps two owners alive, one per entity. + +What depends on it: + +- the gate on Commit's shield block; +- whether `Detonate` takes a host parameter; +- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. + +Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half +of (b) can proceed while this waits. + +**Defaults taken, not rulings.** The operator may overrule any of these: + +- A blast shot ignores `DamageSpread`, and P sits on the centre lane. +- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. +- The delayed fuse point is clamped to the last reached cell's exit. +- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. + - **Q12-5 (not ruled). Should the HUD show which target items are exposed from the current bearing?** - **Recommended:** a follow-up after 12.3. It would be a presentation read of `Silhouette` @@ -2709,22 +3344,33 @@ Open: - **Orphaned `Airburst*` YAML** in nine weapon prefabs. It is harmless, and Unity drops it on the next save of each prefab. -### Subtraction ledger (estimate) +### Subtraction ledger (estimates, with actuals) | Sub-cut | Removed | Added | Notes | |---|---|---|---| | 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | -| 12.1 | ~15 (two frame copies, Aimed plumbing) | ~6 (`ToSchematic`) | no behaviour change | -| 12.2 | ~65 (HullKernel, WeightedPick, POnHull, live direction, bounding extent, HUD estimate, stale comments) | ~95 (TravelDirection, Silhouette, CommitProbability, lateral draw, Lane, 5 PendingShot/Outcome fields) | 6d kernel tests rewritten (~−150/+220) | -| 12.3 | ~80 (DamageSchematic, ApplyHit) | ~45 (Absorb, DamageHull, lane orchestration) | 2 FireAuthority tests replaced; ~6 new | -| 12.4 | ~45 (Splash, its half-hull footprint, the label read, BurstRadius plumbing) | ~60 (Detonate, circle-square overlap, point transforms, `WeaponFuse`, slot 30) | Splash tests rewritten; ~7 new. Areas need no ray loop, so this is smaller than the ray draft | -| **Net Aetheria src** | **~205** | **~205** | one persisted slot added (nullable), one renamed; no targets, dependencies or formats; CultMath gains 2 functions | +| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | +| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | +| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | +| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | +| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | -The net is roughly flat: the positive part buys the bearing-aware scatter, sequential -absorption, and a blast model that replaces a half-hull approximation. The rest is -subtraction. +Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: + +- 12.1: `b851b0d1^..b851b0d1`; +- 12.2: `b851b0d1..c7b6e80c`; +- 12.3: `c7b6e80c..d402283c`. + +The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: + +- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; +- 12.3: sequential, proportional, order-free absorption; +- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, + `Absorb` and `Step`'s branch. + +12.4's own delta is ~+60, and that is the estimate to hold it to. **Section history:** Cut 6d's "The rule" (the 2D kernel) and its premise that pOnHull folds -into PBase at Fire, 9.2's SigmaFloor rationale, and Cut 4's Splash rule (`FireControl.cs:488-497`) +into PBase at Fire, 9.2's SigmaFloor rationale, and Cut 4's Splash rule (`FireControl.cs:946-959`) describe models this cut replaces. Self should mark them as history in the status header as 12.2 and 12.4 land. diff --git a/docs/locomotion-cut.md b/docs/locomotion-cut.md new file mode 100644 index 000000000..84a143ea9 --- /dev/null +++ b/docs/locomotion-cut.md @@ -0,0 +1,940 @@ +# Locomotion: Control Allocation and Capability-Aware AI + +Date: 2026-09-22 + +Status: target and cut map, from an Imagination pass revised on 2026-09-22. Cut 1 (the Longinus and Djinni hulls, +five thruster designs, and tutorial entrance station rule) landed on `codex/locomotion-cut1` and was merged into +`codex/fire-control-12` at `e160bbe5` on 2026-09-26. Cuts 2 to 5 remain target work. The operator's Q1, Q2, +Q3 and Q5 rulings below still govern that work. **The cuts were renumbered by the 2026-09-22 revision**: the +content restore became Cut 1, and what the first committed version called Cuts 1 to 4 became Cuts 2 to 5. +Historical anchors below are against `origin/codex/item-provenance` at `cd846916`; the then-current working tree +on `codex/fire-control-10` (`e8fbb6a6`) had an identical tree for every file named below. The first half of this +document is the **target** (the ends). The second half is the **cut map** (the means). When the campaign closes, +split the target out as +`docs/locomotion-target.md` and keep this file as history. + +Claims marked **(probe)** come from running code: scratch builds of `Aetheria.Shared` from `cd846916` against +CultLib `45c2f40`, run in the session scratchpad. That covers catalog dumps, a legacy-msgpack decode, base-commit +flight measurements and a solver comparison. None of the probe sources are kept. Each result below says what was +run. The base suite passes 244/244 in that scratch copy. + +## Rulings + +Operator, 2026-09-22: + +- **Problem statement.** "We're gonna need much smarter locomotion AI if they're to survive getting a thruster + shot out. Longinus (non X) had one big thruster in the back and two attitude thrusters in the front. Losing an + attitude thruster means you can't rotate that way anymore. Losing a thruster on the Djinn usually means some form + of crab walking just became optimal. Naively trying to use a thruster that's been shot out makes them real easy + to cheese." +- **R1, two layers.** (1) **Control allocation owns which thrusters fire.** Each live actuator contributes a + body-frame wrench (Fx, Fy, τ) per unit throttle, with throttle in [0,1]. A bounded least-squares solve finds the + throttles for the desired wrench (Johansen & Fossen 2013 control-allocation survey). This replaces the rotation + buckets and the strafe torque compensation. Thrusters that are destroyed, unpowered, disabled or degraded drop + out of the matrix or scale down in it. (2) **The AI plans with the allocator's achievable set.** For a desired + Δv it scores candidate headings by achievable acceleration along Δv plus turn time in the directions the ship can + still rotate. Crab walking and the long way round emerge from that scoring; neither is special-cased. +- **R2, the same-inputs invariant.** "Player and AI are controlling the same ship with the same inputs, this is an + Aetheria invariant." The player's controls go through the same allocator, through the same input surface the AI + writes. Any second path that bypasses it is a forbidden writer. +- **R3.** A capability-aware **combat facing/tactics** layer is wanted as its own later cut. Example: a one-way + turner keeps its target drifting the way it can turn and leans on its turret arcs. This document maps it at + target-shape level only. +- **R4.** If CultMath lacks a bounded least-squares solver, CultMath gets one, as a CultLib cut. Aetheria does not + grow a local helper. CultMath has none (probe: `packages/cultmath/src/CultMath` at `45c2f40` holds vector types, + `math`, `BatchMath`, `Voronoi` and `Random`, with no linear solver of any kind). + +Self, relaying an operator answer, 2026-09-22. This is the layer split, stated precisely: + +- **Below the inputs**, shared by player and AI, is the ship's own controller plus the allocator. It turns toward + `LookDirection` and realises `MovementDirection` with whatever actuators are live. **Choosing a feasible turn + direction belongs here**, including the long way round when one rotation direction is dead. If it lived in the + AI planner, a player ship with a dead attitude thruster would still stick when looking toward its dead side. + Holding heading against torque coupling (strafing with an off-centre thruster) also belongs here. +- **Above the inputs**, AI only, is the heading planner. It decides what to write into + `LookDirection`/`MovementDirection`, for example to turn sideways and crab-walk. The player makes that choice by + hand. +- The long-way test therefore belongs to the shared-controller cut and drives `LookDirection` directly, as the + player would. The crab-walk test belongs to the planner cut. + +Operator rulings on this map, 2026-09-22, relayed by Self: + +- **Q1 = A, the divergence is accepted.** "The divergence is fine." A small divergence from base behaviour on + intact thruster ships is accepted: the Longinus's forward creep of about 10.7 m/s² while turning in place, and + the Djinn's roughly 9% faster strafe. Base is pinned exactly only where the old mixer and the allocator agree, + and the accepted divergences are named with their measured magnitudes in Cut 3's verification, so Soul can tell + an accepted divergence from a regression. **No per-thruster role rules are reintroduced to match base.** +- **Q2 = B, differential main thrust is allowed.** "That's absolutely viable and can be a better decision than + turning around the long way, I wouldn't introduce a thruster exclusion for this." Any live actuator's torque may + be spent on rotation, mains included. A damaged ship turning toward its dead side lurches forward, and the + planner may prefer that to the long way round when it is faster. +- **Q3, fixtures follow the data.** "Good catch on the Longinus main thrusters, I was misremembering." Two aft 2×2 + mains, as both legacy catalogs author them. +- **Restore the legacy thruster hulls.** Asked whether the lost layouts could come back: "can we restore these + layouts?" They become Cut 1, the campaign's first cut, ahead of the solver. + +Second round, same day: + +- **Cut 1 is approved as inventoried, optional gear included.** "Yes and yes": the Longinus and Djinni hulls, + Medium Drive, and Small Drive, Victoire, Talaria and RevvITup 2.0 as well. +- **The second hull behaviour is not `VelocityConversion`.** "I don't think VelocityConversion was on either + hull." The decode agrees: it is `VelocityLimitData`. See Cut 1. +- **Q5 is dissolved, not answered: the rotation demand is not sized at all.** "It should only do that if the + ship's controller also *wants* to accelerate forwards that much, but I'm not sure how to encode that while + allowing the lurch. Like, lurch burn to compensate for a missing attitude thruster is also only a good move when + you both need to turn a bit and don't mind being all the way over there after. It doesn't help when, say, + there's a guy to port and your aft thruster is out, because you'd just end up turning in a great big circle + without changing your bearing at all." On the shape that replaced it: "Yeah, that's better." + - **Cut 3** now runs one weighted least-squares solve over the whole desired wrench (Fx, Fy, yaw) at once, + under throttle bounds. No separate rotation demand, no `TorqueFloor`-sized demand, no piecewise fall-back, and + no branch anywhere on whether a ship can turn a given way. The weights are the design knob, and both error + terms share a currency because each axis is normalized by what that ship can actually achieve on it. + - **Cut 4** chooses manoeuvres by predicted outcome over a short horizon, simulating each candidate, never by a + rule. The operator's case above is its test. + +--- + +# Part I: Target + +## Objective + +A ship that loses actuators keeps flying as well as its remaining actuators allow, whether a player or an AI is +flying it. The AI picks headings and thrust directions from what the ship can still do, so shooting out a thruster +creates a real handicap and not an exploit. + +## Invariants + +1. **Same inputs.** A ship is commanded only through `Ship.MovementDirection` (body frame, each component a + fraction of the ship's capacity along that half-axis) and `Entity.LookDirection` (world-frame heading command; + the controller reads only its `xz`). The player writes both (`Gameplay/ActionGameManager.cs:1254`, `:1263`). The + AI writes the same two fields. Nothing else commands a ship's actuators. Identical inputs to identical ships give + identical actuator throttles, whoever wrote them. +2. **One owner for actuator commands.** The ship controller (new, `ShipControl`, below) is the only writer of + `Thruster` and `AetherDrive` throttles. `Ship.cs`'s mixer, its rotation buckets and its strafe compensation stop + existing, and do not survive as a mode or a fallback. +3. **The allocator reads live actuator state every tick.** An actuator's column is derived each tick from its live + stats and liveness: `EquippedItem.Active` (enabled, thermally online, durability online), a power grant above the + same epsilon `Thruster.Execute` uses, and the current resolved `Thrust` (or the drive's own stats) with its + durability, heat and power curves applied. No bucket, cache or event-pruned set decides whether an actuator + counts. A repaired or re-powered actuator rejoins by the same read that dropped it. +4. **Manual and programmatic inputs share one commit path.** A player keypress, an AI state write, and any future + scripted or network input all land in the same two fields, and they reach the actuators by the same + `ShipControl` step inside `Ship.Update`. No input source writes `Thruster.Axis`, `AetherDrive.Axis` or + `Entity.Direction` directly. +5. **Heading changes only through actuators.** `Entity.Direction` is written by actuator execution + (`Thruster.Execute`, `AetherDrive.Execute`), by wormhole scripting (`Ship.ExitWormhole`), by spawn placement + (`ActionGameManager.cs:848`) and by save unpack (`EntitySerializer.cs:66`). The controller does not write it. + Today's snap-to-look lerp (`Ship.cs:280-284`) is a free, thrusterless rotation, and it goes. +6. **Kinematic rotation is retained.** Rotation stays kinematic: yaw rate is proportional to live throttle + (`Thruster.cs:106-107`, `AetherDrive.cs:154-155`), with no angular momentum. Nothing found here argues for + raising it. It keeps turn time exact (angle divided by rate), which is what lets the planner compare a long + way round against a slow lurching turn without simulating either. +7. **A dead actuator never receives throttle.** A destroyed, disabled or unpowered actuator gets zero throttle, + not a saturated request it cannot honour. +8. **Capability is one model.** What a ship can do is computed in one place (`ShipControl`), from the same live + column matrix the allocator solves with: the achievable extremes, the turn-direction comparison, and the solve + itself. The AI reads those and predicts with the controller's own step; it never keeps a private estimate of + what the ship can do, and never branches on which actuators are missing. + +## Canonical implementations + +- **Solver:** CultMath (`F:\Projects\CultLib\packages\cultmath`), a box-constrained linear least-squares solver, + added by Cut 2. It is the only numerical authority. Aetheria composes the problem and calls it. +- **Allocation reference:** T. A. Johansen and T. I. Fossen, "Control allocation: A survey," *Automatica* 49(5), + 2013. Its weighted-least-squares form with priorities is what `ShipControl` states. The active-set method follows + O. Härkegård, "Efficient active set algorithms for solving constrained least squares problems in aircraft control + allocation," CDC 2002, which is bounded-variable least squares in Stark and Parker's sense. + +## Out of scope + +- **Fire-control Cut 12** (edge-on hit placement in `FireControl.cs`, `Entity.ApplyHit`, `DamageSchematic`) is + being mapped concurrently by another Imagination pass. Cuts 2 to 4 here touch none of those files. The allocator + is a read-only downstream consumer of item destruction (`ItemDamage`, `Entity.cs:488`). **Overlap:** Cut 5 (combat + facing) will rewrite `Agents/States/Combat.cs:88-127`, and presenting armour edge-on is exactly what Cut 12's hit + placement rewards. Cut 5 must be mapped after Cut 12 lands. Cut 1 restores catalog content, and the only file it + and Cut 12 could both touch is `GameData/Aetheria.cc`; Cut 1 must not be in flight while another cut writes the + catalog. +- **The old AetheriaEve/CultMesh/daemon rebuild** is taxidermy. It is not precedent and not a consumer. +- Thruster geometry (the normalized moment-arm `Thruster.Torque`, `Thruster.cs:64-69`, which ignores lever length + and uses unweighted shape cells for centre of mass) is the game's model and stays as authored. Arrival braking in + `MoveToState`, docking approach and formation flight are also out. + +## Not consumers + +- `Assets/Scripts/Gameplay/*` presentation (`ShipInstance` particles, audio parameters) reads throttles and never + writes them. +- `TurretController` writes `Entity.LookDirection` for turret entities. That is the AI above the inputs for a + non-ship, and it does not drive ship actuators. See the audit. + +## 0b: identity, lifecycle, authority + +Nothing persistent changes. Actuator wrench columns, capacities, the chosen turn direction and planner state are +runtime-derived per tick, or per planner cadence, and are never saved. + +| Kind | Named by | Lifecycle | Decides | +|---|---|---|---| +| Actuator column (runtime) | the `Thruster`/`AetherDrive` behaviour instance on an `EquippedItem` | rebuilt every tick from live stats; appears and disappears with liveness | `ShipControl` derives; item state (durability, heat, power, `Enabled`) is owned by `EquippedItem`/`PowerBus` as today | +| `MovementDirection`, `LookDirection` (runtime) | fields on `Ship`/`Entity` | overwritten each frame by whoever flies the ship | player (`ActionGameManager`) or AI (`Agent` layer), never both on one ship (`Zone.cs:111` gives agents only to `!IsPlayerShip`) | +| Committed turn direction, planner plan (runtime) | fields on `ShipControl` / `Agent` | reset on wormhole, dock, spawn | `ShipControl` / `Agent` | +| `ThrusterData`, `AetherDriveData`, `HullData.Hardpoints/Shape` (persisted, catalog) | catalog record key | authored | unchanged; read only | +| `GameplaySettings.TorqueMultiplier`, `AetherTorqueMultiplier` (authored, `Settings.cs:206-207`; values in `Assets/Resources/Settings.asset`: 0.1, 0.1) | field name | authored | unchanged. `TorqueFloor` (`:205`, authored 0.5) loses its last consumer in Cut 3 and is deleted with its authored value | + +This campaign adds no authored **field**. Cut 1 adds authored **records** (hulls, thruster designs and their +products) to `GameData/Aetheria.cc` using the schema as it stands. If Hands finds it needs a tunable, that is an +operator question, and any new field on a persisted CultCache document must be nullable (MessagePack nil rule). + +--- + +# Part II: Body map (what the probes established) + +## The mixer today + +`Ship.Update` (`Ship.cs:251-292`) runs before `base.Update` executes behaviours. The agents' writes come earlier +still, because `Zone.cs:170-173` updates agents before entities. The steps: + +1. `RecalculateThrust` (`Ship.cs:147-247`) sums per-bucket thrust over thrusters whose item is `Active`. +2. It zeroes every thruster's `Axis` (`:256`). +3. Strafe: the right and left buckets get `±MovementDirection.x`, minus a torque compensation for the thrusters + whose torque sign matches the bucket total (`:257-274`). +4. Forward and reverse buckets add `±MovementDirection.y` (`:275-276`). +5. Rotation: `deltaRot = sqrt(|dot(look, right)|)·sign` (`:278-285`) is added to the clockwise bucket and subtracted + from the counter-clockwise bucket (`:287-288`). +6. `AetherDrive.Axis = (MovementDirection.y, MovementDirection.x, deltaRot)` (`:291`). There is no allocation for + drives at all. + +Buckets are built once in `Activate` from mount rotation and from `Thruster.Torque` against +`TorqueFloor` (`:102-118`). `RemoveThruster` prunes them permanently on `ItemDestroyed` (`:123`, `:133-143`). + +`Thruster.Axis` saturates to [0,1] (`Thruster.cs:45-49`). `Execute` (`:94-115`) applies `Velocity -= facing·input· +Thrust/Mass·dt`, then rotates `Direction` kinematically by `input·Torque·Thrust·TorqueMultiplier/Mass·dt`. Its gate +is `input > .01 && PowerSupply > 1e-4`. + +## Findings (probe) + +Fixtures here are built from the legacy catalog (next section): hull shape, hardpoints, thruster items with +constant `Thrust` (Large Drive 250000, Medium Drive 200000; mass 100 each), `TorqueFloor` 0.5, `TorqueMultiplier` +0.1. Each ship sits at (37, −12) facing `normalize(0.8, 0.6)`, runs 3 warm-up ticks, is reset, and is measured over +one tick at dt 0.02. Body-frame acceleration is (r, f) in m/s². Heading rate is compass degrees per second, +clockwise positive. + +**Torque factors** (`Thruster.Torque`, the sine of the moment arm): Longinus mains ±0.159 (below the floor, so +never used for rotation), attitude thrusters ±0.998. Djinni stern 0 / ±0.161, laterals ±0.727 (aft) and ±0.867 +(fore), bow 0. + +**Base flight table** (`cd846916`, after warm-up). These are the Cut 3 pins: + +| Input | Longinus (r, f, °/s) | Djinni (r, f, °/s) | +|---|---|---| +| forward `(0,1)` | 2.37, 172.38, 0 | 0.11, 55.56, 0 | +| reverse `(0,−1)` | 0, 0, 0 | 0, −18.52, 0 | +| strafe right `(1,0)` | 0, 0, 0 (compensation zeroes the lone attitude thruster) | 34.03, 0.42, 0 (axes 1.00 / 0.84) | +| strafe left `(−1,0)` | 0, 0, 0 | −34.03, 0.42, 0 | +| look 90° right | 68.97, 0, +394.33 | 0.01, 0.59, +169.13 | +| look 90° left | −68.97, 0, −394.33 | 0.01, −0.50, −169.13 | +| look 30° right | 48.77, 0, +278.84 | 0, 0.30, +119.59 | +| look directly behind | 0, 0, 0, **and `|Direction|` falls to 0.96** | same | +| forward+strafe `(.707,.707)` | 1.18, 121.89, 0 | 24.12, 39.49, 0 | + +The small cross terms (2.37, 0.42) are real. `Execute` rotates `Direction` thruster by thruster within one tick, so +later thrusters push along an already-rotated nose. The allocator cannot see this, so pins need a tolerance for it. + +**Defects the table exposes:** + +- **Dead-side stick.** On a Longinus with `Th.CCW` destroyed, looking 90° left gives a heading error of 90.0° + after 30 s. It never turns. Looking right, it reaches within 2° in 0.32 s, the same as intact. +- **Main-thruster cheese.** On a Djinni with all three stern thrusters destroyed, `Agent` plus `MoveToState` toward + a point 300 m to port leaves the ship at speed 0 after 120 s. `Agent.Accelerate` points at Δv and burns forward + thrusters only (`Agent.cs:64-68`), and it never reaches the strafe branch. Intact, the same run arrives in + 4.34 s. With only Stern 2 lost it arrives in 4.98 s; with only Port Aft lost, 4.40 s. +- **Snap-to-look.** Inside `|sin error| < .01`, `Ship.cs:283` lerps `Direction` toward the look vector, which is a + rotation no thruster pays for. When the look is directly behind, the lerp goes through the origin and shrinks + `|Direction|`, and thruster force scales with that length (`Thruster.cs:105` uses the unnormalized item + direction). Base also turns *slower* as error passes 90°: `deltaRot` follows `sin`, so it is zero at 180°. +- **First tick.** Items are not yet `Active` on the first `Update` after `Activate`, so the first-tick strafe + compensation divides by a zero count (NaN, unused) and the strafe fires uncompensated. +- **`TurnTime` is wrong and unused.** `Ship.cs:61-66` divides angle by the sum of torque *factors* over mass, which + is unitless nonsense: 261535 s for a Longinus 90° turn. It has no consumer. +- **Permanent pruning.** `RemoveThruster` never restores a thruster whose durability comes back, and `Recalculate*` + reads `Active` while the mixer still commands inactive thrusters. +- **The live catalog's only ship flies on a drive.** `GameData/Aetheria.cc` holds one ship hull, **LonginusX** + (mass 2500, drag 0.1, 6×17), with an `AetherDrive` hardpoint and **no `Thruster` hardpoint**. It moves on + "Traction" (rotor mass (30,20,5), λ (0.5,1,1), MaxRpm 40000..80000). Two thruster designs exist ("deep space + burnout", "Large Drive"), but no live ship can mount them (`docs/content-batch-one.md` §1 says the same). + **AetherDrive is therefore the load-bearing actuator for the player today**, and the allocator must reproduce its + base behaviour exactly. Cut 1 restores the thruster hulls, which is what makes any play check on a thruster ship + possible at all. +- **Existing tests sit on an off-centre thruster.** `BrownoutTests`/`ConditionRatioTests` equip a one-cell thruster + by `TryFindSpace` onto a 5×5 hull. It lands at (1,1) with `Torque` −0.707, and `MovementDirection (0,−1)` fires it + while it spins the ship. Under an allocator that holds heading, a lone off-centre thruster cannot fire without + rotating, so those fixtures must be re-seated (Cut 3). + +## Legacy hull layouts (probe) + +The live catalog is the post-breach minimal rebuild, so absence there proves nothing. The layouts come from the +git-LFS objects of `GameData/Legacy/AetherDB.2021-03-05.msgpack` (`142998fd…`) and `AetherDB.2021-04-14.msgpack` +(`21ce7bd2…`), both present in `.git/lfs/objects`, decoded with MessagePack `ConvertToJson`. Shape cells are +`bool[w,h]`, flattened as `x*h + y`, with y = 0 at the stern. + +**Longinus** (2021-04-14: mass 2500, drag 0.1, 6×17; the 2021-03-05 copy has mass 5000). The shape matches the +live LonginusX's. Rows from y = 0: `.####.` ×4, `######` ×5, `.####.` ×2, `..##..` ×6. + +| Hardpoint | Pos | Shape | Rotation | +|---|---|---|---| +| Th.L | (1,0) | 2×2 | Reversed (pushes forward) | +| Th.R | (3,0) | 2×2 | Reversed | +| Th.CW | (2,14) | 1×2 | CounterClockwise (pushes starboard, nose right) | +| Th.CCW | (3,14) | 1×2 | Clockwise (pushes port, nose left) | + +**The data has two aft 2×2 thrusters, not one.** The operator remembered "one big thruster" and ruled on it, +2026-09-22: "Good catch on the Longinus main thrusters, I was misremembering." Fixtures follow the data. + +**Djinni** (mass 10000, drag 0.2, 14×17). Rows from y = 0: `....######....`, `...########...`, +`..##########..`, `.############.` ×2, `##############` ×5, `.############.`, `...########...` ×3, +`....######....`, `.....####.....`, `......##......`. + +| Hardpoint | Pos | Shape | Rotation | +|---|---|---|---| +| Stern 1 | (6,0) | 2×1 | Reversed | +| Stern 2 | (5,1) | 2×1 | Reversed | +| Stern 3 | (7,1) | 2×1 | Reversed | +| Port Aft | (4,4) | 1×2 | CounterClockwise | +| Port Fore | (4,11) | 1×2 | CounterClockwise | +| Starboard Aft | (9,4) | 1×2 | Clockwise | +| Starboard Fore | (9,11) | 1×2 | Clockwise | +| Bow | (6,14) | 2×1 | None (pushes aft) | + +Legacy thruster items used for the fixtures: Large Drive 2×2, 250000 thrust, mass 100. Medium Drive 2×1, thrust +100000..300000 (fixtures use a constant 200000), mass 100. Small Drive 1×1, 75000. + +## Solver choice (probe) + +The comparison ran on the fixtures' own normalized columns, plus a 16-column doubled Djinni: 2000 random wrench +demands each, cost measured against a 20000-iteration projected-gradient reference, double precision, +allocation-heavy scratch C#, Release build. + +| Method | Worst excess cost | Suboptimal cases | Mean time | +|---|---|---|---| +| Primal active set (bounded-variable LS, Härkegård WLS form) | ≤ 7e-15 | 0 in every case | 1.8–9.5 µs (n 3–8), 32–38 µs (n 15–16); ≤ 30 iterations cold | +| Redistributed pseudo-inverse | up to 4e3 | 270–1564 of 2000 (n ≤ 8) | about half the active set's time | + +**Pick the active set.** It is exact, its cold-start cost is bounded, and it warm-starts from last tick's solution. +The redistributed pseudo-inverse saves about half the time and is wrong in up to 78% of cases on these very +matrices. A production float implementation without allocations will be faster than the scratch numbers. + +## Consumer audit + +`cd846916`, every `*.cs` outside `Assets/Plugins`. "Dies" means deleted, not deprecated. + +| Surface | Readers / writers | Fate | +|---|---|---| +| `Ship.MovementDirection` (`Ship.cs:27`) | W: `ActionGameManager.cs:1263` (player), `Agent.cs:67,73,77` (AI); tests `BrownoutTests.cs:143,402`, `ConditionRatioTests.cs:184,193,243,258`. R: `Ship.cs:261-291` | **Survives** as the translation input. Its meaning is defined by `ShipControl` (fraction of capacity per half-axis) | +| `Entity.LookDirection` (`Entity.cs:55`) | W: `ActionGameManager.cs:1254` (player), `Agent.cs:66`, `MoveTo.cs:26`, `Combat.cs:126`, `TurretController.cs:72,75` (turret entities; no `EntityTypeRestriction`, `TurretController.cs:14-15`). R (as heading): `Ship.cs:278`. R (as aim): `LockWeapon.cs:94`, `GuidedProjectileManager.cs:56`, `EntityInstance.cs:404`, `ShipInstance.cs:117` (tractor), `ZoneRenderer.cs:432`, `ActionGameManager.cs:376` (reticle target pick) | **Survives** as heading command *and* aim. `MoveTo.cs:26` dies in Cut 4 (a double writer: `Accelerate` overwrites it when Δv > 20). The heading/aim conflation is Cut 5's question (Q4) | +| `Thruster.Axis` (`Thruster.cs:45-49`) | W: `Ship.cs:256-288` only. R: `ShipInstance.cs:139` (particles), `Thruster.cs:88` (power request), `:96` (audio), `:102-111` | Getter **survives**. The public setter **dies**; only `ShipControl` commands throttle | +| `AetherDrive.Axis` (`AetherDrive.cs:81-85`) | W: `Ship.cs:291` only. R: drive internals, audio `:170` | Same as `Thruster.Axis` | +| `IAnalogBehavior` (`Behaviors.cs:96-99`) | implemented by `Thruster.cs:40`; consumer only a comment, `Entity.cs:2007` | **Dies** (Cut 3 deletes first) | +| Bucket sets `_forward/_reverse/_right/_left/_clockwise/_counterClockwiseThrusters`, `_thrusterItems`, `_aetherDrives`, `_aetherDriveItems` (`Ship.cs:30-39`) | `Ship.cs` only | **Die** | +| `ForwardThrust`, `ReverseThrust`, `LeftStrafeThrust`, `RightStrafeThrust`, `ClockwiseTorque`, `CounterClockwiseTorque`, `Left/RightStrafeTotalTorque`, `Left/RightStrafeTorqueThrusters` (`Ship.cs:50-59`) | written by `RecalculateThrust`, read by the mixer; **no reader outside `Ship.cs`** (grep, including Unity side and tests) | **Die** | +| `Ship.TurnTime` (`Ship.cs:61-66`) | no consumer | **Dies**; replaced by `ShipControl.TurnTime` (Cut 3), consumed in Cut 4 | +| `RemoveThruster`, `RemoveAetherDrive`, their `ItemDestroyed` subscriptions (`Ship.cs:123-143`) | internal | **Die**; liveness is read live (invariant 3) | +| `Agent.Accelerate` (`Agent.cs:58-79`), `FORWARD_DELTA_THRESHOLD`/`THRUST_DELTA_THRESHOLD` (`:16-17`) | called by `MoveTo.cs:27`, `Combat.cs:127` | **Rewritten** in Cut 4 (planner). Signature kept | +| `Agent.TopSpeed` (`Agent.cs:26`) | `MoveTo.cs:25`, `Combat.cs:127`. No live catalog item carries `VelocityLimit` or `VelocityConversion` (probe), so it is 100 everywhere | Survives | +| `GameplaySettings.TorqueFloor` (`Settings.cs:205`) | `Ship.cs:115,118` only | **Dies** in Cut 3. The full-wrench solve has no threshold to apply it to, and nothing else reads it | +| `TorqueMultiplier`, `AetherTorqueMultiplier` | `Thruster.cs:107`, `AetherDrive.cs:155` | Survive; also enter the yaw-rate row of each column | +| `Entity.Direction` writers | `Thruster.cs:106`, `AetherDrive.cs:154`, `Ship.cs:79` (wormhole), `Ship.cs:283` (snap), `ActionGameManager.cs:848` (spawn), `EntitySerializer.cs:66` | `Ship.cs:283` **dies** (invariant 5); the others survive | +| `Entity.Velocity` external writers | `HullCollider.cs:15` (collision impulse), `ShieldManager.cs:79` (bounce), `VelocityConversion.cs:41`, `VelocityLimit.cs:48` | Not control; these are external forces and constraints. Untouched | +| Undock capability check (`ActionGameManager.cs:913`) | `GetBehavior() == null` | Survives. It could later read the capability model; not this campaign | +| Mining, Survey, HaulingTask, StationTowing (`Agents/Tasks/*.cs`) | data-only task classes; no movement code (grep) | Not consumers. `PatrolOrbitsState` drives `MoveToOrbitState`, which Cut 4 covers | +| Player input path | `Input.Player.Look` → `_entityYawPitch` → `LookDirection` (`:1250-1254`); `Input.Player.Move` → `MovementDirection` (`:1261-1264`). No other action writes motion (grep of `Input.Player.*`: targeting, heat, shield, stance, tractor, ping, UI) | **Already on the shared surface**. The only bypasses are the ones `Ship.cs` itself holds (the snap, direct `Axis` writes), and both die in Cut 3 | + +Out-of-scope note: the working tree has an uncommitted change to `Assets/Scripts/AetheriaInput.cs` (a "Cycle Target +Item" action). That is fire-control work, and it does not touch motion. + +--- + +# Part III: Cut map + +## Status header + +Status: cut map. Ends are owned by Part I above; this section owns the means. + +Cut 1 landed and is merged into `codex/fire-control-12` at `e160bbe5`. Cuts 2 to 5 have not landed. + +Rulings (operator, 2026-09-22): **Q1 A** ("The divergence is fine"), **Q2 B** ("that's absolutely viable and can +be a better decision than turning around the long way, I wouldn't introduce a thruster exclusion for this"), **Q3** +fixtures follow the data ("Good catch on the Longinus main thrusters, I was misremembering"), and Cut 1 exists +because of "can we restore these layouts?". The Q2 text below is marked as history; the live design is in Cut 3. + +Second-round rulings (operator, 2026-09-22): Cut 1 approved as inventoried, optional gear included ("Yes and +yes"); the second hull behaviour is not `VelocityConversion` ("I don't think VelocityConversion was on either +hull") and the decode names it `VelocityLimitData`; **Q5 is dissolved** by the full-wrench solve ("Yeah, that's +better"), so the Q5 text below and every "sized demand" design it belonged to are history. + +Open: **Q4** (heading versus aim) blocks only Cut 5. **Q6** (the one weight in Cut 3's solve) does not block +Hands, who starts at the recommended default; the operator settles it by feel in the Cut 1 play check. + +**Tutorial station (operator, 2026-09-25):** "What we're testing is going to be the tutorial level, right? Let's drop a station in there." The live catalog already has a station hull: Zenith, sold by Aeronautics Unlimited. The story-station path is dead because `StoryProcessor` has been commented out at `Galaxy.cs:242` since `ce1a0a46` (2021). Ruled: **faction station now**. The tutorial's entrance zone (`Galaxy.Entrance`) always gets a generated faction station with a docking bay. That is deterministic, not merely likely, and the test pins it on the real tutorial construction path. Reviving the Ink story stations belongs to a later narrative campaign, which must first find out why they were switched off. + +Legacy hazard, recorded 2026-09-26 (Soul, Cut 1 fix batch 2): `VelocityLimit` is a hull behaviour gated by the hull's `Active` (`Entity.cs:1624`, `:1636`, `VelocityLimit.cs:45-49`). Thrusters are items with their own gating. Any path that takes the hull offline while the thrusters keep firing removes the cap. Under frozen fixture physics, with the player's override on the thrusters, speed reached 1237. On the shipped settings the hull did not go offline in 300 s. The behaviour dates from 2020-04 and 2021-03. Cut 3 and Cut 4 measure arrival times under this cap, so they must not assume it always holds. + +Follow-ups outside this campaign: +- `TurretController` has no `EntityTypeRestriction`, so equipping it on a ship would create a third + `LookDirection` writer racing the player or agent. Restrict it to non-ship hulls when turret work next opens + (`TurretController.cs:14`). +- `Thruster.Execute` applies rotation per thruster inside the tick, which couples later thrusters' force to earlier + thrusters' rotation. It is a small artefact, and it is `Thruster.cs`'s to fix, not the allocator's. +- `AetherDrive.Execute` logs "FUCK FUCK FUCK FUCK" on NaN velocity (`AetherDrive.cs:157-158`), a leftover probe. + +## Cut 1. Restore the legacy thruster ship hulls to the live catalog + +A pure content cut, separable from every code cut, so Soul can falsify it on its own. Operator, 2026-09-22: "can +we restore these layouts?", and on the inventory below, optional gear included: "Yes and yes". **The list is +approved**; what is left for review is anything Hands finds that this map did not name. + +- **Repo/branch:** Aetheria, implemented on `codex/locomotion-cut1` and merged into `codex/fire-control-12` at + `e160bbe5`. The restored hulls are now the real fixtures for Cuts 3 and 4. The catalog mutation is complete. +- **Standing rule** (memory: `aetheria-content-audits-pre-breach`): `GameData/Aetheria.cc` is the post-breach + bare-minimum rebuild, so absence there is not evidence of absence, and **the operator's word is the authority** + on what the pre-breach content was. The list below is therefore an **operator review item before Hands writes + anything into the catalog**. + +### What the legacy record actually holds (probe) + +Sources, all decoded in the scratchpad: the git-LFS objects behind `GameData/Legacy/AetherDB.2021-04-14.msgpack` +(`21ce7bd2…`, 76 KB) and `AetherDB.2021-03-05.msgpack` (`142998fd…`, 59 KB), and the last +`GameData/AetherDB.msgpack` before the Cut 9 import (`8963a686…`, 46 KB, the commit's own parent at `70fbaca1^`). +All three are present in `.git/lfs/objects`; nothing had to be fetched. + +The comparison explains the loss. The 2021-04-14 record carries 5 ship-class hulls and 45 gear designs. The import +source carries 3 hulls (LonginusX, Zenith, Turret) and 25 gear. **Longinus, Djinni and every thruster below the +2×2 class were already gone from the file Cut 9 imported**, which is why the live catalog has no thruster ship. + +**Recoverable in full from 2021-04-14** (name, shape, mass, price, hardpoint list with position, shape, rotation +and armour, behaviours, temperature band, manufacturer, prefab and schematic paths): + +| Record | Kind | Legacy detail | Notes | +|---|---|---|---| +| **Longinus** | ship hull | 6×17, mass 2500, drag 0.1, price 7,500,000, maker Alakrita | The shape is byte-identical to the live LonginusX's. 4 thruster hardpoints (`Th.L`/`Th.R` 2×2 aft, `Th.CW`/`Th.CCW` 1×2 in the nose) plus control module, 2 energy, 2 launcher, 2 radiator, reactor, sensors | +| **Djinni** | ship hull | 14×17, mass 10000, drag 0.2, price 10,000,000, maker Rossum & Douglas | 8 thruster hardpoints (3 stern, 4 lateral, 1 bow) plus 4 launcher, 2 ballistic, reactor, control module, 4 radiator, sensors, and a shield hardpoint | +| **Medium Drive** | thruster gear | 2×1, mass 100, thrust 100000..300000, price 50,000, no maker | **The one item both restored hulls need.** Fits the 2×1 stern/bow hardpoints and, rotated, the 1×2 lateral and nose ones | +| **Small Drive** | thruster gear | 1×1, mass 50, thrust 75000, price 25,000, no maker | Optional; the cheap end of the line | +| **Victoire** | thruster gear | 2×1, mass 25, thrust 200000..1500000, maker Alakrita | Optional; the racing thruster, and a natural Longinus fit | +| **Talaria** | thruster gear | 2×1, mass 150, thrust 75000..250000, maker Aeronautics Unlimited | Optional | +| **RevvITup 2.0** | thruster gear | 2×2, mass 150, thrust 250000..1000000, maker NiteLife Energy | Optional; a second 2×2 beside the two the catalog already ships | + +Everything those records depend on is **already in the live catalog**: all 12 factions (Alakrita and Rossum & +Douglas included), both existing 2×2 thrusters, and the Unity presentation. The prefabs still carry the hardpoint +transforms by their legacy names — `Th.L`, `Th.R`, `Th.CW`, `Th.CCW` in `Assets/Content/Prefabs/Ships/ +Longinus.prefab` (GUID `4a3db609…`, already the hull the live LonginusX points at) and `Thruster Stern 1..3`, +`Thruster Port/Starboard Fore/Aft` and `Thruster Bow` in `Djinni.prefab` (GUID `79024f63…`). The schematics exist +too (`schema_Longinus.png` `51702555…`, already referenced; `djinni_schematic_transparent.png` `66f3acdd…`, unused +today). So no art, no prefab work and no faction work is needed. + +**Not recoverable, because it post-dates the breach and has to be authored:** + +- `HardpointData.FiringArc` (Key 6) for the Djinni's weapon hardpoints. The legacy hardpoint record stops at + armour. 0 means "use `GameplaySettings.FiringArc`"; the `firing-arc-migrate` command in `tools/AetherDb` is the + precedent for deriving one from mount rotation. +- `ItemRole` lists on the restored designs, and `ProductRole` qualities on their products. + `RoleAuthoringTests` refuses a stat naming a role its design lacks, and `docs/content-batch-one.md` fixes the + nomenclature (`injector`/`nozzle` for thrusters, `plating` for hulls). The live "deep space burnout" carries + `injector, nozzle`; "Large Drive" carries none. +- A `FactionProductData` per restored design. `LoadoutGenerator` picks products, not designs, so a design without + one exists but is never generated or sold. The catalog holds 47 products today. +- Any `PowerSupply` term on the restored thrusters' stats (the stats-and-power cut's brownout curve). The shipped + thrusters request zero energy, so matching them is the safe default and is a content decision. +- Provenance is runtime, not authored: `ItemManager` mints the `Lot` (`docs/item-provenance-target.md`). + +**The second hull behaviour, identified** (probe; the operator was right that it is not `VelocityConversion`). +Both legacy hulls carry a behaviour LonginusX does not, union tag 11 in the legacy record. The union table is in +`Assets/Scripts/ServerShared/Behaviors/Behaviors.cs` at the legacy-project commit `7006a6b0` and unchanged at +`b4c06b50`: **tag 11 is `VelocityLimitData`**; `VelocityConversionData` is tag 10, and it is on neither hull. The +table cross-checks against the live catalog on every record that survived: tag 6 `ReflectorData` (live LonginusX +has exactly that and nothing else), 8 `ThrusterData` and 9 `WearData` ("deep space burnout" carries both), 3 +`RadiatorData` (Arctica), 7 `ShieldData` (legacy "Shield"), 31 `CapacitorData` (legacy "Capacitor"). + +The payload is a single `PerformanceStat` in `Key(1)`, `VelocityLimitData.TopSpeed`, authored `50..50` with the +legacy exponents `1, 0, 1.5` on both hulls. (Corrected 2026-09-26: the decode behind the restore gives Longinus +`100..100` with exponents `1, 0, 0` and Djinni `50..50` with `1, 0, 1.5`. The catalog carries those values.) So **both restored hulls are capped**, where a hull +without the behaviour leaves `Agent.TopSpeed` at its 100 default (`Agent.cs:26`) and leaves the player uncapped. +`VelocityLimit.Execute` clamps `Entity.Velocity` (`VelocityLimit.cs:48`), so this is a real handling difference on +the restored hulls and Cut 4's arrival times are measured with it. + +**The legacy stat shape differs from today's.** A legacy `PerformanceStat` is +`[Min, Max, HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent]`; today it is `Min`, `Max` and +a `Terms` list of `StatTerm` (`ItemData.cs:632-643`, keys 2 to 4 retired). Every restored stat has to be +translated, not copied. `docs/stats-power-cut1-migration.md` is the precedent for that mapping and the authority +on what each legacy exponent becomes. + +### The cut + +- **Deletes first:** nothing. This is content restoration. +- **Adds:** the two hull records and all five thruster designs (`Medium Drive`, `Small Drive`, `Victoire`, + `Talaria`, `RevvITup 2.0`), approved 2026-09-22; a `FactionProductData` for each; roles for each; firing arcs on + the Djinni's weapon hardpoints; each hull's `VelocityLimit` behaviour with its translated stat. +- **Mechanism:** a one-shot command in `tools/AetherDb` (`restore-hulls [apply]`), in the shape of the existing + `*-migrate [apply]` commands: dry run by default, opens the catalog through `AetheriaStores.Open(catalogWritable: + true)` only when applying, writes every record in one `Commit`, and refuses to run twice. The Cut 9 importer + (`tools/AetherDb/Import.cs`, added in `70fbaca1` and deleted by Cut 10) is the precedent for reading the legacy + `[tag, payload]` records and rewriting them by the target type's `[Key]` shape; read it out of git history rather + than re-deriving the mapping. **Delete the command in the same campaign, as Cut 10 deleted the importer**, so the + carrying cost of the restore is zero once it has run. +- **Authority map:** + - Owner: the catalog (`GameData/Aetheria.cc`) owns authored content; the operator owns what the content *is*. + - Inputs: the legacy LFS records, the existing factions and assets, the operator's review of the list above. + - Outputs: new `HullData`, `GearData` and `FactionProductData` records. + - Derived state: none. Runtime `Lot`s and provenance are minted as for any other design. + - Forbidden writers: nothing else may write the catalog while this cut is in flight; the one-shot command must + refuse a second apply rather than upsert twice. + - Deletion line: the command goes once the records are in. +- **Verification:** + - `FireControlCut7Tests.ShippedCatalogOpensAndGeneratesAnArmedHull` still passes unchanged: the real catalog + opens, every `EquippableItemData` deserializes, and LonginusX still generates an armed loadout. + - New `RestoredHullsTests` against the real catalog, read-only: each restored hull exists, has the expected + thruster hardpoint count (Longinus 4, Djinni 8), every thruster hardpoint accepts a catalog thruster design + through `TryEquip` at that hardpoint's own position, and the ship then moves and turns under + `MovementDirection`/`LookDirection` for a few ticks. Before Cut 3 this runs on the old mixer, which is the + point: it proves the content, not the controller. + - `LoadoutGenerator` generates each restored hull when filtered to it, which is the check that the products and + roles are authored correctly. + - `dotnet run --project tools/AetherDb -- census` names the two hulls with their makers, and `dangling` reports + no new dangling reference. + - Operator: the Studio click-through, then a play check flying a restored hull. That play check is what + unblocks the rest of this campaign, and it is where Q6's weight gets settled by feel. +- **Operator questions:** none open. The list is approved and tag 11 is identified. +- **Ledger estimate:** no source removed. About 3 to 7 catalog records added, plus their products, plus a one-shot + command of roughly 150 lines that is deleted again in this campaign. Net carrying cost: the content, and nothing + else. + +## Cut 2. CultMath: bounded least squares (CultLib) + +- **Repo/branch:** `F:\Projects\CultLib`, a branch from `45c2f40` (Aetheria's pinned `CultLibRevision`, + `Directory.Build.props`). Depends on nothing. +- **First:** run `dotnet test packages/cultmath/tests/CultMath.Tests` green at base and record the count. +- **Deletes first:** none. This is a pure addition, and it is the ruled owner fill (R4). +- **Adds:** one C# file in `packages/cultmath/src/CultMath/` with a static solver for + `min ||A x − b||²` subject to `lo ≤ x ≤ hi`. It serves a dense, small problem (m ≤ ~24 rows, n ≤ ~24 columns). + Rules: + - The method is a primal active set (bounded-variable LS). Each iteration solves the free set by Cholesky on the + normal equations, or by QR if Hands prefers conditioning. It terminates on KKT: at each bound the gradient sign + must be consistent with that bound. + - `float` in, `Span`/`ReadOnlySpan` arguments, caller-supplied workspace. No allocation per call. + - `x` is both warm start and result. The solver clamps it to the box first. + - It returns an iteration count or status, has a bounded iteration cap, is deterministic, and keeps its + last feasible iterate on a cap hit. A rank-deficient free set is handled (the caller adds a small ridge row, + and the solver must not throw on a singular pivot). + - Naming stays boring and HLSL-adjacent per `docs/design.md` Rules, for example a + `CultMath.BoundedLeastSquares` static class. It is C#-only like the intercept helpers (`math.cs`). No Rust or + HLSL mirror, because it is not a batch kernel and no shader needs it. +- **Tests** in `packages/cultmath/tests/CultMath.Tests`: + - `InteriorOptimumEqualsNormalEquations`: a problem whose unconstrained optimum lies inside the box returns it. + Pins the free-set solve. + - `KktHoldsOnRandomBoxedProblems`: randomized problems up to 24×16 are checked against a projected-gradient + reference to 1e-4 relative cost, and each active bound's gradient sign is checked. Pins optimality and the + multiplier test. + - `AllBoundsActive`, `ZeroColumn`, `DuplicateColumns`: degenerate columns return without throwing and are + optimal. Pins rank handling. + - `WarmStartMatchesColdStart`: a solution from a perturbed warm start equals the cold solution. Pins + that the start does not bias the result. + - `AsymmetricBounds`: `lo = −1, hi = 1` on some columns and `[0,1]` on others. Pins bidirectional actuators. + - Fixtures must not be degenerate: non-square, non-identity A, and b not in A's range. +- **Release:** build the Unity package (`packages/cultmath/scripts/build-unity-package.ps1`), bump + `unity/org.gamecult.cultmath/package.json` to 0.2.4, and tag `cultmath-unity-v0.2.4` by CultLib's own release + path. In Aetheria, bump `Packages/manifest.json`'s `org.gamecult.cultmath` tag and `Directory.Build.props` + `CultLibRevision` in one commit. +- **Authority map:** Owner: CultMath. Inputs: A, b, bounds, warm start. Outputs: x and status. Forbidden writers: + no least-squares or linear-solve code in `Aetheria/Assets/Scripts/**` (negative grep in Cut 3). +- **Verification:** `dotnet build packages/cultmath/src/CultMath/CultMath.csproj`; `dotnet test` of CultMath.Tests. + Aetheria's `dotnet test tests/Aetheria.Shared.Tests` still passes 244 after the bump. Unity compiles the new + package, and only the operator can check that. +- **Ledger estimate:** +150..220 lines of source and +150 lines of test in CultLib; Aetheria gets a 2-line bump. + +## Cut 3. `ShipControl`: shared controller and allocator replace the mixer + +- **Repo/branch:** Aetheria, a branch from the Cut 2 bump commit. Depends on Cut 2. Cut 1 is not a + prerequisite for the code, but its restored hulls are what the operator's play check needs. +- **First:** before editing, commit the base flight numbers above as test constants, reproduced by the fixture spec + in "Findings". They are computed at `cd846916`, never captured from new code. Also run a LonginusX-with-Traction + pin at base: `AetherDrive.Axis` for each probe input is `(my, mx, sqrt(|sin e|)·sign)`, including e = 30°, which + gives `Axis.z = 0.7071`. +- **Deletes first** (separate commit, build and tests green, no behaviour change): + - `Ship.cs:61-66` `TurnTime` (6 lines). + - `Behaviors.cs:96-99` `IAnalogBehavior` (4 lines) and its mention in the `Thruster` declaration (`Thruster.cs:40`). + - `Entity.cs:2007`, the commented `IAnalogBehavior` line. +- **Deletes (behaviour commit):** + - `Ship.cs:30-39` (bucket and item sets, 10 lines). + - `:50-59` (capacity properties and strafe lists, 10 lines). + - `:96-118` (bucket construction; `Activate` keeps `base.Activate()` and gathers actuators, about 20 lines). + - `:123-124` (subscriptions) and `:127-143` (`RemoveAetherDrive`, `RemoveThruster`, 17 lines). + - `:145-249` (the `ThrustCalculation` region, 105 lines). + - `:255-291` (mixer and snap, 37 lines). + - The public `Axis` setters: `Thruster.cs:45-49` and `AetherDrive.cs:81-85` become getters plus a command method + only `ShipControl` calls. + - Total is about 200 lines out of `Ship.cs`. +- **Keeps:** `Ship.cs:253` gate (active, not in a wormhole); `:294-310` drag, position, gravity and rotation + quaternion; wormhole code; `Thruster.Execute`/`AetherDrive.Execute` physics unchanged. The throttle is still + `_input`/`_axis`, so power requests, audio and particles read what they read today. +- **Adds:** `ShipControl` in `Assets/Scripts/ServerShared/ShipControl.cs`, owned by `Ship` and stepped from + `Ship.Update` where the mixer was. It is one file and one owner. Types and rules: + - **Actuator columns.** A narrow interface implemented by `Thruster` and `AetherDrive`. There are two + implementers and one consumer; this replaces `IAnalogBehavior`. + - Each actuator reports its columns every tick: body-frame (Fx, Fy) in m/s² and yaw rate in rad/s + (clockwise positive) per unit throttle, plus bounds. It then accepts its throttles back. + - **Thruster:** one column, [0,1]. F is the negative item facing (`Rotate` of (0,1) by mount rotation) times + live `Evaluate(Thrust)/Mass`. Yaw is `Torque·Thrust·TorqueMultiplier/Mass`. + - **Zeroing:** the column is zero when `!Item.Active.Value` or `Item.PowerSupply ≤ 1e-4`, which is the same + predicate `Execute` gates on. `Thruster.Thrust` (stale, updated only in `Execute`) is not the source. + - **AetherDrive:** three axes. Translation is split into ± columns, [0,1] each, because the drive's efficiency + depends on sign and speed (`AetherDrive.cs:138`). Torque is one column, [−1,1]. + - **Drive authority is nominal.** Effectiveness is the drive's force at `MaximumRpm` from its own stats (rotor + mass, λ, λ multiplier, coupling efficiency, `AetherTorqueMultiplier`), scaled by the same liveness and by its + condition. It is not the instantaneous `Rpm`. Rotor spin-up is the drive's internal dynamics, not an + allocation decision. This choice is what keeps LonginusX exact: for a lone drive, self-consistent capacity + cancels the scale, so throttle equals input. + - **One solve over the whole wrench.** There is no rotation stage and no translation stage. Each tick + `ShipControl` builds a desired wrench `v = (Fx_d, Fy_d, ω_d)` and solves, once, + + min over u in [lo, hi] of Σ_axis ( w_axis · (B_axis · u − v_axis) / cap_axis )² + ridge · |u|² + + where `B` is the live column matrix, `cap_axis` is that axis's achievable extreme, and `ridge` is about 1e-3. + The normalization is what lets a rotation error and a translation error be compared: each residual reads as a + fraction of what this ship can do on that axis. + - **Achievable extremes.** `cap` for an axis is the largest value that axis can take inside the throttle box, + which for a box constraint is just the sum of the positive entries of that row (the negative ones for the + other half-axis). Six numbers, no solve: `+x`, `−x`, `+y`, `−y`, clockwise, counter-clockwise. They are also + what `MovementDirection` is a fraction of. Probe values: Longinus `+y` 172.41, `±x` 68.97, yaw 8.25 rad/s + (472.9°/s); Djinni `+y` 55.56, `−y` 18.52, `±x` 37.04, yaw 3.25 rad/s (186.2°/s). + - **Demand.** `F_d = (mx · cap(±x), my · cap(±y))` from `MovementDirection`. `ω_d = min(profile(e) · cap(dir), + e / dt)` toward the `LookDirection` heading, `profile(e) = sqrt(sin e)` for e ≤ 90° (base's own curve) and 1 + beyond, with the `e/dt` term landing the heading without overshoot in place of the deleted snap. A zero + `LookDirection` means hold heading: no demand, no NaN. + - **Direction choice** stays a time comparison, `angle_in_that_direction / cap(dir)`, infinite where `cap` is + zero, with the committed direction held unless the other is faster by a margin. It is not a branch on whether + the ship "can" turn: under Q2 = B `cap(dir)` is almost always non-zero, and the comparison is what makes the + long way round appear. + - **Weights.** Translation rows weigh 1. The yaw row weighs `w`, the single knob (Q6), default **3**. There is + no other tuning constant in the objective. + - **The three regimes fall out of that solve; none of them is a rule** (closed-loop probe, below): + - (a) *Turning while forward thrust is also wanted.* The mains are already firing for `F_d`, so their torque + costs nothing extra, and the ship turns with them. + - (b) *Turning in place.* `F_d` is zero, so every newton of main thrust is pure error, and the solver spends it + only to the extent the remaining yaw residual is worth more. The intact Longinus is no longer forced into + the 84 m/s² burn the earlier sized-demand design produced. + - (c) *No attitude authority in that direction.* The only way to cut the yaw residual at all is the mains, so + the solver takes the lurch, and the direction comparison decides whether that beats the long way round. + - **`TorqueFloor` has no consumer left.** It was read only at `Ship.cs:115,118`. The field goes from + `GameplaySettings` (`Settings.cs:205`) along with its authored value in `Assets/Resources/Settings.asset`; + `TorqueMultiplier` and `AetherTorqueMultiplier` stay, because they are physics. + - **Budget.** One solve per ship per tick, plus six sums. The earlier design's four extra capacity solves are + gone. The fix for cost is solver efficiency, not a cache, because a cache would be a second truth for liveness + (invariant 3). + +### Closed-loop probe behind Cut 3 + +The single-tick table in Part II is measured against the real engine at `cd846916`. The numbers in this section +come from a **separate scratch model**: the fixture columns above, kinematic rotation, `dt` 0.02, the objective as +stated, and the base mixer re-implemented against the same integrator so the two are compared like for like. They +are design evidence and test targets, not engine pins; Hands reproduces them against the real engine and reports +the differences. + +| Manoeuvre (Longinus fixture) | base mixer | solve at w = 3 | +|---|---|---| +| Turn 90° in place | 0.32 s, 13.9 m/s of drift | 0.30 s, 16.9 m/s of drift, main peaks at 0.73 throttle | +| Turn 90° at full forward | 0.32 s | 0.30 s | +| Strafe right, 1 s, look held | no strafe at all | 10.7 m/s gained, heading holds within 1.1° | +| Djinni turn 90° in place | 0.68 s, no drift | 0.68 s, no drift, no stern throttle | + +The drift in a turn is dominated by the nose thruster, which pushes sideways whatever the controller does: base +pays 13.9 m/s for a 90° turn and the solve pays 16.9 m/s, in about the same time. That is the honest shape of +regime (b): the main does contribute torque, but the turn does not become a forward burn. + +The yaw weight trades heading-holding against how hard the mains are spent, and on this hull both come from the +same pair of actuators: + +| w | strafe gained in 1 s | heading deviation while strafing | 90° turn in place | main peak | +|---|---|---|---|---| +| 0.5 | 0.1 m/s | 179° (heading lost) | 1.62 s | 0.14 | +| 1 | 46.4 m/s | 59° | 0.60 s | 0.37 | +| 2 | 13.7 m/s | 3.8° | 0.36 s | 0.63 | +| **3** | **10.7 m/s** | **1.1°** | **0.30 s** | **0.73** | +| 5 | 9.9 m/s | 0.4° | 0.28 s | 0.79 | + +Damaged cases at w = 3: a Longinus with `Th.CCW` destroyed reaches a heading 90° to port **the long way round** +(0.74 s, sweeping +269°) and a heading 20° to port **the short way, on differential main thrust** (1.18 s, sweeping +−18°, gaining 19.7 m/s). A hull whose single main sits on the centre line has no counter-clockwise authority at +all and always takes the long way (0.86 s). A Longinus with both nose thrusters gone still turns 20° in 0.60 s on +its mains alone, and does it while gaining 83 m/s if forward thrust was also asked for against 20 m/s if it was +not — regime (a) against regime (b) on one fixture. + +- **Per-file changes** (against `cd846916`): + - `Ship.cs:251-292`: `Update` calls `ShipControl.Step(dt)` inside the existing `:253` gate. The drag and position + code below stays. + - `Ship.cs:92-119`: `Activate` collects actuators into `ShipControl` (all `Thruster` and `AetherDrive` + behaviours). No classification. + - `Ship.cs:121-125`: the constructor drops both `ItemDestroyed` subscriptions. + - `Thruster.cs:40`: implement the actuator interface, drop `IAnalogBehavior`. `:45-49`: public getter, internal + command. `:61-71`: `Torque` stays as the geometry factor. + - `AetherDrive.cs:81-85`: same pattern. Add the nominal-authority read next to its stats. + - `tests/Aetheria.Shared.Tests/BrownoutTests.cs:111-131` and `ConditionRatioTests.cs:94-95`: equip the consumer + thruster on the hull's centre column, using `TryEquip(item, int2(2, y))` with a free y, so `Torque == 0`. These + tests pin brownout and condition, not flight. Their asserted values (for example `.1f` at + `BrownoutTests.cs:162`) must not change. Fix the fixture, never the assertion. `PowerCurveTests.cs:52-62`'s + comment about the `Axis` being clobbered becomes true for a different reason; update the wording only. +- **Authority map:** + - Owner: `ShipControl` decides every actuator throttle and the feasible turn direction. + - Inputs: `MovementDirection`, `LookDirection.xz`, current `Direction`, `Mass`, `dt`, each actuator's live + column, `TorqueMultiplier`/`AetherTorqueMultiplier`. + - Outputs: per-actuator throttle (`Thruster._input`, `AetherDrive._axis`); the achievable extremes, the + movement-command mapping, `TurnTime(heading)`, and the step itself, which Cut 4 uses as its prediction model. + - Derived state: columns, extremes and the demand are per-tick derived, never stored across ticks except the + warm start and the committed turn direction, which are command state and not truth. `Thruster.Axis` and + `AetherDrive.Axis` are display and command readbacks. + - Forbidden writers: `Ship.Update`'s mixer (gone); any assignment to `Thruster.Axis`/`AetherDrive.Axis` outside + `ShipControl`; `Ship.cs:283`'s direct `Direction` write; bucket pruning on `ItemDestroyed`; any Unity-side + write of throttles. Also forbidden inside `ShipControl`: any test of whether an actuator is "for" rotation or + translation, any per-direction special case, and any second solve that re-decides part of the wrench. + - Shared paths: player writes (`ActionGameManager.cs:1254,1263`) and AI writes (`Agent`, `MoveTo`, `Combat`) both + reach actuators only through `ShipControl.Step`. Wormhole exit leaves the gate closed until the animation + ends, as today. + - Deletion line: the delete commits above land before `ShipControl` gains behaviour. +- **Verification:** + - Builds: `dotnet build Aetheria.Shared`; `dotnet test tests/Aetheria.Shared.Tests -m:1 -p:CultLibRoot=…`. + Unity batchmode compile (operator, or Self when the editor is closed). + - Tests (new file `LocomotionControlTests.cs`, fixtures exactly as in "Findings": off-origin, off-axis nose with + `|fx| > |fy|`, nonzero dt, asymmetric damage): + - `IntactLonginusMatchesBase` and `IntactDjinniMatchesBase`: every row of the base table within tolerance + (2% of that ship's full-scale acceleration or turn rate), except the four accepted divergences below, which + are pinned at their own probed values. Pins the allocator against base. + - **Accepted divergences** (operator, 2026-09-22, "The divergence is fine"). Soul reads these as pinned + behaviour, not as regressions, and anything outside them is a regression: + 1. Longinus turning in place spends its mains for torque, peaking near 0.73 throttle at w = 3. The + manoeuvre costs 16.9 m/s of drift against base's 13.9 and finishes in 0.30 s against base's 0.32. + 2. Longinus strafing gains a weak strafe it never had: about 10.7 m/s in a held second, heading holding + within about 1°, where base produced exactly nothing. + 3. Djinni strafe reaches 37.0 m/s² against base's 34.0 (+9%), by balancing torque with Stern 3 and Bow. + 4. Djinni turning in place may run a stern thruster and the bow thruster against each other at low throttle; + net force stays zero, but their exhaust is visible. + Plus two intended fixes, which are not divergences to tolerate but behaviour this cut is buying: turns past + 90° run at full rate instead of decaying to zero at 180°, and `|Direction|` no longer shrinks when the look + is directly aft. + - `LonginusXDriveAxisEqualsBaseFormula`: a drive-only hull; for every probe input, `AetherDrive.Axis` equals + `(my, mx, sqrt(|sin e|)·sign)` to 1e-3. Pins the live player ship's feel exactly. + - `CentreMainHullTakesTheLongWayRound`: the long-way fixture, built so the long way is the *only* way. One + centred main (torque 0, so no differential authority) and one clockwise attitude thruster, the + counter-clockwise one destroyed through the real `ItemDamage` path. Write `LookDirection` directly, as the + player would, to 90° left. Heading changes clockwise every tick (never counter-clockwise), and the ship + arrives within 2° in under 1.2 s (model: 0.86 s). Base comparison: a ship of that shape never turns at all. + Pins the shared feasible-direction choice. + - `LonginusMissingCcwPrefersTheLongWayAtNinetyDegrees`: the two-main Longinus with `Th.CCW` destroyed, looking + 90° left. It sweeps clockwise through about +270° and arrives (model: 0.74 s). Pins that the comparison is by + time, not by a rule about which actuators are "for" turning. + - `LonginusMissingCcwLurchesRoundTheShortWayAtTwentyDegrees`: the same ship looking 20° left. It sweeps + counter-clockwise through about −20° on differential main thrust, arrives within 2°, and its speed rises + while it does (model: 1.18 s, 19.7 m/s). Pins that the lurching turn is available and is chosen when it is + faster. + - `IntactShipTurningInPlaceDoesNotBurnForward`: the intact Djinni, looking 90° right with + `MovementDirection` zero. Its speed at the end of the turn is under 1 m/s and no stern or bow thruster + exceeds a low throttle. Pins regime (b) on a hull that has the actuators to keep translation clean. The + intact Longinus, which does not, is covered by accepted divergence 1 instead, with its own bound: drift no + worse than 1.3× base's 13.9 m/s and turn time no worse than base's 0.32 s. + - `NoAttitudeAuthorityStillTurns`: a Longinus with both nose thrusters destroyed, looking 20° right. It + arrives, on differential main thrust, and it gains speed doing so (model: 0.60 s). Pins regime (c). + - `TurningIsCheaperWhenForwardThrustIsAlsoWanted`: the same mains-only ship, 20° right, run twice with + `MovementDirection` zero and at full forward. Both arrive in the same time; the second gains much more + speed because the burn was wanted anyway (model: 20 m/s against 83 m/s). Pins regime (a) as a consequence of + one solve rather than a special case. + - `StrafeHoldsHeading`: the intact Longinus, full strafe held for 1 s with `LookDirection` fixed. Heading + deviation stays under 2° (model at w = 3: 1.1°). Pins the weight's other half, which is what stops the nose + swinging when a ship strafes with a thruster that also yaws. + - `LonginusMissingCcwTurnsRightAsBefore`: looking 90° right reaches within 2° in 0.32 s ± one tick (base + value). Pins that damage on one side leaves the other side alone. + - `LookDirectlyBehindTurns`: intact, look exactly aft. The ship turns, `|Direction|` stays 1 ± 1e-4, and there + is no NaN. Pins the snap's deletion and the >90° profile. + - `ZeroLookHoldsHeading`: default `LookDirection` gives no NaN in `Direction` or any throttle. + - `DestroyedThrusterNeverReceivesThrottle`: for each Djinni thruster in turn, destroy it, then drive 50 + ticks of mixed inputs (forward, strafe, turn both ways, diagonal). Its `Axis` is 0 on every tick. Repeat for + `Enabled = false` and for a zero power grant. Pins invariant 7. + - `RepairedThrusterRejoins`: destroy, restore durability, `UpdatePerformance`. The next tick's strafe uses it. + Pins the live read (invariant 3) against the old permanent pruning. + - `PlayerAndAiInputsGiveIdenticalThrottles`: two identical Djinni hulls with the same damage. One gets inputs + the way `ActionGameManager` writes them (`LookDirection` with nonzero pitch from yaw/pitch Euler, + `MovementDirection` from a stick value). The other gets them through an `Agent` state writing the same yaw, + flat. After `Update`, every actuator throttle is identical. Pins R2. + - `LonginusMissingMainHoldsHeadingUnderThrust`: `Th.L` destroyed, forward input. Heading drift stays under 1° + over 2 s, and forward acceleration is at least 70 m/s² (probe: 78 with 12 lateral slip). Pins torque-coupling + compensation in the shared layer. + - Negative greps, verified against `cd846916` to hit only the lines this cut deletes: + - `\.Axis\s*[-+]?=` in `Assets/Scripts` should match nothing outside `ShipControl.cs`. At base it matches + exactly `Ship.cs:256,264,273,275,276,287,288,291`. `FieldDriver.cs`'s `RectTransform.Axis` does not match; + checked. + - `_forwardThrusters|_clockwiseThrusters|StrafeTorqueThrusters|RecalculateThrust|TurnTime\(` should match only + `ShipControl.cs`'s new `TurnTime`. + - `TorqueFloor` should match nothing in `Assets/Scripts`, and nothing in `Assets/Resources/Settings.asset` + once Unity rewrites it. + - `IAnalogBehavior` should match nothing. + - `lerp\(Direction` in `Ship.cs` should match nothing. + - No least-squares code outside CultMath: `Cholesky|pseudo.?inverse|ActiveSet` in `Assets/Scripts` should + match nothing. + - Stryker: `dotnet stryker --since:` from `tests/Aetheria.Shared.Tests`. Every non-equivalent + survivor in `ShipControl.cs`, `Thruster.cs` and `AetherDrive.cs` is killed or triaged by name. Float boundary + flips are equivalent, per the fire-control Cut 11 ruling. + - Operator: fly LonginusX and confirm it feels unchanged (strafe, turn, look behind now turns). Then fly a + restored Longinus or Djinni from Cut 1, shoot a thruster off it, and confirm the damaged handling and the + lurching turn. Without Cut 1 this half of the check cannot be done at all. +- **Operator questions:** Q6, below, which does not block the cut. Q1, Q2, Q3 are ruled and Q5 is dissolved. +- **Ledger estimate:** −200 lines in `Ship.cs`, −12 elsewhere (`IAnalogBehavior`, `TorqueFloor` and its authored + value). `ShipControl.cs` adds about 250–350 lines, tests about 500. Net source roughly +100. That buys + actuator-loss capability, which is the explicit ask, and removes seven parallel role sets, the snap, permanent + pruning, one authored setting and four capacity solves a tick. + +## Cut 4. Heading planner in the AI + +- **Repo/branch:** Aetheria, after Cut 3. +- **First:** pin base AI results as constants. On base, a Djinni with all stern thrusters destroyed never reaches + the target (speed 0 at 120 s). Intact, it reaches it in 4.34 s. Both come from the probe (Agent plus MoveTo, dt + 0.02, target 300 m to port from (37,−12), nose `normalize(0.8,0.6)`). +- **Deletes first:** `Agent.cs:16-17` (the two thresholds) and `Agent.cs:60-78` (the body of `Accelerate`, 19 + lines). `MoveTo.cs:26` (the duplicate `LookDirection` write). The signature `Accelerate(float2, bool noTurn)` + stays, since `Combat.cs:127` and `MoveTo.cs:27` call it. +- **Adds:** in `Agent` (above the inputs, AI only). **The planner chooses by predicted outcome, never by a + rule.** It simulates each candidate and compares where the ship actually ends up. + - **Candidate manoeuvre:** a pair (heading command, movement command) held for the horizon. The set is the + headings the ship might fly with the translation that points the demand along Δv at each: 16 evenly spaced + headings (Hands default; 8 to 32 is the sensible range), plus the Δv direction and the current heading. + - **Prediction:** roll each candidate forward over a horizon of 1.0 s (Hands default; 0.5 to 2.0 s is the + sensible range) in 5 substeps (3 to 10), stepping the *same* `ShipControl` solve and the same kinematics the + ship will actually fly. There is no second physics model and no closed-form estimate: one owner for what the + ship does, which is invariant 8 applied to prediction. + - **Score:** the predicted outcome at the horizon — remaining distance to the objective, and the closing speed + that ends the horizon (so a manoeuvre that arrives pointing the wrong way with no closing speed loses to one + that is simply nearer and still closing). Infinite where the candidate ends further away than it started. + - **This is what answers the operator's case.** A ship whose rotation has to be bought with main thrust turns in + a great circle: the prediction shows the heading improving while the range opens, so a lateral burn that keeps + the bearing and closes wins on its own numbers. Nothing in the planner tests whether the ship is a one-way + turner, whether a thruster is missing, or which actuator would be used. + - Pick the argmin with hysteresis: replan when a candidate beats the current plan by a margin, on a cadence + (0.25 s default) or when the actuator set changes. The cadence exists for cost: 18 candidates × 5 substeps is + about 90 solves per replan, roughly 1 ms per replan per ship at the scratch timings, so a 0.25 s cadence over + 20 AI ships is a few percent of a core. Hands measures and reports; if it is too slow the answer is fewer + candidates or a shorter horizon, both of which are stated ranges, not a cache. + - Write `LookDirection = θ*` unless `noTurn`. Write `MovementDirection` as the per-half-axis fractions that point + the translation demand along Δv, inverting `ShipControl`'s own mapping (a `ShipControl` helper, so the AI never + re-derives it), with a deadband near zero Δv. + - `noTurn` (combat) skips the heading half of the candidate but still predicts the translation. A Djinni with no + stern facing its target strafes or backs off on its own. +- **Authority map:** + - Owner: `Agent` decides what the AI writes to the two inputs. + - Inputs: target velocity and objective position, the `ShipControl` step used as the prediction model, current + velocity and heading. + - Outputs: `LookDirection` (unless `noTurn`) and `MovementDirection`. + - Derived: the plan (heading, time of last replan) is command state only. + - Forbidden writers: `MoveToState` writing `LookDirection`; any AI code writing throttles or `Direction`; any + second model of what the ship will do, whether a closed-form estimate, a capability table of its own, or a + branch on which actuators are missing. + - Shared paths: `MoveToOrbitState` (patrol) and `CombatState` both go through `Accelerate`. +- **Verification:** + - `DjinniWithoutSternReachesTargetByCrabbing`: stern cluster destroyed, target 300 m at a bearing 30° right of + the nose, driven through `Agent` plus a fixed-target `MoveToState`. The ship arrives within 10 m in under 8 s. + Base never arrives (speed 0 at 120 s). Pins R1(2) and the stuck-forever cheese. + - `CrabBeatsTheBigCircle`: **the operator's case, and the reason Cut 4 predicts instead of deciding.** The + fixture is a hull that can strafe cleanly but can only rotate by spending a main: two off-centre mains aft and + one amidships lateral pair whose torque cancels, no attitude thrusters, with an objective abeam to port at + 300 m. A turn-to-face policy run in the same harness spends the manoeuvre sweeping a wide arc that barely + improves the bearing and opens the range; the planner's choice closes it. Pinned as time to reach the + objective, and as range at the horizon, never as "the planner chose to strafe". + - `CrabBeatsPointAndBurnWhenMainIsWeak`: Djinni with Stern 1 and Stern 2 destroyed (asymmetric; Stern 3's torque + is balanced by the laterals), target 300 m at 60° left. The planner arrives strictly sooner than a naive + point-and-burn agent run in the same harness through the same Cut 3 controller. A relation, not a captured + number. + - `IntactShipsStillPointAndBurn`: an intact Djinni arrives no later than base's 4.34 s + 10%, with the restored + hull's own `VelocityLimit` in force. Pins no regression. + - `LonginusWithoutReverseFlipsToBrake`: target velocity opposite the current velocity; the predicted-outcome + score puts the ship round, because a Longinus has no reverse thrust. + - `PlannerTakesTheLurchWhenItPays`: the Longinus with `Th.CCW` destroyed and a Δv 20° to port, where the + lurching turn is genuinely faster. The ship gets there sooner than a fixed-heading strafe would in the same + harness. Pins that the planner inherits the controller's comparison rather than avoiding the lurch on + principle. + - `PlannerPredictionMatchesFlight`: run a candidate's prediction, then fly the same commands for the same + horizon, and compare the end states. They agree within a small tolerance. Pins that the prediction uses the + real controller and that nobody has grown a second model. + - Negative grep: `LookDirection` in `Agents/States/MoveTo.cs` should match nothing. + - Stryker `--since:` over `Agent.cs`, `MoveTo.cs` and `ShipControl.cs` queries. + - Operator: watch a patrol Djinni with a stern thruster shot out crab to its next orbit. +- **Ledger estimate:** −22 lines, +140 lines of source (the prediction loop replaces the closed-form score), + +300 lines of test. No new type beyond the plan and the candidate. + +## Cut 5. Combat facing and tactics (target shape only) + +This cut gets mapped after fire-control Cut 12 lands, because that cut decides what presenting a side is worth. + +- **Owner:** `CombatState` (`Combat.cs:88-127`), above the inputs. +- **Shape:** extend Cut 4's predicted-outcome scoring with what the weapons want — arcs, ranges and the aim the + fire-control path will take — so the facing a ship holds is the one its predicted damage favours, not a rule + about its turn asymmetry. A ship that turns one way fast and the other way slowly will, under that scoring, tend + to keep its target drifting toward its fast side, because that is what predicts better. A ship with good turret arcs and poor rotation holds a heading and lets the + turrets work. Weapons with fixed arcs pull the heading onto the target. It uses the Cut 4 planner for thrust. +- **Fork:** Q4 (heading versus aim), which must be ruled before mapping. + +## Operator questions + +Each question gives options and a recommendation. Self paces these one at a time. Q1, Q2 and Q3 were ruled on +2026-09-22; their text is kept as history, and the live design is in the cuts above. + +- **Q6: the yaw weight in Cut 3's solve (does not block Hands).** The solve has exactly one knob, the weight on + the yaw residual against the translation residuals, after each axis is normalized by that ship's achievable + extreme. On a Longinus both the strafe and the turn come from the same nose thrusters, so the knob trades + heading-holding against how hard the mains are spent for torque (closed-loop model, table in Cut 3): + + | w | strafe gained in a held second | heading deviation while strafing | 90° turn in place | main peak | + |---|---|---|---|---| + | 1 | 46.4 m/s | 59° | 0.60 s | 0.37 | + | 2 | 13.7 m/s | 3.8° | 0.36 s | 0.63 | + | 3 | 10.7 m/s | 1.1° | 0.30 s | 0.73 | + | 5 | 9.9 m/s | 0.4° | 0.28 s | 0.79 | + + The Djinni is unaffected at every value: it has the actuators to do both cleanly. Base, for reference, does not + strafe at all and turns 90° in 0.32 s. + - **A: w = 3.** Heading holds while strafing, turns match base's speed, and the mains carry a real share of the + torque in a turn. + - **B: w ≈ 1.** The mains stay nearly out of turns, but a Longinus asked to strafe swings its nose most of the + way round, because strafing and turning are the same thrusters. + - **C: w = 5.** Heading holding is near-exact and the mains are spent hardest. + + **Recommended: A.** Hands starts there and the Cut 3 pins are written against it. This is a feel question, so + the honest place to settle it is the Cut 1 play check; changing it afterwards moves numbers in pins, not + structure. +- **Q4: heading versus aim (blocks Cut 5 only).** `LookDirection` is both the heading command and the aim, read by + `LockWeapon.cs:94`'s lock cone, guided projectiles, reticle targeting and the tractor beam. Capability-aware + facing wants the AI to hold a heading that is not its aim, and under R2 any split applies to the player too. + - **A.** Add a heading input beside the aim on the shared surface. The player's heading follows the view unless a + player control is added later. + - **B.** Keep one input, and the AI accepts degraded lock cones and missile aim while it manoeuvres. + + **Recommended: A**, decided when Cut 5 is mapped. + +### Ruled or dissolved (history) + +- **Q1: intact-ship divergence from base.** **Ruled A, 2026-09-22: "The divergence is fine."** The four accepted + divergences and their magnitudes are listed in Cut 3's verification. The rejected option was to require exact + base behaviour, which would have meant reintroducing per-thruster role rules into the objective. +- **Q2: rotation from translation thrusters.** **Ruled B, 2026-09-22: "that's absolutely viable and can be a + better decision than turning around the long way, I wouldn't introduce a thruster exclusion for this."** The + option this map recommended (A: rotation authority only from thrusters above `TorqueFloor`, so a lost attitude + thruster means that direction is gone) was rejected. Cut 3 and Cut 4 are written to B. +- **Q3: Longinus fixture layout.** **Ruled, 2026-09-22: "Good catch on the Longinus main thrusters, I was + misremembering."** Fixtures follow the data: two aft 2×2 mains. +- **Q5: how the rotation demand is sized.** **Dissolved, 2026-09-22.** The question assumed a separate rotation + demand that something had to size. The operator refused the premise — "it should only do that if the ship's + controller also *wants* to accelerate forwards that much" — and the answer is one solve over the whole wrench + with both residuals in the same currency, which makes the three regimes consequences rather than rules. Neither + of the options this map offered (size from attitude authority with a fall-back; size from everything) survives, + and `TorqueFloor` loses its last consumer. What is left of the question is Q6, the single weight. + +## Subtraction ledger (estimates) + +| Cut | Removed | Added | Dependencies / targets | +|---|---|---|---| +| 1 | 0 source; the one-shot command is deleted again in this campaign | 3–7 catalog records + products; ~150 lines of throwaway tool + ~120 test | No new package, target, asset or authored field. Existing prefabs, schematics and factions are reused | +| 2 | 0 | ~200 src + ~150 test (CultLib) | CultMath gains one file; cultmath-unity 0.2.4; Aetheria pin bump | +| 3 | ~212 (`Ship.cs` ~200, `Behaviors.cs` 4, `Entity.cs` 1, setters, `TorqueFloor` and its authored value) | ~300 src + ~500 test | `IAnalogBehavior` and one authored setting removed; four capacity solves a tick removed; no new package, target or authored field | +| 4 | ~22 | ~140 src + ~300 test | none | +| 5 | not mapped | not mapped | not mapped | diff --git a/docs/merge-to-master-checklist.md b/docs/merge-to-master-checklist.md new file mode 100644 index 000000000..4feec9edb --- /dev/null +++ b/docs/merge-to-master-checklist.md @@ -0,0 +1,172 @@ +# Aetheria merge checklist: `codex/fire-control-12` into `master` + +Compiled 2026-09-29 by Eyes from the maps on the branch. Pointers are `doc:line` in `docs/` at the branch tip. "Unrecorded" means the maps do not say the check was done. + +**What merging does.** `origin/master..codex/fire-control-12` is 257 commits. It carries the item-provenance work after 2026-09-17, stats-and-power (resolver, power bus, input capacitors, priority tiers, power-supply term and brownout, nominal requests, condition ratio, shield reserve, authored roles), fire-control Cuts 1-12.4(b), locomotion Cut 1 (restored hulls), the shield-panel interceptor, and the Addressables cuts. Self performs the git merge after you sign off. Nothing here is for the merge itself; it is what only you can check first. + +**Correction to the brief.** `codex/cultcache-cutover`, `codex/aetheria-state-rebuild` and `codex/item-provenance` are all ancestors of the branch tip, and the first two are already on `origin/master`. Master's merge commit `9b85211f` (2026-09-17) took cultcache-cutover and item-provenance at their 09-17 tips, after your play smoke passed (`cultcache-migration-cut.md:148`). Master has two commits the branch lacks (`9b85211f`, `263ac421`, which deletes dead narrative fixtures). `git merge-tree` of the two showed no conflicts. No `aetheria-state-rebuild` doc exists. + +## 1. Before you start + +- Open branch `codex/fire-control-12` at its tip, `4b594e116c4063ef5ee1dcb937bab0288008e63e` (2026-09-26, in sync with `origin/codex/fire-control-12`). +- Unity `6000.3.24f1` (`ProjectSettings/ProjectVersion.txt`). +- Batchmode: `tasklist` on 2026-09-29 showed Unity Hub and the licensing client but no `Unity.exe`, so none was running. Batchmode refuses an open editor and the maps say to close it and not kill anything (`item-provenance-cut.md:485,519`). Recheck when you start. +- The Unity manifest pins `cultmath-unity-v0.2.4` (`Packages/manifest.json`); the tag exists in CultLib. Unity needs network access to resolve it on first open (`fire-control-cut.md:2205-2212`). +- **Delete or move `GameData/run.cc` and `GameData/player.cc` before the first launch.** Both are git-ignored and stale (2026-09-22, 91,580 and 1,964 bytes). The maps' fresh-launch checks assume neither exists (`cultcache-migration-cut.md:2745`), and an old run store throws on the first lot lookup (`item-provenance-cut.md:595-596`). The persisted shape has changed since (power tier slot, fuse slot 32). + +**Your uncommitted work.** The merge happens in a separate worktree and does not touch it. `git status` lists 22 modified tracked files and 3 untracked. `git diff --stat` shows content changes in only 9; I infer the other 13 (materials, render textures) differ by line endings, from the LF/CRLF warnings. +- Content changes: `Asset Sources/Adrasteia1.blend`, `Car Paint White.mat`, `Steel.mat`, `Map Gravity Lines.mat`, `Minimap Gravity Lines.mat`, `LiberationSans SDF - Fallback.asset`, `Assets/Scenes/ARPG.unity` (+374), `Assets/Scenes/FieldShieldTest.unity` (+263), `Assets/Scripts/AetheriaInput.cs` (+40). +- Line-ending-only (inferred): the other Map/Sector `.mat` files, 8 `.renderTexture` files, `Stardust.mat`. +- Untracked: `Asset Sources/Adrasteia1_ContourRoute.blend`, `Ships.blend`, `adra1.png`. +- **Decide about three that belong to merged campaigns (my reading of the diffs):** + - `AetheriaInput.cs` (+40) adds the `Cycle Target Item` action (key J). The branch already has that action in `Assets/Resources/Aetheria.inputactions:72,326` and reads it in `ActionGameManager.cs:412`, but not in the generated wrapper. It is the wrapper catching up to committed data. Recommend including it. + - `FieldShieldTest.unity` (+263) adds `ShieldInterceptor`, `ShieldEnvelope`, a `Panel` with `Prototype` and `ShieldPanel`. This is the shield-panel Cut 4 rig you clicked together (`shield-panel-cut.md:840-846`). It is not on the branch. Without it the interceptor code merges with no rig. + - `ARPG.unity` (+374) adds "Debug Info Panel" and "Debug Info Text". `ActionGameManager.DebugInfoText` (`ActionGameManager.cs:151,1287,1333`) is the fire-control debug HUD and it returns early when null. Without the scene change the HUD "hull" factor in step 10 is invisible. + +## 2. One play session, in order + +### 2a. Fixtures to author first + +1. **Scratch catalog for 12.4.** Copy the catalog; do not edit `GameData/Aetheria.cc`. Author one weapon with `Fuse = Proximity` and a `BlastRadius`, and one with `Fuse = Delayed`, a `BlastRadius` and a penetration of about 2 cells (`fire-control-cut.md:3094,2656-2658`). Shipped penetration is at most 0.25 and enclosed cockpits need about 2 cells, so a penetrator authored from shipped values cannot reach the interior (`:3224-3225`). Fields are `aetheria.weaponitemdata` slot 29 (`BlastRadius`) and slot 32 (`Fuse`). Unrecorded: how to point the game at the scratch catalog, and which tool you author with (the map says only "Studio or `AetherDb`", `:2665`). +2. **Studio open, no game running.** You need it at 2c and 2d. Batchmode-based checks are the agent's, not yours. + +### 2b. Fresh launch (no `run.cc`, no `player.cc`) + +3. **Launch to main menu.** Expect no Addressables errors, and the Groups window shows `Assets/Content` under `Default Local Group` (`addressables-cut.md:301`). `player.cc` appears and Continue is disabled (`cultcache-migration-cut.md:2746`). Proves: Addressables Cut 1, CultCache cutover. The 09-17 smoke covered the last two on the old tree; this is a cheap recheck on the new schema. +4. **New Game (tutorial).** Expect the galaxy generates and the start ship is LonginusX (`settings-globals-cut.md:498`, an expectation, not a recorded check for this stack). Expect a Zenith station with a docking bay in the entrance zone every time (`locomotion-cut.md:375`). Proves: locomotion Cut 1 station rule. +5. **Addressables, "Use Asset Database".** Ships, turret and Zenith spawn; thruster and aether-drive particles show; fire an instant weapon (Autocannon) and a charged one (ChargeBlast SG); action bar icons show (`addressables-cut.md:362-366`). Proves: Addressables Cut 2. + +### 2c. Flight, power and economy + +6. **Provenance in play.** Starting-ship items show tier colour; the properties panel shows Manufacturer, product name and flavour for branded gear. Dock and buy: the brand still shows in cargo. `give Lamp` (any design name): no Manufacturer row. Kill an NPC and pick up loot: brand shown (`item-provenance-cut.md:528-539`). Proves: item-provenance Cut C. +7. **Fly a restored hull (Longinus or Djinni).** Thrusters move and turn it (`locomotion-cut.md:501`). Watch the speed cap: `VelocityLimit` is gated by the hull's `Active`, and a hull taken offline with thrusters firing loses the cap (`:377`). It did not happen in 300 s on shipped settings; report if you see runaway speed. Unrecorded: how to spawn a restored hull in play (the map names no path). +8. **Weak reactor (O2).** Fly with a reactor too small for the loadout. Expect visible, legible starvation rather than a cooked reactor (`stats-and-power-cut.md:705-706`). Proves: power bus (map Cut 3). +9. **Brownout and refill (O6, O4).** Brownout reads as degradation, not breakage (`:774`, `:990`). A weapon refilling under brownout stutters instead of firing full rate and cooking the ship (`:729-730`). Proves: power-supply term, brownout, input capacitors. +10. **Memory across zones (O1).** Play across several zones with kills, open the Profiler or Task Manager, and confirm memory does not climb (`:672-673`, `:980-982`). Proves: stat resolver, the entity-leak fix (map Cut 2). Combine with steps 11 and 12. + +### 2d. Combat (against a LonginusX-class AI), then save and Studio + +11. **Fire-control Cut 1 arcs.** Side-mounted weapons still fire at targets abeam; a turret tracks all the way round; nothing fires through the hull (`fire-control-cut.md:397-398`). +12. **Fire-control Cut 3 and shots.** Shots show rolled impacts and misses; a miss reads as a near-miss, not a bug; damage matches the HUD; aiming at a revealed subsystem concentrates damage; kill time is in the same order as before (`:659-661`). Reveal and selection work, kills drop loot, pickup stores it, the tractor beam still pulls (`headless-playground-cut.md:789-794`). No crash or frozen hot ship when the target vanishes (the 2026-09-20 failure, `fire-control-cut.md:1464-1470`). Recheck after the fixes: unrecorded. +13. **12.2, bow then beam.** The HUD `hull` factor reads higher from the beam. Turn your armoured face into a slow missile and watch where it lands (`:2380-2383`). +14. **12.3, launcher into a bow, then a flank.** Use a GT 3K or pswarm launcher. The schematic display pulses the facing edge only (`:2519-2520`). +15. **12.4, scratch catalog.** Watch a proximity airburst beside a LonginusX and a delayed penetrator into its nose. Damage must land where the model shows the blast; this confirms the centre-of-mass anchor. Bow is already confirmed +y (`:3095-3097`, `:2028-2031`). Proves: 12.4(b) `Detonate`. +16. **Wormhole, save, Continue.** Take a wormhole, quit, Continue: same items, same tier and brand (`item-provenance-cut.md:536-537`). +17. **Die.** Continue is disabled and `run.cc` holds no ledger (`:540-541`). +18. **Shield panel, `FieldShieldTest`.** Follow the numbered look in `shield-panel-cut.md:683-700`. Nothing visible on load. Click the nose: a patch grows and the field ripple runs too. Click the flank: the panel lies flat, not tilted. Click the same spot twice: it strikes the same panel. Spam far-apart clicks: at most 12 panels, oldest replaced. Let them fade: none left. Then the Cut 5 payoff: strong hits dice the patch, and repeated hits on one spot break through on a later hit (`:737-739`). Proves: shield-panel Cuts 3, 4, 5. +19. **Studio click-through.** + - Open `Aetheria.cc`. The fire-control behaviour union renders (`fire-control-cut.md:503-506`). + - `LonginusX` schematic still underlays the shape drawer (`addressables-cut.md:370-371`). + - The restored hull records open (`locomotion-cut.md:501`; the map gives no checklist beyond "the Studio click-through"). + - The scratch catalog's new `Fuse` and `BlastRadius` fields edit (from 2a; no map line). + - Open `GameData/run.cc` after step 16. `aetheria.provenanceledger` renders `Lots` with `Attributed` origins and per-role lists. If Studio cannot draw an int-keyed dictionary of unions, that is a Studio defect to raise, not a schema change (`item-provenance-cut.md:541-544`). +20. **Addressables, "Use Existing Build".** Run *Build > New Build > Default Build Script*, then repeat step 5. This proves the player catalog carries GUID keys. Then *Analyze*, "Check Resources to Addressable Duplicate Dependencies", and record the result; 5 known leftovers are expected (`addressables-cut.md:367-370`). + +### 2e. Desk review (not play) + +21. **Role content (O7).** Review `docs/stats-power-cut7-roles.md`. Rows marked "generic, needs review" are arbitrary-seed, not brand identity, and no design yet has a second product with a raised role (`:156`, `:194-202`; `stats-and-power-cut.md:797-799`, `:991`). + +## 3. Feel calls during the session + +Only what the maps name. +- **Balance numbers.** `CommitHorizon` (first value 0.3 s), `SchematicCellSize` (0.35) and the targeting stat ranges are first guesses; the smoke is the arbiter (`fire-control-cut.md:664-665`, `:855-859`). `UnaidedAccuracy` is ruled "really, really bad" (`:253-259`); judge whether it reads as a last resort. +- **Arc rule (Q2).** Arcs apply to the player as well as the AI. The map says it is a real difficulty change to be felt in the smoke (`:836-841`). +- **AI holds fire more** once `pOnHull` prices shots. The map calls that tuning, not broken AI (`:1396-1400`). +- **Penetration retune (F12-2)** is a ruled follow-up, "Retuning penetration is a follow up" (`:3223-3229`). The map does not tie it to a smoke. The only link is 2a step 1: shipped penetrators cannot reach interiors, so step 15 needs the scratch value. +- **Locomotion Q6, yaw weight (w = 3 recommended).** The map says settle it in the Cut 1 play check (`locomotion-cut.md:372-373`, `:903-904`), but the weight belongs to Cut 3's solver, which has not landed. There is nothing to feel today. Ambiguity, not a merge gate. + +## 4. Already done: do not redo + +- Play smoke for cultcache-cutover plus item-provenance, 2026-09-17: new game, combat, loot, save and Continue with brand and quality intact, death clears the run, stance toggle, `FieldShieldTest` (`cultcache-migration-cut.md:148-153`). It ran on the old tree, so steps 3, 6, 16 and 17 are rechecks, not repeats of a gap. +- Studio click-through for the CultCache port, 2026-09-17: AmmoType refs repointed at `515859cf`, `dangling` at 0, schematic underlay restored and checked (`cultcache-migration-cut.md:161-165`). +- Bow is +y on the schematic, confirmed 2026-09-22 from the schematic images (`fire-control-cut.md:2028-2029`). The blast-anchor half is still open (step 15). +- Shield-panel play evidence, 2026-09-18, rigged scene: a panel spawns, is reused and spiderwebs; it shatters only near the rim (`shield-panel-cut.md:119-124`). Partial: the Cut 5 fixes landed after, so step 18 is still open. +- A play smoke on 2026-09-20 found the frozen-ship, no-hits and `LockWeapon` crash (`fire-control-cut.md:1464-1470`). That was a failure report, not a pass. Cuts 8 and 9 answered it and no recheck is recorded. +- Stats-and-power Cuts 0-1 landed with Soul verification and your re-author ruling (`stats-and-power-cut.md:79-99` onward). No operator check remains from them. + +## 5. Not part of this merge + +- Stats-and-power tier control in the schematic UI (O5) and the deletion of `IOrderedBehavior`/`SortPosition` (O3): no `PowerTier` under `Assets/Scripts/UI` at the tip, and `SortPosition`/`IOrderedBehavior` still exist in `Reactor.cs`, `Entity.cs` (my grep). `stats-and-power-cut.md:752-753`, `:985-989`. +- Stats-and-power UI reconciliation (O8: properties panel, trade menu, schematic HUD, Studio over a stat with terms): the map's Cut 8 has not landed; the branch's "Cut 8" commits are the condition ratio (`:819`, `:991-992`). +- Settings-into-globals: only Cut 0 (delete 5 dead assets, `ea8b420e`) landed. Its play smoke and Studio click-through wait for Cut 1 (`settings-globals-cut.md:498-511`). The map header still says nothing landed. +- Build delivery: the two commits are the map and a ruling (`build-delivery-cut.md:5`, operator checks `:252,324,360,404`). +- Locomotion Cuts 2-5, including Q6 and Q4 (`locomotion-cut.md:360,905`). +- `content-batch-one.md` is a spec that writes no catalog data (`:5-8`). +- Fire-control follow-ups F12-2 to F12-8 (`fire-control-cut.md:3223-3250`). + +## 6. Open questions still yours + +- **Q12-5 (not ruled):** should the HUD show which target items are exposed from the current bearing? Recommended as a follow-up after 12.3, on the target-item cycling UI (`fire-control-cut.md:1959`, `:3215-3218`). +- **Locomotion Q4:** heading versus aim; blocks Cut 5 only (`locomotion-cut.md:905-913`, `:372`). +- **Locomotion Q6:** the yaw weight; open, does not block (`:372-373`). +- **Defaults Self took in 12.4, which you may overrule** (`fire-control-cut.md:3208-3213`): a blast ignores `DamageSpread` and P sits on the centre lane; `IncomingHit` fires for the host of a contact or delayed hit only; the delayed fuse point clamps to the last reached cell's exit; `Detonate` runs a flat armour, pool, hull pass. +- **Stale headers, not open questions:** the fire-control 12.3 "pending ruling" at `:2581` was ruled at `:2588`; Q12-9 is ruled A (`:3171`); the shield-panel Q1-Q7 block (`shield-panel-cut.md:61`, `:800-`) was not updated after Cuts 1-5 landed, Q5 being ruled C (`:839-846`). + +## 7. What happens after you sign off + +- **Self merges `codex/fire-control-12` to `master` with `--no-ff`**, in a separate worktree. That single merge carries item-provenance, the stats-and-power work, fire control and locomotion Cut 1. The cultcache-cutover and item-provenance merge into master already happened on 2026-09-17 (`9b85211f`, `cultcache-migration-cut.md:148`), so the maps' "merge the two together" instruction (`item-provenance-cut.md:82`) is spent. The tree merges clean today. +- Any of section 1's three uncommitted files you want included need to be committed to the branch first, or they stay out. +- **Soul gates outstanding:** + - Fire-control 12.4 Soul passes: running tonight. The 12.4 section carries no "closed" status yet (`fire-control-cut.md:2602`). 12.0-12.3 are closed (`:2168-2210`, `:2253`, `:2385`, `:2522`). + - CultMath bounded least squares is locomotion Cut 2, not in this stack (`locomotion-cut.md:508`, `:360`). It gates nothing here. + - Unrecorded: a Unity batchmode compile at the tip `4b594e11`. 12.2 and 12.3 record clean compiles at their own heads. +- **Soul status at merge time: Self fills in.** + +## 8. Results: play smoke, 2026-09-30 (operator plays, agent records) + +Tree: `codex/fire-control-12` at `113164fa` plus the fixes listed below. `run.cc` and `player.cc` moved to +`GameData/stale-2026-09-22/` before launch. + +**Compile.** The Unity 6000.3.24f1 batchmode compile at `113164fa` was clean (exit 0, no `error CS`). The four +never-compiled files needed no fix. One warning, `EntityInstance._destroyed` assigned but never read, is legacy +(`729ab0e8`). + +**Changes made during the smoke.** +- `e0a80208`: `AetheriaInput.cs` regenerated; Cycle Target Item bound through `Input.Player.CycleTargetItem`, + retiring the by-name `FindAction` stopgap. Batchmode recompile clean. +- `97edf48c`: `ARPG.unity` Debug Info Panel and `FieldShieldTest.unity` shield-panel Cut 4 rig committed. +- Console: control characters from text input are no longer appended, and Backspace deletes (see step 6 finding). +- `give` (operator ruling 2026-09-30): an unknown name and a full cargo bay now say so instead of doing nothing. A + ship hull is refused unless docked; when docked it goes through `ActionGameManager.CommissionShip`, the same + primitive `TradeMenu.Buy` now uses, and moors a bare player ship at the docked entity with no docking bay + ("mothballed ships do not require docking bays"). Non-ship hulls are refused. This is the step 7 path to a + restored hull. +- Scratch weapons authored in place in `GameData/Aetheria.cc` (not committed; restore with + `git checkout -- GameData/Aetheria.cc`): `Smoke Proximity` (Proximity, blast 8), `Smoke Delayed` (Delayed, + blast 4, penetration 2 cells), `Smoke Refused` (Proximity, blast 30, range 20). Each is a Spectra clone, energy, + 1 column by 2 rows, to fit the test ship's only energy hardpoint. + +| Step | Result | Evidence | +|---|---|---| +| 3 Launch to main menu | pass | Operator reached play through step 6 with no reported Addressables errors. | +| 4 New Game | pass | As above. | +| 5 Addressables, Use Asset Database | partial | Charged weapon (ChargeBlast SG) unprovable: the test ship's only energy hardpoint is 1x2 and ChargeBlast SG is 2x2. Instant weapon not separately reported. | +| 6 Provenance in play | partial | Properties panel correct for starting gear and for a purchased item. **Tier colours missing everywhere**: known defect, `settings-globals-cut.md:150` (Unity-serialised `RarityTier.Color` loads as zero); fixed by settings-globals Cut 1, not part of this merge. `give Lamp` was first refused (console finding), then silently did nothing: `Lamp` is a test-fixture name, absent from the live catalog, and `give` reported no miss (now it does). No-Manufacturer check still open with a real unbranded design. Loot brand not yet reported. | + +**Finding, console (not fire control).** Every console command was refused with "commands take only letters, +digits, spaces and hyphens", and Backspace inserted a glyph. Cause: `Keyboard.onTextInput` delivers control +characters (`\b`, `\r`), and `ConsoleView` appended them to the input line. Enter's `\r` rode into every command; +`d503f076` (2026-09-14, on master) correctly changed the parser from stripping disallowed characters to refusing +them, which exposed it. Backspace had never been handled (only Delete), which is legacy (`7006a6b0`). + +**Feel notes.** Engaging an AI Djinni: the player was destroyed decisively. "Game is hard." + +**Finding, ship purchase and docking bays (design gap, not a merge gate).** The intended UX is that a purchased +ship is assigned its own docking bay. The code does not do that: +- `TradeMenu.Buy` (`TradeMenu.cs:373-381`) creates a bare `Ship` parented to the docked station and assigns it no + bay. It also charges `data.Price` rather than the lot's quality price that `GetPrice` computes for other items. +- `LoadoutGenerator` equips exactly one docking bay per station (`LoadoutGenerator.cs:84-92`), so Zenith has one. +- The inventory panel's Current button (`InventoryPanel.cs:110-120`) sets the new ship as current and overwrites + the single bay's `DockedShip`; the previous ship stays a station child with no bay. + +**Smoke stopped after step 6 (operator, 2026-09-30).** The operator rejected the script as a monolithic play order +that does not respect how the game works: step 7 alone meant spawning and fitting a whole Djinni by catalog name. +Direction: operator checks become a list of behaviours verified over time, each set up by an authored, retained +scenario selectable from the main menu (a Eureka cut). Steps 7-20 and handoff checks A-F are unplayed. The operator +prefers to merge now, with those as open entries to verify through scenarios; Self rules on the merge. + +**Stale expectations found in this checklist.** Step 6 transcribes `item-provenance-cut.md:529-537` (written +2026-09-17 03:38, `7425ff1b`). "Starting-ship items show tier colour" was falsified by that day's smoke and recorded +at `settings-globals-cut.md:150` (`db420981`, 17:36); the provenance map was never corrected and this checklist +copied it despite its own section 5. "`give Lamp`" names a test fixture (`LoadoutTests.cs:289-312`), never a live +catalog design, and `give` could not have run at all between `d503f076` (09-14) and `f935c29b`. diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md new file mode 100644 index 000000000..d7354ecbc --- /dev/null +++ b/docs/scenarios-cut.md @@ -0,0 +1,674 @@ +# Scenarios: Cut Map + +Date: 2026-09-30, 02:10 CEST. Imagination pass. + +Status: cut map. Nothing has landed. The target lives in section 0 until Self splits it out into +`docs/scenarios-target.md`; this document owns the means. + +Anchors are against Aetheria `origin/master` `dcd7bbc5` (the fire-control merge), read in the clone +`C:\ws72-fuse5` with `git show`/`git grep`. The operator's tree `F:\Projects\Aetheria` was not touched. No +build ran and no Yggdrasil job ran. **(read)** marks a claim read from source at the anchor. **(probe)** marks a +claim measured against a real file. + +--- + +## 0. Target + +### 0.1 The request, in the operator's words (2026-09-30) + +> "I feel like this would be much more manageable as a list of things to verify over time rather than a +> monolithic smoke script that doesn't respect how the game works. Move gear onto Djinni? That's a lot of items +> to spawn, and am I expected to have the catalog memorized to spawn them? If you want me to test a bunch of +> behaviors under specific conditions, find a way to spawn me in an arena where those conditions are present. If +> you want me to kill a guy and loot him, spawn me next to a ship with no armor or weapons, that sort of thing. I +> wouldn't mind a scenario affordance in the main menu for this sort of job, that way the test fixtures can be +> authored, retained and available. But that sounds like a Eureka cut." + +What those words settle, so no question below re-asks them: +- **A scenario is an arena whose conditions are present at spawn.** The player's ship and every other entity are + already fitted and placed. The operator never spawns gear by catalog name and never fits a ship by hand. +- **The fixtures are authored and retained,** and the main menu lists them. +- **Verification is a list of behaviours checked over time,** not a play order. + +### 0.2 Ends + +- The main menu lists scenarios. Picking one starts a run in its arena. +- A scenario is typed catalog data. It is authored once, kept in git, and replayed at any later revision. +- Every operator check is a typed **check** record: what to do, what to observe, where the rule came from, and + the scenario that sets it up. Results accumulate as typed records carrying the revision they were observed at. +- Checks A-F and checklist steps 7-20 are the first consumers (section 5). + +### 0.3 Invariants + +- **One owner decides what a new run starts with.** A plain New Game and a scenario launch share the menu path, + the galaxy build, `StartGame` and the staging owner. Scenario launch is New Game with a scenario as input; there + is no second boot path. A plain New Game is the absent-scenario case. +- **One primitive admits an entity into a zone.** Zone construction, staging, warp arrival and undock all admit + through it, and agents are created there and nowhere else. +- **A scenario sets conditions and nothing else.** Once staged, its entities are ordinary run state. The rules + under test run through the same code whether or not a scenario started the run. No scenario-only branch exists + in simulation code. +- **Fixtures are typed state.** Scenario, loadout presets and test designs are CultCache catalog records. Checks and + results are CultCache records. No load-bearing JSON, text scripts or prose checklists. +- **A scenario that cannot stage is refused before anything is cleared,** with its failures listed. +- **The catalog stays read-only at runtime** (`AetheriaStores.cs:13-21`). + +### 0.4 Not in scope + +- The full run lifecycle owner (`Run`) of `docs/headless-playground-cut.md` Cut 1 (warp, dock, save, death as one + ServerShared owner) and its `AetherDb play` interpreter (Cut 3). This map takes over only the new-run staging + half. The playground map is stale (anchors `1e647953`, fork S superseded by settings-globals) and must be + re-anchored before Hands. When it lands, its `new` command stages a `Scenario` record instead of reading a + `.play` text line. +- Brokkr. It is not needed for any cut here: agents author scenarios headless through `AetherDb`, and the operator + launches them from the menu. Brokkr's Unity actions (`setEditorPlayState`, `captureEditorView`, scene edits; + `F:\Projects\Brokkr` `1357705`, read) have no way to pick a scenario in the menu. Agent-driven Unity play would + need an Aetheria install cut plus a "launch scenario" intent. Recorded as a follow-up, not a fork. +- The ship-purchase docking-bay gap (checklist section 8). It is a design gap in `TradeMenu.Buy` and + `CommissionShip`, not a verification affordance. `CommissionShip` stays; `TradeMenu.Buy` is its consumer. +- Tier colours (settings-globals Cut 1). The check is recorded as a known failure (section 5). +- Balance and feel tuning. A check can record a feel call; it does not tune. + +--- + +## 1. Body findings + +### 1.1 The new-run path today (read) + +| Concern | Anchor | Notes | +|---|---|---| +| Menu | `MainMenu.cs:92-184` | Continue `:103-108`; New Game `:110-176`; Settings; Quit. The same component runs in-game (`InGame`, `:96`), so the menu reappears on death (`ActionGameManager.cs:1171`). | +| Clear the run | `MainMenu.cs:113` | `RunSave.Clear` at click time, before the galaxy is built. | +| Galaxy | `MainMenu.cs:124-175` | `TutorialPassed` is never set anywhere, so New Game always takes the prelude branch `:146-175`. The background noise position comes from `UnityEngine.Random` (`:128,153`). The seed defaults to the clock (`Galaxy.cs:106,158`). | +| Handoff | statics `ActionGameManager.CurrentGalaxy`, `IsTutorial` (`:96-97`) | read by `StartGame`, `SectorMap`, and nulled by `Die` (`:1173`). | +| Start | `StartGame` `:796-834`, called from `Start` `:491` | New run `:803-815`: `PopulateLevel(Entrance)`, `LoadoutGenerator.GenerateShipLoadout` filtered by `GameSettings.StartingHullName` (`GameSettings.cs:13`, authored `LonginusX`), unpack, `Zone.Entities.Add`, `Activate`, `BindToEntity`. Continue `:817-833`. | +| Zone entry | `PopulateLevel` `:710-744` | generates the pack lazily (`:714-722`), moves the pilot (`:731-737`). | + +### 1.2 What a scenario can reuse (read, one probe) + +- **Loadout presets already exist.** `aetheria.loadout` (`Loadout.cs:13-20`) holds a hull design, per-cell slot + designs with rotation, and weapon groups. It is a catalog type (`AetheriaStores.cs:9`), authored in Studio or by + the editor console command `capturepreset` (`ActionGameManager.cs:624-662`, `Loadouts.Commit` `Loadout.cs:70-87`). + `Loadouts.Materialize` (`:96-159`) builds a live `Ship` all-or-nothing, listing every failure. + - **No game path calls `Materialize`** (`LoadoutGenerator.cs:152-153` says so; only `LoadoutTests.cs:310,899` + call it). + - **(probe)** The live catalog holds no preset: the schema name `aetheria.loadout` does not occur in + `GameData/Aetheria.cc` (12,698,108 bytes at `dcd7bbc5`), while every populated type's name does (for example + `aetheria.hulldata`, `aetheria.weaponitemdata`). Method: `grep -a -o` over the file. + - `Materialize` always builds a `Ship` (`:131`). A turret hull cannot be materialized. + - It resolves every design through a product (`Resolve`, `:103-115`). A design with no product fails. +- **Unbranded lots exist.** `ItemManager.CreateLot(design, maker, quality)` (`ItemManager.cs:161`) with no maker is + how `give` made its items (`ActionGameManager.cs:538,542`). Provenance's "no Manufacturer row" check depends on + them. +- **Economy exclusion follows from products.** Generation iterates products and requires + `product.Manufacturer.IsSet()` and `design.Price > 0` (`LoadoutGenerator.cs:126-133`). A design with no product + is never generated, stocked or sold. Loot is what a killed entity carries (`EntityInstance.cs:302-321`), so it + cannot leak through loot either unless a scenario fits it. +- **Prelude galaxies offer every product.** `IsAvailable` returns true when `Galaxy.IsPrelude` + (`LoadoutGenerator.cs:154-156`). A preset materialized in a prelude arena never fails on faction availability. +- **Agents are made only at zone construction.** `Zone`'s constructor gives every packed non-player `Ship` a + `Minion` (`Zone.cs:120-131`, `CreateAgent` `:135-142`). An entity added later gets none. A `Minion` targets the + first visible enemy and fights (`Minion.cs`). +- **An entity joins a zone in four other places,** each by hand: `spawnturret` (`ActionGameManager.cs:587`), + `PopulateLevel` (`:735`), `StartGame` (`:813`) and `Entity.TryUndock` (`Entity.cs:1006`). +- **IFF overrides are entity-local and unsaved.** `Entity.SetIff(other, bool?)` (`Entity.cs:529-533`) decides + `IsHostileTo` ahead of faction rules (`:535-552`). A `Materialize`d ship has no faction, so it is neutral unless + overridden. +- **A target that dies and a target that leaves are the same case in fire control.** + `targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target)` (`FireControl.cs:610`), and death + removes the entity from `Zone.Entities` (`Zone.cs:93`). Check A.3 ("the target warps away") is therefore set up by + killing a fragile target while a fused round is in flight. No departure machinery is needed. +- **An orbital entity with no orbit stays where it is put** (`OrbitalEntity.cs` `Update`: position follows the + orbit only when one is set). That is how `spawnturret` placed turrets (`ActionGameManager.cs:583-588`). +- **Headless tests already open the live catalog and the authored settings** + (`RestoredHullsTests.cs`: `AuthoredSettings.Load`, `OpenReadOnlyRealCatalog`). A staging test can generate a + prelude galaxy, build the arena zone and stage a scenario with no Unity. + +### 1.3 Debug affordances a scenario replaces (read) + +| Affordance | Anchor | Added for | +|---|---|---| +| `give ` | `ActionGameManager.cs:515-544` | ad hoc items; the hull route through `CommissionShip` was added tonight in `f935c29b` for step 7 | +| `spawnturret` | `:564-589` | a hostile turret next to the player | +| Neutral wanderers | `ZoneGenerator.cs:342-356`, `EligibleWandererFactions` `:361-365`, `ZoneGenerationSettings.NeutralWandererCount` (`Settings.cs:156`), tests `IffAndCombatTests.cs:329-350` | commit `f732cff9` (2026-09-17): "Spawn neutral wanderers so combat has something non-hostile to target". The code comment calls it a "Testing affordance". Every generated zone carries two. | + +Staying, because no scenario replaces them: `iff` (flip a live target's stance mid-play), `trackmissile` (camera), +`revealzones` (map), `tow` (a verb bound to the console), `capturepreset` (the in-play authoring tool for presets). + +### 1.4 Collisions with open branches (read) + +- `codex/mining` rewrites `Zone.cs` (+239/-). Cut 1 edits the constructor loop and adds the admission primitive. + Land Cut 1 after mining merges, or re-anchor. +- `codex/moddable-ships` edits `AetheriaStores.cs` (line 9, the catalog type list). Cuts 1 and 3 edit the same + lines. +- `codex/item-provenance` carries the narrative map, which edits `MainMenu.cs:132,160` and the `Galaxy` + constructors. Cut 2 edits the same New Game block. + +--- + +## 0b. Identity, lifecycle, authority + +No cut is mapped against an empty cell. + +| Kind | What names it | Lifecycle | Who decides | +|---|---|---|---| +| **Scenario** (`aetheria.scenario`, catalog) | `CultName` `Name`; key `scenario:`, derived like `Loadouts.KeyOf` | Authored, then edited in place. A rename is a new record; the old one is deleted, and the checks that pointed at it are repointed in the same commit. A result never points at a scenario (it points at a check), so history survives. A scenario whose presets stop materializing is caught by the staging test (Cut 4), not at play time. | Authored by agents through `AetherDb` or by the operator in Studio. The operator owns what an arena contains. | +| **Loadout preset** (`aetheria.loadout`, existing) | key `loadout:` (`Loadout.cs:62`) | Unchanged: authored, replaced only explicitly (`Commit(..., replace)`). | Unchanged. | +| **Test design** (catalog `ItemData`, no product) | name; key minted by `Upsert` | Authored with the scenario that needs it. Lives as long as a preset references it. Per Q3. | Operator (Q3) on whether they exist at all. | +| **Check** (`aetheria.check`, ledger store) | `CultName` `Slug`; key `check:` | Authored. Its statement is edited in place when a ruling changes (git keeps the history). It is retired, never deleted, once any result references it: `Retired` plus a reason. | The rule's owner (a ruling or a map) decides what it says. Agents transcribe it with a source pointer. | +| **Check result** (`aetheria.checkresult`, ledger store) | key `checkresult::` | Append-only. Never edited or deleted. A correction is a new result whose note says what it corrects. The latest by `At` is the current status. Order is carried in the data (`At`), not in delivery. | The operator's observation. Who writes the record is Q2. | +| **Pending scenario** (runtime) | `ActionGameManager.PendingScenario`, a static | Written once by the menu's launch, read and nulled once by `StartGame`'s new-run branch. Null for Continue. Never saved. | Command-only transport. It decides nothing. | +| **Staged entities** (runtime, then run state) | ordinary entities | Born at staging. From then on they are run state and saved with the zone like any other entity. Two things are not saved: whether a ship was left unpiloted, and scenario IFF overrides. After Continue, an unpiloted scenario ship gets a `Minion` by the constructor rule and its stance reverts to faction rules (neutral, since presets carry no faction). A scenario run is meant to be played through in one sitting. | `RunStart` at birth; the zone afterwards. | + +--- + +## 2. Target shape + +### 2.1 Types (names and rules; bodies are Hands') + +`Assets/Scripts/ServerShared/Scenario.cs`, catalog: + +- `Scenario` (`aetheria.scenario` v1): + - `Name` (`CultName`). + - `Brief`: the conditions in one or two lines, shown in the menu. + - `Seed` (`uint`): the arena galaxy and the background noise both derive from it, so the arena is the same layout + every launch. Combat rolls are already zone-seeded (`Zone.cs:84`). Lot quality still comes from the + clock-seeded `ItemManager.Random` (`ItemManager.cs:20`), so stats vary slightly per launch. Accepted. + - `Ambient` (`bool`): true keeps the arena zone as generated. False keeps planets, orbits and stations but drops + generated ships and turrets, so nothing wanders into the conditions. + - `Player` (`ScenarioShip`). + - `Entities` (`List`). +- `ScenarioShip`: `Loadout` (`CultRecordRef`), `Position` (`float2`, zone xz), `Direction` (`float2`), + `Cargo` (`List>`, stored in the first cargo bay). +- `ScenarioEntity : ScenarioShip`: `Stance` (`Neutral | Hostile`, applied both ways through `SetIff`) and `Piloted` + (`bool`: a `Minion`, or no agent at all). +- A preset's hull type picks the entity class: a ship hull gives a `Ship`; a turret hull gives an orbital entity + with no orbit, stationary at its position. A station hull is refused as a staging failure. + +`Assets/Scripts/ServerShared/Checks.cs`, ledger store (`GameData/Checks.cc`, committed, LFS by the existing +`GameData/*.cc` rule): + +- `Check` (`aetheria.check` v1): `Slug` (`CultName`), `Statement` (what to do and what must be observed), + `Source` (the ruling or map pointer, `doc:line`), `Scenario` (`CultRecordRef`, optional), `Setup` + (text, only when there is no scenario: "FieldShieldTest scene", "CultCache Studio", "Addressables play mode Use + Existing Build"), `Retired` and `RetiredReason`. +- `CheckResult` (`aetheria.checkresult` v1): `Check` (ref), `Result` (`Pass | Fail | Partial | Blocked`), + `Revision` (the commit the operator played, plus `+dirty` when the tree had changes), `At` (UTC), `Note`, + `Recorder`. + +Naming: "check", not "behaviour". `Behavior` is already the gear-behaviour type family +(`Assets/Scripts/ServerShared/Behaviors/`), and a document named `Behaviour` beside it reads as a typo. + +Where they live, and why: +- Scenarios, presets and test designs are design-side fixtures. They go in the catalog: presets are already + catalog data, cross-type refs resolve inside one store, and Studio edits them side by side. +- Checks and results are the verification ledger. They go in their own store. The shipped catalog carries no + history, results never trigger a catalog write, and "one commit lands in one store" holds: recording a result + touches the ledger only. + +### 2.2 The staging owner + +**Owner: `RunStart`, a static class in `Assets/Scripts/ServerShared/RunStart.cs`.** It decides what a new run +starts with: how the arena zone is generated, the player ship, the other entities, their stance, pilot and cargo. +It holds no Unity type. + +- **Inputs:** `ItemManager` (and through it the catalog), the `Galaxy`, `ZoneGenerationSettings`, the optional + `Scenario`, and, for the absent-scenario case, the default hull filter and faction `StartGame` uses today + (`:805-809`). +- **Outputs:** + - the arena's `ZonePack`, with the `Ambient` rule applied inside generation (`ZoneGenerator.GenerateZone` + gains the parameter), not generated and then pruned; + - the admitted player `Ship`, flagged `IsPlayerShip`; + - the admitted scenario entities; + - a failure list. Staging is all-or-nothing, as `Materialize` is. +- **A check entry** that validates a scenario without a zone (every preset materializes, every cargo design + exists). The menu calls it before `RunSave.Clear`; the staging tests call it on every catalog scenario. +- **Derived state:** `ActionGameManager.CurrentEntity` is bound to the returned player. `PendingScenario` is + transport only. +- **Forbidden writers:** `StartGame` may not generate, unpack or admit the player ship. `MainMenu` may not stage. + No console command admits entities (`give` and `spawnturret` die, Q4). +- **Shared paths:** New Game and scenario launch both run menu, galaxy, `StartGame`, `RunStart`. Continue does not + stage. +- **Deletion line:** `StartGame` `:805-814` (generator, unpack, add, activate). The only surviving line is the + bind. + +**Admission primitive: `Zone.Admit(Entity entity, bool piloted)`.** It adds the entity to `Entities`, activates it, +and, when `piloted`, gives it the zone's `Minion` (the `CreateAgent` rule, `Zone.cs:135-142`). It is the only writer +of `Entities.Add` and the only creator of agents. +- The constructor loop (`:120-131`) calls it with `piloted: ship && !IsPlayerShip`. It keeps its own rule that + moves a ship packed at the origin (`:129-130`); that is generation placement, not admission. +- `PopulateLevel` (`ActionGameManager.cs:735`), `Entity.TryUndock` (`Entity.cs:1006-1007`) and `RunStart` call it. + `spawnturret` (`:587`) is deleted. +- The `Death` subscription (`Zone.cs:93`) stays on `ObserveAdd` and still covers every join. + +**`Loadouts.Materialize` gains two rules** (`Loadout.cs:96-159`): +- A design with **no product at all** is built unbranded, through `CreateLot(design, default, quality)` at one + named quality constant (0.95, the quality `give` used). A design whose products are all unavailable still fails, + as today. The rule turns "no manufacturer makes it" into "outside the economy", which generation already obeys. +- The **hull type picks the entity class** (section 2.1). +- The comment at `LoadoutGenerator.cs:150-153` ("No game path materializes a preset yet") is rewritten to name + `RunStart`. + +### 2.3 The Unity lowering + +- `MainMenu.ShowMain` (`:92-184`) gains **Scenarios**, shown when `Debug.isDebugBuild` (editor and development + builds). It opens a panel with one button per catalog scenario, ordered by name. Under each button is its brief + and its checks, each with its latest result and revision, read from the ledger (display only). A scenario whose + check entry fails shows the failures and is disabled. +- **One launch body** is extracted from the New Game lambda (`:110-176`). New Game calls it with no scenario; a + scenario button calls it with one. With a scenario, it runs the prelude branch (`:146-175`) with `Seed`, derives + the noise position from `Seed` instead of `UnityEngine.Random`, sets `PendingScenario`, and loads `ARPG`. The + check entry runs first; `RunSave.Clear` (`:113`) moves after it, so a refused scenario leaves the saved run intact. +- `StartGame`'s new-run branch (`:803-815`) calls `RunStart`, binds the returned player and nulls + `PendingScenario`. The Continue branch asserts it is null. +- `AetheriaStores.Open` gains an optional ledger path. The game attaches `GameData/Checks.cc` read-only; `AetherDb` + attaches it writable for the record commands (Cut 3). + +### 2.4 Authoring + +- **Agents author headless through `AetherDb`.** A seed command (Cut 4) declares the first set in C#. It writes a + record only when the key is absent. When the key exists and differs from what it would write, it refuses and + reports; that means Studio edited it, and Studio wins. When the key exists and matches, it does nothing. It + validates every scenario through the `RunStart` check entry before committing. So the catalog is the authority, + and the seed code never clobbers it. +- **The operator authors in Studio and in play:** Studio for scenario fields; `capturepreset` for a ship fitted by + hand in play. +- Results are recorded per Q2. + +--- + +## 3. Cuts + +Build budget, every cut: +- `Aetheria.Shared` (netstandard2.1, the Unity-independence check), `tests/Aetheria.Shared.Tests`, `tools/AetherDb` + (net10.0), Debug. +- CultLib is unchanged; the pins are those in `hands-fuse8/run.sh`. +- No new project, target, package or daemon. One new store file (`GameData/Checks.cc`), three new schemas. +- **Build host:** Yggdrasil builds the headless projects (`run.sh `). `Assembly-CSharp` is proven only by a + Unity 6000.3.24f1 batchmode compile on Starfire, with the editor closed by the operator. Never kill Unity. +- `run.sh` mounts a pinned `data/Aetheria.cc` over the tree's LFS file. Any cut that changes the catalog (Cut 4) + must refresh that pin, or its content tests read the old catalog. Cut 4's tests that read the ledger mount it the + same way. Yggdrasil's disk is saturated: one job per cut, `--artifacts-path` in the container. + +Order: 0, 1, 2, 3, 4. Cut 0 is subtraction alone, so Soul can falsify it alone. Cuts 1 and 3 are headless. Cut 2 +is the only Unity-side cut. Cut 4 is content. + +### Cut 0. Retire the ad hoc test affordances (Q4) + +- **Repo/branch:** Aetheria, `codex/scenarios` from `master` `dcd7bbc5`. +- **Deletes first** (per Q4 A; B keeps the item branch of `give` and the wanderers): + - `ActionGameManager.cs:515-544`, `give` (30 lines). + - `ActionGameManager.cs:564-589`, `spawnturret` (26 lines). + - `ZoneGenerator.cs:342-356`, neutral wanderers (15 lines), and `EligibleWandererFactions` `:361-365` (5). + - `Settings.cs:156`, `NeutralWandererCount` (1). It is absent from `Settings.asset` (settings-globals `:72`), so + no asset edit is needed. + - `IffAndCombatTests.cs:329-350`, the two wanderer tests (22). +- **Keeps:** `CommissionShip` (`:898-903`) and its consumer `TradeMenu.Buy`; the console fixes in `f935c29b`. +- **Docs:** `docs/merge-to-master-checklist.md` section 8, the `give` bullet: add "retired by the scenarios cut" + with the commit. `docs/settings-globals-cut.md:72,348` get a one-line history note. +- **Verification:** + - builds: `Aetheria.Shared`, the test project. Batchmode compile (`ActionGameManager.cs` changed). + - tests: the full suite is green. The locomotion "real enemy-count check" (`95394f78`) counts generated ships; + if it counted wanderers, fix its expectation and say so. + - negative: `rg -n '"give"|"spawnturret"' Assets/Scripts` is empty; + `rg -n "NeutralWanderer|EligibleWandererFactions" Assets tests tools` is empty. Both patterns were checked to + match only these sites today. + - operator: none. There is nothing to see until Cut 2. +- **Ledger:** about -99 lines; 0 added. + +### Cut 1. `RunStart`, `Zone.Admit`, and the scenario type (headless) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cut 0. Re-anchor `Zone.cs` if mining has merged. +- **First:** with `AetherDb hardpoint-fit` and a scratch `Materialize` against the live catalog, confirm that + `Djinni`, `LonginusX` and one turret hull each have a product and materialize bare. If one has no product, stop + and report: the Q3 rule would make it unbranded, which may not be intended for a real hull. +- **Deletes first:** + - `ActionGameManager.cs:805-814`, the new-run spawn (it moves into `RunStart`; this cut leaves `StartGame` + calling `RunStart` with no scenario, so behaviour is unchanged). + - The four hand-written joins: `ActionGameManager.cs:735-736`, `Entity.cs:1006-1007`, `Zone.cs:124-125`, and the + agent add at `Zone.cs:128`. Each becomes `Admit`. +- **Adds:** `Scenario.cs`, `RunStart.cs`, `Zone.Admit`, the `ambient` parameter on `ZoneGenerator.GenerateZone` + (`:38-43`; the ship loop `:334-340` and `PlaceTurrets` calls `:309,331` honour it), `Scenario` in + `AetheriaStores.CatalogTypes` (`:9`), and the two `Materialize` rules (`Loadout.cs:103-115,131`). +- **Authority map:** section 2.2. Owner `RunStart`; admission `Zone.Admit`; deletion line as above. +- **Verification:** + - builds: `Aetheria.Shared`, tests, `AetherDb`. Batchmode compile (`ActionGameManager.cs`, `Entity.cs` changed). + - tests (new `RunStartTests`: live catalog, authored settings, a prelude galaxy at a fixed seed). Each rule + must fail under its own mutation: + - With no scenario, staging yields a player of the `StartingHullName` hull, `IsPlayerShip`, admitted, at the + origin. This pins that New Game is unchanged. + - With a scenario, the player is the preset's hull and fit at the authored position and direction, and its + cargo holds the listed designs. + - A piloted entity has exactly one agent in `Zone.Agents`; an unpiloted one has none and stays put over 5 s of + `Zone.Update`. + - `Hostile` makes both `IsHostileTo` directions true. `Neutral` makes both false. + - `Ambient: false` leaves no ship or turret in the arena pack that the scenario did not place. Stations remain. + - A turret preset stages as a stationary orbital entity. A station preset is a staging failure. + - A scenario naming a missing design fails with that design named. The zone is unchanged, and no + `RunSave` call was made (the check entry touches no store). + - A product-less design materializes unbranded; a design whose only product is unavailable still fails. + This is two tests, because the second case is the existing contract. + - `Admit` is the only agent creator: after a warp (`PopulateLevel`'s join through `Admit(pilot, false)`) the + pilot has no agent. The constructor's pack path still gives every non-player ship a `Minion`. + - Stryker on `RunStart.cs`, `Zone.cs` (`Admit` and the constructor loop), `Loadout.cs` (`Materialize`), scoped + to the diff. Triage survivors by name. + - negative: `rg -n "Entities\.Add\(" Assets/Scripts --glob '!**/ZoneGenerator.cs'` matches only + `Zone.cs` (once, inside `Admit`), `Sensor.cs:169` and `Entity.cs:242` (both are `VisibleEntities`/pings, not + zones). `rg -n "new Minion\(" Assets/Scripts` matches only `Zone.cs`. + - operator: none beyond the Cut 2 smoke. +- **Ledger:** `Scenario.cs` +60; `RunStart.cs` +110; `Zone` +12 -8; `Loadout.cs` +20; `ZoneGenerator` +4; + `ActionGameManager` -11 +4; `Entity` ±2; `AetheriaStores` ±1; tests +300. + +### Cut 2. The main menu launches scenarios (Unity) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cut 1. +- **Deletes first:** the duplicated body of the two galaxy branches in the New Game lambda (`MainMenu.cs:124-175`) + collapses into one launch body. The standard branch (`:124-145`) is live code only when `TutorialPassed` is + true, and it is kept. Only the duplication goes. +- **Adds:** the Scenarios panel, `PendingScenario`, the seed-derived noise, the check entry before + `RunSave.Clear`. `StartGame` passes `PendingScenario` to `RunStart`. +- **Authority map:** + - Owner of what the run starts with: `RunStart` (unchanged). + - `MainMenu` owns which scenario was picked and when the run is cleared. `PendingScenario` is a command-only + transport, consumed once. + - Forbidden: `MainMenu` stages nothing and admits nothing. `StartGame` reads `PendingScenario` only in the + new-run branch. + - Shared paths: both buttons run one launch body. In-game menu (after death) and title menu run the same one. +- **Verification:** + - builds: batchmode compile, editor closed. + - negative: `rg -n "RunSave\.Clear" Assets/Scripts/UI/MainMenu.cs` appears once, after the check entry. + `rg -n "PendingScenario\s*=" Assets/Scripts` matches the launch body and `StartGame`'s null only. + - operator (Unity, Starfire): + 1. Title menu: Scenarios is listed. New Game still starts a LonginusX at the entrance with a Zenith station + (the Cut 1 test pins the ship; this proves the lowering). + 2. Launch any scenario. The arena matches its brief; launch it again and the layout is the same. + 3. Die in a scenario. From the in-game menu, relaunch the same scenario. + 4. Quit mid-scenario, then Continue. The run continues (the 0b rule: unpiloted ships now patrol). + 5. Break a scenario on purpose in a scratch catalog (a missing design). It is shown disabled with the failure, + and Continue still offers the previous run. +- **Ledger:** `MainMenu` +45 -25; `ActionGameManager` +4; one static. + +### Cut 3. The verification ledger (headless; Q2) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cut 1 (independent of Cut 2). +- **Adds:** `Checks.cs` (`Check`, `CheckResult`); `AetheriaStores.LedgerTypes` and the optional path; + `GameData/Checks.cc` (empty, committed). + - Under Q2 B, `AetherDb` gains two commands: + - `checks [scenario]`: each check with its latest result, its revision, and "never" when there is none. + - `record --revision [--note ]`. It refuses an unknown or + retired slug. It opens the ledger writable and the catalog read-only. + - Under Q2 A, the menu panel gains Pass/Fail/Partial and a note field per check. It writes through the same + ledger primitive, and the game attaches the ledger writable. +- **Authority map:** + - Owner of results: the ledger store. The only writer is the Q2 writer, which calls one append primitive, + `Ledger.Record`. + - Derived, display only: the menu's latest-status line and `checks`' output. + - Forbidden: nothing edits or deletes a `CheckResult`; nothing writes a result into the catalog. +- **Verification:** + - tests (new `LedgerTests`, temp stores): + - Recording appends and never replaces: two results for one check both survive a reopen, and the latest is + the one with the later `At`, even when written first. + - A retired or unknown slug is refused with nothing written. + - The catalog file is byte-identical after `record` (a catalog opened read-only cannot be written; this pins + that the command opened it that way). + - A result carries the revision it was given, and `record` without `--revision` is a usage error. + - Stryker on `Checks.cs` and the `AetherDb` record path. + - negative: `rg -n "CheckResult" Assets/Scripts --glob '!**/Checks.cs'` matches only the menu's read (and under + Q2 A its record call). +- **Ledger:** `Checks.cs` +50; `AetheriaStores` +4; `AetherDb` +90 (B) or `MainMenu` +50 (A); tests +120. + +### Cut 4. The first scenarios, checks and history (content) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cuts 2 and 3. Hands authors on the branch clone, never in + `F:\Projects\Aetheria`. +- **First:** tell the operator that the uncommitted smoke weapons in their tree's `GameData/Aetheria.cc` must be + restored (`git checkout -- GameData/Aetheria.cc`, their own command) before they pull. This cut recreates them as + committed designs. +- **Adds:** + - A transient `AetherDb scenario-seed [apply]` command, with the never-clobber rule of section 2.4. + - It writes the test designs (section 5.3), the presets (5.4), the scenarios (5.2) and the checks (5.1). + - It imports the 2026-09-30 smoke results (checklist section 8) as `CheckResult`s at revision `113164fa`, + recorder "operator (play), recorded by agent". + - It runs once, the data is committed, and the command is deleted in the same cut. The catalog is the authority + from then on. + - `ScenarioConditionTests` (live catalog; pinned catalog refreshed, see the build budget). Per scenario, stage it + and assert the conditions its checks need, not the behaviour, which existing suites own. For example: + - `Fused rounds`: the bare hull lies within the arc on the player's initial aim line and has no target + selected; the close hull is nearer than `Smoke Proximity`'s `BlastRadius`; `Smoke Refused` has + `Range < BlastRadius`; a direct-fire weapon can kill the fragile hull. + - `Duel`: the AI is piloted, hostile both ways, and carries a slow missile weapon; the player can see it. + - `Arcs`: the three bare hulls lie in the bow, beam and stern sectors of the player's mounts; the turret is + hostile. + - Every catalog scenario passes the check entry. This is the rot test. + - Every non-retired check with a scenario points at an existing one. Every check without a scenario has a + `Setup`. + - Docs: `docs/merge-to-master-checklist.md` sections 2 and 8 get one line: steps 7-20 and handoff checks A-F are + now checks in `GameData/Checks.cc`, listed by `AetherDb checks`. The prose steps stay as history. +- **Verification:** + - tests: `ScenarioConditionTests`, with mutation by editing a fixture. Moving the close hull outside the blast + radius, dropping the AI's missile weapon, or flipping a stance must each fail its condition. This is a one-off + Soul probe, not a committed suite. + - `AetherDb checks` lists every check in section 5.1, with the imported results on 3, 4, 5, 6 and "never" + elsewhere. + - operator: play each scenario and report per check. That report is the first real use of the ledger. +- **Ledger:** seed command +300 then -300; catalog +5 designs, +11 presets, +10 scenarios; ledger +44 checks, +7 + results; tests +250; docs ±4. + +--- + +## 4. Authority map (whole campaign) + +- **Owner of a new run's contents:** `RunStart`. It reads the scenario (or its absence) and writes the arena pack + and the admitted entities. +- **Owner of zone membership and agents:** `Zone.Admit`. +- **Owner of fixtures:** the catalog (`Scenario`, `Loadout`, test designs), written by the seed, Studio or + `capturepreset`, and read-only at runtime. +- **Owner of verification history:** the ledger store. It is append-only and written by the Q2 writer. +- **Derived:** menu status lines; `AetherDb checks`; `PendingScenario` (transport). +- **Forbidden writers:** `StartGame` spawning ships; `MainMenu` staging; console commands admitting entities; + anything editing a result; any catalog write at runtime. +- **Shared paths:** New Game and every scenario launch: one launch body, `StartGame`, `RunStart`, `Admit`. Warp, + undock, construction and staging: `Admit`. +- **Deletion line:** Cut 0's affordances; `StartGame` `:805-814`; the four hand-written joins; the duplicated New + Game branches; the transient seed command. + +--- + +## 5. The first consumers + +Sources: `docs/merge-to-master-checklist.md` section 2 (steps 7-20) and section 8; checks A-F from +`F:\Projects\HANDOFF-aetheria-play-smoke-2026-09-29.md` section 3. That file sits outside the repo, so the checks +below restate A-F and the ledger becomes their home. The rulings behind A-F are in `fire-control-cut.md`, in the +block "Ruled (operator, 2026-09-30)" (`:2625-2690`). + +### 5.1 Checks + +| Slug | Statement (abridged; the record carries the full text) | Source | Setup | +|---|---|---|---| +| `restored-hull-flies` | Thrusters move and turn the Djinni. | step 7; `locomotion-cut.md:501` | Djinni shakedown | +| `restored-hull-speed-cap` | Take the hull offline with thrusters firing; speed does not run away. | step 7; `:377` | Djinni shakedown | +| `weak-reactor-starves-legibly` | A reactor too small for the loadout shows starvation, not a cooked reactor. | step 8; `stats-and-power-cut.md:705-706` | Starved reactor | +| `brownout-degrades` | Brownout reads as degradation, not breakage. | step 9; `:774,990` | Starved reactor | +| `brownout-refill-stutters` | A weapon refilling under brownout stutters rather than firing at full rate. | step 9; `:729-730` | Starved reactor | +| `memory-flat-across-zones` | Several zones with kills; memory does not climb. | step 10; `:672-673,980-982` | Long haul | +| `side-mounts-fire-abeam` | Side-mounted weapons fire at the beam target. | step 11; `fire-control-cut.md:397-398` | Arcs | +| `turret-tracks-round` | The hostile turret tracks the player all the way round. | step 11 | Arcs | +| `no-fire-through-hull` | Nothing fires at the stern target through the hull. | step 11 | Arcs | +| `shots-roll` | Rolled impacts and misses; a miss reads as a near-miss; damage matches the HUD. | step 12; `:659-661` | Duel | +| `subsystem-aim` | Reveal and selection work; aiming at a revealed subsystem concentrates damage. | step 12 | Duel | +| `kill-drops-loot` | The kill drops its cargo; pickup stores it; the tractor beam pulls. | step 12; `headless-playground-cut.md:789-794` | Duel | +| `loot-keeps-brand` | Picked-up loot shows its manufacturer. | step 6; `item-provenance-cut.md:528-539` | Duel | +| `target-vanish-safe` | The target dies with shots in flight: no crash, no frozen ship. | step 12; `fire-control-cut.md:1464-1470` | Duel | +| `beam-reads-higher` | The HUD `hull` factor reads higher from the beam than from the bow. | step 13; `:2380-2383` | Duel | +| `armour-face-missile` | Turn the armoured face into the AI's slow missile; it lands there. | step 13 | Duel | +| `ai-engages` | The AI still engages normally. | D; `:2658-2660` | Duel | +| `launcher-edge-pulse` | Launcher into the bow, then a flank: the schematic pulses the facing edge only. | step 14; `:2519-2520` | Launcher angles | +| `guided-explodes-on-detonation` | A guided round's explosion shows only when the simulation detonated. | E | Launcher angles | +| `guided-target-left-fades` | Kill the target while a guided round flies: the round fades with no explosion. | E | Launcher angles | +| `guided-hit-at-target` | A guided hit plays its effect at the target. | E | Launcher angles | +| `fused-clear-line-max-range` | Nothing selected, clear line: the round bursts at max range along the arc-clamped aim. | A.1; `:2625,2636-2642` | Fused rounds | +| `fused-first-hull-stop` | Nothing selected, aim at the bare hull: the round stops and detonates on it. | A.2; `:2632-2634` | Fused rounds | +| `fused-target-left-no-burst` | Fire at the fragile target, kill it with the direct weapon before arrival: no burst. | A.3; `:2636-2642`, `FireControl.cs:610` | Fused rounds | +| `fused-out-of-arc` | Select the stern target: the round fires and bursts at its range along the arc-clamped aim. | A.4; `:2643-2645` | Fused rounds | +| `fused-arming-pushout` | Select the close hull: the burst is pushed out to `BlastRadius` from the ship's centre. | A.5; `:2646-2653` | Fused rounds | +| `airburst-at-model` | A proximity airburst beside the LonginusX lands damage where the model shows the blast. | step 15; `:3094-3097` | Fused rounds | +| `penetrator-reaches-interior` | A delayed penetrator into the LonginusX nose detonates inside. | step 15 | Fused rounds | +| `hud-forecast-fused` | The HUD shows Direct, Burst at N m or Refused for fused weapons, and a percentage for direct weapons. | C; `:2661-2663` | Fused rounds | +| `unbranded-no-manufacturer` | An unbranded smoke weapon shows no Manufacturer row. | step 6 | Fused rounds | +| `refused-shot-free` | A refused shot costs no ammo, energy, heat, sound, visibility or cooldown. | B; `:2664-2666` | Refused rounds | +| `refused-beam-no-flicker` | A refused beam never starts. | B | Refused rounds | +| `refused-charge-never-charges` | A refused charged weapon never starts charging. | B | Refused rounds | +| `instant-and-charged-fire` | Autocannon and ChargeBlast SG fire; effects, particles and action-bar icons show. | step 5; `addressables-cut.md:362-366` | Weapon feel | +| `burst-cadence` | Burst cadence is unchanged. | F | Weapon feel | +| `charge-release-press` | A fast release-and-press on a charged weapon starts no second charge. | F | Weapon feel | +| `existing-build-play` | Under "Use Existing Build", the Weapon feel checks hold (GUID keys in the player catalog). | step 20; `addressables-cut.md:367-370` | Weapon feel, Setup: play mode Use Existing Build | +| `ai-never-fires-refused` | The hostile AI whose only weapon is refused never fires it. | D | AI fused discipline | +| `ai-never-bomb-fishes` | The neutral AI carrying a fused weapon never fires it. | D | AI fused discipline | +| `continue-keeps-items` | Warp, quit, Continue: same items, same tier and brand. | step 16; `item-provenance-cut.md:536-537` | Long haul | +| `death-ends-run` | Die: Continue is disabled and `run.cc` holds no ledger. | step 17; `:540-541` | Long haul | +| `shield-panel-rig` | The numbered shield-panel look, including the Cut 5 dicing and break-through. | step 18; `shield-panel-cut.md:683-700,737-739` | Setup: FieldShieldTest scene | +| `studio-*` (5 checks) | The fire-control union renders; the schematic underlay; restored hull records; the fuse fields edit; the provenance ledger renders from a real `run.cc`. | step 19 | Setup: CultCache Studio | +| `addressables-duplicates` | Analyze the duplicate dependencies: 5 known leftovers. | step 20 | Setup: Addressables Analyze | +| `new-game-start`, `purchase-keeps-brand`, `tier-colour` | New Game; buying keeps the brand; tier colours show. | steps 3, 4, 6 | Setup: New Game | + +Imported results (checklist section 8, revision `113164fa`, 2026-09-30): +- `new-game-start`: pass. +- `purchase-keeps-brand`: pass. +- `instant-and-charged-fire`: partial ("charged weapon unprovable: 1x2 hardpoint"). +- `tier-colour`: fail ("known defect, `settings-globals-cut.md:150`; fixed by settings-globals Cut 1"). +- `unbranded-no-manufacturer`: blocked ("`give Lamp` named a test fixture"). +- `loot-keeps-brand`: no result ("not yet reported"). + +### 5.2 Scenarios + +All use `Ambient: false` except Long haul. Positions are authored in the seed and pinned by the condition tests. + +| Scenario | Player | Entities | Checks | +|---|---|---|---| +| Djinni shakedown | Djinni, full fit | none | 2 | +| Starved reactor | a combat hull with an undersized reactor, energy weapons and a shield | 1 unpiloted bare hull ahead, neutral | 3 | +| Arcs | a hull with side mounts | 3 unpiloted bare hulls (bow, beam, stern), neutral; 1 turret preset, hostile | 3 | +| Duel | combat fit | 1 piloted LonginusX, hostile, carrying a slow missile weapon, cargo holding a branded item | 8 | +| Launcher angles | GT 3K or pswarm plus a direct weapon | 1 unpiloted LonginusX, hostile; 1 fragile bare hull | 4 | +| Fused rounds | Smoke Proximity, Smoke Delayed, a direct weapon | unpiloted: LonginusX mid range, hostile; bare hull on the clear bearing; fragile bare hull; close bare hull inside `BlastRadius`; stern target | 9 | +| Refused rounds | Smoke Refused (instant, beam, charged) plus one working weapon | 1 unpiloted bare hull | 3 | +| Weapon feel | a hull with a 2x2 energy hardpoint: ChargeBlast SG, Autocannon, a burst weapon | 1 unpiloted bare hull | 4 | +| AI fused discipline | a sturdy hull | 1 piloted neutral with a fused weapon; 1 piloted hostile whose only weapon is refused | 2 | +| Long haul | standard combat fit, `Ambient: true` | none | 3 | + +### 5.3 Test designs (Q3) + +None has a product. They are unbranded and outside the economy. +- `Smoke Proximity`: Proximity, blast 8. +- `Smoke Delayed`: Delayed, blast 4, penetration 2 cells. +- `Smoke Refused`: Proximity, blast 30, range 20. + +These three are the operator's own designs from tonight, Spectra clones, energy, 1x2. +- `Smoke Refused Beam` and `Smoke Refused Charge`: a constant weapon and a charged weapon with a fuse and + `Range < BlastRadius`. +- All must pass `CultRecordRefs.Validate` (R-heat, stat modifiers, roles). + +### 5.4 Presets + +About eleven, named after their scenarios. The seed author picks designs with `AetherDb hardpoint-fit`. The only +hard constraints are those the condition tests pin. + +--- + +## 6. Subtraction ledger + +| Cut | Removed | Added | Targets, schemas, stores | +|---|---|---|---| +| 0 | `give` 30, `spawnturret` 26, wanderers 20, setting 1, tests 22 | 0 | no change | +| 1 | `StartGame` spawn 10, joins 6 | `Scenario.cs` 60, `RunStart.cs` 110, `Admit` 12, `Materialize` 20, `GenerateZone` 4; tests 300 | +1 schema (`aetheria.scenario`) | +| 2 | duplicated New Game body about 25 | menu panel and launch body about 45; 1 static | none | +| 3 | none | `Checks.cs` 50, stores 4, `AetherDb` 90 (or the menu 50); tests 120 | +2 schemas, +1 store file | +| 4 | the seed command, once spent (300) | seed 300; condition tests 250; catalog and ledger data | catalog content, ledger content | + +Net production code is about +250. That buys: +- the main-menu scenario affordance; +- typed checks with history; +- one staging owner and one admission primitive (from five hand-written joins); +- the first game consumer of the presets that already existed. + +It removes three ad hoc test affordances and the wanderers that ran in every zone. + +--- + +## 7. Operator questions + +Each question stands alone. The first is what the scenario request leaves open. + +**Q1. Is a scenario arena part of a real run?** +Your words settle what an arena holds. They leave open what surrounds it. Checks 16 and 17 (warp, save, +Continue, death) need a real run. Every other check needs only the arena. +- **A.** The arena is the entrance zone of a small prelude galaxy built from the scenario's seed. Stations, + planets and wormholes are there, and the run saves on warp and quit, continues and ends at death like any run. + Launching a scenario replaces your saved run, as New Game does. Scenario-only details (unpiloted ships, forced + stances) do not survive a Continue. +- **B.** A sealed arena: one zone, no galaxy, no saving. Launching never touches your saved run. Checks 16 and 17 + go back to plain New Game play. This is a second zone-entry path with no galaxy under it, which `PopulateLevel` + and the sector map do not support today. +- **Recommended: A.** It is the same New Game path with an input, so there is no second boot path. Every rule + under test then runs exactly as it does in a real run. Replacing the saved run seems harmless for a dev + affordance. +- **Depends on it:** Cut 2's launch body; the 0b lifecycle row for staged entities; whether Long haul exists. + +**Q2. Who records a check's result, and where?** +You play and observe. Something has to write "pass at `abc123`, note ...". +- **A.** In game. The Scenarios panel gets Pass/Fail/Partial and a note per check. The game writes the ledger file + in your working tree, and you commit it with your other work. +- **B.** The agent. You tell the session what you saw, and it records typed results with `AetherDb record` on a + branch and pushes. The menu shows each check's latest result, read-only. +- **C.** No typed results. Checks are typed, and results go in a prose ledger doc, as checklist section 8 does now. +- **Recommended: B.** It matches how tonight's smoke already worked: you play, the agent records. Nothing in the + game writes a file that git tracks, and no agent needs to touch your tree. The menu still shows what is stale. + A can be added later on the same append primitive. +- **Depends on it:** Cut 3's writer and whether the game attaches the ledger writable. + +**Q3. Where do test-only item designs live?** +Checks A-C need fused weapons the catalog does not ship. Tonight they were authored in place and left +uncommitted. +- **A.** Committed catalog designs with no product. With no product nothing makes them, so they are never + generated, stocked, sold or looted. A preset builds them unbranded (a new `Materialize` rule). Studio lists them + beside real content. +- **B.** Committed designs with a product and price 0. Generation skips price 0 today, but only through a filter + written for another reason, so the exclusion is a coincidence. +- **C.** A scratch catalog swapped in per session, as checklist step 2a proposed. Scenarios would then depend on + which catalog is loaded, and the fixtures would not be retained. +- **Recommended: A.** +- **Depends on it:** the `Materialize` rule in Cut 1; section 5.3. + +**Q4. Which ad hoc test affordances do scenarios retire?** +`give` got its hull route tonight (your ruling: "mothballed ships do not require docking bays"), and step 7 was its +reason. `spawnturret` places a turret. Neutral wanderers spawn two non-hostile ships in every generated zone, "so +combat has something non-hostile to target" (`f732cff9`). +- **A.** Delete all three. Scenarios cover ships, cargo, turrets and neutral targets. `CommissionShip` stays for + purchases. +- **B.** Delete the hull route and `spawnturret`. Keep `give` for items (ad hoc poking outside scenarios) and keep + the wanderers. +- **C.** Keep everything. +- **Recommended: A.** Each one is a second way to create what scenarios now author, and the wanderers change every + zone of real play for a testing reason. `iff`, `trackmissile`, `revealzones`, `tow` and `capturepreset` stay. +- **Depends on it:** Cut 0. + +Defaults Self took, which you may overrule: +- Scenarios show only in editor and development builds. +- The ledger is its own committed store, `GameData/Checks.cc`, not the catalog. +- "Check", not "behaviour", because `Behavior` is the gear type family. +- Brokkr is not installed in this campaign (section 0.4). +- The scenario record holds no "start docked" field. No first consumer needs one. + +--- + +## 8. Follow-ups outside this campaign + +- Re-anchor `docs/headless-playground-cut.md`. Its Cut 1 now builds on `RunStart` and `Zone.Admit`; its `new` + command stages a `Scenario`. +- Brokkr install plus a "launch scenario" intent, if agents should drive Unity play. +- A durable agent authoring command for presets (fit by design name with validation), instead of transient seed + commands. +- `AetherDb` carries spent one-shot migration commands (`targeting-catalog-6c`, `-6d`, `firing-arc-migrate`, and + others; `Program.cs` is 1,477 lines). This is a subtraction pass of its own. +- `ItemManager`'s clock seed (`ItemManager.cs:20`) makes lot quality vary per scenario launch. It is recorded, + not fixed here. diff --git a/docs/ship-collection-authoring.md b/docs/ship-collection-authoring.md new file mode 100644 index 000000000..b4e85f7a1 --- /dev/null +++ b/docs/ship-collection-authoring.md @@ -0,0 +1,83 @@ +# Ship collection authoring + +Status: authoring contract for new ships. Longinus and Djinni predate this contract and keep their current prefabs. + +## Objective and ownership + +One Blender collection exports one FBX model. Its hierarchy names the ship's presentation anchors. Unity builds +`Assets/Content/Prefabs/Ships/.prefab` from that model and assigns the components and references that the +runtime currently requires on a hand-built ship prefab. `GameData/Aetheria.cc` remains the owner of `HullData`, +hardpoint cells and types, gameplay stats, products, and the prefab's Addressables key. The model never decides a +hit, thruster force, weapon arc, or shield capacity. + +Today `ShipInstance` reads manually filled arrays on each ship prefab. It matches their transform names against +`HullData.Hardpoints[].Transform`. Its shared ping, invisible material, explosion, shield, and tractor references +also live on each prefab. The builder derives the arrays from the FBX hierarchy and supplies the shared Unity +assets once, from their existing project paths. The old prefabs are left alone. + +## Blender collection and export + +- Name the collection `Ship.` and configure its collection exporter as FBX. Export to + `Assets/Models/Ships/.fbx`. The filename is the generated prefab name. Blender's collection exporter can + export a collection directly and repeat that export after edits. +- Export meshes and empties, with animation disabled. Use Blender's `-Z Forward`, `Y Up` axis conversion for Unity. + Keep the collection origin at the ship's simulation centre, with the nose pointing toward the exported +Z axis. + Apply object scales on visual and collision meshes; the `Shield` empty's scale is the deliberate exception. +- The importer owns every prefab under `Assets/Content/Prefabs/Ships/` whose matching model is in + `Assets/Models/Ships/`. Editing generated components by hand is temporary; the next export rebuilds them. + Put appearance in the FBX and gameplay values in the catalog. + +The exported hierarchy uses **exact, case-sensitive names**. Names beginning `HP.`, `Pivot.`, or `SHIP.` are +reserved. IDs after a role prefix must be unique across the model. Other visual objects may have any names. + +```text + FBX root + Body visible meshes, any nesting + SHIP.MapIcon one mesh; assigned the shared map material and Minimap layer + SHIP.HullCollider one low-poly closed convex mesh; rendered invisible, given a MeshCollider + SHIP.Shield one empty; its position and scale set the shield envelope + SHIP.TractorBeam one empty; its pose sets the tractor effect origin + HP.Thruster.PortAft empty; exact name copied to HullData.Hardpoints[].Transform + Emitter one mesh; thruster particle emission shape + HP.Weapon.PortGun empty; exact name copied to the catalog + Muzzle.0 empty; +Z is firing direction + Muzzle.1 optional additional barrel + HP.Radiator.Port empty; exact name copied to the catalog + RadiatorMesh one mesh; temperature emission surface + HP.Equipment.Reactor empty; exact name copied to the catalog + Pivot.0.-90.90.-80.80.60 optional empty parent of articulated mounts +``` + +`HP.Equipment` covers sensors, reactor, shield, cargo, and other mounts without a special visual component. +`HP.Weapon` covers ballistic, energy, and launcher mounts; the catalog chooses which. Every `HP.*` becomes an +`EquipmentHardpoints` entry. Thruster, weapon, and radiator roles also get their matching components and arrays. +`Muzzle.` children are sorted numerically; their transforms become `FiringPoint`. A weapon +mount requires at least one. A `Pivot` name carries `Group.YawMin.YawMax.PitchMin.PitchMax.Speed` as signed +integers in degrees and degrees per second. It gets `ArticulationPoint`; child mounts follow its transform. Pivots are +presentation only: the simulation's catalog firing arc remains authoritative. + +`SHIP.Shield` receives the existing Shield prefab as a child. That prefab's effective unscaled radius is one +Unity unit; the marker's three scale components are the intended envelope radii in world units. The builder adds +`ShieldEnvelope` to the Shield prefab root if absent. `SHIP.TractorBeam` receives the existing Tractor Beam +prefab. The four `SHIP.*` markers are required exactly once. Ship-specific mesh materials remain authored in +Blender/Unity's material import mapping; the builder only assigns the shared map-icon material. + +## Build, validation, and failure + +Re-exporting an FBX in `Assets/Models/Ships/` schedules a prefab rebuild after Unity finishes importing it. +`Aetheria/Build Ship Prefab From Selected Model` performs the same build on demand. The builder validates all +reserved names, required anchors, component meshes, muzzle numbering, and shared asset references **before** +replacing a prefab. A bad export reports an error and leaves the last valid prefab in place. The output is a +Prefab Variant of the FBX model, so mesh and transform identities remain tied to the source export. + +After adding a ship, author its `HullData` and product in the catalog, set `HullData.Prefab` to the generated +prefab's GUID, and set every `HardpointData.Transform` to an exact `HP.*` name. Run the catalog asset check and +the Unity compile. A visual play check still owns size, materials, shield fit, barrel orientation, and thrust +appearance. The builder cannot infer the 2D schematic from a 3D mesh. + +## Cut and build budget + +This adds an Editor-only builder, its focused smoke, and this contract. It adds no runtime component, persistent field, package, +service, or build target. It replaces per-ship array and shared-reference authoring for newly exported ships. +The focused verification target is Unity Editor script compilation plus a synthetic named-hierarchy check; a +real Blender collection export is required before claiming end-to-end import fidelity. diff --git a/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj b/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj index dc0384bc1..8e36142f1 100644 --- a/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj +++ b/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj @@ -8,6 +8,9 @@ + + diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 7ff47aa53..921961b15 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -331,7 +331,7 @@ public sealed class FireAuthorityTests : IDisposable e.Zone.Update(10f); // well past arrival Assert.Equal(before, e.Target.Hull.Durability); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); } // R4: ShotCommitted fires exactly CommitHorizon before ShotResolved, once per shot. Mutation: publish only @@ -420,7 +420,7 @@ public sealed class FireAuthorityTests : IDisposable e.Zone.Update(.016f); // one ordinary tick Assert.Equal(new[] { "committed", "resolved" }, order); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); } // R10/Q6: a shot's parameters freeze when the trigger is pulled. Changing the weapon's authored Damage @@ -488,64 +488,16 @@ public sealed class FireAuthorityTests : IDisposable Assert.True(aimedItem.EquippableItem.Durability < 1f); } - // The damage rule moved verbatim (Entity.DamageSchematic/ApplyHit): damage above armor plus item - // durability zeroes the item and the remainder hits the hull. Mutation: skip subtracting armor, or skip - // passing the remainder on to the hull. - [Fact] - public void HardpointHitDamagesItemThenHull() - { - var cell = new int2(1, 1); - EquippableItem occupant = null; - var e = Build(TestSettings(), armor: 2, hullDurability: 1000, - beforeActivate: (items, shooter, target) => - { - // No item occupies (1,1) in this fixture's hull -- occupy it before Activate() so the - // item-then-hull chain has an item to pass through at a controlled cell. - var occupantRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); - occupant = new EquippableItem { Data = occupantRef, Durability = 3, Lot = 6 }; - Assert.True(target.TryEquip(occupant, cell)); - }); - var occupantItem = e.Target.Equipment.Single(x => x.EquippableItem == occupant); - - var beforeHull = e.Target.Hull.Durability; - // Cut 12.2 (docs/fire-control-cut.md): ApplyHit's direction parameter is now an already-schematic - // bearing (the caller's job, formerly ApplyHit's own internal ToSchematic call) -- unread here anyway, - // since penetration 0 never enters the march. - e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 0, damage: 10, bearing: float2(0, 1)); - - Assert.Equal(0f, e.Target.Armor[cell.x, cell.y]); // 2 armor consumed - Assert.Equal(0f, occupantItem.EquippableItem.Durability); // 3 item durability consumed - Assert.Equal(beforeHull - 5f, e.Target.Hull.Durability, 2); // remaining 5 hits the hull - } - - // R7: the penetration march is planar, in the target's own schematic frame. Firing straight into a - // target's nose marches the hit shape toward the target's stern, along +Y in this hull's local grid - // regardless of which way the entity happens to be facing in world space. - // Cut 12.2 (docs/fire-control-cut.md): the world-to-schematic rotation this test pinned moved out of - // ApplyHit and into its caller (FireControl.Commit computes `bearing = target.ToSchematic(travelDirection)` - // once, at the commit tick, and passes the already-rotated bearing in) -- ApplyHit itself no longer reads - // Direction at all. This test now performs that same rotation itself, at the call site, to keep pinning - // the march's own planar behaviour once a schematic bearing is in hand; the end-to-end claim that - // Direction feeds the rotation correctly is what FireControlCut12Tests.HitsLandOnTheFacingEdge and - // TurningArmourIntoTheShotTakesItOnTheArmour now cover, through FireControl.Commit itself. - [Fact] - public void PenetrationMarchIsPlanar() - { - var e = Build(TestSettings(), armor: 5); - e.Target.Direction = float2(1, 0); // facing world +X, not the default +Z - - var cell = new int2(1, 0); - var hitDirection = normalize(float2(1, 0)); // the shot arrived travelling along the target's own forward - var bearing = e.Target.ToSchematic(hitDirection); - e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 1.5f, damage: 30, bearing: bearing); - - // Rotating hitDirection into this Direction's frame (forward=(1,0), right=(0,-1)) makes the local - // penetration vector (0,1): the march should have advanced from (1,0) into (1,1), consuming that - // cell's armor too. Mutation: skip the rotation and march along the raw world hitDirection (1,0) - // instead, which would consume (2,0) rather than (1,1). - Assert.True(e.Target.Armor[1, 1] < e.Target.MaxArmor[1, 1]); - Assert.Equal(e.Target.MaxArmor[2, 0], e.Target.Armor[2, 0]); // untouched -- proves it wasn't a world-space march - } + // HardpointHitDamagesItemThenHull and PenetrationMarchIsPlanar (Entity.ApplyHit/DamageSchematic, deleted by + // Cut 12.3 -- docs/fire-control-cut.md) pinned two rules that no longer have that function to call: + // - "damage above armor plus item durability zeroes the item and the remainder hits the hull" is now + // FireControlCut123Tests.DamageIsAbsorbedInOrderAlongTheRay, through the real Fire/Commit/Apply path + // rather than a direct ApplyHit call. + // - "the penetration march is planar, in the target's own schematic frame" is subsumed by 12.2's own + // FireControlCut12Tests.HitsLandOnTheFacingEdge and TurningArmourIntoTheShotTakesItOnTheArmour (the + // bearing FireControl.Apply's lane walk reads is already the committed, schematic-frame bearing Commit + // computed, the same one those tests pin end-to-end) and by FireControlCut123Tests.ArmourFacesTheShot, + // which pins that the lane's own facing cell is planar across several non-axis-aligned facings. // The absorb rule now exists once, in FireControl: an active shield that CanTakeHit absorbs, and the // schematic is untouched. Mutation: remove the shield branch (the hit always reaches the hull). diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index e46c1a86e..ab2966025 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -231,10 +231,14 @@ public sealed class FireControlCut11Tests : IDisposable Assert.Equal(hull, e.Target.Hull.Durability); // and every hit was absorbed } - // The same rule on Splash's path, whose absorb branch had no test coverage at all. Mutation: delete - // TakeHit from Splash's absorb branch. + // The same rule on a blast's path (12.4(b) rewrites this to Detonate, docs/fire-control-cut.md + // "ShieldPaysForWhatReachesIt": "the reserve drops by the entity's covered share, not by the whole + // blast"), whose absorb branch had no test coverage at all. The radius sits well inside the hull's own + // solid interior (the 5x3 shape's centre of mass, at least 1 cell from every edge) so the disc never spills + // off the hull -- the covered share equals the whole damage exactly, the same amount Splash always charged, + // so this expectation is not loosened. Mutation: delete TakeHit from Detonate's absorb branch. [Fact] - public void SplashShieldAbsorptionDrainsTheReserve() + public void ABlastShieldAbsorptionDrainsTheReserve() { var e = Build(equipShield: true, shieldCapacity: 30); Charge(e); @@ -244,7 +248,7 @@ public sealed class FireControlCut11Tests : IDisposable var blasts = 0; while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && blasts < 200) { - FireControl.Splash(e.Zone, e.Target.Position, radius: 20, damage: 5, damageType: DamageType.Kinetic); + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 5, damageType: DamageType.Kinetic); blasts++; } @@ -301,19 +305,19 @@ public sealed class FireControlCut11Tests : IDisposable var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(1f); - Assert.Empty(committed); + SafeAssert.Empty(committed); e.Zone.Entities.Remove(e.Target); e.Zone.Update(1f); - foreach (var outcome in new[] { Assert.Single(committed), Assert.Single(resolved) }) + foreach (var outcome in new[] { SafeAssert.Only(committed), SafeAssert.Only(resolved) }) { Assert.Equal(shotId, outcome.ShotId); Assert.False(outcome.Hit); Assert.False(outcome.Shielded, "a shot at nothing did not hit a shield"); Assert.False(outcome.ShieldBroken, "a shot at nothing did not break a shield"); } - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); } // ---- A targeting system's Tracking is what forgives a target that moves off its predicted line. ---- @@ -354,40 +358,11 @@ public sealed class FireControlCut11Tests : IDisposable } // ---- Splash damages the side the blast came from. ---- - - // The operator's "damage on the wrong side of the ship" was a legacy Unity bug that Splash never shipped, - // but nothing defended against it. Flipping the sign in Splash's - // `right = float2(forward.y, -forward.x)` survived every earlier suite, and it survives this test too at - // the default facing (0, 1): the flip only changes which side is hit when a ship faces more along world x - // than z. So the target is spun through facings where the flip bites as well as ones where it does not, - // and blasted from each side in turn. The blast side is computed here independently of FireControl -- - // starboard is the facing rotated clockwise -- so this is not the code checking itself. - [Theory] - [InlineData(0f, 1f)] - [InlineData(1f, 0f)] - [InlineData(.8f, .6f)] - [InlineData(-.8f, -.6f)] - [InlineData(-.6f, .8f)] - public void SplashDamagesTheSideTheBlastCameFrom(float fx, float fy) - { - foreach (var fromStarboard in new[] { true, false }) - { - var e = Build(markers: true); - var facing = normalize(float2(fx, fy)); - e.Target.Direction = facing; - var starboard = float2(facing.y, -facing.x); - var side = fromStarboard ? starboard : -starboard; - var blast = e.Target.Position + float3(side.x, 0, side.y) * 10f; - - FireControl.Splash(e.Zone, blast, radius: 20, damage: 10, damageType: DamageType.Kinetic); - - var nearMarker = fromStarboard ? e.StarboardMarker : e.PortMarker; - var farMarker = fromStarboard ? e.PortMarker : e.StarboardMarker; - var label = $"facing ({fx}, {fy}), blast from {(fromStarboard ? "starboard" : "port")}"; - Assert.True(nearMarker.EquippableItem.Durability < 100, $"{label}: the marker facing the blast took no damage"); - Assert.Equal(100f, farMarker.EquippableItem.Durability); - } - } + // + // Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in + // FireControlCut124Tests.cs succeeds this test and Cut4Tests.SplashIsDirectional -- a blast is now an area + // (12.4(b)), not a directional half-hull split, so "the side the blast came from" is pinned by which cells + // the disc actually covers, not by a facing-rotated half-shape. // ---- pSpread is the share of the spread cone the target's silhouette covers. ---- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs new file mode 100644 index 000000000..b01d04b76 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -0,0 +1,1935 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): a resolved hit's damage now travels down +// its own lane -- armour, then the occupying item, then whatever is left to the hull, in order -- replacing +// Entity.DamageSchematic's even split and Entity.ApplyHit's 0.5-step march (both deleted by this cut). Builds +// its own fixtures, the convention every cut's test file follows. Every armour value this file cares about is +// set directly on Entity.Armor after Build, overriding the fixture's own flat HullData.Armor -- the only way +// to get a non-uniform plate without a second hull-authoring path. +public sealed class FireControlCut123Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut123-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut123Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = .3f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public HullData HullData; + public EquippedItem[] Markers; + public EquippedItem Cockpit; + public Dictionary Custom = new Dictionary(); + } + + // A shooter off the origin (Cut 11.3), a target over a caller-supplied hull shape, straight ahead so the + // default facing (0,1) makes the shot travel exactly (0,1) in world XZ (velocity 0 -- PredictedIntercept + // returns target.Position exactly, and the shooter/target share the same world X) -- the same clean + // geometry FireControlCut12Tests' own SigmaFloor fixture relies on. A full-strength targeting system + // (Accuracy 1, Resolution huge) so only the fixture's own geometry decides the numbers. + private static Shape LShape() { var s = new Shape(2, 2); s[new int2(0, 0)] = true; s[new int2(1, 0)] = true; s[new int2(0, 1)] = true; return s; } + + private Engagement Build( + GameplaySettings settings, Shape hullShape, float precision, + float penetration = 0, float damageSpread = 0, float2? targetFacing = null, + (int2 Cell, float Durability)[] markers = null, int2? cockpitCell = null, float cockpitDurability = 40, + (int2 Cell, float Durability)[] bars = null, (int2 Cell, float Durability)[] lBars = null, + (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000, Armor = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(5), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(damageSpread), + Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f) + } } + }); + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new GearData + { + Name = "Cockpit", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new CockpitData() } + }); + // A multi-cell item (2x1), for the proportional-absorption fix batch's own fixtures -- the only shape + // that can ever be shared between two lanes (cells are disjoint between lanes; only an item spanning + // several cells can straddle two of them). + cache.Upsert(new GearData + { + Name = "Bar", Hardpoint = HardpointType.Tool, Shape = SolidShape(2, 1), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + // A non-convex multi-cell item (an L: three cells of a 2x2 box), for F8's model sweep -- a straight + // lane can cross an L's two arms non-contiguously, which a plain 2x1 bar can never do. + cache.Upsert(new GearData + { + Name = "LBar", Hardpoint = HardpointType.Tool, Shape = LShape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + if (custom != null) + foreach (var cu in custom) + cache.Upsert(new GearData { Name = cu.Name, Hardpoint = HardpointType.Tool, Shape = cu.Shape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; // F8: shared-item fixtures equip more items (bars) than earlier cuts needed + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut123", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var lot = 1; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var gun = Make("Gun", lot++, 1); + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + + var targetGun = Make("Gun", lot++, 1); + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + var markerItems = markers?.Select(m => + { + var mi = Make("Marker", lot++, m.Durability); + Assert.True(target.TryEquip(mi, m.Cell), $"marker must fit at {m.Cell}"); + return target.Equipment.Single(x => x.EquippableItem == mi); + }).ToArray(); + + if (bars != null) + foreach (var bar in bars) + { + var bi = Make("Bar", lot++, bar.Durability); + Assert.True(target.TryEquip(bi, bar.Cell), $"bar must fit at {bar.Cell}"); + } + if (lBars != null) + foreach (var bar in lBars) + { + var bi = Make("LBar", lot++, bar.Durability); + Assert.True(target.TryEquip(bi, bar.Cell), $"L-bar must fit at {bar.Cell}"); + } + + var customItems = new Dictionary(); + if (custom != null) + foreach (var cu in custom) + { + var ci0 = Make(cu.Name, lot++, cu.Durability); + Assert.True(target.TryEquip(ci0, cu.Cell), $"custom {cu.Name} must fit at {cu.Cell}"); + customItems[cu.Name] = target.Equipment.Single(x => x.EquippableItem == ci0); + } + + EquippedItem cockpit = null; + if (cockpitCell != null) + { + var ci = Make("Cockpit", lot++, cockpitDurability); + Assert.True(target.TryEquip(ci, cockpitCell.Value), $"cockpit must fit at {cockpitCell.Value}"); + cockpit = target.Equipment.Single(x => x.EquippableItem == ci); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + // Cut 11.3: the shooter sits off the origin. + shooter.Position = float3(37, 0, -11); + target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter -- travel direction is exactly (0,1) + if (targetFacing != null) target.Direction = targetFacing.Value; + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(1f); // fire times nonzero (Cut 11.3) + + return new Engagement + { + Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, + HullData = hullData, Markers = markerItems, Cockpit = cockpit, Custom = customItems + }; + } + + // Fires until a hit lands, retrying with a fresh shot each time (a miss already resolved and left the + // pending queue empty) -- the roll can still miss outright, and none of these tests care which attempt + // actually connects. + private static ShotOutcome FireUntilHit(Engagement e, float? damageOverride = null, int attempts = 60) + { + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + for (var i = 0; i < attempts && (outcome == null || !outcome.Hit); i++) + { + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride); + e.Zone.Update(.01f); // velocity 0 -> zero flight time, commits and resolves in this tick + } + return outcome; + } + + // DamageIsAbsorbedInOrderAlongTheRay replaces FireAuthorityTests.HardpointHitDamagesItemThenHull and + // PenetrationMarchIsPlanar (both called the deleted Entity.ApplyHit directly). A 3x3 solid hull's only + // interior cell (1,1) -- Shape.Shrink needs all 8 neighbours present -- carries a Tool item, so the marker + // must sit on the centre column; every cell of that column shares the SAME lateral shadow (ell has only an + // x component at this bearing, and every column-1 cell has x=1), so once a shot's committed Lateral falls + // in that column's own interval, its lane is the full 3-cell column in near-to-far order -- (1,0) is always + // the impact cell there, regardless of precision. Retries until a hit lands on that column (the other two + // columns' own shadows are adjacent, not overlapping, so this is just "which column the draw picked", not a + // low-probability corner). Damage 30, penetration 3: a 10-armour front cell, then the marker, then a + // 50-armour soft cell the remainder never reaches. + // Kills: an even split (would put damage/3 = 10 on every cell, exactly canceling the front armour by + // coincidence but consuming only 10 of the marker's durability and 10 off the soft cell's -- distinguishing + // both); items absorbing before armour (irrelevant here since the front cell carries no item, but the + // marker cell's own Absorb call still checks armour(0) before the item); no carry-forward (would re-deal + // the full 30 fresh to the marker instead of the 20 that survived the front cell, and would put 30 on the + // soft cell instead of 0). + [Fact] + public void DamageIsAbsorbedInOrderAlongTheRay() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: 3f, + markers: new[] { (new int2(1, 1), 1000000f) }); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[1, 0] = 10f; e.Target.MaxArmor[1, 0] = 10f; // front: a plate + e.Target.Armor[1, 2] = 50f; e.Target.MaxArmor[1, 2] = 50f; // soft cell: armoured but never reached + + var marker = e.Markers[0]; + var markerDurabilityBefore = marker.EquippableItem.Durability; + var hullBefore = e.Target.Hull.Durability; + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 1); attempt++) + outcome = FireUntilHit(e, damageOverride: 30f, attempts: 1); + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit on the centre column within the attempt budget"); + Assert.Equal(new int2(1, 0), outcome.Cell); // fixture precondition: (1,0) is always this column's impact cell + + Assert.Equal(0f, e.Target.Armor[1, 0]); // 10 armour fully consumed + Assert.Equal(markerDurabilityBefore - 20f, marker.EquippableItem.Durability, 2); // the carried-forward 20 + Assert.Equal(50f, e.Target.Armor[1, 2]); // untouched -- the remainder never reached it + Assert.Equal(hullBefore, e.Target.Hull.Durability); // nothing left over for the hull + } + + // ArmourFacesTheShot: only the bow half (y >= height/2) of a 1x9 hull carries a plate, penetration 0 so only + // the impact cell can ever record a hit. For each of three facings, including one where |fx| > |fy|, the + // shooter is repositioned so the shot's own TravelDirection is EXACTLY that facing -- the same relationship + // FireControlCut12Tests.TurningArmourIntoTheShotTakesItOnTheArmour uses (Direction = travelDirection exposes + // the stern; Direction = -travelDirection exposes the bow), just repeated at three different WORLD + // directions instead of one, so the bearing this produces is exactly (0,1) or (0,-1) in schematic space + // every time (R7: the schematic frame is planar for any world facing, not only the default one) -- with no + // diagonal bearing in play, there is no lane-selection ambiguity to fight, only Entity.ToSchematic's own + // rotation to check. + [Fact] + public void ArmourFacesTheShot() + { + var shape = SolidShape(1, 9); + var e = Build(TestSettings(), shape, precision: .4f, penetration: 0f); + for (var y = 0; y < shape.Height; y++) + { + e.Target.Armor[0, y] = y >= 5 ? 1f : 0f; + e.Target.MaxArmor[0, y] = e.Target.Armor[0, y]; + } + + void CheckOneDirection(float2 travelDirection, bool bowFacesShot) + { + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + e.Target.Direction = bowFacesShot ? -travelDirection : travelDirection; + + var actualTravel = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + Assert.Equal(travelDirection.x, actualTravel.x, 3); // fixture precondition + Assert.Equal(travelDirection.y, actualTravel.y, 3); + + var hitCells = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hitCells.Add(x.pos)); + for (var i = 0; i < 60; i++) { FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } + + Assert.True(hitCells.Count > 5, $"expected several hits for travel direction {travelDirection}, bow facing {bowFacesShot}, got {hitCells.Count}"); + foreach (var c in hitCells) + if (bowFacesShot) Assert.True(c.y >= 5, $"bow should face the shot, but {c} is on the stern half"); + else Assert.True(c.y < 5, $"stern should face the shot, but {c} is on the bow half"); + } + + void Check(float2 travelDirection) + { + travelDirection = normalize(travelDirection); + CheckOneDirection(travelDirection, bowFacesShot: true); + CheckOneDirection(travelDirection, bowFacesShot: false); + } + + Check(float2(0, 1)); + Check(float2(.8f, .6f)); + Check(float2(.9f, .3f)); // |fx| > |fy| + } + + // BuriedCockpitNeedsPenetration: a 3x3 solid hull's only interior cell (1,1) -- Shape.Shrink needs all 8 + // neighbours present, which only the centre of a 3x3 has -- carries a Cockpit item, ringed by the hull's + // own 8 border cells. Firing straight down any of the 4 axis-aligned bearings crosses exactly one ring + // cell before the cockpit (and one more after, unread since nothing needs to go further). + [Fact] + public void BuriedCockpitNeedsPenetration() + { + float RunOnce(float2 facing, float penetration, float damage, float ringArmor) + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: penetration, + targetFacing: facing, cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + foreach (var c in e.HullData.Shape.Coordinates) + { + var isRing = c.x != 1 || c.y != 1; + e.Target.Armor[c.x, c.y] = isRing ? ringArmor : 0f; + e.Target.MaxArmor[c.x, c.y] = e.Target.Armor[c.x, c.y]; + } + var before = e.Cockpit.EquippableItem.Durability; + var outcome = FireUntilHit(e, damageOverride: damage); + Assert.NotNull(outcome); + Assert.True(outcome.Hit, $"fixture: facing {facing} must eventually hit"); + return before - e.Cockpit.EquippableItem.Durability; + } + + // Penetration 0: only the ring's own facing cell is ever reached -- the cockpit, one cell further in, + // must be untouched from all four axis-aligned bearings. + foreach (var facing in new[] { float2(0, 1), float2(0, -1), float2(1, 0), float2(-1, 0) }) + Assert.Equal(0f, RunOnce(facing, penetration: 0f, damage: 40f, ringArmor: 5f)); + + // A penetration that clears the ring (one cell, entry difference ~1): the cockpit takes exactly the + // damage minus what the ring absorbed. + var lost = RunOnce(float2(0, 1), penetration: 2f, damage: 40f, ringArmor: 5f); + Assert.Equal(35f, lost, 2); + + // Enough damage, with the ring cleared, destroys the cockpit and fires Death with CockpitDestroyed. + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: 2f, + targetFacing: float2(0, 1), cockpitCell: new int2(1, 1), cockpitDurability: 20f); + foreach (var c in e.HullData.Shape.Coordinates) + { + var isRing = c.x != 1 || c.y != 1; + e.Target.Armor[c.x, c.y] = isRing ? 0f : 0f; + e.Target.MaxArmor[c.x, c.y] = 0f; + } + CauseOfDeath? death = null; + using var d = e.Target.Death.Subscribe(cod => death = cod); + var outcome = FireUntilHit(e, damageOverride: 100f); + Assert.NotNull(outcome); + Assert.Equal(CauseOfDeath.CockpitDestroyed, death); + } + } + + // SpreadWidensTheImpactAcrossLanes: spread 1 (n=1, three parallel lanes one cell apart) at penetration 0 -- + // a 3-wide, 2-tall hull, stern (y=0) facing the shot (default facing == travel direction, Cut 12's own + // TurningArmourIntoTheShotTakesItOnTheArmour convention). Retries until the committed impact lands on the + // centre column (x=1), so the three lanes are exactly this hull's three columns, each with its own + // facing-edge cell at y=0. + [Fact] + public void SpreadWidensTheImpactAcrossLanes() + { + var e = Build(TestSettings(), SolidShape(3, 2), precision: .6f, penetration: 0f, damageSpread: 1f); + for (var x = 0; x < 3; x++) + for (var y = 0; y < 2; y++) + { + e.Target.Armor[x, y] = 0f; + e.Target.MaxArmor[x, y] = 0f; + } + + ShotOutcome outcome = null; + var hitCells = new List<(int2 pos, float damage)>(); + using (e.Target.ArmorDamage.Subscribe(x => hitCells.Add(x))) + { + for (var attempt = 0; attempt < 200; attempt++) + { + hitCells.Clear(); + outcome = FireUntilHit(e, attempts: 1); + if (outcome != null && outcome.Hit && outcome.Cell.x == 1) break; + } + } + + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit landing on the centre column within the attempt budget"); + + // Exactly the three facing-edge cells (y=0), damage/3 each; nothing behind them (y=1). + var byCell = hitCells.GroupBy(h => h.pos).ToDictionary(g => g.Key, g => g.Sum(h => h.damage)); + Assert.Equal(3, byCell.Count); + foreach (var x in new[] { 0, 1, 2 }) + { + Assert.True(byCell.TryGetValue(new int2(x, 0), out var d), $"expected a hit on facing cell ({x},0)"); + Assert.Equal(5f / 3f, d, 3); // weapon Damage is 5 (this fixture's Gun, no override) + Assert.False(byCell.ContainsKey(new int2(x, 1)), $"cell ({x},1) is behind the facing edge and must be untouched"); + } + } + + // ARayThroughACornerCrossesTheCornerCell: the diagonal case the old 0.5-step march (Entity.ApplyHit, + // deleted by this cut) could skip. A bearing angled off both axes and a lateral offset comfortably inside + // one cell's own shadow (not on a shared boundary) crosses several cells; the exact slab walk must not + // drop one of them the way a fixed-step Euclidean march could stumble at a shared corner. + [Fact] + public void ARayThroughACornerCrossesTheCornerCell() + { + var shape = SolidShape(3, 3); + var hull = new HullData { Name = "Diagonal", HullType = HullType.Ship, Shape = shape }; + var b = normalize(float2(1, 1)); + var buffer = new LaneCell[shape.Coordinates.Length]; + + var count = FireControl.Lane(hull, b, -0.1f, buffer); + var cells = Enumerable.Range(0, count).Select(i => buffer[i].Cell).ToList(); + + Assert.Contains(new int2(0, 0), cells); + Assert.Contains(new int2(1, 1), cells); + Assert.Contains(new int2(2, 2), cells); + Assert.True(cells.IndexOf(new int2(0, 0)) < cells.IndexOf(new int2(1, 1))); + Assert.True(cells.IndexOf(new int2(1, 1)) < cells.IndexOf(new int2(2, 2))); + } + + // LaneRemainderReachesTheHull (Q12-3 = A): a ray that exits a thin, bare hull -- both cells reached, no + // armour anywhere -- delivers its whole remainder to Hull.Durability, not to nowhere. + [Fact] + public void LaneRemainderReachesTheHull() + { + var e = Build(TestSettings(), SolidShape(1, 2), precision: 1f, penetration: 5f); + e.Target.Armor[0, 0] = 0f; e.Target.MaxArmor[0, 0] = 0f; + e.Target.Armor[0, 1] = 0f; e.Target.MaxArmor[0, 1] = 0f; + // Every fixture's target carries a bystander Gun at (0,0) (Entity.Update NREs on an entity that never + // equipped anything) -- irrelevant to this test, but its own 1 durability would otherwise eat 1 of the + // 40 damage as a confound. Zeroing it here removes that, matching what "no armour or item anywhere" + // actually needs. + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + + var hullBefore = e.Target.Hull.Durability; + var outcome = FireUntilHit(e, damageOverride: 40f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.Equal(hullBefore - 40f, e.Target.Hull.Durability, 2); + } + + // Stryker survivor triage (this cut's own changed lines), retargeted to the public owner after 12.4(b) + // deletes Entity.Absorb (docs/fire-control-cut.md, "Retargeted, because Absorb is deleted"): + // ArmorAbsorb's `d > 0f` guard boundary-flipped to `d >= 0f` survived -- at exactly 0 incoming, real code + // emits nothing ("only for incoming > 0"), the mutant would additionally fire ArmorDamage(cell, 0). Not a + // rare float coincidence: a spent lane (rem already 0) walks past every remaining cell in + // FireControl.Apply's own loop, so this is the ordinary "nothing left" case, not an edge case. + [Fact] + public void ArmorAbsorbEmitsNothingAtExactlyZero() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); + e.Target.Armor[1, 1] = 5f; e.Target.MaxArmor[1, 1] = 5f; + + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + + var remainder = e.Target.ArmorAbsorb(new int2(1, 1), 0f); + + Assert.Equal(0f, remainder); + Assert.Equal(0, armorEvents); + Assert.Equal(5f, e.Target.Armor[1, 1]); + } + + // Stryker survivor, retargeted to the public owner after 12.4(b) deletes Entity.Absorb: ItemAbsorb's + // `total <= 0.1f` guard survived at exactly 0.1f -- real code leaves the item untouched, the mutant would + // additionally spend it on the item. A spent lane still walks cells with a remainder of 0, so this is + // pinned as a pool of exactly one contribution, ItemAbsorb's own degenerate case. + [Fact] + public void ItemAbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); + var marker = e.Markers[0]; + + var itemEvents = 0; + using var i = e.Target.ItemDamage.Subscribe(_ => itemEvents++); + + Span incoming = stackalloc float[1] { .1f }; + e.Target.ItemAbsorb(marker, incoming); + + Assert.Equal(0.1f, incoming[0], 3); + Assert.Equal(0, itemEvents); + Assert.Equal(50f, marker.EquippableItem.Durability); + } + + // Stryker survivor: Entity.DamageHull's `damage > .1f` guard boundary-flipped to `damage >= .1f` survived -- + // at exactly 0.1f, real code does nothing; the mutant would subtract it from Hull.Durability and fire + // HullDamage. + [Fact] + public void DamageHullDoesNothingAtExactlyPointOne() + { + var e = Build(TestSettings(), SolidShape(1, 2), precision: 1f); + var before = e.Target.Hull.Durability; + var events = 0; + using var h = e.Target.HullDamage.Subscribe(_ => events++); + + e.Target.DamageHull(0.1f); + + Assert.Equal(0, events); + Assert.Equal(before, e.Target.Hull.Durability); + } + + // Stryker survivor: FireControl.Apply's `target.IncomingHit.OnNext(shot.Source)` (moved here from the + // deleted Entity.ApplyHit's own first line) survived a Statement-to-";" mutation -- nothing asserted that + // it still fires on a resolved, unshielded hit. + [Fact] + public void ApplyFiresIncomingHitOnAResolvedHit() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f); + Entity source = null; + using var s = e.Target.IncomingHit.Subscribe(src => source = src); + + var outcome = FireUntilHit(e, damageOverride: 5f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Same(e.Shooter, source); + } + + // Stryker survivors: FireControl.Apply's two `walked[li] > 0` checks (metalLanes counting, and the + // absorb-loop entry) both boundary-flipped to `walked[li] >= 0` survived -- since a lane count can never be + // negative, `>= 0` is always true, so BOTH mutants make every lane count as "met metal" and enter the + // absorb loop, even a lane that is entirely off the hull. A 1-wide hull with spread 1 (3 lanes: centre plus + // one on each side) puts the two side lanes off-hull by construction -- only the centre lane can ever find + // metal. The metalLanes mutant would divide the shot's damage by 3 instead of 1 (a wrong, smaller number + // reaching the one real cell); the loop-entry mutant would additionally walk the two off-hull lanes with + // `walked[li]` still correctly 0 iterations internally, but still fall through to `hullDamage += rem` + // (rem never consumed), leaking a full extra damagePerLane share into the hull for each miss. + [Fact] + public void LanesThatMissTheHullNeitherDiluteTheSplitNorLeakDamage() + { + var e = Build(TestSettings(), SolidShape(1, 9), precision: .4f, penetration: 0f, damageSpread: 1f); + for (var y = 0; y < 9; y++) { e.Target.Armor[0, y] = 1000f; e.Target.MaxArmor[0, y] = 1000f; } + + var hullBefore = e.Target.Hull.Durability; + var hits = new List<(int2 pos, float damage)>(); + using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x)); + + var outcome = FireUntilHit(e, damageOverride: 30f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.Single(hits); // only the centre lane's own impact cell ever records armour damage + Assert.Equal(30f, hits[0].damage, 2); // the WHOLE damage, not damage/3 -- metalLanes counted only the hit + Assert.Equal(hullBefore, e.Target.Hull.Durability); // nothing leaked to the hull from the off-hull lanes + } + + // Stryker survivor: FireControl.Apply's penetration cutoff (`Entry - impactEntry >= shot.Penetration`) + // boundary-flipped to `>` survived -- not a rare float coincidence: an axis-aligned lane's entry values are + // exact integers (schematic cell spacing), so penetration authored as an exact integer routinely lands + // exactly on a cell's own entry difference. Penetration 2 exactly against a 3-cell column (entries ~0,1,2): + // the third cell sits exactly AT the boundary and must be excluded ("< penetration" is reached; the mutant + // would let it through). + // F3 fix batch (Soul, 2026-09-25): the marker at (1,1) used to carry 1e6 durability, so it alone absorbed + // the whole 30 damage before the walk could ever reach (1,2) -- the boundary cell took 0 whether the `>` + // mutant let it through or not, so the test could not fail. The marker now carries only 10, leaving a + // remainder large enough that (1,2) would visibly lose armour if the walk reached it. + [Fact] + public void PenetrationExcludesACellExactlyAtItsOwnBoundary() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: 2f, + markers: new[] { (new int2(1, 1), 10f) }); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[1, 2] = 50f; e.Target.MaxArmor[1, 2] = 50f; // exactly at entry-diff 2 == penetration + var marker = e.Markers[0]; + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 1); attempt++) + outcome = FireUntilHit(e, damageOverride: 30f, attempts: 1); + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit on the centre column within the attempt budget"); + Assert.Equal(new int2(1, 0), outcome.Cell); + + Assert.Equal(0f, marker.EquippableItem.Durability); // fixture: the marker's own 10 is fully spent, 20 left over + Assert.Equal(50f, e.Target.Armor[1, 2]); // exactly at entry-diff 2 -- excluded, not "< penetration" -- the + // remaining 20 would otherwise have visibly damaged it + } + + // F3: a non-integer penetration depth. Entries down an axis-aligned column are exact integers, so a + // fractional penetration is never itself a boundary -- this pins the ordinary "< penetration" comparison at + // a depth that isn't a round number, independent of the exact-integer boundary case above. + [Fact] + public void PenetrationExcludesCellsBeyondANonIntegerDepth() + { + var e = Build(TestSettings(), SolidShape(1, 4), precision: 1f, penetration: 1.5f); + e.Target.Armor[0, 0] = 5f; e.Target.MaxArmor[0, 0] = 5f; // entry-diff 0: always reached + e.Target.Armor[0, 1] = 5f; e.Target.MaxArmor[0, 1] = 5f; // entry-diff 1: within 1.5, reached + e.Target.Armor[0, 2] = 50f; e.Target.MaxArmor[0, 2] = 50f; // entry-diff 2: beyond 1.5, excluded + e.Target.Armor[0, 3] = 50f; e.Target.MaxArmor[0, 3] = 50f; // entry-diff 3: beyond 1.5, excluded + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + + var hullBefore = e.Target.Hull.Durability; + var outcome = FireUntilHit(e, damageOverride: 30f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.Equal(0f, e.Target.Armor[0, 0]); // 5 consumed + Assert.Equal(0f, e.Target.Armor[0, 1]); // 5 consumed + Assert.Equal(50f, e.Target.Armor[0, 2]); // untouched -- beyond the 1.5 depth + Assert.Equal(50f, e.Target.Armor[0, 3]); // untouched -- beyond the 1.5 depth + Assert.Equal(hullBefore - 20f, e.Target.Hull.Durability, 2); // 30 - 5 - 5 left over for the hull + } + + // F4: a U-shaped hull, notched. Two arms (x=0 and x=2) rise from a connecting base (y=0); the left arm has + // a gap at y=1 -- present at y=0,2,3, missing at y=1 (so the lane hits the base at y=0, a gap at y=1, then + // the rest of the arm at y=2,3). An AP round (no blast) with ample penetration must stop at that first gap + // and leave the far side of the notch (y=2,3, "the far prong") untouched. Kills the Stryker survivor that + // deletes Lane's own contiguity break (`FireControl.cs:~895`, M9): without it, Lane would keep walking past + // the gap and Apply would spend damage on cells the real rule says are unreachable. + private static Shape NotchedU() + { + var shape = new Shape(3, 4); + shape[new int2(0, 0)] = true; shape[new int2(0, 2)] = true; shape[new int2(0, 3)] = true; // left arm, notch at y=1 + for (var y = 0; y < 4; y++) shape[new int2(2, y)] = true; // right arm, solid + shape[new int2(1, 0)] = true; // the base connecting the two arms + return shape; + } + + [Fact] + public void AnAPRoundStopsAtTheFirstGapAndLeavesTheFarSideOfTheNotchUntouched() + { + var e = Build(TestSettings(), NotchedU(), precision: 1f, penetration: 10f); // ample: more than the whole hull's depth + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[0, 0] = 5f; e.Target.MaxArmor[0, 0] = 5f; // near side of the notch + e.Target.Armor[0, 2] = 1000f; e.Target.MaxArmor[0, 2] = 1000f; // far side -- must stay untouched + e.Target.Armor[0, 3] = 1000f; e.Target.MaxArmor[0, 3] = 1000f; // far side -- must stay untouched + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + + // The hull's other arm (x=2) carries no armour, so a stray hit landing there while retrying for the + // notched column would leak its own damage straight to the hull -- tracked here per-attempt (reset each + // retry) so only the accepted attempt's own contribution is asserted, not a cross-attempt total. + var hullThisAttempt = 0f; + ShotOutcome outcome = null; + using (e.Target.HullDamage.Subscribe(x => hullThisAttempt += x)) + { + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 0); attempt++) + { + hullThisAttempt = 0f; + outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); + } + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 0, "fixture: needed a hit on the notched arm within the attempt budget"); + Assert.Equal(new int2(0, 0), outcome.Cell); // fixture precondition: the base is always this lane's impact cell + + Assert.Equal(0f, e.Target.Armor[0, 0]); // 5 consumed + Assert.Equal(1000f, e.Target.Armor[0, 2]); // untouched -- beyond the first gap + Assert.Equal(1000f, e.Target.Armor[0, 3]); // untouched -- beyond the first gap + Assert.Equal(35f, hullThisAttempt, 2); // 40 - 5 left over for the hull, not spent past the gap + } + + // F5: side-lane remainders must reach the hull too, axis-aligned so the lanes share no cells and lane + // order cannot matter (Q12-3 = A, "the lane remainder goes into the hull where the lane ends"). A 3x2 + // hull, spread 1 (three one-cell-wide columns), armour only on the facing row: every lane's remainder + // survives the facing cell and must land in the hull. Kills the Stryker survivor that guards the + // hull-remainder add with `if (li == n)` (only the centre lane's remainder would count). + [Fact] + public void EverySideLanesRemainderReachesTheHullNotOnlyTheCentreLanes() + { + var e = Build(TestSettings(), SolidShape(3, 2), precision: .6f, penetration: 2f, damageSpread: 1f); + for (var x = 0; x < 3; x++) + for (var y = 0; y < 2; y++) + { + e.Target.Armor[x, y] = y == 0 ? 1f : 0f; + e.Target.MaxArmor[x, y] = 1f; + var occ = e.Target.GearOccupancy[x, y]; + if (occ != null) occ.EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + } + + // Tracked per-attempt (reset each retry), the same reasoning as the notched-U test above: a stray hit on + // the wrong lateral offset still spends real damage on this hull, and would otherwise contaminate a + // before/after total taken across every retry. + var hullThisAttempt = 0f; + ShotOutcome outcome = null; + using (e.Target.HullDamage.Subscribe(x => hullThisAttempt += x)) + { + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 1); attempt++) + { + hullThisAttempt = 0f; + outcome = FireUntilHit(e, damageOverride: 15f, attempts: 1); + } + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit landing on the centre column within the attempt budget"); + + // Each lane: 15/3 = 5 per lane, minus the facing cell's 1 armour = 4 left over; three lanes -> 12. + Assert.Equal(12f, hullThisAttempt, 2); + } + + // F6/12.4(b): "armour absorbs first" on one cell shared by armour and an occupying item is now pinned on + // the public owner, Detonate, by ABlastIsAbsorbedThroughArmourFirstPerCell in FireControlCut124Tests.cs -- + // this superseded AbsorbSpendsArmourBeforeTheItemOnTheSameCell, which called the now-deleted Entity.Absorb + // directly (docs/fire-control-cut.md, "Absorption order"). + + // F7: direct-hit frame handedness at |fx| > |fy| is otherwise only guarded by Cut 11's Splash tests, which + // 12.4 deletes. A target facing (2,1) normalized (|fx| > |fy|), shot from its own starboard side (computed + // here independently of Entity.ToSchematic, as the standard right-perpendicular of the facing -- the same + // relationship ToSchematic is supposed to implement, not a call into it). Only starboard-half cells (x >= + // width/2 in the hull's own schematic frame) carry armour; port cells (x < width/2) do not. Kills a flip of + // ToSchematic's right axis when |fx| > |fy| (M6a). + [Fact] + public void ADirectHitFromStarboardDamagesStarboardCellsWhenFacingIsMostlyLateral() + { + var shape = SolidShape(5, 5); + var facing = normalize(float2(2, 1)); // |fx| > |fy| + var e = Build(TestSettings(), shape, precision: .4f, penetration: 0f, targetFacing: facing); + + // Starboard is the standard right-perpendicular of the facing (forward.y, -forward.x) -- computed here, + // not read from Entity.ToSchematic. + var starboardWorld = float2(facing.y, -facing.x); + e.Shooter.Position = e.Target.Position + float3(starboardWorld.x, 0, starboardWorld.y) * 100f; // shooter sits to starboard + + for (var x = 0; x < 5; x++) + for (var y = 0; y < 5; y++) + { + var starboard = x >= 3; // starboard half; x == 2 (the middle column) is left ambiguous and unarmoured + e.Target.Armor[x, y] = x <= 1 ? 0f : starboard ? 1f : 0f; + e.Target.MaxArmor[x, y] = 1f; + } + + var hitCells = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hitCells.Add(x.pos)); + for (var i = 0; i < 80; i++) { FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } + + Assert.True(hitCells.Count > 5, $"expected several hits, got {hitCells.Count}"); + foreach (var c in hitCells) + Assert.True(c.x >= 3, $"starboard should face the shot, but {c} is on the port side"); + } + + // ===================== F1: an independent direct-hit model at angled bearings ===================== + // Every existing test through Fire commits at (0,+-1) -- ARayThroughACornerCrossesTheCornerCell calls Lane + // directly instead. This model is an exact double-precision ray/box walk, independent of + // FireControl.Lane/AlongBearing/SlabAxis, ported from the Soul probe (SoulApply123.cs) that verified this + // cut's arithmetic against production to 14,984/15,000 shots (the 16 mismatches were a float boundary). + // Used only to compute these tests' own expected values -- never to call production. + private struct MCell { public int2 C; public double En, Ex; } + + private static List ModelLane(Shape shape, double bx, double by, double s) + { + double lx = -by, ly = bx; + var all = new List(); + foreach (var c in shape.Coordinates) + { + double t0 = double.NegativeInfinity, t1 = double.PositiveInfinity; var ok = true; + void Ax(double d, double p0, double lo, double hi) + { + if (!ok) return; + if (Math.Abs(d) < 1e-12) { if (!(p0 > lo && p0 < hi)) ok = false; return; } + var a = (lo - p0) / d; var q = (hi - p0) / d; + t0 = Math.Max(t0, Math.Min(a, q)); t1 = Math.Min(t1, Math.Max(a, q)); + } + Ax(bx, s * lx, c.x - .5, c.x + .5); + Ax(by, s * ly, c.y - .5, c.y + .5); + if (ok && t1 - t0 > 1e-7) all.Add(new MCell { C = c, En = t0, Ex = t1 }); + } + all.Sort((p, q) => p.En.CompareTo(q.En)); + var walked = new List(); + for (var i = 0; i < all.Count; i++) + { + if (i > 0 && all[i].En > all[i - 1].Ex + 1e-4) break; + walked.Add(all[i]); + } + return walked; + } + + private static double[,] ToD(float[,] a) + { + var d = new double[a.GetLength(0), a.GetLength(1)]; + for (var i = 0; i < a.GetLength(0); i++) for (var j = 0; j < a.GetLength(1); j++) d[i, j] = a[i, j]; + return d; + } + + private sealed class DirectHitModel { public double[,] Armor; public Dictionary Items; public double Hull; } + + // Single lane (spread 0 -- F2's lane-spacing ruling only concerns spread >= 1): armour, then the occupying + // item, then the hull, near-to-far, clipped to the penetration depth from the impact cell's own entry. + private static DirectHitModel ModelDirectHit(Engagement e, Shape shape, double[,] armorBefore, + Dictionary itemsBefore, double bx, double by, double s, double damage, double penetration) + { + var r = new DirectHitModel { Armor = (double[,]) armorBefore.Clone(), Items = new Dictionary(itemsBefore) }; + var lane = ModelLane(shape, bx, by, s); + if (lane.Count == 0) { r.Hull = damage; return r; } + var rem = damage; var en0 = lane[0].En; + for (var i = 0; i < lane.Count; i++) + { + if (i > 0 && lane[i].En - en0 >= penetration) break; + var c = lane[i].C; + if (rem > 0) { var a = r.Armor[c.x, c.y]; r.Armor[c.x, c.y] = Math.Max(a - rem, 0); rem = Math.Max(rem - a, 0); } + if (rem > .1) + { + var item = e.Target.GearOccupancy[c.x, c.y]; + if (item != null) { var d = r.Items[item]; r.Items[item] = Math.Max(d - rem, 0); rem = Math.Max(rem - d, 0); } + } + } + r.Hull = rem; + return r; + } + + // F1: a full per-cell parity check against the independent model above, at three angled bearings -- 45 + // degrees, a shallow angle (within about 11 degrees of an axis) and one with |bx| > |by|. Kills a 0.5-cell + // walk (M4), a mirrored bearing when |bx| > |by| (M6b) and a bearing snapped to the nearest axis (M20): all + // three produce a visibly wrong set of cells or a wrong entry order against this model at these bearings. + [Fact] + public void DirectHitAtAnAngledBearingMatchesAnIndependentModel() + { + void Check(float2 travelDirection, int seed) + { + var shape = SolidShape(5, 5); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 3f); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + + var rng = new System.Random(seed); + foreach (var c in shape.Coordinates) + { + var a = (float) Math.Round(rng.NextDouble() * 6, 2); + e.Target.Armor[c.x, c.y] = a; e.Target.MaxArmor[c.x, c.y] = Math.Max(a, 1f); + } + // Equipment includes the ship's own EquippedHull -- excluded here, or randomising its durability + // would corrupt Hull.Durability itself, not a schematic-cell item. + foreach (var it in e.Target.Equipment) + if (it.EquippableItem != e.Target.Hull) + it.EquippableItem.Durability = (float) Math.Round(rng.NextDouble() * 15, 2); + + var armorBefore = ToD(e.Target.Armor); + var itemsBefore = e.Target.Equipment.Where(x => x.EquippableItem != e.Target.Hull) + .ToDictionary(x => x, x => (double) x.EquippableItem.Durability); + + ShotOutcome outcome = null; + double hullEv = 0; + using (e.Target.HullDamage.Subscribe(x => hullEv += x)) + { + for (var attempt = 0; attempt < 400 && (outcome == null || !outcome.Hit); attempt++) + outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + // fixture precondition: default facing makes the schematic bearing equal the world travel direction. + var actualTravel = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + Assert.Equal(travelDirection.x, actualTravel.x, 3); + Assert.Equal(travelDirection.y, actualTravel.y, 3); + + var model = ModelDirectHit(e, shape, armorBefore, itemsBefore, outcome.Bearing.x, outcome.Bearing.y, outcome.Lateral, 40, 3); + + foreach (var c in shape.Coordinates) + Assert.Equal(model.Armor[c.x, c.y], e.Target.Armor[c.x, c.y], 2); + foreach (var kv in model.Items) + Assert.Equal(kv.Value, (double) kv.Key.EquippableItem.Durability, 2); + Assert.Equal(model.Hull, hullEv, 2); + } + + Check(normalize(float2(1, 1)), seed: 1); // 45 degrees + Check(normalize(float2(.2f, 1)), seed: 2); // shallow: ~11 degrees off the y-axis + Check(normalize(float2(2, 1)), seed: 3); // |bx| > |by| + } + + // F1: the spec's own diagonal case (Probe1's corner-crossing lane), through the full Fire -> Commit -> + // Apply path rather than a direct Lane call. Retries until a hit's own ArmorDamage events show the lane + // walking exactly the three corner cells in order -- kills the same M4/M6b/M20 mutants as above from a + // different angle: a 0.5-cell march, a mirrored bearing or an axis-snapped bearing would each produce some + // other event sequence (or none matching), and 400 attempts would never find this exact one. + [Fact] + public void DiagonalDirectHitReachesEveryCornerCellInOrderThroughFireCommitApply() + { + var shape = SolidShape(3, 3); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 100f); // ample: never cuts the walk short + e.Shooter.Position = e.Target.Position - float3(1, 0, 1) * (100f / (float) Math.Sqrt(2)); // exactly 45 degrees + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + + // Everything bare except the three corner cells (light armour, so damage passes through every one of + // them into whatever comes next): a bearing this close to 45 degrees admits the diagonal's own row/column + // neighbours too (their shadow overlaps the corner at this angle -- confirmed by sweeping FireControl.Lane + // directly over s), so the walked lane is usually five cells, not three; the spec only requires the + // three corner cells to be present and in that relative order (Probe1's own "reaches (2,2)"), the same + // standard ARayThroughACornerCrossesTheCornerCell already pins for a direct Lane call. + void ResetArmor() + { + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[0, 0] = 5f; e.Target.MaxArmor[0, 0] = 5f; + e.Target.Armor[1, 1] = 5f; e.Target.MaxArmor[1, 1] = 5f; + e.Target.Armor[2, 2] = 5f; e.Target.MaxArmor[2, 2] = 5f; + } + ResetArmor(); + + var events = new List(); + using var sub = e.Target.ArmorDamage.Subscribe(x => events.Add(x.pos)); + var found = false; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 400 && !found; attempt++) + { + events.Clear(); + outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); + var i0 = events.IndexOf(new int2(0, 0)); + var i1 = events.IndexOf(new int2(1, 1)); + var i2 = events.IndexOf(new int2(2, 2)); + found = outcome != null && outcome.Hit && i0 >= 0 && i1 > i0 && i2 > i1; + if (!found) ResetArmor(); // a hit landing off the diagonal may have spent some of these cells' armour + } + Assert.True(found, "fixture: needed a hit whose lane reaches (0,0), then (1,1), then (2,2), in that order"); + + Assert.Equal(0f, e.Target.Armor[0, 0]); + Assert.Equal(0f, e.Target.Armor[1, 1]); + Assert.Equal(0f, e.Target.Armor[2, 2]); + } + + // ===================== F2: spread lanes diffuse sideways instead of overlapping ===================== + // Operator ruling (2026-09-25): "I would prefer if the overlapping damage cells were diffused sideways to + // thicken the line rather than doubling up." FireControl.Apply now spaces lanes by the shadow width a cell + // casts at the committed bearing rather than by one cell. These tests pin the resulting BEHAVIOUR -- no + // cell struck twice, mirrored damage, a footprint that widens at an angle -- not the spacing value itself + // (operator, correcting an earlier framing: "test behavior, not shape"). + + // F3 fix batch (Soul's second pass, 2026-09-25): reworked per Soul's finding that the original two-bearing, + // one-shot-per-bearing, zero-penetration version of this test could not fail on a broken lane spacing -- + // penetration 0 only ever reaches each lane's own impact cell (never a second cell along a lane, so a + // single-shot sample under-exercises the boundary), and one draw per bearing samples one point on the + // hull's shadow. (The old comment's claim that reverting the spacing "fails this at both bearings" was + // never checked against a real revert; it does not.) Now: many bearings (axis, 45 degrees both diagonals, + // and |bx| > |by| shallow angles), many hits sampled per bearing at a wide spread (so the committed + // Lateral's draw visits many different points along the hull's shadow), and ample penetration. Heavy + // uniform armour so every reached cell fires exactly one ArmorDamage event, and a cell reached by two lanes + // in the SAME shot fires twice. + // F1's own structural fix (`FireControl.Lanes`, now built on top of `FireControl.Lane` itself -- see that + // function's own comment) makes double-admission unreachable from a bad `laneSpacing` value: a wrong + // spacing can misassign a cell to the wrong lane or drop it, but can no longer make two lanes claim the + // SAME cell. Hand verification (fc123fix2-mut.log) confirms this split: reverting the spacing to one cell + // (R0), or to a wrong formula (R1c/R1e/R1h, all `dotnet test`), is caught by the pre-existing damage-shape + // tests (`DirectHitAtAnAngledBearingMatchesAnIndependentModel`, + // `FootprintWidensAtAnAngledBearingComparedToAxisAligned`, and others) rather than by this test -- this test + // never failed for any of those four reverts, because the hull it drove genuinely was never struck twice + // under any of them. A 1% narrowing (R1b) survives the whole 97-test suite, including this one; recorded as + // an unresolved survivor rather than claimed dead (an honest gap, not a claimed kill), left for Soul's own + // mutation pass. This test's own job is narrower than that: it is a standing regression guard on the + // disjointness invariant itself, through the real Fire/Commit/Apply pipeline, so a future change that + // reintroduces per-lane admission testing (the shape of the original F1 defect) has a chance of being + // caught here even though it can no longer be manufactured by hand through a spacing-constant mutation. + + // ===================== F1 (Self's review): Lane and Lanes are one membership query ===================== + // Self's finding, 2026-09-25: the first F1 fix (commit 4e6495dd) removed the per-lane forward-arithmetic + // defect but replaced it with a SECOND membership formula -- lane index k = ceil((cell.Lo - centreLateral) + // / laneSpacing), agreeing with FireControl.Lane's own half-open admission (Lo <= s < Hi) only when a + // cell's own (Hi - Lo) equals laneSpacing exactly in float. `FireControl.Lanes` is now built entirely out + // of `FireControl.Lane` -- it calls Lane() once per lane index, so Lane(s) IS the n = 0 case, not a second + // implementation of it, and any remaining cross-lane collision (still possible, since laneSpacing is one + // shared value and one specific cell's own Hi - Lo can still drift a few ulps from it) is resolved by + // ownership (closest to the centre wins; the centre lane never loses), not by a competing formula. This + // test pins that contract directly through the PUBLIC FireControl.Lanes and FireControl.Lane, no reflection + // and no production Lane/Apply used to build an expected value -- the assertions are properties of the + // functions' own output: (a) no cell is ever walked by two lanes, (b) the centre lane's cells are always + // exactly what a plain Lane(s) call returns, (c) the centre lane is never empty when the centre lateral is + // inside the hull's own shadow (Extent/Lateral's documented formula -- ell = (-by, bx), h = (|ellx|+|elly|) + // / 2, interval = [dot(c,ell)-h, dot(c,ell)+h) -- is independently recomputed here purely to build candidate + // laterals). Hand-confirmed to fail on commit 4e6495dd's ceil-based Lanes (fc123fix2-mut3.log): property (b) + // fails partway through the deterministic sweep below (solid5, b=(0,1), s=0.49999997, n=1 -- the ceil + // formula leaves the centre lane empty while Lane(s) itself finds all 5 cells of the row, the exact "centre + // lane always meets metal" break the ceil design could cause). Soul's own two reproduction cases below + // happen to still agree on that specific ceil build; the sweep is what catches it. + [Fact] + public void LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull() + { + static float2 Ell(float2 b) => float2(-b.y, b.x); + static (float Lo, float Hi) CellExtent(int2 c, float2 ell) + { + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + var centre = dot((float2) c, ell); + return (centre - h, centre + h); + } + static float Spacing(float2 b) { var ell = Ell(b); return Math.Abs(ell.x) + Math.Abs(ell.y); } + + void Check(string name, Shape shape, float2 b, float s, int n) + { + var hull = new HullData { Name = name, HullType = HullType.Ship, Shape = shape }; + var spacing = Spacing(b); + var laneCount = 2 * n + 1; + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) buffers[li] = new LaneCell[shape.Coordinates.Length]; + var walked = FireControl.Lanes(hull, b, s, n, spacing, buffers); + + // (a) no cell appears in two lanes. + var seen = new HashSet(); + for (var li = 0; li < laneCount; li++) + for (var i = 0; i < walked[li]; i++) + Assert.True(seen.Add(buffers[li][i].Cell), + $"{name} b=({b.x:R},{b.y:R}) s={s:R} n={n}: cell {buffers[li][i].Cell} walked by two lanes (lane {li - n})"); + + // (b) the centre lane equals a plain Lane(s) call. + var solo = new LaneCell[shape.Coordinates.Length]; + var soloWalked = FireControl.Lane(hull, b, s, solo); + Assert.True(soloWalked == walked[n], $"{name} b=({b.x:R},{b.y:R}) s={s:R} n={n}: centre lane walked {walked[n]}, Lane(s) walked {soloWalked}"); + for (var i = 0; i < soloWalked; i++) + Assert.Equal(solo[i].Cell, buffers[n][i].Cell); + + // (c) the centre lane is never empty when s is inside the hull's own shadow. + var ell = Ell(b); + var insideShadow = shape.Coordinates.Any(c => { var (lo, hi) = CellExtent(c, ell); return s >= lo && s < hi; }); + if (insideShadow) + Assert.True(walked[n] > 0, $"{name} b=({b.x:R},{b.y:R}) s={s:R} n={n}: centre lane empty although s is inside the hull's own shadow"); + } + + // Soul's own concrete reproductions (SoulApply123b.Disjoint, 2026-09-25). + Check("solid5", SolidShape(5, 5), float2(0, 1), -0.50000006f, 1); + Check("solid9", SolidShape(9, 9), normalize(float2(-0.69478226f, 0.7192201f)), -5.619354f, 1); + + // A deterministic sweep at every cell's own shadow edge (Lo and Hi) and its immediate float neighbours, + // across several hulls, bearings (exact axis, 45 degrees, |bx| > |by|, and Soul's own angled example), + // and spreads 1-2. + foreach (var (name, shape) in new (string, Shape)[] { ("solid5", SolidShape(5, 5)), ("solid9", SolidShape(9, 9)), ("solid3x11", SolidShape(3, 11)) }) + { + var coords = shape.Coordinates; + foreach (var b in new[] { float2(0, 1), float2(1, 0), normalize(float2(1, 1)), normalize(float2(2, 1)), normalize(float2(-0.69478226f, 0.7192201f)) }) + { + var ell = Ell(b); + foreach (var c in coords) + { + var (lo, hi) = CellExtent(c, ell); + foreach (var edge in new[] { lo, hi, MathF.BitIncrement(lo), MathF.BitDecrement(lo), MathF.BitIncrement(hi), MathF.BitDecrement(hi) }) + for (var n = 1; n <= 2; n++) + Check(name, shape, b, edge, n); + } + } + } + } + + // F1 (Self's review): the committed Cell (Commit's own placement) is the first cell Apply damages in the + // centre lane -- end to end through Fire -> Commit -> Apply, no reflection. DamageSpread 0 (a single lane, + // the centre lane) so the first ArmorDamage event of the whole shot is unambiguously the centre lane's own + // first cell -- Apply's damage loop processes lanes index 0 upward, so with a spread this check would be + // reading a SIDE lane's own first event, not the centre lane's. Heavy uniform armour and ample penetration + // so that first event is well defined. + [Fact] + public void CommittedCellIsTheFirstCellTheCentreLaneDamages() + { + var bearings = new[] + { + float2(0, 1), normalize(float2(1, 1)), normalize(float2(-1, 1)), + normalize(float2(2, 1)), normalize(float2(1, 2)) + }; + foreach (var travelDirection in bearings) + { + var shape = SolidShape(9, 9); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 100f, damageSpread: 0f); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + var firstHit = new int2?[1]; + using var s = e.Target.ArmorDamage.Subscribe(x => { if (firstHit[0] == null) firstHit[0] = x.pos; }); + var outcome = FireUntilHit(e, damageOverride: 50f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.NotNull(firstHit[0]); + Assert.Equal(outcome.Cell, firstHit[0].Value); + } + } + [Fact] + public void NoCellIsStruckByTwoLanesInOneShotAtAnyBearing() + { + // Within the fixture's own FiringArc (170 degrees, i.e. +-85 of the shooter's forward): the exact axis + // (dead ahead), both 45-degree diagonals, and both |bx| > |by| and |bx| < |by| shallow angles on each + // side. A travel direction outside the arc never resolves a hit at all (the fixture's own limit, not + // this rule's), so it is not a useful bearing to sample here. + var bearings = new[] + { + float2(0, 1), // exact axis + normalize(float2(1, 1)), normalize(float2(-1, 1)), // 45 degrees + normalize(float2(2, 1)), normalize(float2(-2, 1)), // |bx| > |by| + normalize(float2(1, 2)), normalize(float2(-1, 2)) // |bx| < |by| + }; + const int hitsWanted = 300; + + foreach (var travelDirection in bearings) + { + var shape = SolidShape(9, 9); + var e = Build(TestSettings(), shape, precision: .02f, penetration: 100f, damageSpread: 2f); // ample penetration, 5 lanes; a wide spread samples many points along the shadow + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + + var thisShot = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => thisShot.Add(x.pos)); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + + var got = 0; + for (var attempt = 0; attempt < 20000 && got < hitsWanted; attempt++) + { + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + thisShot.Clear(); + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride: 50f); + e.Zone.Update(.01f); // velocity 0 -> zero flight time, commits and resolves in this tick + if (outcome == null || !outcome.Hit) continue; + got++; + Assert.Equal(thisShot.Count, thisShot.Distinct().Count()); // no cell fires ArmorDamage twice within this shot + } + Assert.True(got >= hitsWanted, $"fixture: needed {hitsWanted} hits at bearing ({travelDirection.x:R},{travelDirection.y:R}), got {got}"); + } + } + + // The footprint widens at an angled bearing rather than concentrating: the same spread, on the same hull, + // covers more lateral distance at 45 degrees than axis-aligned. Reverting the lane spacing to one cell + // removes this widening (every bearing gets the same one-cell spacing regardless of angle). + [Fact] + public void FootprintWidensAtAnAngledBearingComparedToAxisAligned() + { + double Span(float2 travelDirection) + { + var shape = SolidShape(9, 9); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 0f, damageSpread: 2f); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + var hits = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x.pos)); + var outcome = FireUntilHit(e, damageOverride: 50f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.True(hits.Count >= 3, $"fixture: needed several lanes to find metal, got {hits.Count}"); + + var b = normalize(travelDirection); + var ell = float2(-b.y, b.x); + var projections = hits.Select(c => (double) dot((float2) c, ell)).ToList(); + return projections.Max() - projections.Min(); + } + + var axisSpan = Span(float2(0, 1)); + var angledSpan = Span(normalize(float2(1, 1))); + + Assert.True(angledSpan > axisSpan * 1.2, $"the footprint should widen at an angled bearing: axis {axisSpan}, 45deg {angledSpan}"); + } + + // ===================== F8: proportional absorption when a multi-cell item is shared between lanes ===================== + // Operator ruling (2026-09-25, docs/fire-control-cut.md, Cut 12.3 status): "proportional absorption; go." + // When two or more lanes of one shot reach the same multi-cell item, the item absorbs their COMBINED + // incoming damage (up to its own remaining durability) and each lane's leftover is in proportion to what + // it brought -- replacing the old behaviour where whichever lane was processed first (always the + // lower-indexed, physically left-of-travel lane) drained the item and every other lane inherited whatever + // it left behind. + + // F8: the ruling's own literal case, through Fire/Commit/Apply (no reflection). Tool-hardpoint items + // (Bar, Marker) can only occupy INTERIOR cells (Entity.ItemFits, `hullData.InteriorCells[itemCoord]` -- + // Shape.Shrink needs all 8 neighbours present, the same constraint DamageIsAbsorbedInOrderAlongTheRay's own + // comment already notes for a single-cell marker on a 3x3 hull). A marker sitting BEHIND the shared item + // needs its own interior row too, so this fixture is 4 cells deep: SolidShape(5,4)'s two interior rows are + // y=1 (the Bar, columns 1-3) and y=2 (one marker behind each of the Bar's own two cells). Note + // Entity.ItemDamage reports each lane's own INCOMING contribution, not the clamped/actual-absorbed amount + // (the same convention a single Absorb call already has -- an overkill hit against a nearly-dead item still + // reports the whole swing), so it cannot distinguish proportional from sequential absorption by itself; + // the item's own durability (the ACTUAL total absorbed) and each marker's own durability (the ACTUAL + // leftover that reached it) are the two numbers this test reads. + // With damageSpread 1 retried until the committed impact is column 2 (the middle of the three interior + // columns), the lanes are columns {1,2,3}: the item's own two cells (1,1)/(2,1) are each a DIFFERENT lane's + // own facing cell, so armour placed directly on them (5 and 15) gives the two lanes different post-armour + // remainders even though Apply splits the shot's damage evenly per lane before armour: D1 = 30-5 = 25 + // (column 1), D2 = 30-15 = 15 (column 2). Column 3 carries no item, an unrelated private lane. + private (float leftoverLow, float leftoverHigh, float absorbed) RunSharedItemCase(float itemDurability) + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 1), itemDurability) }, + markers: new[] { (new int2(1, 2), 1000f), (new int2(2, 2), 1000f) }); + var bar = e.Target.GearOccupancy[1, 1]; // (1,1) and (2,1) are the same Bar + + void ResetState() + { + for (var x = 0; x < 5; x++) + for (var y = 0; y < 4; y++) + { + e.Target.Armor[x, y] = 0f; + e.Target.MaxArmor[x, y] = 20f; + } + e.Target.Armor[1, 1] = 5f; + e.Target.Armor[2, 1] = 15f; + bar.EquippableItem.Durability = itemDurability; + e.Markers[0].EquippableItem.Durability = 1000f; // behind (1,1) + e.Markers[1].EquippableItem.Durability = 1000f; // behind (2,1) + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + } + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 400 && (outcome == null || !outcome.Hit || outcome.Cell.x != 2); attempt++) + { + ResetState(); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 2, "fixture: needed a hit on the centre interior column within the attempt budget"); + + var leftover1 = 1000f - e.Markers[0].EquippableItem.Durability; // column 1's own leftover (D1=25) + var leftover2 = 1000f - e.Markers[1].EquippableItem.Durability; // column 2's own leftover (D2=15) + var absorbed = itemDurability - bar.EquippableItem.Durability; + return leftover1 <= leftover2 ? (leftover1, leftover2, absorbed) : (leftover2, leftover1, absorbed); + } + + // Item durability 30 < D1+D2 (40): the item takes all 30 of its own durability (absorbed = min(40,30)); the + // combined 10 leftover splits 25*(10/40)=6.25 and 15*(10/40)=3.75 -- proportional to what each lane + // brought, not to which lane Apply happens to visit first. Under the pre-fix-batch code (sequential, one + // lane draining the item before the other carries on) the two leftovers are not this pair at all -- see + // MirrorSymmetryHoldsWithASharedMultiCellItem below for the hand-confirmed pre-fix numbers on this exact + // shape of fixture. + [Fact] + public void ProportionalAbsorptionSplitsTheLeftoverBetweenTheLanes() + { + var (leftoverLow, leftoverHigh, absorbed) = RunSharedItemCase(itemDurability: 30f); + Assert.Equal(3.75f, leftoverLow, 2); // 15 * (10/40) + Assert.Equal(6.25f, leftoverHigh, 2); // 25 * (10/40) + Assert.Equal(30f, absorbed, 2); // the item's own durability, fully spent + } + + // Item durability 50 >= D1+D2 (40): the item takes everything either lane brought; nothing continues past + // it into either marker. + [Fact] + public void ProportionalAbsorptionLeavesNothingWhenTheItemAbsorbsEverything() + { + var (leftoverLow, leftoverHigh, absorbed) = RunSharedItemCase(itemDurability: 50f); + Assert.Equal(0f, leftoverLow, 2); + Assert.Equal(0f, leftoverHigh, 2); + Assert.Equal(40f, absorbed, 2); + } + + // F8: mirror symmetry with a multi-cell item, deterministic. Two fixtures on SolidShape(9,4) (interior rows + // y=1, the Bar's own row, and y=2, the marker row behind it -- see RunSharedItemCase above), fired straight + // ahead (bearing (0,1)) at damageSpread 1, each retried until the committed impact is the true centre + // column (x=4, as CommittedCellIsTheFirstCellTheCentreLaneDamages already relies on for this bearing), so + // the three lanes are always columns {3,4,5}. Fixture B's item and armour are fixture A's own layout + // reflected about column 4 (mirror(x) = 8-x) -- A's Bar spans {3,4} (centre lane shares it with the LEFT + // side lane); B's spans {4,5} (centre lane shares it with the RIGHT side lane), the mirror image of A's + // placement. Column 4 is the self-mirror point and carries the same armour (15) on both; column 3 (A, + // private) mirrors column 5 (B, private), both armour 2 (irrelevant to the assertion, just present so that + // lane is metal). + // + // Compares the SORTED list of each fixture's own two marker leftovers -- not the bearing or the lateral + // draw (which would also have to mirror the float lane-spacing arithmetic to stay exact). A's two D values + // are 25 (column 3) and 15 (column 4); B's are 15 (column 4) and 25 (column 5) -- the same two D values on + // both fixtures, so the proportional rule (sum then split, independent of which physical lane is which) + // gives the SAME sorted leftover pair on both. Item durability 20 (not equal to either D -- 25 was tried + // first and turned out degenerate: durability exactly equal to D_high made both fixtures land on {0,15} by + // coincidence under the pre-fix code too, since one contributor is always driven to exactly 0 either way). + // Hand-confirmed against commit d1cc047a (this cut's own parent, before the fix batch) on this exact + // fixture shape at itemDurability 20: A's leftovers sorted {0, 20} vs B's sorted {5, 15} -- different, so + // the assertion below fails there. See this cut's own report for the raw per-fixture numbers. + [Fact] + public void MirrorSymmetryHoldsWithASharedMultiCellItem() + { + const float itemDurability = 20f; + + (float low, float high) RunFixture(bool mirrored) + { + var barAnchor = mirrored ? new int2(4, 1) : new int2(3, 1); + var markerCells = mirrored + ? new[] { (new int2(4, 2), 1000f), (new int2(5, 2), 1000f) } + : new[] { (new int2(3, 2), 1000f), (new int2(4, 2), 1000f) }; + var e = Build(TestSettings(), SolidShape(9, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (barAnchor, itemDurability) }, markers: markerCells); + var bar = e.Target.GearOccupancy[barAnchor.x, barAnchor.y]; + + void ResetState() + { + for (var x = 0; x < 9; x++) + for (var y = 0; y < 4; y++) + { + e.Target.Armor[x, y] = 0f; + e.Target.MaxArmor[x, y] = 20f; + } + if (!mirrored) { e.Target.Armor[3, 1] = 5f; e.Target.Armor[4, 1] = 15f; e.Target.Armor[5, 1] = 2f; } + else { e.Target.Armor[4, 1] = 15f; e.Target.Armor[5, 1] = 5f; e.Target.Armor[3, 1] = 2f; } + bar.EquippableItem.Durability = itemDurability; + e.Markers[0].EquippableItem.Durability = 1000f; + e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + } + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 600 && (outcome == null || !outcome.Hit || outcome.Cell.x != 4); attempt++) + { + ResetState(); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 4, "fixture: needed a hit on the true centre column within the attempt budget"); + + var l0 = 1000f - e.Markers[0].EquippableItem.Durability; + var l1 = 1000f - e.Markers[1].EquippableItem.Durability; + return l0 <= l1 ? (l0, l1) : (l1, l0); + } + + var a = RunFixture(mirrored: false); + var b = RunFixture(mirrored: true); + + Assert.Equal(a.low, b.low, 2); + Assert.Equal(a.high, b.high, 2); + } + + // F8 (Soul's owed test, "Owed with the pending ruling's batch" in docs/fire-control-cut.md, Cut 12.3 + // status): many axis-aligned spread-n shots on a wide hull, each must strike exactly 2n+1 facing cells -- + // no column dropped. This is a standing regression guard on the lane-spacing invariant itself (independent + // of the proportional-absorption fix batch's own item coordination), pinned through the real + // Fire/Commit/Apply pipeline. Hand-confirmed to fail with R1b (lane spacing narrowed by 1%, + // `laneSpacing = (shadowExtent.Hi - shadowExtent.Lo) * .99f` at FireControl.cs) because that narrowing + // eventually drops a column at a spacing-boundary lateral -- see this cut's own report for the exact + // FireControl.cs edit and count. + [Fact] + public void EveryAxisAlignedSpreadShotStrikesExactlyTwoNPlusOneFacingCells() + { + var e = Build(TestSettings(), SolidShape(15, 2), precision: .02f, penetration: 0f, damageSpread: 3f); // n=3 -> 7 lanes + for (var x = 0; x < 15; x++) + for (var y = 0; y < 2; y++) + { + e.Target.Armor[x, y] = 1000f; + e.Target.MaxArmor[x, y] = 1000f; + } + + var got = 0; + var shortfalls = 0; + for (var attempt = 0; attempt < 8000 && got < 400; attempt++) + { + var hits = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x.pos)); + var outcome = FireUntilHit(e, damageOverride: 70f, attempts: 1); + if (outcome == null || !outcome.Hit) continue; + // Only impacts safely inside the hull count: within 3 columns of either edge, a side lane + // legitimately runs off the hull (fewer than 7 facing columns is correct there, not a spacing + // defect) -- restricting to the interior keeps this test about the spacing invariant alone. + if (outcome.Cell.x < 3 || outcome.Cell.x > 11) continue; + got++; + if (hits.Select(h => h.x).Distinct().Count() != 7) shortfalls++; // 2*3+1 facing columns, one hit each + } + Assert.True(got >= 400, $"fixture: needed 400 interior-column hits, got {got}"); + Assert.Equal(0, shortfalls); + } + + // ===================== F8/Soul: the model sweep, rewritten as an independent fixed point ===================== + // Soul's finding (2026-09-25): the earlier sweep copied FireControl.Apply's own structure (a survey of + // shared items, then an AdvanceLane/Resolve loop, lane-driven) closely enough that reverting production to + // a plain sequential per-lane absorb (M1, no pooling at all) still passed it. A model built the same way + // production is built can share production's own blind spot. `FixedPointItemResolution` below settles items + // in a DIFFERENT order from production's own inline, per-deposit check: it repeats PASSES over every lane + // (armour only, never coupled across lanes) and then, once a WHOLE pass of every lane is done, checks every + // currently open pool for readiness in one batch (`ReadyToSettle`) -- production instead asks the readiness + // question the instant a single lane deposits, mid-walk, never waiting for a whole pass. `ReadyToSettle` + // still re-derives its answer fresh from each other lane's CURRENT position every time it is asked (not + // from a table built once up front): a precomputed-once reach table was tried first and produced a real + // deadlock on a non-convex item crossed twice by the SAME lane, because a set keyed by item identity cannot + // tell a two-lane first pool apart from the one-lane pool the item's second visit degenerates into. A + // genuine cross-item cycle (every remaining lane blocked, nothing left to converge) is not modelled at all -- + // this sweep gates its own draws (`LanesReaching`, below) so a cycle is never actually presented to this + // fixed point. Finding one here would mean the gating missed a real case, not a numbers question for this + // function to answer with its own tie-break; see the cycle-break check at the end of this function. + // + // Geometry itself is NOT reimplemented a third time: which cells belong to which lane is read directly from + // `FireControl.Lanes`, the one public function this whole cut already treats as the single geometric owner + // (`LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull` above pins its own contract independently). + // An earlier version of this rewrite reimplemented lane membership with the plain double-precision + // `ModelLane` walk (the same primitive F1's own single-lane model uses); reading the real `Lanes` output + // instead removes any chance of the model and production disagreeing about which cell belongs to which + // lane, leaving only genuine float32-vs-double arithmetic drift in the absorption chain -- small enough + // that the tolerance below could actually be tightened, rather than only claimed tightened. + private static (double[,] armor, Dictionary items, double hull) FixedPointItemResolution( + Entity target, HullData hullData, double[,] armorBefore, Dictionary itemsBefore, + float2 bearing, float lateral, float damage, float penetration, int n) + { + var armor = (double[,]) armorBefore.Clone(); + var items = new Dictionary(itemsBefore); + var laneCount = 2 * n + 1; + var ell = float2(-bearing.y, bearing.x); + var laneSpacing = Math.Abs(ell.x) + Math.Abs(ell.y); // the same shadow-width formula Extent/Lateral own + + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) buffers[li] = new LaneCell[hullData.Shape.Coordinates.Length]; + var walked = FireControl.Lanes(hullData, bearing, lateral, n, laneSpacing, buffers); + + // The one clip production performs, against `Lanes`' own real output -- a two-line geometric fact, not + // the resolution order under test. + var cells = new List[laneCount]; + for (var li = 0; li < laneCount; li++) + { + var clipped = new List(); + if (walked[li] > 0) + { + var impactEntry = buffers[li][0].Entry; + var count = 1; + while (count < walked[li] && buffers[li][count].Entry - impactEntry < penetration) count++; + for (var i = 0; i < count; i++) clipped.Add(buffers[li][i].Cell); + } + cells[li] = clipped; + } + var metal = cells.Count(c => c.Count > 0); + var per = (double) damage / metal; + + var at = new int[laneCount]; // how many of this lane's own cells are fully settled + var carry = new double[laneCount]; // damage this lane carries into cell `at[li]` + var done = new bool[laneCount]; + for (var li = 0; li < laneCount; li++) { done[li] = cells[li].Count == 0; carry[li] = done[li] ? 0 : per; } + + var hull = 0.0; + var openPool = new Dictionary>(); + + void Land(int li) { done[li] = true; hull += carry[li]; } + + // Every non-finished lane, at the moment this is called (always AFTER a full pass over every lane, never + // mid-pass), is either done or sitting blocked at exactly one item cell -- there is no third "still + // walking" state left once a pass completes. So "no other unfinished lane can still reach this item" + // reduces to: does `item` occur anywhere in an OTHER non-contributing lane's remaining cell list, from + // where it currently sits onward. Unlike a precomputed table keyed by item identity, this re-derives + // the answer fresh each time from each lane's CURRENT position -- which is what correctly demotes a + // non-convex item's SECOND visit by the same lane to a solo pool once every other lane has already + // passed the item for good, without needing a separate notion of "which visit number" this is. + bool ReadyToSettle(EquippedItem item, Dictionary contributors) + { + for (var li2 = 0; li2 < laneCount; li2++) + { + if (done[li2] || contributors.ContainsKey(li2)) continue; + for (var i = at[li2]; i < cells[li2].Count; i++) + if (target.GearOccupancy[cells[li2][i].x, cells[li2][i].y] == item) return false; + } + return true; + } + + void Settle(EquippedItem item) + { + var deposits = openPool[item]; + openPool.Remove(item); // a later visit opens a fresh pool, the same rule as production + var total = deposits.Values.Sum(); + // Entity.ItemAbsorb's own .1f gate, decided once on the pool total -- an earlier version of this + // model omitted it entirely (absorbing any positive total, however small) and it took a real + // production trace to catch: a 0.03 pooled total should pass straight through untouched, the same + // as a solo 0.03 hit always has, not vanish into an item with plenty of spare durability. + var before = items[item]; + var absorbed = total > 0.1 ? Math.Min(total, before) : 0.0; + items[item] = Math.Max(before - absorbed, 0); + var fraction = total > 0 ? absorbed / total : 0; + foreach (var (lane, amount) in deposits) + { + carry[lane] = amount - amount * fraction; + at[lane]++; + if (at[lane] >= cells[lane].Count) Land(lane); + } + } + + // The fixed point: repeat PASSES over lanes (armour only, never coupled across lanes) and then over + // every currently open item (settle whichever now has its whole precomputed reach set deposited) until + // a whole pass changes nothing. + var progressed = true; + while (progressed) + { + progressed = false; + + for (var li = 0; li < laneCount; li++) + { + if (done[li]) continue; + while (at[li] < cells[li].Count) + { + var c = cells[li][at[li]]; + var item = target.GearOccupancy[c.x, c.y]; + // Already deposited here on an earlier pass, still waiting on the item to settle -- do NOT + // touch armour or deposit again; this cell is not revisited until Settle advances `at[li]`. + if (item != null && openPool.TryGetValue(item, out var waiting) && waiting.ContainsKey(li)) break; + + // Armour applies at EVERY cell this lane reaches, item or not (armour is never shared -- + // Entity.ArmorAbsorb runs unconditionally on a cell before any item check, and this model + // mirrors that ordinary per-lane physics without needing to copy production's own walk). + if (carry[li] > 0) { var a = armor[c.x, c.y]; armor[c.x, c.y] = Math.Max(a - carry[li], 0); carry[li] = Math.Max(carry[li] - a, 0); } + if (item == null) + { + at[li]++; + progressed = true; + continue; + } + if (!openPool.TryGetValue(item, out var deposits)) openPool[item] = deposits = new Dictionary(); + deposits[li] = carry[li]; + progressed = true; + break; // this lane goes no further until the item settles + } + if (!done[li] && at[li] >= cells[li].Count) Land(li); + } + + foreach (var item in new List(openPool.Keys)) + if (ReadyToSettle(item, openPool[item])) { Settle(item); progressed = true; } + } + + // Cycle break: the model never meets a cycle. This sweep gates its own draws (`LanesReaching`, below) so + // a genuine cross-item cycle -- every remaining lane blocked, nothing left to converge -- is never + // actually presented to this fixed point. A pool still open here means the gating let a real cycle + // through, which is a defect in the sweep, not a numbers question this function should silently answer + // with its own tie-break. + if (openPool.Count > 0) + throw new InvalidOperationException( + "FixedPointItemResolution: the model never meets a cycle -- a pool is still open after the fixed " + + "point settled, which means this sweep's own gating let a genuine cross-item cycle through."); + + return (armor, items, hull); + } + + // How many of a shot's own lanes geometrically reach `item` at all, at the COMMITTED bearing/lateral -- read + // from the real `FireControl.Lanes`/clip, the same as `FixedPointItemResolution` above. Used only to gate + // the sweep below onto draws that actually exercise cross-lane sharing, the same way RunSharedItemCase and + // MirrorSymmetryHoldsWithASharedMultiCellItem retry until their own committed column lands where they need + // it. + private static int LanesReaching(HullData hullData, float2 bearing, float lateral, int n, float penetration, Entity target, EquippedItem item) + { + var laneCount = 2 * n + 1; + var ell = float2(-bearing.y, bearing.x); + var laneSpacing = Math.Abs(ell.x) + Math.Abs(ell.y); + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) buffers[li] = new LaneCell[hullData.Shape.Coordinates.Length]; + var walked = FireControl.Lanes(hullData, bearing, lateral, n, laneSpacing, buffers); + + var count = 0; + for (var li = 0; li < laneCount; li++) + { + if (walked[li] == 0) continue; + var impactEntry = buffers[li][0].Entry; + var clipped = 1; + while (clipped < walked[li] && buffers[li][clipped].Entry - impactEntry < penetration) clipped++; + var reached = false; + for (var i = 0; i < clipped; i++) + if (target.GearOccupancy[buffers[li][i].Cell.x, buffers[li][i].Cell.y] == item) { reached = true; break; } + if (reached) count++; + } + return count; + } + + [Fact] + public void ModelSweepAgreesWithProductionIncludingSharedMultiCellItems() + { + var rng = new System.Random(20260925); + // Height 4: an L-shaped item's 2x2 bounding box needs TWO consecutive interior rows (rows 1 and 2 for + // height 4 -- Entity.ItemFits requires every one of an item's own cells to be an interior cell, the + // same constraint RunSharedItemCase's own comment explains); a plain 2x1 bar only needs one but is kept + // on the same row for a uniform itemY across both item shapes. + var shapes = new[] { SolidShape(9, 4), SolidShape(7, 4) }; + var bearings = new[] { float2(0, 1), normalize(float2(1, 1)), normalize(float2(2, 1)) }; + var trials = 0; + + foreach (var shape in shapes) + foreach (var travelDirection in bearings) + { + for (var t = 0; t < 15; t++) + { + var useLBar = t % 2 == 0; + var itemX = 1 + rng.Next(shape.Width - 3); + const int itemY = 1; // the only row that is interior with room for a 2-row-tall L above it (height 4) + // Soul: durability BELOW a single lane's own share (damage 40 over up to 5 lanes, so roughly + // 8-13 per lane before armour) guarantees the item overflows whenever it is shared -- the + // per-lane split always matters, never a coincidental full-absorb-either-way case. + var durability = (float) (2 + rng.NextDouble() * 4); + var e = useLBar + ? Build(TestSettings(), shape, precision: 1f, penetration: 3f, damageSpread: 2f, + lBars: new[] { (new int2(itemX, itemY), durability) }) + : Build(TestSettings(), shape, precision: 1f, penetration: 3f, damageSpread: 2f, + bars: new[] { (new int2(itemX, itemY), durability) }); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + var sharedItem = e.Target.GearOccupancy[itemX, itemY]; + + foreach (var c in shape.Coordinates) + { + var a = (float) Math.Round(rng.NextDouble() * 6, 2); + e.Target.Armor[c.x, c.y] = a; e.Target.MaxArmor[c.x, c.y] = Math.Max(a, 1f); + } + + var armorBefore = ToD(e.Target.Armor); + var itemsBefore = e.Target.Equipment.Where(x => x.EquippableItem != e.Target.Hull) + .ToDictionary(x => x, x => (double) x.EquippableItem.Durability); + var hullBefore = e.Target.Hull.Durability; + + // Every rejected attempt below still fires a real shot and mutates real state -- reset back to + // the captured baseline before each one, or a rejected miss/mismatch would silently corrupt the + // "before" snapshot the accepted shot is later compared against (the bug that produced this + // sweep's very first false failure while writing this rewrite: armour and item durability had + // already been chipped by earlier rejected attempts by the time an accepted shot was found). + void ResetState() + { + foreach (var c in shape.Coordinates) e.Target.Armor[c.x, c.y] = (float) armorBefore[c.x, c.y]; + foreach (var kv in itemsBefore) kv.Key.EquippableItem.Durability = (float) kv.Value; + e.Target.Hull.Durability = hullBefore; + } + + const int n = 2; // damageSpread 2 -> floor(2.5) = 2 + ShotOutcome outcome = null; + var hullEvent = 0.0; + using (e.Target.HullDamage.Subscribe(d => hullEvent += d)) + { + // Retry until the shot both hits AND actually shares `sharedItem` between >=2 lanes -- + // the property this test exists to exercise, not left to chance. + for (var attempt = 0; attempt < 1200; attempt++) + { + ResetState(); + hullEvent = 0.0; + var o = FireUntilHit(e, damageOverride: 40f, attempts: 1); + if (o == null || !o.Hit) continue; + if (LanesReaching(e.HullData, o.Bearing, o.Lateral, n, 3f, e.Target, sharedItem) < 2) continue; + outcome = o; + break; + } + } + if (outcome == null) continue; // fixture: rare exhaustion of the shared-hit budget, skip this draw + trials++; + + var model = FixedPointItemResolution(e.Target, e.HullData, armorBefore, itemsBefore, + outcome.Bearing, outcome.Lateral, 40f, 3f, n); + + // Tightened from an earlier `< .1` (Soul, 2026-09-25). That wide tolerance was masking two + // different things at once: genuine float32-vs-double arithmetic drift (small, ~1e-3) AND this + // sweep's own model disagreeing with `Lanes`' rare cross-lane cell-ownership tie-break (large + // enough to read as a clean 0.03-0.09, reproducible, NOT noise -- caught and removed above by + // reading lane membership from the real `FireControl.Lanes` instead of reimplementing it). + // With that class of disagreement gone, what's left really is arithmetic noise. + const double tolerance = .01; + foreach (var c in shape.Coordinates) + Assert.True(Math.Abs(model.armor[c.x, c.y] - e.Target.Armor[c.x, c.y]) < tolerance, + $"armor{c}: model {model.armor[c.x, c.y]:F4} production {e.Target.Armor[c.x, c.y]:F4}"); + foreach (var kv in model.items) + Assert.True(Math.Abs(kv.Value - (double) kv.Key.EquippableItem.Durability) < tolerance, + $"item#{kv.Key.GetHashCode()}: model {kv.Value:F4} production {kv.Key.EquippableItem.Durability:F4}"); + Assert.True(Math.Abs(model.hull - hullEvent) < tolerance, + $"hull: model {model.hull:F4} production {hullEvent:F4}"); + } + } + Assert.True(trials >= 40, $"fixture: needed at least 40 shared-item trials (of up to 90 draws), got {trials}"); + } + + // ===================== Soul's own probes (Cut 12.3, one-apply-path fix batch) ===================== + // These pin the rules the earlier pooled-but-still-branching implementation could not: the cycle tie-break + // (two non-convex items crossed by two lanes in opposite relative order), the pooled path's own threshold + // gate, and a zero deposit not stalling the pool it belongs to. + + private static Shape CellsShape(int w, int h, params (int x, int y)[] cells) + { + var s = new Shape(w, h); + foreach (var c in cells) s[new int2(c.x, c.y)] = true; + return s; + } + + private static void ZeroArmor(Engagement e, int w, int h) + { + for (var x = 0; x < w; x++) + for (var y = 0; y < h; y++) { e.Target.Armor[x, y] = 0f; e.Target.MaxArmor[x, y] = 20f; } + } + + private static ShotOutcome FireCentre(Engagement e, int centreX, Action reset, float damage) + { + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 800 && (outcome == null || !outcome.Hit || outcome.Cell.x != centreX); attempt++) + { + reset(); + outcome = FireUntilHit(e, damageOverride: damage, attempts: 1); + } + Assert.True(outcome != null && outcome.Hit && outcome.Cell.x == centreX, "fixture: centre hit"); + return outcome; + } + + // A genuine cycle: lane col2 reaches item X before item Y down its own walk; lane col3 reaches Y before X. + // Neither item can resolve in either lane's own causal order, so the cycle rule (resolve every open pool at + // once) must fire. Fixture B is fixture A reflected about the centre column -- mirror symmetry means the + // two per-lane leftovers (the outer marker's and the centre marker's) must come out identical on both, and + // total damage must still be fully conserved (armour + items + hull == the shot's own damage) on both. + [Fact] + public void CycleTieBreakIsMirrorSymmetric() + { + (float outer, float centre, float conservation) Run(bool mirrored) + { + var custom = mirrored + ? new[] + { + ("SoulX", CellsShape(3, 4, (1, 0), (2, 0), (2, 1), (2, 2), (2, 3), (1, 3), (0, 3)), new int2(3, 1), 10f), + ("SoulY", CellsShape(2, 2, (0, 0), (0, 1), (1, 1)), new int2(3, 1), 10f) + } + : new[] + { + ("SoulX", CellsShape(3, 4, (1, 0), (0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3)), new int2(1, 1), 10f), + ("SoulY", CellsShape(2, 2, (1, 0), (1, 1), (0, 1)), new int2(2, 1), 10f) + }; + var markers = mirrored + ? new[] { (new int2(4, 3), 1000f), (new int2(3, 3), 1000f) } + : new[] { (new int2(2, 3), 1000f), (new int2(3, 3), 1000f) }; + var e = Build(TestSettings(), SolidShape(7, 6), precision: .6f, penetration: 10f, damageSpread: 1f, + markers: markers, custom: custom); + var x = e.Custom["SoulX"]; + var y = e.Custom["SoulY"]; + var hullBefore = 0f; + void Reset() + { + ZeroArmor(e, 7, 6); + e.Target.Armor[mirrored ? 4 : 2, 1] = 10f; + x.EquippableItem.Durability = 10f; + y.EquippableItem.Durability = 10f; + e.Markers[0].EquippableItem.Durability = 1000f; + e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + e.Target.Hull.Durability = 1000000f; + hullBefore = e.Target.Hull.Durability; + } + FireCentre(e, 3, Reset, 90f); + var armorLost = 10f; + for (var xx = 0; xx < 7; xx++) for (var yy = 0; yy < 6; yy++) armorLost -= e.Target.Armor[xx, yy]; + var itemsLost = (10f - x.EquippableItem.Durability) + (10f - y.EquippableItem.Durability) + + (1000f - e.Markers[0].EquippableItem.Durability) + (1000f - e.Markers[1].EquippableItem.Durability); + var hullLost = hullBefore - e.Target.Hull.Durability; + var outerMarker = 1000f - e.Markers[0].EquippableItem.Durability; + var centreMarker = 1000f - e.Markers[1].EquippableItem.Durability; + return (outerMarker, centreMarker, armorLost + itemsLost + hullLost); + } + var a = Run(false); + var b = Run(true); + Assert.Equal(90f, a.conservation, 2); // nothing lost: the whole shot lands somewhere + Assert.Equal(90f, b.conservation, 2); + Assert.Equal(a.outer, b.outer, 2); // mirror symmetry: the tie-break carries no left/right bias + Assert.Equal(a.centre, b.centre, 2); + } + + // The item's own .1f threshold is decided ONCE on the pooled total, never per contribution. Two lanes + // striking a shared 2x1 bar, each depositing the SAME post-armour remainder: with 0.04 each (sum 0.08, + // still under .1f) nothing should absorb, matching two separate sub-threshold solo hits. With 0.06 each + // (sum 0.12, OVER .1f, but NEITHER contribution alone clears it) the item must still absorb -- a mutant + // that moved the threshold onto each contribution individually, instead of the pool total, would leave + // this second case untouched, since 0.06 never clears .1f by itself. + [Fact] + public void PooledPathHonoursTheItemThresholdLikeTheSoloPath() + { + (float lost, int events) RunShared(float perLanePostArmour) + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, bars: new[] { (new int2(1, 1), 50f) }); + var bar = e.Target.GearOccupancy[1, 1]; + void Reset() + { + ZeroArmor(e, 5, 4); + var armor = 30f - perLanePostArmour; // damagePerLane is 90/3 = 30 (damageSpread 1, 3 lanes) + e.Target.Armor[1, 1] = armor; e.Target.Armor[2, 1] = armor; e.Target.MaxArmor[1, 1] = 30; e.Target.MaxArmor[2, 1] = 30; + bar.EquippableItem.Durability = 50f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + var n = 0; + ShotOutcome o = null; + for (var attempt = 0; attempt < 800 && (o == null || !o.Hit || o.Cell.x != 2); attempt++) + { + Reset(); + n = 0; + using var sub = e.Target.ItemDamage.Subscribe(_ => n++); + o = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.NotNull(o); + Assert.True(o.Hit && o.Cell.x == 2, "fixture: needed a hit on the centre interior column within the attempt budget"); + return (50f - bar.EquippableItem.Durability, n); + } + + var underThreshold = RunShared(0.04f); // sum 0.08: under .1f either way + Assert.Equal(0f, underThreshold.lost, 4); + Assert.Equal(0, underThreshold.events); + + var overThreshold = RunShared(0.06f); // sum 0.12: over .1f as a pool, under .1f per contribution + Assert.Equal(0.12f, overThreshold.lost, 3); + // Soul's own event-cardinality pin: a mutation that deletes the pooled ItemDamage.OnNext call survived + // every other test in this file, because most fixtures only ever check durability. One event per + // contributing lane (both contributed > 0 here), not one event per resolve. + Assert.Equal(2, overThreshold.events); + } + + // A lane whose armour eats its entire share before ever reaching a shared item still deposits (a zero + // amount) and must not stall the pool waiting for a contribution that will never come with more than zero + // in it -- the pool resolves as soon as every OTHER lane that could still reach the item is done, whatever + // this lane itself brought. + [Fact] + public void ZeroDepositDoesNotStall() + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 1), 10f) }, markers: new[] { (new int2(1, 2), 1000f), (new int2(2, 2), 1000f) }); + var bar = e.Target.GearOccupancy[1, 1]; + void Reset() + { + ZeroArmor(e, 5, 4); + e.Target.Armor[1, 0] = 1000f; e.Target.MaxArmor[1, 0] = 1000f; e.Target.Armor[2, 1] = 15f; + bar.EquippableItem.Durability = 10f; + e.Markers[0].EquippableItem.Durability = 1000f; e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + FireCentre(e, 2, Reset, 90f); + Assert.Equal(0f, bar.EquippableItem.Durability, 3); // the bar's own 10 durability, fully spent by column 2 alone + Assert.Equal(0f, 1000 - e.Markers[0].EquippableItem.Durability, 3); // column 1's own zero deposit: nothing reaches its marker + Assert.Equal(5f, 1000 - e.Markers[1].EquippableItem.Durability, 3); // column 2's own leftover (30-15-10=5) reaches its marker + } + + // Soul's own claim (2026-09-25, fix batch on 166b6202): a zero-deposit contributor -- column 1's lane here, + // whose armour eats its whole share before it ever reaches the bar -- must never fire a phantom ItemDamage + // event for that zero. Same fixture as ZeroDepositDoesNotStall above (that test pins durability and marker + // leftovers; this one pins the event itself, which Entity.ItemAbsorb's `incoming[i] > 0f` guard decides). + // Kills a boundary flip of that guard to `>=` (a zero deposit would then fire) and a swap that reports the + // post-split share instead of the incoming one. + [Fact] + public void ZeroDepositContributorFiresNoItemDamageEvent() + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 1), 10f) }, markers: new[] { (new int2(1, 2), 1000f), (new int2(2, 2), 1000f) }); + var bar = e.Target.GearOccupancy[1, 1]; + void Reset() + { + ZeroArmor(e, 5, 4); + e.Target.Armor[1, 0] = 1000f; e.Target.MaxArmor[1, 0] = 1000f; e.Target.Armor[2, 1] = 15f; + bar.EquippableItem.Durability = 10f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + + // A fresh subscription per attempt (the PooledPathHonoursTheItemThresholdLikeTheSoloPath convention, + // above): a rejected attempt still fires a real shot, and its events must not contaminate the accepted + // attempt's own count. + var barEvents = new List(); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 800 && (outcome == null || !outcome.Hit || outcome.Cell.x != 2); attempt++) + { + Reset(); + barEvents.Clear(); + using var sub = e.Target.ItemDamage.Subscribe(x => { if (x.item == bar) barEvents.Add(x.damage); }); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.True(outcome != null && outcome.Hit && outcome.Cell.x == 2, "fixture: needed a hit on the centre interior column within the attempt budget"); + + Assert.Single(barEvents); // column 2's own deposit only -- column 1's zero deposit fires nothing + Assert.Equal(15f, barEvents[0], 3); // column 2's post-armour remainder (30 - 15 armour) + } + + // An item crossed by a lane whose CLIPPED walk ends before the item's own cell on that lane must not wait + // for that lane -- reach is decided by the clipped walk (what Apply's own penetration clip left), never by + // the lane's raw, unclipped geometry. A 2x1 bar at row 2; penetration 1.5 clips every lane to rows 0-1, so no + // lane can ever reach the bar at all -- if StillReachableByAnotherLane read the raw walk instead, a lane + // that (on the raw geometry) also crosses the bar's row would look like a future reacher and the other + // lane's own pool would stall waiting for it forever. + [Fact] + public void ClippedLaneDoesNotHoldThePool() + { + // 2x1 bar at (1,2)-(2,2); straight shot, lanes cols 1,2,3; penetration 1.5 -> each lane reaches rows 0,1 only. + var e = Build(TestSettings(), SolidShape(5, 5), precision: .6f, penetration: 1.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 2), 10f) }); + var bar = e.Target.GearOccupancy[1, 2]; + void Reset() + { + ZeroArmor(e, 5, 5); + bar.EquippableItem.Durability = 10f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + + var hull = 0f; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 800 && (outcome == null || !outcome.Hit || outcome.Cell.x != 2); attempt++) + { + Reset(); + hull = 0f; + using var sub = e.Target.HullDamage.Subscribe(d => hull += d); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.True(outcome != null && outcome.Hit && outcome.Cell.x == 2, "fixture: needed a hit on the centre column within the attempt budget"); + + Assert.Equal(10f, bar.EquippableItem.Durability, 3); // never reached -- clipped out before row 2 + Assert.Equal(90f, hull, 2); // the whole shot (3 lanes x 30, no armour, nothing absorbed) reaches the hull + } + + // A lane's own LATER cell of the same item never holds that item's pool open -- only OTHER lanes are future + // reachers (StillReachableByAnotherLane's own `alreadyContributed` check, F1 fix batch). Lane column 1 + // crosses a vertical 1x2 item X twice (X spans rows 1-2 in that single column), then a 2x1 bar Z it shares + // with column 2's lane at row 3. Column 2 reaches Z first and must wait for column 1; column 1's two visits + // to X are each a solo pool (nothing else ever reaches X) and must resolve at once rather than stalling on + // column 1's own second visit, so column 1 goes on to reach Z and the pool there is the genuine + // {column 1: 25, column 2: 30} -- not a cycle-rule resolve of Z with column 2 alone (which deleting the + // `alreadyContributed` guard produces instead). + [Fact] + public void OwnRepeatVisitDoesNotHoldThePool() + { + var e = Build(TestSettings(), SolidShape(5, 6), precision: .6f, penetration: 10f, damageSpread: 1f, + bars: new[] { (new int2(1, 3), 20f) }, markers: new[] { (new int2(1, 4), 1000f), (new int2(2, 4), 1000f) }, + custom: new[] { ("SoulV", CellsShape(1, 2, (0, 0), (0, 1)), new int2(1, 1), 5f) }); + var z = e.Target.GearOccupancy[1, 3]; + var x = e.Custom["SoulV"]; + void Reset() + { + ZeroArmor(e, 5, 6); + z.EquippableItem.Durability = 20f; + x.EquippableItem.Durability = 5f; + e.Markers[0].EquippableItem.Durability = 1000f; + e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + + FireCentre(e, 2, Reset, 90f); + var l1 = 1000f - e.Markers[0].EquippableItem.Durability; + var l2 = 1000f - e.Markers[1].EquippableItem.Durability; + Assert.Equal(0f, z.EquippableItem.Durability, 3); + Assert.Equal(25f * 35f / 55f, l1, 2); // column 1's own leftover after X (30 - 5, X's own 5 durability): 15.909 + Assert.Equal(30f * 35f / 55f, l2, 2); // column 2's leftover pooled with column 1's at Z (durability 20): 19.091 + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs new file mode 100644 index 000000000..d55bc6df6 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -0,0 +1,2661 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 12.4(b) (docs/fire-control-cut.md, "The detonation primitive"): behavioural tests for Detonate -- the +// blast-as-area owner that replaces FireControl.Splash and Entity.Absorb -- and for Apply's switch on the +// frozen Fuse (null/Proximity/Contact/Delayed). Builds its own fixtures, the convention every cut's test file +// follows, with the rules this cut's own tests must obey: SchematicCellSize 2 throughout (the 12.3 fixture's +// cell size of 1 would hide a missing `/ SchematicCellSize`), the target off the origin, and a hull whose +// centre of mass is not an integer cell. +public sealed class FireControlCut124Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut124-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut124Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings(float commitHorizon = .5f) => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = commitHorizon, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // 5 wide x 4 tall: centre of mass (2, 1.5) -- not an integer cell, the fixture rule this whole file obeys. + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // The interior cell (1,1) of a 3x3 solid hull, ringed by its 8 border cells -- Shape.Shrink needs all 8 + // neighbours present, which only the centre of a 3x3 has (the same fixture 12.3's BuriedCockpitNeedsPenetration + // and PenetratorBurrowsBeforeItBursts both need: an item reachable only by penetrating the ring). + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public HullData HullData; + public EquippedItem Cockpit; + public Dictionary Custom = new Dictionary(); + } + + // The gun's behaviour data: a plain InstantWeaponData, or a LockWeaponData whose lock either grows to full + // (LockAngle 180 admits any bearing) or never grows (LockAngle 0 admits none, so it only decays). A stat + // term on Range lets a test scale it below its authored value through the power supply. + private static InstantWeaponData WeaponBehavior(float damage, float range, StatSource? rangeTerm, float velocity, float penetration, + bool lockWeapon, bool lockAcquires) + { + var data = lockWeapon ? new LockWeaponData + { + LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(lockAcquires ? 180 : 0), + DirectionImpact = Constant(1), Decay = Constant(1) + } : new InstantWeaponData(); + data.Damage = Constant(damage); + data.Range = rangeTerm == null ? Constant(range) + : new PerformanceStat { Min = range, Max = range, Terms = { new StatTerm { Source = rangeTerm.Value, Exponent = 1f } } }; + data.MinRange = Constant(0); + data.Velocity = Constant(velocity); + data.Spread = Constant(0); + data.DamageSpread = Constant(0); + data.Penetration = Constant(penetration); + data.Count = Constant(1); + data.BurstTime = Constant(0); + data.Cooldown = Constant(1000); + data.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + return data; + } + + private Engagement Build( + GameplaySettings settings, Shape hullShape, + float2? targetFacing = null, float targetRange = 100f, float velocity = 0f, + WeaponFuse? fuse = null, float blastRadius = 0f, float penetration = 0f, float damage = 500f, + WeaponModifiers modifiers = WeaponModifiers.None, + bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true, + int2? cockpitCell = null, float cockpitDurability = 1000f, + (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null, + float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f, + float hardpointArc = 0f, StatSource? rangeTerm = null, bool lockWeapon = false, bool lockAcquires = true) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000000, Mass = 1000, Armor = 0, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = gunCell ?? new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape(), FiringArc = hardpointArc } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blastRadius, WeaponModifiers = modifiers, + Behaviors = { WeaponBehavior(damage, weaponRange, rangeTerm, velocity, penetration, lockWeapon, lockAcquires) } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData { Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } + }); + cache.Upsert(new GearData + { + Name = "Cockpit", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new CockpitData() } + }); + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + var barShape = new Shape(2, 1); + foreach (var v in barShape.AllCoordinates) barShape[v] = true; + cache.Upsert(new GearData + { + Name = "Bar", Hardpoint = HardpointType.Tool, Shape = barShape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + if (custom != null) + foreach (var cu in custom) + cache.Upsert(new GearData { Name = cu.Name, Hardpoint = HardpointType.Tool, Shape = cu.Shape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut124", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + EquippableItem MakeWeapon(int lot) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName("Gun")), Durability = 1, Lot = lot + }; + + var lot = 1; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var gun = MakeWeapon(lot++); + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var targetGun = MakeWeapon(lot++); + Assert.True(target.TryEquip(targetGun, gunCell ?? new int2(0, 0))); + + EquippedItem cockpit = null; + if (cockpitCell != null) + { + var ci = Make("Cockpit", lot++, cockpitDurability); + Assert.True(target.TryEquip(ci, cockpitCell.Value), $"cockpit must fit at {cockpitCell.Value}"); + cockpit = target.Equipment.Single(x => x.EquippableItem == ci); + } + + var customItems = new Dictionary(); + if (custom != null) + foreach (var cu in custom) + { + var ci0 = Make(cu.Name, lot++, cu.Durability); + Assert.True(target.TryEquip(ci0, cu.Cell), $"custom {cu.Name} must fit at {cu.Cell}"); + customItems[cu.Name] = target.Equipment.Single(x => x.EquippableItem == ci0); + } + + if (equipShield) + { + var reactor = Make("Reactor", lot++, 10); + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + var shield = Make("Shield", lot++, 10); + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + // Off-origin (the fixture rule): the shooter sits away from the world origin, and the target sits + // `targetRange` ahead of it along the shooter's own +z, so a stationary target's TravelDirection is + // exactly (0,1) when targetFacing is left at its default. + shooter.Position = float3(-53, 0, -101); + target.Position = shooter.Position + float3(0, 0, targetRange); + if (targetFacing != null) target.Direction = normalize(targetFacing.Value); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + if (equipShield) target.Shield.Item.Enabled.Value = shieldActive; + + zone.Update(1f); // fire times nonzero, same convention 12.3's fixture uses + + return new Engagement + { + Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, + HullData = hullData, Cockpit = cockpit, Custom = customItems + }; + } + + // Numeric cross-check, independent of FireControl's own exact-overlap implementation: the area of a disc + // (centred at world point (cx,cy), radius r) that lies inside the axis-aligned rectangle [xlo,xhi]x[ylo,yhi], + // by fine grid sampling. Used only to compute an EXPECTED value from first principles; the tests that need + // exact values instead (conservation, exact half-splits) get them from geometry, not sampling. + private static double DiscAreaInRect(double cx, double cy, double r, double xlo, double xhi, double ylo, double yhi, int samples = 1000) + { + var bxlo = Math.Max(xlo, cx - r); + var bxhi = Math.Min(xhi, cx + r); + var bylo = Math.Max(ylo, cy - r); + var byhi = Math.Min(yhi, cy + r); + if (bxhi <= bxlo || byhi <= bylo) return 0; + long inside = 0; + var dx = (bxhi - bxlo) / samples; + var dy = (byhi - bylo) / samples; + for (var i = 0; i < samples; i++) + { + var x = bxlo + (i + .5) * dx; + var dxc = x - cx; + for (var j = 0; j < samples; j++) + { + var y = bylo + (j + .5) * dy; + var dyc = y - cy; + if (dxc * dxc + dyc * dyc <= r * r) inside++; + } + } + return inside * dx * dy; + } + + private static double DiscAreaInCell(double cx, double cy, double r, int2 cell, int samples = 1000) => + DiscAreaInRect(cx, cy, r, cell.x - .5, cell.x + .5, cell.y - .5, cell.y + .5, samples); + + // ==== Geometry ==== + + // TheDiscIsConservedOverTheGrid: on a large solid, armourless, itemless hull that contains the disc + // entirely, the total hull damage delivered equals `damage`, at three different disc placements. + // Kills: sampling in place of the exact overlap; the normaliser (2r)^2 in place of pi*r^2; a bounding box + // that clips the rim cells; a dropped chord breakpoint -- any of these would move the total away from + // `damage` even though the whole disc lies on metal. + [Theory] + [InlineData(0f, 0f, 0.3f)] // radius under one cell, centred on a cell's own integer coordinate + [InlineData(.5f, .5f, 1.7f)] // centred on a cell corner + [InlineData(.37f, -.21f, 1.2f)] // an arbitrary off-grid centre + public void TheDiscIsConservedOverTheGrid(float ox, float oy, float radius) + { + var e = Build(TestSettings(), SolidShape(21, 21)); + var centreCell = new int2(10, 10); // well inside the 21x21 grid at any of the radii above + var worldCentre = e.Target.Position.xz + float2(ox, oy) * e.Items.GameplaySettings.SchematicCellSize; + // Shift so the schematic centre lands at centreCell + (ox,oy): CenterOfMass is (10,10) for a 21x21 + // solid hull, so the untranslated target position already maps there. + var before = e.Target.Hull.Durability; + + FireControl.Detonate(e.Zone, worldCentre, radius * e.Items.GameplaySettings.SchematicCellSize, 100f, DamageType.Kinetic); + + var delivered = before - e.Target.Hull.Durability; + Assert.Equal(100f, delivered, 2); + } + + // EachCellTakesItsShareOfTheDisc: a solid hull with no items, and per-cell armour large enough that + // ArmorAbsorb never depletes it -- so the ArmorDamage event for a cell reports that cell's raw share. + // Pass: the named cell's reported share matches damage * overlap/(pi r^2), overlap integrated numerically + // by the test. Kills: an even split over the covered cells (Splash's rule), and a share decided by the + // cell's distance from the centre rather than its overlap area. + [Fact] + public void EachCellTakesItsShareOfTheDisc() + { + var e = Build(TestSettings(), SolidShape(9, 9)); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1e6f; e.Target.MaxArmor[c.x, c.y] = 1e6f; } + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var com = e.HullData.Shape.CenterOfMass; // (4,4) for a 9x9 solid hull + var namedCell = new int2(5, 4); // one cell off-centre laterally + var radius = 2.3; // world units under cellSize*4, comfortably covering several cells + + var deposits = new Dictionary(); + using (e.Target.ArmorDamage.Subscribe(x => deposits[x.pos] = x.damage)) + { + FireControl.Detonate(e.Zone, e.Target.Position.xz, (float) radius, 1000f, DamageType.Kinetic); + } + + var expectedOverlap = DiscAreaInCell(com.x, com.y, radius / cellSize, namedCell); + var expectedShare = 1000.0 * expectedOverlap / (Math.PI * (radius / cellSize) * (radius / cellSize)); + Assert.True(deposits.TryGetValue(namedCell, out var actual), "named cell took no share at all"); + Assert.Equal(expectedShare, actual, 1); + } + + // TheShareOffTheHullIsLost (was ExternalBlastWastesMostOfItsEnergy): the disc is centred exactly on the + // hull's own edge, so exactly half its area sits off the grid by construction (no numeric integration + // needed -- a disc split by a straight line through its centre halves exactly). Pass: the delivered total + // is half the blast's damage. Kills: renormalising the shares over the occupied cells (which would deliver + // the full damage). + [Fact] + public void TheShareOffTheHullIsLost() + { + var e = Build(TestSettings(), SolidShape(5, 4)); // x: 0..4 (CoM.x = 2), left edge at schematic x = -0.5 + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + // World point at schematic (-0.5, 1.5): CoM.y = 1.5 (the fixture's own non-integer centre), 2.5 cells + // to port of CoM.x -- exactly the hull's left edge, with default facing (0,1) so world x is schematic x. + var worldCentre = e.Target.Position.xz + float2(-2.5f * cellSize, 0); + var before = e.Target.Hull.Durability; + + // Radius 0.5 cells: the on-hull half never reaches the next cell boundary at schematic x = 0.5. + FireControl.Detonate(e.Zone, worldCentre, .5f * cellSize, 100f, DamageType.Kinetic); + + var delivered = before - e.Target.Hull.Durability; + Assert.Equal(50f, delivered, 1); + } + + // CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius: a long thin hull whose centre of mass lies well + // beyond the blast radius, while its near end lies inside. Kills: Splash's own centre-distance cull. + [Fact] + public void CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius() + { + var e = Build(TestSettings(), SolidShape(1, 20)); // CoM at schematic (0, 9.5) + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + // The near end (schematic y = 0) is `9.5 * cellSize` world units from CoM; the blast sits right there, + // far outside a radius that would ever reach the centre. + var nearEndWorld = e.Target.Position.xz + float2(0, -9.5f * cellSize); + var before = e.Target.Hull.Durability; + + FireControl.Detonate(e.Zone, nearEndWorld, cellSize, 100f, DamageType.Kinetic); + + Assert.True(e.Target.Hull.Durability < before, "the near end of a long hull must take damage even though its centre is far outside the radius"); + } + + // Per-cell armour large enough that ArmorAbsorb never depletes it: every ArmorDamage event then reports its + // cell's raw share, whether or not an item sits on the cell. + private static void ArmourEverything(Engagement e) + { + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1e6f; e.Target.MaxArmor[c.x, c.y] = 1e6f; } + } + + // The independent expectation: `damage` shared over a disc of radius rCells centred at schematic (cx,cy) in + // proportion to numeric overlap with each occupied cell. + private static double ExpectedDelivered(Shape shape, double cx, double cy, double rCells, double damage) + { + var overlap = 0.0; + foreach (var c in shape.Coordinates) overlap += DiscAreaInCell(cx, cy, rCells, c); + return damage * overlap / (Math.PI * rCells * rCells); + } + + // ==== 12.4 fix batch F1: a disc tangent to a cell edge must not count the whole square ==== + + // A cell edge exactly r from the centre, with the piece's midpoint at 0, used to fall through + // `h < yhi` / `-h > ylo` into the "whole rectangle" branch, so the square counted in full. BlastRadius 1 + // world unit is half a cell (edges at +-.5 from an integer centre of mass), 3 is one and a half (edges at + // +-.5 and +-1.5): both are tangent to cell edges. Measured through Fire before the fix: 127.32 and 100.80 + // delivered of 100. + [Theory] + [InlineData(1f)] + [InlineData(3f)] + public void ATangentDiscDeliversExactlyItsDamageThroughFire(float blastRadius) + { + var e = Build(TestSettings(), SolidShape(5, 5), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: blastRadius, damage: 100f); + ArmourEverything(e); + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.Equal(100f, total, 2); + } + + [Theory] + [InlineData(1f)] + [InlineData(3f)] + public void ATangentDiscDeliversExactlyItsDamageThroughDetonate(float radius) + { + var e = Build(TestSettings(), SolidShape(5, 5)); + ArmourEverything(e); + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius, 100f, DamageType.Kinetic); + Assert.Equal(100f, total, 2); + } + + // ==== 12.4 fix batch F2: the exact overlap is the only decider of "the disc touches the hull" ==== + + // An L-shaped hull whose centre of mass sits far from the far end of its long arm. The blast lands on that + // arm's end, 14 world units from the centre of mass -- beyond radius + half the bounding box's diagonal + // measured from it, which the old candidate cull used, so the cull threw away a blast that overlaps a + // cell. Expected value: a numeric overlap of the cells, not the code under test. + [Fact] + public void ABlastAtTheFarEndOfAnLShapedHullLands() + { + var s = new Shape(10, 4); + foreach (var c in s.AllCoordinates) s[c] = c.x < 4 || c.y == 0; + var e = Build(TestSettings(), s); + ArmourEverything(e); + var com = e.HullData.Shape.CenterOfMass; + var p = float2(9.9f, 0f); + var expected = ExpectedDelivered(e.HullData.Shape, p.x, p.y, 1.0, 100.0); + Assert.True(expected > 10.0, $"fixture: the blast must overlap the hull, expected {expected}"); + + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz + (p - com) * 2f, 2f, 100f, DamageType.Kinetic); + + Assert.Equal(expected, total, 1); + } + + // The same rule on the shipped hulls: a blast just grazing the corner farthest from the centre of mass. The + // corners of the shipped Longinus and LonginusX reach past the old cull's box-half-diagonal bound. + [Theory] + [InlineData("Longinus")] + [InlineData("LonginusX")] + public void AGrazingBlastAtAShippedHullsFarthestCornerLands(string hullName) + { + Shape shape; + var cache = OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), out _); + try { shape = cache.GetByName(hullName).Shape; } + finally { cache.Dispose(); } + + var com = shape.CenterOfMass; + var far = float2.zero; + var best = -1f; + foreach (var c in shape.Coordinates) + foreach (var (dx, dy) in new[] { (-.5f, -.5f), (.5f, -.5f), (-.5f, .5f), (.5f, .5f) }) + { + var corner = float2(c.x + dx, c.y + dy); + var d = length(corner - com); + if (d > best) { best = d; far = corner; } + } + // One cell of radius, centred .9 cell outward of the corner: the disc reaches the corner by .1 cell. + var centre = far + normalize(far - com) * .9f; + + var e = Build(TestSettings(), shape, gunCell: shape.Coordinates[0]); + ArmourEverything(e); + var expected = ExpectedDelivered(shape, centre.x, centre.y, 1.0, 100.0); + Assert.True(expected > .05, $"fixture: the blast must graze the hull, expected {expected}"); + + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz + (centre - com) * 2f, 2f, 100f, DamageType.Kinetic); + + Assert.Equal(expected, total, 1); + } + + // ==== 12.4 fix batch F3: a lethal blast must not break the entity enumeration ==== + + private Ship AddShip(Engagement e, float3 position, float hullDurability, int lot) + { + var hullRef = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Hull")); + var ship = new Ship(e.Items, e.Zone, new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = lot }, new EntitySettings()); + var marker = new EquippableItem + { + Data = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Marker")), Durability = 1000000, Lot = lot + 1 + }; + Assert.True(ship.TryEquip(marker, new int2(1, 1))); + e.Zone.Entities.Add(ship); + ship.Activate(); + ship.Position = position; + return ship; + } + + // A blast that kills an entity mid-pass: death removes it from Zone.Entities, and the next MoveNext of the + // live enumeration threw. The shot was never removed, so it detonated again the next tick (the probe saw + // 2.69 then 5.38 on the target). Pass: nothing throws, the shot resolves and leaves exactly once, and the + // target is damaged exactly once. + [Fact] + public void ALethalBlastResolvesOnceAndBreaksNothing() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var near = AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300); + var far = AddShip(e, e.Shooter.Position + float3(-5000, 0, 0), 1f, 302); + + var resolved = 0; + var targetDamage = 0f; + using var r = e.Zone.ShotResolved.Subscribe(_ => resolved++); + using var a = e.Target.ArmorDamage.Subscribe(x => targetDamage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => targetDamage += x); + using var i = e.Target.ItemDamage.Subscribe(x => targetDamage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + SafeAssert.NoShots(e.Zone); + Assert.Equal(1, resolved); + SafeAssert.NotIn(e.Zone, near); // fixture: the blast really was lethal to a ship mid-pass + SafeAssert.In(e.Zone, far); + Assert.True(targetDamage > 0f, "fixture: the target must be inside the blast"); + var afterFirstTick = targetDamage; + + e.Zone.Update(.01f); + + Assert.Equal(1, resolved); + Assert.Equal(afterFirstTick, targetDamage, 4); + } + + // AOneBlastKillsEveryShipInsideIt: three ships, each dead to one hit, inside one blast. Each takes hull + // damage exactly once and dies exactly once, and the shot leaves the queue once. Kills: the snapshot loop + // breaking after the first kill (the other two would never be touched). + [Fact] + public void AOneBlastKillsEveryShipInsideIt() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var ships = new[] + { + AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300), + AddShip(e, e.Target.Position + float3(-8, 0, 0), 1f, 302), + AddShip(e, e.Target.Position + float3(0, 0, 8), 1f, 304), + }; + var hullHits = new int[3]; + var deaths = new int[3]; + var subs = new List(); + for (var n = 0; n < 3; n++) + { + var k = n; + subs.Add(ships[k].HullDamage.Subscribe(_ => hullHits[k]++)); + subs.Add(ships[k].Death.Subscribe(_ => deaths[k]++)); + } + var resolved = 0; + subs.Add(e.Zone.ShotResolved.Subscribe(_ => resolved++)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + foreach (var s in subs) s.Dispose(); + + Assert.Equal(new[] { 1, 1, 1 }, hullHits); + Assert.Equal(new[] { 1, 1, 1 }, deaths); + foreach (var s in ships) Assert.False(e.Zone.Entities.Contains(s), "a killed ship must have left the zone"); + SafeAssert.NoShots(e.Zone); + Assert.Equal(1, resolved); + } + + // AShieldlessShipOutsideTheDiscIsNotTouched: nothing of any kind reaches an entity the disc does not + // overlap. Kills: an uncovered entity falling through to the damage call (`covered ??= new List`). + [Fact] + public void AShieldlessShipOutsideTheDiscIsNotTouched() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 100f); + var outside = AddShip(e, e.Target.Position + float3(60, 0, 0), 1f, 300); + var touched = 0; + using var a = outside.ArmorDamage.Subscribe(_ => touched++); + using var h = outside.HullDamage.Subscribe(_ => touched++); + using var i = outside.ItemDamage.Subscribe(_ => touched++); + var targetHit = 0f; + using var t = e.Target.HullDamage.Subscribe(x => targetHit += x); + var armourHit = 0f; + using var ta = e.Target.ArmorDamage.Subscribe(x => armourHit += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(0, touched); + Assert.Equal(1f, outside.Hull.Durability); + Assert.True(e.Zone.Entities.Contains(outside)); + Assert.True(targetHit + armourHit > 0f, "fixture: the blast must land on the target"); + } + + // ADestroyedCockpitRaisesDeathOnce: the blast destroys the cockpit, Death removes and deactivates the + // entity, and the blast's own DamageHull then still runs; no subscriber sees a second Death. + [Fact] + public void ADestroyedCockpitRaisesDeathOnce() + { + var e = Build(TestSettings(), SolidShape(3, 3), cockpitCell: new int2(1, 1), cockpitDurability: 10f); + e.Target.Hull.Durability = 10f; // fragile enough that the blast's own hull damage is lethal too + var deaths = new List(); + using var d = e.Target.Death.Subscribe(deaths.Add); + var hullEvents = 0; + using var h = e.Target.HullDamage.Subscribe(_ => hullEvents++); + + FireControl.Detonate(e.Zone, e.Target.Position.xz, .6f, 5000f, DamageType.Kinetic); + + Assert.Equal(new[] { CauseOfDeath.CockpitDestroyed }, deaths); + Assert.False(e.Zone.Entities.Contains(e.Target), "the dead target must have left the zone"); + Assert.True(hullEvents >= 1, "fixture: the blast's hull damage still lands after the cockpit dies"); + Assert.True(e.Target.Hull.Durability < .01f, "fixture: and that hull damage is itself lethal"); + } + + // ADirectHullKillRaisesDeathOnce: the non-blast path (Entity.DamageHull straight, as a projectile hit does) + // raises Death once, and further damage to the corpse does not raise it again. + [Fact] + public void ADirectHullKillRaisesDeathOnce() + { + var e = Build(TestSettings(), SolidShape(3, 3)); + var deaths = 0; + using var d = e.Target.Death.Subscribe(_ => deaths++); + + e.Target.DamageHull(2000000f); + e.Target.DamageHull(5f); + + Assert.Equal(1, deaths); + } + + // ==== Absorption order ==== + + // ABlastIsAbsorbedThroughArmourFirstPerCell (replaces AbsorbSpendsArmourBeforeTheItemOnTheSameCell): a disc + // wholly inside one armoured item cell, so its share is the whole damage -- the three damage levels of the + // superseded test. Kills: the item absorbing before the armour, and armour being skipped. + [Fact] + public void ABlastIsAbsorbedThroughArmourFirstPerCell() + { + var cell = new int2(1, 1); + Engagement Fresh() => Build(TestSettings(), SolidShape(3, 3), custom: new[] { ("Marker", new Shape(), cell, 50f) }); + + void Reset(Engagement e) { e.Target.Armor[cell.x, cell.y] = 10f; e.Target.MaxArmor[cell.x, cell.y] = 10f; e.Custom["Marker"].EquippableItem.Durability = 50f; } + var cellSize = 2f; + var worldCentre = Fresh().Target.Position.xz; // recomputed fresh below per case (CoM == cell (1,1) for a 3x3 hull) + + { + var e = Fresh(); Reset(e); + FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 5f, DamageType.Kinetic); // below armour + Assert.Equal(5f, e.Target.Armor[cell.x, cell.y], 2); + Assert.Equal(50f, e.Custom["Marker"].EquippableItem.Durability, 2); + Assert.Equal(1000000f, e.Target.Hull.Durability, 1); + } + { + var e = Fresh(); Reset(e); + FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 25f, DamageType.Kinetic); // between armour and armour+durability + Assert.Equal(0f, e.Target.Armor[cell.x, cell.y], 2); + Assert.Equal(35f, e.Custom["Marker"].EquippableItem.Durability, 2); // 25 - 10 armour = 15 excess + Assert.Equal(1000000f, e.Target.Hull.Durability, 1); + } + { + var e = Fresh(); Reset(e); + FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 80f, DamageType.Kinetic); // above both + Assert.Equal(0f, e.Target.Armor[cell.x, cell.y], 2); + Assert.Equal(0f, e.Custom["Marker"].EquippableItem.Durability, 2); + Assert.Equal(1000000f - 20f, e.Target.Hull.Durability, 1); // 80 - 10 armour - 50 item = 20 + } + } + + // ArmourProtectsOnlyItsOwnCellsShare: a 2x1 item with armour on one cell only, and a disc symmetric about + // the seam so both cells take the same share. Pass: the item takes (shareA - armourA)+ + shareB. Kills: + // pooling the raw shares and then applying armour to the pool (which would let cell A's plate eat cell B's + // share). + [Fact] + public void ArmourProtectsOnlyItsOwnCellsShare() + { + var cellA = new int2(1, 1); + var cellB = new int2(2, 1); // Bar occupies (1,1)-(2,1) + var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 1000f) }); + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var com = e.HullData.Shape.CenterOfMass; + + e.Target.Armor[cellA.x, cellA.y] = 100f; e.Target.MaxArmor[cellA.x, cellA.y] = 100f; + e.Target.Armor[cellB.x, cellB.y] = 0f; e.Target.MaxArmor[cellB.x, cellB.y] = 0f; + + // Centred exactly on the seam between the two cells (schematic x = 1.5), same y as both cells: by + // symmetry shareA == shareB exactly, no numeric integration needed. + var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - com) * cellSize; + var radius = 1f; // world units -- stays within the seam's own pair of cells (each 1 cell wide) + + // Armour (100) exceeds a symmetric share (damage/2), so pick damage large enough that shareA - armourA + // is positive: shareA ~= damage/2 (by symmetry, exactly, regardless of the exact overlap fraction -- + // both cells see the identical geometry mirrored across the seam). + FireControl.Detonate(e.Zone, worldCentre, radius, 400f, DamageType.Kinetic); + + var shareEach = 200f; // damage/2, by the seam symmetry + var expectedItemLoss = Math.Max(0f, shareEach - 100f) + shareEach; // (shareA - armourA)+ + shareB + Assert.Equal(1000f - expectedItemLoss, e.Custom["Bar"].EquippableItem.Durability, 1); + Assert.Equal(0f, e.Target.Armor[cellA.x, cellA.y], 1); // cell A's own plate is spent by its own share (200 > 100), same as any single-cell absorb + } + + private static Shape BarShape() + { + var shape = new Shape(2, 1); + foreach (var v in shape.AllCoordinates) shape[v] = true; + return shape; + } + + // AMultiCellItemAbsorbsItsCoveredCellsAsOnePool (the ProbeC2 geometry): a 2x1 item with no armour, and a + // disc straddling its seam that puts less than .1 on each cell but more than .1 on the two together. Pass: + // the item absorbs the sum. Kills: resolving the item per cell in any form -- Absorb resurrected, ItemAbsorb + // called per deposit, or a pool that resolves before every covered cell has deposited. + [Fact] + public void AMultiCellItemAbsorbsItsCoveredCellsAsOnePool() + { + var cellA = new int2(1, 1); + var cellB = new int2(2, 1); + var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 50f) }); + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + + // Centred exactly on the seam, radius small enough that the whole disc is contained within the two + // cells combined (radius 0.4 cells < 0.5 cells' worth of margin either side of the seam). + var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - e.HullData.Shape.CenterOfMass) * cellSize; + var radiusCells = .4f; + // Full disc retained (contained within the pair of cells), split evenly by the seam symmetry: total + // share = damage exactly, .08 on each side when damage = .16. + FireControl.Detonate(e.Zone, worldCentre, radiusCells * cellSize, .16f, DamageType.Kinetic); + + Assert.Equal(50f - .16f, e.Custom["Bar"].EquippableItem.Durability, 3); + } + + // ItemDamageFiresOncePerCoveredCell: the same pooled geometry as above. Pass: the number of ItemDamage + // events equals the number of covered cells with a positive post-armour deposit (two), each carrying that + // cell's own deposit. Kills: one event per item, and events for zero-deposit cells. + [Fact] + public void ItemDamageFiresOncePerCoveredCell() + { + var cellA = new int2(1, 1); + var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 50f) }); + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - e.HullData.Shape.CenterOfMass) * cellSize; + + var events = new List(); + using (e.Target.ItemDamage.Subscribe(x => events.Add(x.damage))) + { + FireControl.Detonate(e.Zone, worldCentre, .4f * cellSize, .16f, DamageType.Kinetic); + } + + Assert.Equal(2, events.Count); + Assert.All(events, amt => Assert.Equal(.08f, amt, 2)); + } + + // ==== Symmetry and frame ==== + + // MirroredBlastsDoMirroredDamage: a hull, its armour and its items all mirror-symmetric about the bow + // axis, facing (2,1)/sqrt(5). Two independent engagements, blasted at world points mirrored across the + // target's own bow axis -- the mirror computed here from the facing, not through ToSchematic. Pass: + // per-cell armour and item durability mirror. Kills: the +.5 cell convention returning in the overlap, a + // missing or wrong CenterOfMass anchor, and any scheduling rule that depends on enumeration order. + [Fact] + public void MirroredBlastsDoMirroredDamage() + { + var facing = normalize(float2(2, 1)); + var markerLeft = new int2(1, 1); + var markerRight = new int2(3, 1); // mirror pair about x = 2 on a 5-wide hull + + Engagement Fresh() + { + var e = Build(TestSettings(), SolidShape(5, 3), targetFacing: facing, + custom: new[] { ("MarkerL", new Shape(), markerLeft, 200f), ("MarkerR", new Shape(), markerRight, 200f) }); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 40f; e.Target.MaxArmor[c.x, c.y] = 40f; } + return e; + } + + // Reflect a world-space offset across the target's own bow axis (its forward direction), computed here + // independently of Entity.ToSchematic: reflecting across the line through the origin along `forward` + // negates the lateral (starboard) component and keeps the along-bow component. + float2 Mirror(float2 offset, float2 forward) + { + var along = dot(offset, forward) * forward; + var lateral = offset - along; + return along - lateral; + } + + var e1 = Fresh(); + var e2 = Fresh(); + var cellSize = e1.Items.GameplaySettings.SchematicCellSize; + var offset = float2(6f, 3f); // an arbitrary world offset from the target, not aligned to any axis + var mirroredOffset = Mirror(offset, facing); + + FireControl.Detonate(e1.Zone, e1.Target.Position.xz + offset, 3f * cellSize, 300f, DamageType.Kinetic); + FireControl.Detonate(e2.Zone, e2.Target.Position.xz + mirroredOffset, 3f * cellSize, 300f, DamageType.Kinetic); + + foreach (var c in e1.HullData.Shape.Coordinates) + { + var mirroredCell = new int2(4 - c.x, c.y); + Assert.Equal(e1.Target.Armor[c.x, c.y], e2.Target.Armor[mirroredCell.x, mirroredCell.y], 1); + } + Assert.Equal(e1.Custom["MarkerL"].EquippableItem.Durability, e2.Custom["MarkerR"].EquippableItem.Durability, 1); + Assert.Equal(e1.Custom["MarkerR"].EquippableItem.Durability, e2.Custom["MarkerL"].EquippableItem.Durability, 1); + Assert.Equal(e1.Target.Hull.Durability, e2.Target.Hull.Durability, 1); + } + + // ABlastDamagesTheCellsNearestIt. This succeeds SplashDamagesTheSideTheBlastCameFrom + // (FireControlCut11Tests.cs) and SplashIsDirectional (FireControlCut4Tests.cs). Kills: a flip of + // ToSchematicPoint's right axis, an x/y swap, and a missing `/ SchematicCellSize`. + [Theory] + [InlineData(0f, 1f)] + [InlineData(1f, 0f)] + [InlineData(2f, 1f)] + [InlineData(-.6f, .8f)] + public void ABlastDamagesTheCellsNearestIt(float fx, float fy) + { + var facing = normalize(float2(fx, fy)); + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: facing); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + // Starboard computed here independently of FireControl -- the facing rotated clockwise. + var starboard = float2(facing.y, -facing.x); + var port = -starboard; + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var blastWorld = e.Target.Position.xz + float3(port.x, 0, port.y).xz * 10f; + + var damaged = new HashSet(); + using (e.Target.ArmorDamage.Subscribe(x => damaged.Add(x.pos))) + { + FireControl.Detonate(e.Zone, blastWorld, 7f, 500f, DamageType.Kinetic); + } + + // Port cells (schematic x < CoM.x) must take damage; starboard cells (schematic x > CoM.x) must not. + // The centre column (x == CoM.x, only when CoM.x is an integer) is excluded from the assertion since + // it can legitimately fall on either side depending on float rounding. + var com = e.HullData.Shape.CenterOfMass; + var portDamaged = false; + foreach (var c in e.HullData.Shape.Coordinates) + { + if (c.x < com.x - .5f) { if (damaged.Contains(c)) portDamaged = true; } + if (c.x > com.x + .5f) Assert.False(damaged.Contains(c), $"facing ({fx},{fy}): starboard cell {c} took damage from a port blast"); + } + Assert.True(portDamaged, $"facing ({fx},{fy}): no port cell took damage"); + } + + // ==== Shields (Q12-9 = A) ==== + + // ShieldPaysForWhatReachesIt: the reserve drops by the entity's covered share, not by the whole blast. The + // disc is centred exactly on the hull's edge (as in TheShareOffTheHullIsLost), so exactly half the raw + // damage is the covered share -- a shield capacity between the covered share and the raw damage tells the + // two apart cleanly. Kills: charging the full damage. + [Fact] + public void ShieldPaysForWhatReachesIt() + { + var e = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 70f); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); // charge the reserve to full + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var worldCentre = e.Target.Position.xz + float2(-2.5f * cellSize, 0); + + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); // fixture: the reserve is exactly full + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70.5f)); + + // damage 100, half retained (50) -- 50 <= capacity 70, so a correct implementation absorbs it whole and + // never breaks; a "charge the full 100" mutant would find 100 > 70 and break the shield instead. + var cellEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => cellEvents++); + FireControl.Detonate(e.Zone, worldCentre, .5f * cellSize, 100f, DamageType.Kinetic); + + Assert.False(e.Target.Shield.Broken); + Assert.Equal(0, cellEvents); // the shield took it all, nothing reached a cell + // The reserve dropped by the covered share and by nothing else: 70 - 50 = 20 left. It is read through the + // public query as a threshold probe, since no recharge runs between Detonate and here. + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 19.9f)); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 20.1f)); + } + + // An entity the disc does not touch has nothing to decide: it is skipped whole, its active shield is not + // consulted and nothing is dealt. The blast lands 20 cells away from a shielded hull. + [Fact] + public void AnUntouchedEntitysShieldIsNotConsulted() + { + var e = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 70f); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); + var events = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => events++); + using var h = e.Target.HullDamage.Subscribe(_ => events++); + + FireControl.Detonate(e.Zone, e.Target.Position.xz + float2(40f, 0f), 2f, 1000f, DamageType.Kinetic); + + Assert.Equal(0, events); + Assert.False(e.Target.Shield.Broken); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); + } + + // ABlastShotsShieldIsDecidedAtDetonation (Q12-9 = A): Commit decides no shield for a shot that carries a + // fuse, whatever the shield's own state at commit -- Detonate decides every blast's shield, live. Pinned + // directly on the field Commit would otherwise set: ShotOutcome.Shielded/ShieldBroken must stay false for a + // fused shot even when the shield could have absorbed the whole raw damage at commit. Kills: Commit's + // shield block left ungated for fused shots. + [Fact] + public void ABlastShotsShieldIsDecidedAtDetonation() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 4, damage: 50, + equipShield: true, shieldCapacity: 1000f); // capacity far exceeds the raw damage + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 50f)); // the shield COULD absorb the whole shot + + ShotOutcome outcome = null; + using var s = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.False(outcome.Shielded); + Assert.False(outcome.ShieldBroken); + } + + // The other half of Q12-9 = A: the shield's state at arrival, not at commit, decides. A contact shot in + // 2 s of flight commits 1.5 s in; the shield is flipped between commit and arrival, and the blast (radius + // 15 cells, so the whole 20-cell hull lies inside the disc and the covered share is exactly + // damage * 20 / (pi * 15^2)) must follow the state it finds. The reserve is read through the public + // CanTakeHit as a threshold probe: nothing recharges between Detonate and the read. + [Fact] + public void AShieldRaisedBetweenCommitAndArrivalAbsorbsTheBlast() + { + const float capacity = 1000f; + var share = 50f * 20f / (PI * 15f * 15f); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 30f, damage: 50f, + equipShield: true, shieldCapacity: capacity, shieldActive: false); + ArmourEverything(e); + var cellDamage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => cellDamage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => cellDamage += x); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); + Assert.True(committed.Hit); + Assert.False(committed.Shielded); + Assert.False(committed.ShieldBroken); + + e.Target.Shield.Item.Enabled.Value = true; // up before arrival, down at commit + for (var i = 0; i < 4; i++) e.Zone.Update(.05f); + Assert.True(e.Zone.PendingShots.Any(s => s.ShotId == shotId), "fixture: the shot must still be in flight"); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - .1f), "fixture: the reserve must be full"); + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); + + Assert.Equal(0f, cellDamage); // the shield decided at arrival, so no cell took anything + Assert.False(e.Target.Shield.Broken); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - share - .05f)); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - share + .05f)); + } + + // And the reverse: up at commit, down at arrival -- the blast finds no shield and lands on the cells, and + // the reserve is not charged. + [Fact] + public void AShieldDroppedBetweenCommitAndArrivalDoesNotAbsorbTheBlast() + { + const float capacity = 1000f; + var share = 50f * 20f / (PI * 15f * 15f); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 30f, damage: 50f, + equipShield: true, shieldCapacity: capacity); + ArmourEverything(e); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + var armour = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => armour += x.damage); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); + Assert.False(committed.Shielded); + + e.Target.Shield.Item.Enabled.Value = false; + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); + + Assert.Equal(share, armour, 2); // the whole covered share landed on the cells + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - .1f)); // and the reserve was not charged + } + + // ==== Fuses (through Fire, SchematicCellSize 2) ==== + + // PenetratorBurrowsBeforeItBursts, which pins Q12-8 in both halves. A 3x3 solid hull's only interior cell + // (1,1) carries a Cockpit item, ringed by the hull's own 8 border cells. + [Fact] + public void PenetratorBurrowsBeforeItBursts() + { + // With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, and the + // armour of every ring cell on the lane is unchanged. Nothing is absorbed along the burrow. + { + var e = Build(TestSettings(), SolidShape(3, 3), penetration: 1.5f, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 40f, + cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; // the cockpit's own cell carries no armour + + var before = e.Cockpit.EquippableItem.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(before - 40f, e.Cockpit.EquippableItem.Durability, 1); + foreach (var c in e.HullData.Shape.Coordinates) + if (!(c.x == 1 && c.y == 1)) + Assert.Equal(1000f, e.Target.Armor[c.x, c.y], 1); + } + + // A contact fuse with the same stats leaves the cockpit untouched (P sits at the impact cell, on the + // ring, never reaching the buried cockpit). + { + var e = Build(TestSettings(), SolidShape(3, 3), penetration: 1.5f, fuse: WeaponFuse.Contact, blastRadius: .3f, damage: 40f, + cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + var before = e.Cockpit.EquippableItem.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(before, e.Cockpit.EquippableItem.Durability); + } + + // With the fuse removed and everything else held, the shot is a dumb AP round: the lane's armour is + // spent in order, and the cockpit takes only what the ring left (12.3). Damage in a line. + { + var e = Build(TestSettings(), SolidShape(3, 3), penetration: 2f, fuse: null, blastRadius: 0f, damage: 40f, + cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 5f; e.Target.MaxArmor[c.x, c.y] = 5f; } + e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; + + var before = e.Cockpit.EquippableItem.Durability; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(before - 35f, e.Cockpit.EquippableItem.Durability, 1); // 40 - the front ring cell's 5 armour + } + } + + // TheFusePointStaysOnMetal: a delayed fuse with a penetration deeper than the hull clamps to the far edge. + [Fact] + public void TheFusePointStaysOnMetal() + { + var e = Build(TestSettings(), SolidShape(1, 5), penetration: 100f, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 100f); + // The far edge of a 1x5 hull, straight ahead of the shooter (bearing (0,1)), is schematic y = 4.5 (the + // exit of cell (0,4)); the fuse point clamps there instead of past the hull. Schematic x is the hull's + // own CenterOfMass.x (0, the only column). + var comX = e.HullData.Shape.CenterOfMass.x; + var rCells = .3 / e.Items.GameplaySettings.SchematicCellSize; // BlastRadius is in world units + + // Independent expectation, from the test's own area integral centred on the far edge. + var expected = 0f; + foreach (var c in e.HullData.Shape.Coordinates) + expected += (float) (100.0 * DiscAreaInCell(comX, 4.5, rCells, c) / (Math.PI * rCells * rCells)); + + var before = e.Target.Hull.Durability; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + var delivered = before - e.Target.Hull.Durability; + Assert.Equal(expected, delivered, 0); + } + + // TheFusePointStaysOnMetal, the concave half: a 1x5 column with cell (0,2) missing. A lane's walk ends at + // its first gap, so the reached metal is cells (0,0) and (0,1) whatever the penetration, and the fuse point + // must clamp to the last reached cell's exit (schematic y 1.5, the metal's edge) rather than sit at + // entry + penetration inside the gap (y 2.4 at penetration 2.9) or on the far segment the walk never + // reached (y 2.7 at 3.2). Entries run from -.5, so at penetration 1.5 the point is y 1.0, inside cell (0,1), + // with the disc wholly on metal. + [Theory] + [InlineData(1.5f, 1.0)] + [InlineData(2.9f, 1.5)] + [InlineData(3.2f, 1.5)] + public void TheFusePointStaysOnMetalAcrossAConcaveGap(float penetration, double pointY) + { + var shape = SolidShape(1, 5); + shape[new int2(0, 2)] = false; + var e = Build(TestSettings(), shape, penetration: penetration, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 100f); + var comX = e.HullData.Shape.CenterOfMass.x; + var rCells = .3 / e.Items.GameplaySettings.SchematicCellSize; + var expected = ExpectedDelivered(e.HullData.Shape, comX, pointY, rCells, 100.0); + + var before = e.Target.Hull.Durability; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(expected, before - e.Target.Hull.Durability, 0); + } + + // A contact or delayed blast needs a committed hit, exactly like a direct hit's own gate: on a miss nothing + // happens -- no blast, no IncomingHit. Accuracy 0 pins the roll to a miss. Kills: ApplyBlastHit without its + // `Outcome.Hit` guard, which would detonate at a point derived from a miss's empty geometry. + [Theory] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AContactOrDelayedBlastOnAMissDoesNothing(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 500f, accuracy: 0f); + ArmourEverything(e); + var before = e.Target.Hull.Durability; + var events = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => events++); + using var h = e.Target.HullDamage.Subscribe(_ => events++); + using var i = e.Target.ItemDamage.Subscribe(_ => events++); + using var hit = e.Target.IncomingHit.Subscribe(_ => events++); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.False(outcome.Hit); // fixture: a guaranteed miss + Assert.Equal(ShotResult.Miss, outcome.Result); + SafeAssert.NoShots(e.Zone); + Assert.Equal(0, events); + Assert.Equal(before, e.Target.Hull.Durability); + } + + // Operator ruling 2026-09-30: "fused weapons without a lock explode at max range." A fused shot with no + // target bursts at the weapon's Range along its aim (Entity.LookDirection), whatever its fuse. The fixture + // aims the shooter along (2,1), a non-axis facing; witnesses sit at the burst point and beside it. Radius 4 + // with the witness hull 5x4 cells of 2 world units: a witness centred on the burst point is covered, and + // one 30 units off the aim line is not. + private const float NoLockRange = 60f; + + private static float3 AimLine(Engagement e) + { + var aim = normalize(float2(2, 1)); + return e.Shooter.Position + float3(aim.x, 0, aim.y) * NoLockRange; + } + + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AFusedShotWithNoTargetBurstsAtMaxRangeAlongTheAimLine(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange); + Aim(e, float2(2, 1)); + e.Shooter.Target.Value = null; + var burst = AimLine(e); + var onLine = AddShip(e, burst, 1f, 300); + var offLine = AddShip(e, burst + float3(-30, 0, 30), 1f, 302); + var shooterHit = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => shooterHit += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => shooterHit += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => shooterHit += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(0f, shooterHit); + SafeAssert.NoShots(e.Zone); + Assert.False(e.Zone.Entities.Contains(onLine), "the blast must reach the burst point (fixture: 1 durability, so any damage kills it)"); + Assert.True(e.Zone.Entities.Contains(offLine), "the blast must not reach 42 units off the aim line"); + } + + // The no-lock shot flies to max range at the weapon's Velocity, like a targeted shot flies to its intercept. + [Fact] + public void ANoLockFusedShotFliesToMaxRangeAtItsVelocity() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + Aim(e, float2(2, 1)); + e.Shooter.Target.Value = null; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(NoLockRange / 20f, shot.ArrivalTime - shot.FireTime, 4); + var burst = AimLine(e); + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + } + + // A targeted shot is unchanged. Expected values computed at the base commit (b66ba524, before the no-lock + // rule), not read back from this code: the target's total damage from a proximity blast at the intercept. + [Fact] + public void ATargetedFusedShotIsUnchangedByTheNoLockRule() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(e.Target.Position.x, shot.BurstPosition.x, 3); + Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.Equal(5.65884256f, damage, 3); + } + + // ==== Operator rulings 2026-09-30 (Soul's pass on the no-lock fix): aim, invalid target, contact, shooter ==== + + private static void Aim(Engagement e, float2 direction) => e.Shooter.LookDirection = float3(direction.x, 0, direction.y); + + private static float3 PointAlong(Engagement e, float2 direction, float distance) => + e.Shooter.Position + float3(direction.x, 0, direction.y) * distance; + + private static bool Alive(Engagement e, Entity entity) => e.Zone.Entities.Contains(entity); + + // Ruling 1: an aim beyond the mount's arc flies along the nearest edge of that arc. The mount is (0,1), the + // arc 170 degrees (edge 85), and the aim (+-1,-1) sits 135 degrees off it; the witness on the edge dies and + // the one under the unclamped aim does not (they are 50 units apart). Kills: no clamp; the turn taken the + // wrong way round (the mirrored edge holds no witness). + [Theory] + [InlineData(1f)] + [InlineData(-1f)] + public void ANoLockShotOutsideTheMountArcFliesAlongTheNearestEdgeOfIt(float side) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(side, -1)); + var edge = float2(side * sin(radians(85f)), cos(radians(85f))); + var onEdge = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 300); + var underAim = AddShip(e, PointAlong(e, normalize(float2(side, -1)), NoLockRange), 1f, 302); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.False(Alive(e, onEdge), "the blast must land on the arc's edge"); + Assert.True(Alive(e, underAim), "the blast must not follow an aim the arc does not allow"); + } + + // Ruling 1: an aim inside the arc is used as it is (unnormalised here), and a shooter aiming at nothing + // (LookDirection zero) fires down its mount. Kills: reading MountDirection instead of the aim; a missing + // zero-aim fallback, which would normalise to NaN and burst nowhere. + [Fact] + public void ANoLockShotFliesAlongTheAimAndDownTheMountWhenNothingIsAimed() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + var mount = float2(0, 1); + var aim = normalize(float2(2, 1)); + var onAim = AddShip(e, PointAlong(e, aim, NoLockRange), 1f, 300); + var onMount = AddShip(e, PointAlong(e, mount, NoLockRange), 1f, 302); + + Aim(e, float2(6, 3)); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.False(Alive(e, onAim), "the blast must follow the aim"); + Assert.True(Alive(e, onMount), "the mount direction is not the aim"); + + Aim(e, float2(0, 0)); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.False(Alive(e, onMount), "with nothing aimed the blast follows the mount"); + } + + public enum Invalid { OutOfRange, NotVisible, Unlocked } + + // Ruling 2: a selected target that is out of range, not visible or not locked is not valid targeting data, + // and counts as no lock -- the round bursts at max range along the aim, and the target is left alone. Kills: + // reading "no lock" as Target == null alone (the round would burst at the target's intercept, 100 / 40 / 40 + // units out); dropping the range, visibility or lock gate from PFire's `designated`. The lock row needs a + // LockWeapon whose lock never grows; its control, below, is the same gun with the lock acquired. (A closed + // arc is not an invalid target: see AFusedShotAtATargetOutsideTheArc...) + [Theory] + [InlineData(Invalid.OutOfRange)] + [InlineData(Invalid.NotVisible)] + [InlineData(Invalid.Unlocked)] + public void AnInvalidTargetCountsAsNoLock(Invalid reason) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: reason == Invalid.OutOfRange ? 100f : 40f, + lockWeapon: reason == Invalid.Unlocked, lockAcquires: false); + var aim = normalize(float2(2, 1)); + if (reason == Invalid.NotVisible) e.Shooter.VisibleEntities.Remove(e.Target); + Aim(e, aim); + var burst = PointAlong(e, aim, NoLockRange); + var onAim = AddShip(e, burst, 1f, 300); + var targetBefore = e.Target.Hull.Durability; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == null, "an invalid target must not ride with the round"); // not Assert.Null: a failure would format the entity graph and overflow the stack + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.False(Alive(e, onAim), "the blast must land at max range along the aim"); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + } + + // The lock row's control: the same LockWeapon once its lock has grown carries its target to the intercept. + // Kills: a fixture whose lock could never grow (which would make the unlocked row above pass for nothing). + [Fact] + public void ALockedTargetRidesWithTheRound() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f, lockWeapon: true, lockAcquires: true); + Aim(e, float2(0, 1)); + e.Zone.Update(.1f); + Assert.True(((LockWeapon) e.Weapon).IsLocked, "fixture: the lock must have grown"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "a locked, visible, in-range, in-arc target rides with the round"); + Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); + } + + // The out-of-arc ruling (operator, 2026-09-30): a fused round at a target it holds valid data on but cannot + // bear on flies along the aim clamped to the mount's arc and bursts at the TARGET'S range, not at max range. + // The mount is flipped to (0,-1) so the target 40 ahead of the shooter is 180 degrees off it, in range, + // visible and unlocked by nothing: the arc is the only closed gate. The aim (1,1) is 135 degrees off the + // mount, so it clamps to the 85-degree edge on its own side. Three witnesses: on the edge at 40 (dies), on + // the edge at max range 60 (lives: the burst is at target range), under the unclamped aim at 40 (lives: the + // clamp holds). Kills: dropping `inArc` from PFire (the target would ride and burst at 40 along the target + // line); bursting at Range instead of the target's range; skipping the clamp. Contact is left out: its round + // stops on the first hull it meets, and these witnesses are hulls. + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Delayed)] + public void AFusedShotAtATargetOutsideTheArcBurstsAtTheTargetsRangeAlongTheClampedAim(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f); + e.Shooter.Direction = float2(0, -1); + var look = normalize(float2(1, 1)); + Aim(e, look); + var mount = float2(0, -1); + var edge = float2(mount.x * cos(radians(85f)) - mount.y * sin(radians(85f)), mount.x * sin(radians(85f)) + mount.y * cos(radians(85f))); + var atTargetRange = AddShip(e, PointAlong(e, edge, 40f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 302); + var underAim = AddShip(e, PointAlong(e, look, 40f), 1f, 304); + var targetBefore = e.Target.Hull.Durability; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == null, "a target the weapon cannot bear on must not ride with the round"); + var burst = PointAlong(e, edge, 40f); + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.False(Alive(e, atTargetRange), "the blast must land at the target's range along the clamped aim"); + Assert.True(Alive(e, atMaxRange), "the round bursts at the target's range, not at max range"); + Assert.True(Alive(e, underAim), "the aim is clamped to the arc's edge"); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + } + + // The trigger and Fire share one bearing test (InArc) and differ only in the fused weapon's exemption, so + // this pins the pair at the layer a player uses: Activate. With the target out of arc and the weapon + // otherwise clear, a fused weapon fires a round; the same weapon without a fuse stays silent. Kills: + // removing the exemption from FireControl.ArcPermitsFire (the fused row goes silent); removing the arc gate + // itself (the plain row fires); widening the exemption to a weapon with a blast radius but no fuse. + [Theory] + [InlineData(WeaponFuse.Proximity, true)] + [InlineData(null, false)] + public void OnlyAFusedWeaponFiresAtATargetOutsideItsArc(WeaponFuse? fuse, bool fires) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f); + e.Shooter.Direction = float2(0, -1); + Aim(e, float2(0, -1)); + Assert.Equal(fires, e.Weapon.ArcAllowsFire); + var committed = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++); + + e.Weapon.Activate(); + for (var i = 0; i < 5; i++) e.Zone.Update(.01f); + + Assert.Equal(fires ? 1 : 0, committed); + } + + // ==== Operator ruling 2026-09-30, batch 4: the arming distance ==== + // + // A round never bursts closer than its blast radius (its arming distance), measured from the shooter's + // position and blind to hull size: the fixture's shooter hull is a 5x5 block of 2-unit cells, and a blast of 30 + // arms at 30, not 30 plus whatever the hull reaches. A shooter close to its own burst takes the splash. + + private static float Planar(float3 from, float3 to) => length((to - from).xz); + + // A target inside the arming distance is not detonated on: the round flies on to the arming distance, exactly + // the blast radius from the shooter (35 would be the hull-inclusive figure this replaced). An in-arc target + // stays the round's target and the burst is pushed out along the line to it; the out-of-arc row is a target 10 + // away behind the mount, inside the blast radius, with the mount flipped so the arc is closed: no target rides + // and the burst is on the aim. Kills: no arming distance; a hull term in it; an in-arc round demoted to no + // target; an out-of-arc round that keeps one. + [Theory] + [InlineData(5f, false)] + [InlineData(10f, true)] + public void ACloseBurstIsPushedOutToTheBlastRadius(float targetRange, bool outOfArc) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: targetRange); + var aim = float2(0, 1); + if (outOfArc) + { + e.Shooter.Direction = float2(0, -1); + aim = float2(0, -1); + } + Aim(e, aim); + var burst = PointAlong(e, aim, 30f); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True((shot.Target == e.Target) == !outOfArc, "only a round with a target it can bear on keeps it"); + Assert.Equal(burst.x, shot.BurstPosition.x, 2); + Assert.Equal(burst.z, shot.BurstPosition.z, 2); + } + + // A round pushed out to its arming distance is still a targeted round (operator ruling 2026-09-30): if its target + // leaves the zone before it resolves, it does not burst. The target is 5 units away, inside a blast of 30, and + // the round flies 1.5 seconds; the target is gone after the first tick. The round resolves as a miss with no + // burst point, and the shooter standing inside the would-be disc is untouched. Kills: demoting a close round + // to no target (the round then bursts on schedule and hurts the shooter). + [Fact] + public void APushedOutRoundWhoseTargetLeavesTheZoneDoesNotBurst() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, float2(0, 1)); + var hurt = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => hurt += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => hurt += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => hurt += x.damage); + ShotOutcome resolved = null; + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + Assert.True(SafeAssert.OnlyShot(e.Zone).Target == e.Target, "fixture: the pushed-out round keeps its target"); + e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(3f); + + SafeAssert.NoShots(e.Zone); + Assert.NotNull(resolved); + Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.False(resolved.HasBurstPoint, "a round whose target is gone does not burst"); + Assert.Equal(0f, hurt); + } + + public enum Publication { Committed, Resolved } + + // A presentation observer that throws costs its own notification and nothing more: the shot's queue state is + // settled before anything is published, so it is not committed or resolved a second time on every later tick + // (the queue used to wedge, re-throwing forever). The first tick throws; the second does not, the shot is gone + // or committed exactly once. Kills: publishing before the shot is committed in the list or removed from it. + [Theory] + [InlineData(Publication.Committed)] + [InlineData(Publication.Resolved)] + public void AThrowingObserverCannotWedgeTheShotQueue(Publication throwsOn) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + var commits = 0; + var resolutions = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => { commits++; if (throwsOn == Publication.Committed) throw new InvalidOperationException("observer"); }); + using var r = e.Zone.ShotResolved.Subscribe(_ => { resolutions++; if (throwsOn == Publication.Resolved) throw new InvalidOperationException("observer"); }); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + Assert.ThrowsAny(() => e.Zone.Update(.01f)); + var second = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(second); + Assert.Equal(1, commits); + Assert.True(resolutions <= 1, $"resolved {resolutions} times"); + } + + // The same for the miss a departed target publishes: the shot is removed before that notification, so an + // observer that throws on it cannot keep the shot in the queue to be cancelled, and thrown at, on every tick. + // Kills: publishing the miss before removing the shot. + [Fact] + public void AThrowingObserverOfADepartedTargetsMissCannotWedgeTheShotQueue() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + using var c = e.Zone.ShotCommitted.Subscribe(_ => throw new InvalidOperationException("observer")); + e.Zone.Entities.Remove(e.Target); + + Assert.ThrowsAny(() => e.Zone.Update(.01f)); + var second = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(second); + SafeAssert.NoShots(e.Zone); + } + + public enum Observer { HullDamage, ArmorDamage, IncomingHit } + + // Nothing the queue does is left to a user callback that may throw: a shot is out of the queue before its + // damage is applied, so a damage observer that throws costs the rest of that blast, once, and the shot is not + // applied again on every later tick (its blast, and the observer, used to repeat forever). The observer throws + // on its first call; the following tick is clean and the observer has been called exactly once. Contact for + // the hit report (a roll can miss, so it retries), Proximity for the damage events. + // Kills: applying the shot's damage before removing it from the queue. + [Theory] + [InlineData(Observer.HullDamage)] + [InlineData(Observer.ArmorDamage)] + [InlineData(Observer.IncomingHit)] + public void AThrowingDamageObserverIsCalledOnceAndCannotWedgeTheShotQueue(Observer observer) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: observer == Observer.IncomingHit ? WeaponFuse.Contact : WeaponFuse.Proximity, + blastRadius: 4f, penetration: 1f, damage: 100f); + if (observer == Observer.ArmorDamage) ArmourEverything(e); + var calls = 0; + void Throw() { calls++; throw new InvalidOperationException("observer"); } + using var a = observer == Observer.ArmorDamage ? e.Target.ArmorDamage.Subscribe(_ => Throw()) : Disposable.Empty; + using var h = observer == Observer.HullDamage ? e.Target.HullDamage.Subscribe(_ => Throw()) : Disposable.Empty; + using var i = observer == Observer.IncomingHit ? e.Target.IncomingHit.Subscribe(_ => Throw()) : Disposable.Empty; + + for (var attempt = 0; attempt < 60 && calls == 0; attempt++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + Record.Exception(() => e.Zone.Update(.01f)); + } + Assert.Equal(1, calls); + var later = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(later); + Assert.Equal(1, calls); + SafeAssert.NoShots(e.Zone); + } + + // A round pushed out to its arming distance flies the arming distance: its arrival is that far off, not the + // target's own (5 units, a quarter second) range. 30 units at 20 units/s is 1.5 seconds. Kills: a flight + // distance of the target's range for a pushed-out round. + [Fact] + public void APushedOutRoundDoesNotArriveBeforeItHasFlownTheArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "fixture: the pushed-out round keeps its target"); + Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); + } + + // The same for a no-lock round: a target 10 out behind a mount whose arc is closed is designated but out of + // arc, so the round flies the aim to the target's range, inside the blast radius, and is pushed out to 30. It + // arrives after 30 units at 20 units/s, not after the 10 it was aimed at. Kills: a no-lock flight distance + // that ignores the arming distance. + [Fact] + public void APushedOutNoLockRoundDoesNotArriveBeforeItHasFlownTheArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 10f); + e.Shooter.Direction = float2(0, -1); + Aim(e, float2(0, -1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == null, "fixture: out of arc, the round carries no target"); + Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); + } + + // What the shooter's own hull takes from one round at `targetRange`, counting only rounds that hit when the + // fuse needs a hit (a roll can miss, so it retries). + private static float ShooterSplash(Engagement e, bool needsHit) + { + var taken = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => taken += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => taken += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => taken += x.damage); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + for (var attempt = 0; attempt < 60; attempt++) + { + taken = 0f; + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + if (!needsHit || outcome != null && outcome.Hit) return taken; + } + throw new InvalidOperationException("no round hit in 60 attempts"); + } + + // The arming ruling holds for every fuse, at the point a contact or delayed round detonates on a hull: no + // burst nearer to the shooter than the blast radius. A round that hits a target inside the radius (5 out), or + // one whose near face is inside it (a target 32 out has its face at 28), is detonated on at the radius (30), + // so the shooter takes what any burst 30 out on its line deals: the amount a proximity round at exactly 30 + // deals. Kills: a hull-point detonation that ignores the arming distance (the shooter then took 7.07 from a + // blast 30 at 5 out, more than the armed burst). + [Theory] + [InlineData(WeaponFuse.Contact, 5f)] + [InlineData(WeaponFuse.Delayed, 5f)] + [InlineData(WeaponFuse.Contact, 32f)] + public void AContactOrDelayedBlastNeverDetonatesNearerThanTheArmingDistance(WeaponFuse fuse, float targetRange) + { + var armed = ShooterSplash(Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, penetration: 1f, + damage: 100f, weaponRange: 60f, targetRange: 30f), needsHit: false); + Assert.True(armed > 0f, "fixture: the shooter's own hull takes splash from a burst at the radius"); + + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f, + damage: 100f, weaponRange: 60f, targetRange: targetRange); + var splash = ShooterSplash(e, needsHit: true); + + Assert.Equal(armed, splash, 3); + } + + // A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its + // intercept. Kills: demoting every fused round to the aim. + [Fact] + public void ATargetBeyondTheArmingDistanceStillRidesWithTheRound() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 40f); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "a target the disc clears the shooter to reach is still the round's target"); + Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); + } + + // A contact round stops on the first hull it crosses, but never nearer than its arming distance: a blocker + // whose near face is 2 units out (centred 6) is passed, and the round bursts at its blast radius, 4. Kills: a + // contact point taken as the hull face regardless of arming. + [Fact] + public void AContactRoundNeverStopsNearerThanItsArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + AddShip(e, PointAlong(e, float2(0, 1), 6f), 1f, 300); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.Equal(e.Shooter.Position.z + 4f, outcome.BurstPoint.y, 2); + } + + // A round fired from inside another hull is already in metal: its contact point is its arming distance. The + // host is a column with a 30-unit gap after the cell the muzzle sits in (cells 0-2 and 18-21, the muzzle in + // cell 2), so a contact search that skips the cell the origin is inside finds the far block instead of the + // muzzle's own. Kills: reading a cell's Entry for its Exit (Soul's M7). + [Fact] + public void AContactRoundFiredFromInsideAnotherHullBurstsAtTheArmingDistance() + { + var column = new Shape(1, 22); + foreach (var y in new[] { 0, 1, 2, 18, 19, 20, 21 }) column[new int2(0, y)] = true; + var e = Build(TestSettings(), column, velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var offset = e.Target.ToWorldPoint(float2(0, 2)) - e.Target.Position.xz; + e.Target.Position = float3(e.Shooter.Position.x - offset.x, 0, e.Shooter.Position.z - offset.y); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(e.Shooter.Position.z + 4f, committed.BurstPoint.y, 2); + } + + // A fused round is refused, per round and at the moment Fire reads Range, when its arming distance (the blast + // radius, 30) exceeds that Range: 29.5 refuses, 30.01 fires, and a power-starved Range (authored 100, forced to about + // 10 the way Soul's probe did after the trigger has passed) refuses although the authored figure clears + // it. Fire returns 0 and queues nothing. Kills: a gate that reads the authored Range; `>=` for `>`. + [Theory] + [InlineData(29.5f, false, false)] + [InlineData(30.01f, false, true)] + [InlineData(100f, true, false)] + public void AFusedRoundWhoseArmingDistanceExceedsRangeIsRefusedByFire(float range, bool starvedOfPower, bool fires) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: range, rangeTerm: starvedOfPower ? StatSource.PowerSupply : (StatSource?) null); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + if (starvedOfPower) + { + typeof(EquippedItem).GetProperty("PowerSupply").GetSetMethod(true).Invoke(e.WeaponItem, new object[] { .1f }); + e.Shooter.Resolver.InvalidateSource(e.WeaponItem, StatSource.PowerSupply); + e.Weapon.GetType().GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(e.Weapon, null); + Assert.True(e.Weapon.Range < 30f, "fixture: the runtime Range must have fallen below the arming distance"); + } + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + Assert.Equal(fires, shotId != 0); + Assert.Equal(fires ? 1 : 0, e.Zone.PendingShots.Count); + } + + // The boundary itself: a Range exactly equal to the arming distance (the blast radius) fires. Kills: `>=` for + // `>`. + [Fact] + public void ARangeExactlyEqualToTheArmingDistanceFires() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 30f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + + Assert.True(FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter) != 0, "a Range equal to the arming distance must fire"); + } + + // The same refusal through the weapon a player uses: a refused round sounds nothing (OnFire never runs) and + // queues nothing, and the same weapon with Range to spare fires once. Kills: Execute still announcing a + // refused round (Fire's 0 handed to the presentation as a shot id). + [Theory] + [InlineData(29.5f, 0)] + [InlineData(40f, 1)] + public void ARefusedRoundIsNotAnnounced(float range, int fired) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: range); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var announced = 0; + var committed = 0; + e.Weapon.OnFire += _ => announced++; + using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++); + + e.Weapon.Activate(); + for (var i = 0; i < 5; i++) e.Zone.Update(.01f); + + Assert.Equal(fired, announced); + Assert.Equal(fired, committed); + } + + // Planar, everywhere the gate, the clamp and the burst meet (R7). A target 50 units above the plane at a + // planar range of 40 is 64 units away in three dimensions: inside a Range of 60 by the plane, outside it by + // the straight line. It is designated, rides with the round, and the HUD's diagnostic agrees. Kills: a 3D + // length in PFire's range or Inspect's. + [Fact] + public void ATargetsHeightDoesNotMoveTheRangeGate() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 60f, targetRange: 40f); + e.Target.Position += float3(0, 50, 0); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "the target is inside Range in the plane, so it is valid data"); + Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); + var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.True(diagnostic.Designated, "the HUD's designation is planar too"); + Assert.Equal(40f, diagnostic.Range, 2); + } + + // The F4 clamp is planar as well: the target's height does not change how far the burst is pulled back. + [Fact] + public void TheRangeClampIgnoresTheTargetsHeight() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 100f, targetRange: 90f); + e.Target.Position += float3(0, 200, 0); + e.Target.Velocity = float2(6f, 8f); + var intercept = FireControl.PredictedIntercept(e.Weapon, e.Shooter, e.Target); + Assert.True(Planar(e.Shooter.Position, intercept) > 100f, "fixture: the intercept must lie beyond Range in the plane"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(100f, Planar(e.Shooter.Position, shot.BurstPosition), 2); + } + + // Nothing bursts beyond max range. A target receding faster than the round would be led past Range by the + // intercept; the burst is pulled back along the line to the intercept, to exactly Range. The target moves + // (6,8), so the direction is off-axis and a clamp that moved the point along the wrong line shows. + // Kills: no clamp (the probe burst 180 units out on Range 100); a clamp on one axis only. + [Fact] + public void ATargetedProximityBurstIsPulledBackToMaxRange() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 100f, targetRange: 90f); + e.Target.Velocity = float2(6f, 8f); + var intercept = FireControl.PredictedIntercept(e.Weapon, e.Shooter, e.Target); + var origin = e.Shooter.Position; + var toIntercept = normalize((intercept - origin).xz); + Assert.True(length((intercept - origin).xz) > 100f, "fixture: the intercept must lie beyond Range"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(origin.x + toIntercept.x * 100f, shot.BurstPosition.x, 2); + Assert.Equal(origin.z + toIntercept.y * 100f, shot.BurstPosition.z, 2); + } + + // What a proximity round's commit says. A round whose roll missed still detonates, so it commits as a Burst + // at the intercept, and only a roll that landed is a Hit; a contact or delayed round detonates only on a hit, + // so its guaranteed miss (accuracy 0) stays a Miss and explodes nowhere. Kills: typing a proximity miss as a + // Miss (the HUD said MISS over a real explosion); typing every proximity round a Burst (no Hit among the + // twelve); dropping the burst point from a Hit (it would detonate at the origin). + [Fact] + public void AProximityRoundThatMissesCommitsAsABurstAtTheIntercept() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, accuracy: 0f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.Equal(e.Target.Position.x, committed.BurstPoint.x, 3); + Assert.Equal(e.Target.Position.z, committed.BurstPoint.y, 3); + Assert.Equal(5.65884256f, damage, 3); + } + + [Fact] + public void AProximityRoundThatLandsCommitsAsAHitAndStillDetonates() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var results = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => results.Add(o)); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + for (var n = 0; n < 12; n++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(12, results.Count); + Assert.True(results.All(o => o.Result != ShotResult.Miss), "a proximity round always detonates, so it is never a Miss"); + Assert.True(results.Any(o => o.Result == ShotResult.Hit), "the stationary, fully-accurate target must be hit at least once"); + Assert.True(results.All(o => abs(o.BurstPoint.x - e.Target.Position.x) < 1e-3f && abs(o.BurstPoint.y - e.Target.Position.z) < 1e-3f), + "a hit detonates at the intercept too, not at the origin"); + Assert.True(results.All(o => o.HasBurstPoint), "a proximity outcome names its burst point, hit or not: presentation retargets to it"); + Assert.True(damage > 0f, "the blasts must have landed"); + } + + [Theory] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AContactOrDelayedRoundThatMissesIsAMissAndDoesNotDetonate(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 100f, accuracy: 0f); + var bystander = AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300); + ShotOutcome resolved = null; + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(resolved); + Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.False(resolved.HasBurstPoint, "a contact or delayed round has no frozen burst point"); + Assert.True(Alive(e, bystander), "a missed contact or delayed round detonates nowhere"); + } + + // A targeted fused round commits at its horizon, not at Fire: the target's facing is read late (Bearing + // timing), and only a round with no target commits at once. 100 units at 20/s is a 5 s flight, so with the + // 0.5 s horizon it commits at 4.5 s. Kills: CommitTime = now for every fused shot (the round would be + // committed after the first tick). + [Fact] + public void ATargetedFusedShotCommitsAtItsHorizonNotAtFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(shot.FireTime + 4.5f, shot.CommitTime, 3); + e.Zone.Update(.01f); + Assert.False(e.Zone.PendingShots[0].Committed, "a targeted round reads the target's facing late"); + e.Zone.Update(4.5f); + Assert.True(e.Zone.PendingShots[0].Committed, "and commits once its horizon is reached, with the round still in flight"); + } + + // Ruling 3 (operator, 2026-09-30): a no-lock round of any fuse stops on the first hull its aim line crosses + // before max range and detonates there; with the line clear it bursts at max range. The blocker's near face + // sits 21 units out (a 5x4-cell hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a + // fuse that ignores hulls (a Proximity or Delayed round used to fly on to max range). + [Theory] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Delayed)] + public void ANoLockRoundOfAnyFuseStopsOnTheFirstHullItCrosses(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 302); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + const float expectedReach = 21f; + Assert.Equal(e.Shooter.Position.z + expectedReach, outcome.BurstPoint.y, 2); + Assert.Equal(e.Shooter.Position.x, outcome.BurstPoint.x, 2); + Assert.False(Alive(e, blocker)); + Assert.True(Alive(e, atMaxRange), "the witness at max range is beyond the stop"); + } + + // Ruling 3: a hull beyond max range is not on the round's way, and the shooter's own hull, which the line + // starts inside, is never a candidate. Here the target (100 out) lies on the line of a 60-range Contact + // round. Kills: a burst point beyond the max-range point; a Contact search that admits the shooter. + [Fact] + public void AContactRoundWithNoLockBurstsAtMaxRangeWhenNoHullIsInReach() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var targetBefore = e.Target.Hull.Durability; + var shooterHit = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => shooterHit += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => shooterHit += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => shooterHit += x.damage); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(e.Shooter.Position.z + NoLockRange, outcome.BurstPoint.y, 2); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + Assert.Equal(0f, shooterHit); + } + + // Ruling 3: a Contact round bursting short of max range arrives when it has flown that far -- 21 of 60 units + // at 20 units/s is 1.05 s into a 3 s flight -- and the commit says so through ArrivalIn. Kills: an arrival + // left at the max-range time (the round would still be pending at 1.2 s, and ArrivalIn would read ~3). + [Fact] + public void AContactBurstShortOfMaxRangeArrivesWhenTheRoundHasFlownThatFar() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.NotNull(outcome); + Assert.Equal(1.05f - .01f, outcome.ArrivalIn, 2); + Assert.True(Alive(e, blocker)); + + e.Zone.Update(1.1f); + SafeAssert.NoShots(e.Zone); + Assert.False(Alive(e, blocker)); + } + + // Ruling 4: a round outlives its shooter. A no-lock proximity round whose shooter leaves the zone in flight + // still detonates at max range, whether the shooter goes before or after the commit; a targeted proximity + // round does the same at its intercept, for the same damage a shooter who stayed would have dealt (the value + // measured for ATargetedFusedShotIsUnchangedByTheNoLockRule at b66ba524, 5.65884256). Kills: Step + // cancelling a shot whose source has left the zone. + [Theory] + [InlineData(0)] + [InlineData(1)] + public void ANoLockRoundStillBurstsAfterItsShooterLeavesTheZone(int updatesBeforeLeaving) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var witness = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 300); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + for (var i = 0; i < updatesBeforeLeaving; i++) e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(4f); + + SafeAssert.NoShots(e.Zone); + Assert.False(Alive(e, witness), "the round must still burst at max range"); + } + + [Fact] + public void ATargetedFusedRoundStillBurstsAfterItsShooterLeavesTheZone() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(6f); + + SafeAssert.NoShots(e.Zone); + Assert.Equal(5.65884256f, damage, 3); + } + + // Ruling 4, direct hits: rounds fired at a target and in flight when their shooter leaves still hit it. A dozen + // shots each roll their own dice (seeded by shot id), and with a stationary target and a full-accuracy + // targeting system nearly all land; the test needs only that all resolve and some hit, and that each hit + // still names the departed shooter (identity carried, never asked anything). + [Fact] + public void ADirectRoundStillHitsAfterItsShooterLeavesTheZone() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, damage: 5f); + var resolved = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved.Add(o)); + var struck = 0; + using var s = e.Target.IncomingHit.Subscribe(src => { if (src == e.Shooter) struck++; }); + + for (var i = 0; i < 12; i++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(6f); + + SafeAssert.NoShots(e.Zone); + Assert.Equal(12, resolved.Count); + Assert.True(resolved.Any(o => o.Hit), "at least one round must land on the stationary target"); + Assert.Equal(resolved.Count(o => o.Hit), struck); + } + + // Ruling 6: what a commit came to is typed. A no-lock burst is neither a hit nor a miss, and reads as neither. + // Kills: MakeOutcome collapsing a burst back to a miss (the debug HUD read MISS for a real explosion). + [Fact] + public void ANoLockBurstCommitsAsABurstNotAMiss() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(2, 1)); + ShotOutcome committed = null, resolved = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.True(committed.HasBurstPoint); + Assert.False(committed.Hit); + var burst = PointAlong(e, normalize(float2(2, 1)), NoLockRange); + Assert.Equal(burst.x, committed.BurstPoint.x, 3); + Assert.Equal(burst.z, committed.BurstPoint.y, 3); + Assert.True(ReferenceEquals(committed, resolved), "the resolution republishes the commit unchanged"); + } + + // ==== Soul's probes on hands/fuse-fix2 (2026-09-30), adopted as tests ==== + + private static float2 Dir(float degreesFromMount) => float2(sin(radians(degreesFromMount)), cos(radians(degreesFromMount))); + + private static float2 Clamped(Engagement e, float2 look) + { + Aim(e, look); + return FireControl.AimDirection(e.WeaponItem, e.Shooter); + } + + private static void Near(float2 expected, float2 actual, int digits = 3) + { + Assert.Equal(expected.x, actual.x, digits); + Assert.Equal(expected.y, actual.y, digits); + } + + // AimDirection at the arc's edges: the mount is (0,1) and the arc 170 degrees, so the edge is 85. On the edge + // and inside are untouched, just outside turns to the edge on its own side, straight behind turns to the + // positive edge, a zero aim (and one too short to normalise) is the mount, and a NaN aim never poisons the + // result. Kills: a clamp that turns the wrong way; an edge off by a degree; a missing zero-aim fallback. + [Fact] + public void TheAimClampHoldsAtTheEdgesOfTheArc() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + Near(Dir(85), Clamped(e, Dir(85))); + Near(Dir(85), Clamped(e, Dir(85.5f))); + Near(Dir(-85), Clamped(e, Dir(-86))); + Near(Dir(84), Clamped(e, Dir(84))); + Near(Dir(0), Clamped(e, Dir(0))); + Near(Dir(85), Clamped(e, Dir(180 - 1e-3f)), 2); + Near(float2(0, 1), Clamped(e, float2(0, 0))); + Near(float2(0, 1), Clamped(e, float2(5e-4f, 0))); + Near(Dir(85), Clamped(e, float2(2e-3f, 0))); + e.Shooter.LookDirection = float3(0, 1, 0); + Near(float2(0, 1), FireControl.AimDirection(e.WeaponItem, e.Shooter)); + e.Shooter.LookDirection = float3(float.NaN, 0, float.NaN); + var nan = FireControl.AimDirection(e.WeaponItem, e.Shooter); + Assert.False(float.IsNaN(nan.x) || float.IsNaN(nan.y), "a NaN aim must not produce a NaN flight direction"); + } + + [Fact] + public void AFullCircleMountPassesEveryAim() + { + var settings = TestSettings(); + settings.FiringArc = 360; + var e = Build(settings, SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + Near(float2(0, -1), Clamped(e, float2(0, -3))); + Near(normalize(float2(-1, -1)), Clamped(e, float2(-5, -5))); + } + + [Fact] + public void AZeroWidthArcIsTheMount() + { + var settings = TestSettings(); + settings.FiringArc = 0; + var e = Build(settings, SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + foreach (var d in new[] { 0f, 1e-3f, 30f, 90f, 179f, -120f }) + Near(float2(0, 1), Clamped(e, Dir(d)), 4); + } + + // The hardpoint's authored arc overrides the default (90 here, so the edge is 45), and the arc turns with the + // mount: a shooter facing (1,1) has its edge 45 degrees counter-clockwise of that when aimed along -x. + [Fact] + public void TheHardpointsOwnArcAndTheRotatedMountSetTheEdge() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f, hardpointArc: 90f); + Near(Dir(45), Clamped(e, float2(1, 0))); + Near(Dir(-45), Clamped(e, Dir(-100))); + e.Shooter.Direction = normalize(float2(1, 1)); + var mount = normalize(float2(1, 1)); + var edgeCcw = float2(mount.x * cos(radians(45)) - mount.y * sin(radians(45)), mount.x * sin(radians(45)) + mount.y * cos(radians(45))); + Near(edgeCcw, Clamped(e, float2(-1, 0))); + } + + // A contact round with no lock commits at Fire, against the poses of that tick: a hull that sails onto the + // aim line afterwards is not in its way, and one that leaves it afterwards does not un-stop it. Kills: + // committing later than Fire (the blocker that arrives would stop the round); re-deciding the burst point + // from live poses. + [Fact] + public void AContactRoundIgnoresAHullThatCrossesItsLineAfterFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f) + float3(300, 0, 0), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.Equal(e.Shooter.Position.z + 60f, committed.BurstPoint.y, 2); + blocker.Position = PointAlong(e, float2(0, 1), 25f); + e.Zone.Update(4f); + + SafeAssert.NoShots(e.Zone); + Assert.True(Alive(e, blocker), "the round flew through the hull that arrived after Fire and burst at max range"); + } + + [Fact] + public void AContactRoundStillBurstsAtAHullThatLeavesItsLineAfterFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 302); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(e.Shooter.Position.z + 21f, committed.BurstPoint.y, 2); + blocker.Position += float3(300, 0, 0); + e.Zone.Update(4f); + + Assert.True(Alive(e, blocker), "the hull moved away; the round still burst at its old face"); + Assert.True(Alive(e, atMaxRange), "and did not continue to max range"); + } + + // The contact point is measured from where the round left (FireOrigin), not from where its shooter is when + // the commit tick runs. Unblocked, a max-range burst point is frozen at Fire either way; with a blocker 21 + // units out the origin matters. Kills: Commit reading the shooter's live position (Soul's M6). + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AShooterMovingAfterFireDoesNotMoveTheContactPoint(bool withBlocker) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var origin = e.Shooter.Position; + if (withBlocker) AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Shooter.Position += float3(25f, 0, 10f); + e.Zone.Update(.01f); + + Assert.Equal(origin.x, committed.BurstPoint.x, 2); + Assert.Equal(origin.z + (withBlocker ? 21f : 60f), committed.BurstPoint.y, 2); + } + + // A hull wholly behind the muzzle is not on the round's way. Kills: admitting cells whose exit lies behind + // the origin (the round would burst at the hull behind it, at distance zero). + [Fact] + public void AContactRoundIgnoresAHullBehindTheMuzzle() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + AddShip(e, PointAlong(e, float2(0, -1), 25f), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(e.Shooter.Position.z + 60f, committed.BurstPoint.y, 2); + } + + // The intent ruling: a round fired at a valid target that has left the zone does not burst, at either stage + // (before or after its commit), whatever its fuse -- it resolves as a Miss, and nothing standing beside where + // the target was, nor the shooter, takes blast damage. Kills: a round that detonates at its frozen burst point + // after its target is gone. + [Theory] + [InlineData(WeaponFuse.Proximity, false)] + [InlineData(WeaponFuse.Contact, false)] + [InlineData(WeaponFuse.Delayed, false)] + [InlineData(WeaponFuse.Proximity, true)] + [InlineData(WeaponFuse.Contact, true)] + [InlineData(WeaponFuse.Delayed, true)] + public void ARoundWhoseTargetLeavesTheZoneDoesNotBurst(WeaponFuse fuse, bool committedFirst) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 500f); + var bystanders = new Entity[] + { + AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300), + AddShip(e, e.Target.Position + float3(-8, 0, 12), 1f, 302) + }; + var damage = 0f; + var subscriptions = new List(); + foreach (var o in bystanders.Append(e.Shooter)) + { + subscriptions.Add(o.ArmorDamage.Subscribe(x => damage += x.damage)); + subscriptions.Add(o.HullDamage.Subscribe(x => damage += x)); + subscriptions.Add(o.ItemDamage.Subscribe(x => damage += x.damage)); + } + var resolved = new List(); + subscriptions.Add(e.Zone.ShotResolved.Subscribe(o => resolved.Add(o))); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(committedFirst ? 4.6f : .01f); + Assert.Equal(committedFirst, e.Zone.PendingShots[0].Committed); + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(6f); + + foreach (var s in subscriptions) s.Dispose(); + SafeAssert.NoShots(e.Zone); + Assert.True(bystanders.All(b => Alive(e, b)), "no bystander may take blast damage"); + Assert.Equal(0f, damage); + Assert.Equal(ShotResult.Miss, SafeAssert.Only(resolved).Result); + } + + // A no-lock round outlives a shooter destroyed outright in flight, for every fuse: Death removes the shooter + // and the round still bursts at max range and resolves once as a Burst. + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void ANoLockRoundStillBurstsAfterItsShooterIsDestroyed(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var witness = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 300); + var resolved = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved.Add(o)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Shooter.DamageHull(1e9f); + e.Zone.Update(4f); + + Assert.False(Alive(e, e.Shooter)); + Assert.False(Alive(e, witness), "the round must still burst at max range"); + Assert.Equal(ShotResult.Burst, SafeAssert.Only(resolved).Result); + } + + // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre + // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At + // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. + [Fact] + public void ToWorldPointIsTheInverseOfToSchematicPointAtANonAxisFacing() + { + var facing = normalize(float2(2, 1)); + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: facing); + var t = e.Target; + var com = e.HullData.Shape.CenterOfMass; + var starboard = float2(facing.y, -facing.x); + + var world = t.Position.xz + float2(7f, -3f); + var back = t.ToWorldPoint(t.ToSchematicPoint(world)); + Assert.Equal(world.x, back.x, 3); + Assert.Equal(world.y, back.y, 3); + + var oneToStarboard = t.ToWorldPoint(com + float2(1, 0)); + Assert.Equal(t.Position.x + 2f * starboard.x, oneToStarboard.x, 3); + Assert.Equal(t.Position.z + 2f * starboard.y, oneToStarboard.y, 3); + var oneToBow = t.ToWorldPoint(com + float2(0, 1)); + Assert.Equal(t.Position.x + 2f * facing.x, oneToBow.x, 3); + Assert.Equal(t.Position.z + 2f * facing.y, oneToBow.y, 3); + } + + // A contact blast on a hull at facing (2,1): the shot enters over the starboard side, off the hull's own + // centre line, so the blast point's lateral offset from the centre of mass is not zero and a flipped right + // axis in ToWorldPoint would mirror it onto the far side. Placement is asserted per cell: the impact cell + // takes a share, and no cell farther than one from it takes anything (radius .15 cells). Kills: a flip of + // ToWorldPoint's right axis, which the host's round trip would otherwise hide from every total. + [Fact] + public void AContactBlastLandsOnTheCellItHitAtANonAxisFacing() + { + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: float2(2, 1), fuse: WeaponFuse.Contact, blastRadius: .3f, damage: 100f); + ArmourEverything(e); + var deposits = new Dictionary(); + using var a = e.Target.ArmorDamage.Subscribe(x => deposits[x.pos] = (deposits.TryGetValue(x.pos, out var d) ? d : 0f) + x.damage); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.True(deposits.ContainsKey(outcome.Cell), $"the impact cell {outcome.Cell} took nothing; damaged: {string.Join(",", deposits.Keys)}"); + foreach (var cell in deposits.Keys) + Assert.True(Math.Abs(cell.x - outcome.Cell.x) <= 1 && Math.Abs(cell.y - outcome.Cell.y) <= 1, + $"cell {cell} took damage far from the impact cell {outcome.Cell}"); + } + + // TurningAfterCommitDoesNotMoveTheBlastOnItsHost: a contact-fuse shot. After commit and before arrival, the + // host turns and moves. Pass: the host's per-cell damage equals that of the same shot with no turn. Kills: + // taking P from the live bearing, or from Outcome.Cell's centre in place of the lane point. + [Fact] + public void TurningAfterCommitDoesNotMoveTheBlastOnItsHost() + { + Dictionary ArmorDamageByCell(Action afterCommit) + { + var e = Build(TestSettings(commitHorizon: .5f), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 3f, damage: 100f); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + // Per cell, not a total: a blast that lands on the wrong cells deals the same total. + var perCell = new Dictionary(); + using var a = e.Target.ArmorDamage.Subscribe(x => perCell[x.pos] = (perCell.TryGetValue(x.pos, out var d) ? d : 0f) + x.damage); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + // Step to just past commit (flight time 2s, commit horizon 0.5s -> commits at t ~= 1.5s). + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) + e.Zone.Update(.05f); + + afterCommit?.Invoke(e); + + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) + e.Zone.Update(.05f); + + return perCell; + } + + var noTurn = ArmorDamageByCell(null); + var turned = ArmorDamageByCell(e => + { + e.Target.Direction = normalize(float2(1, 0)); + e.Target.Position += float3(50, 0, -30); + }); + + Assert.NotEmpty(noTurn); + Assert.Equal(noTurn.Keys.OrderBy(k => k.x).ThenBy(k => k.y), turned.Keys.OrderBy(k => k.x).ThenBy(k => k.y)); + foreach (var cell in noTurn.Keys) Assert.Equal(noTurn[cell], turned[cell], 2); + } + + // LabelsDoNotDecideBehaviour: a weapon labelled Airburst with no fuse resolves as a direct hit, and a + // weapon with Fuse = Proximity and no label detonates. + [Fact] + public void LabelsDoNotDecideBehaviour() + { + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: null, blastRadius: 0f, damage: 50f, + modifiers: WeaponModifiers.Airburst); + var before = e.Target.Hull.Durability; + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.True(armorEvents > 0); // a direct hit -- one lane's cells, never an area + } + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 20f, damage: 500f, + modifiers: WeaponModifiers.None); + var before = e.Target.Hull.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.True(e.Target.Hull.Durability < before); + } + } + + // AFuseWithoutARadiusIsADirectHit: Fuse = Contact with a null or zero radius does lane damage. + [Fact] + public void AFuseWithoutARadiusIsADirectHit() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Contact, blastRadius: 0f, damage: 50f); + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(ShotResult.Hit, outcome.Result); + Assert.True(armorEvents > 0); + var shot = e.Zone.PendingShots.Count == 0 ? null : (PendingShot?) e.Zone.PendingShots[0]; + Assert.True(!shot.HasValue, "the shot must be resolved and removed"); // resolved and removed -- confirms it went through Apply's null-fuse path to completion + } + + // ARadiusWithoutAFuseIsADirectHit (Soul F1): the symmetric inert half -- BlastRadius > 0 but the catalog + // record carries no Fuse. Fire freezes Fuse as null regardless of the radius, so this resolves as a direct + // hit exactly like AFuseWithoutARadiusIsADirectHit above. Kills: Fire freezing "detonates" from BlastRadius + // alone, without also requiring a Fuse. + [Fact] + public void ARadiusWithoutAFuseIsADirectHit() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: null, blastRadius: 40f, damage: 50f); + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Null(shot.Fuse); + Assert.Equal(40f, shot.BlastRadius); + + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.True(armorEvents > 0); // a direct hit -- not spread over a disc + } + + // AContactBlastReportsTheHit: IncomingHit fires once, for the host, on a committed contact hit, and never + // for a proximity burst. + [Fact] + public void AContactBlastReportsTheHit() + { + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Contact, blastRadius: 3f, damage: 50f); + var hits = 0; + using var h = e.Target.IncomingHit.Subscribe(_ => hits++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(1, hits); + } + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 20f, damage: 50f); + var hits = 0; + using var h = e.Target.IncomingHit.Subscribe(_ => hits++); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(0, hits); + } + } + + // Fire freezes a proximity shot's burst point at the predicted intercept of a moving target -- where a shot + // of the weapon's own velocity, fired now, meets the target -- not at the target's position now. The + // expectation is solved here from first principles: |d + v t| = s t for the flight time t, planar, the + // shooter's own motion never fed in. Target 100 ahead, drifting (30,0) against a 50 unit/s shot: t = 2.5 s, + // so the burst sits 75 units to the side of where the target is at Fire. + // Kills: BurstPosition frozen at the target's current position (PredictedIntercept never consulted). + [Fact] + public void ProximityBurstPositionIsThePredictedInterceptForAMovingTarget() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 50f); + var v = float2(30f, 0f); + e.Target.Velocity = v; + + var s = e.Shooter.Position.xz; + var d = e.Target.Position.xz - s; + var a = dot(v, v) - 50f * 50f; + var b = 2f * dot(d, v); + var c = dot(d, d); + var t = (-b - sqrt(b * b - 4f * a * c)) / (2f * a); // a < 0: the positive root + Assert.Equal(2.5f, t, 3); + var expected = e.Target.Position.xz + v * t; + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = e.Zone.PendingShots.Single(x => x.ShotId == shotId); + + Assert.Equal(expected.x, shot.BurstPosition.x, 2); + Assert.Equal(expected.y, shot.BurstPosition.z, 2); + Assert.True(length(shot.BurstPosition.xz - e.Target.Position.xz) > 70f, "the burst must lead the target, not sit on it"); + } + + // Detonate has no radius guard: a nonpositive radius covers no cell (a disc with no area overlaps nothing), + // so it delivers nothing and touches no shield. + [Theory] + [InlineData(0f)] + [InlineData(-5f)] + public void DetonateCannotBeReachedWithANonPositiveRadius(float radius) + { + var e = Build(TestSettings(), SolidShape(5, 4)); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 100f; e.Target.MaxArmor[c.x, c.y] = 100f; } + var before = e.Target.Hull.Durability; + + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius, 1000f, DamageType.Kinetic); + + Assert.Equal(before, e.Target.Hull.Durability); + Assert.Equal(100f, e.Target.Armor[2, 1], 2); + } + + // ==== Commit's probes: the shipped catalog is neutral, and the field survives a full write/reopen. ==== + + private static string FindRepoRoot() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) + return dir.FullName; + throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); + } + + private static CultCache OpenReadOnlyRealCatalog(string catalogPath, out SingleFileMessagePackBackingStore store) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + var cache = new CultCache(registry); + store = new SingleFileMessagePackBackingStore(catalogPath, true); + cache.AddBackingStore(store, AetheriaStores.CatalogTypes); + return cache; + } + + // Every WeaponItemData in the shipped catalog has a null BlastRadius and a null Fuse, and the weapon + // schema's own migration report is CompatibleDrift with only slot 32 defaulted and no other slots touched. + [Fact] + public void ShippedCatalogIsNeutralOnBlastRadiusAndFuse() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var cache = OpenReadOnlyRealCatalog(gameData, out var store); + try + { + var weapons = cache.GetAll().ToList(); + Assert.NotEmpty(weapons); + foreach (var w in weapons) + { + Assert.Null(w.BlastRadius); + Assert.Null(w.Fuse); + } + + var reports = store.LastSchemaMigrationReports.Where(r => r.LocalSchemaName == "aetheria.weaponitemdata").ToList(); + Assert.NotEmpty(reports); + foreach (var report in reports) + { + Assert.Equal(CultSchemaMigrationKind.CompatibleDrift, report.Kind); + Assert.Equal(new[] { 32 }, report.DefaultedMissingSlots); + Assert.Empty(report.IgnoredExtraSlots); + } + } + finally + { + cache.Dispose(); + } + } + + // A WeaponItemData round-trip for each WeaponFuse value and null, crossed with radius null, 0 and + // positive, through a written and reopened temporary copy of the shipped catalog. + [Fact] + public void WeaponFuseAndBlastRadiusRoundTripThroughAWrittenCatalog() + { + var src = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var fuseValues = new WeaponFuse?[] { null, WeaponFuse.Contact, WeaponFuse.Proximity, WeaponFuse.Delayed }; + var radiusValues = new float?[] { null, 0f, 12.5f }; + + foreach (var fuse in fuseValues) + foreach (var radius in radiusValues) + { + var tmp = Path.Combine(_root, "roundtrip-" + Guid.NewGuid().ToString("N") + ".cc"); + File.Copy(src, tmp); + try + { + { + var cache = OpenReadOnlyRealCatalog(tmp, out _); + cache.Dispose(); + } + var writeCache = new CultCache(CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null))); + writeCache.AddBackingStore(new SingleFileMessagePackBackingStore(tmp, false), AetheriaStores.CatalogTypes); + var w = writeCache.GetAll().OrderBy(x => x.Name).First(); + w.Fuse = fuse; + w.BlastRadius = radius; + writeCache.Upsert(w); + writeCache.FlushAsync().Wait(); + writeCache.Dispose(); + + var readCache = OpenReadOnlyRealCatalog(tmp, out _); + var reopened = readCache.GetAll().OrderBy(x => x.Name).First(); + Assert.Equal(fuse, reopened.Fuse); + Assert.Equal(radius, reopened.BlastRadius); + readCache.Dispose(); + } + finally + { + File.Delete(tmp); + } + } + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 54e5a02e9..30cf46356 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection; using CultMath; using GameCult.Caching; using UniRx; @@ -63,6 +62,19 @@ public sealed class FireControlCut12Tests : IDisposable return shape; } + // S1 fix batch: a non-convex hull for the empty-lane sweep -- a solid square with its centre cell punched + // out, the same "holed7" shape Soul's own EdgeSweep probe used. + private static Shape HoledShape(int w, int h) + { + var shape = SolidShape(w, h); + shape[new int2(w / 2, h / 2)] = false; + return shape; + } + + // A unit direction at `d` degrees, matching Soul's own probe convention (SoulProbe122b.Deg) -- used here to + // name an exact facing/bearing for a crash repro. + private static float2 Deg(double d) => float2((float) Math.Sin(d * Math.PI / 180), (float) Math.Cos(d * Math.PI / 180)); + private sealed class Engagement { public ItemManager Items; @@ -75,6 +87,8 @@ public sealed class FireControlCut12Tests : IDisposable public EquippedItem[] Markers; public EquippedItem BigMarker; public int2[] BigMarkerCells; + public EquippedItem LMarker; + public int2[] LMarkerCells; } // A shooter off the origin (Cut 11.3), a target over a caller-supplied hull shape, a full-strength @@ -85,7 +99,19 @@ public sealed class FireControlCut12Tests : IDisposable GameplaySettings settings, Shape hullShape, float precision, float velocity = 0, float spread = 0, float2? targetFacing = null, bool equipShield = false, float shieldCapacity = 30, - int2[] markerCells = null, float accuracy = 1f, float penetration = 0, bool equipBigMarker = false) + int2[] markerCells = null, float accuracy = 1f, float penetration = 0, bool equipBigMarker = false, + // S1 fix batch (Hands, 2026-09-25): a caller-chosen zone name -- Cut 6.1's own convention is one fixed + // name so a fixture is replayable byte-for-byte, but Soul's own crash repros (Zone.CombatSeed is + // StableHash(name)) each need their OWN exact name to reproduce a specific die roll. + string zoneName = "Cut12", + // S7 fix batch (Hands, 2026-09-25): Tracking authored through Build, like Precision and Accuracy already + // are, instead of a caller patching PendingShot.Tracking after Fire (TheHudEstimateIsTheCommitPrice's own + // former shape) -- one authoring path for every frozen shooter stat. + float tracking = 100000f, + // S8 fix batch (Hands, 2026-09-25): an L-shaped (3-cell, not 2x2) Tool item -- its true centroid differs + // from both any single cell and the bounding-box centre a mutant could substitute (N11), which a + // rectangular BigMarker cannot distinguish. + bool equipLMarker = false) { var hullData = new HullData { @@ -125,7 +151,7 @@ public sealed class FireControlCut12Tests : IDisposable MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new TargetingSystemData { - Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f) + Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(tracking) } } }); cache.Upsert(new GearData @@ -155,6 +181,15 @@ public sealed class FireControlCut12Tests : IDisposable Name = "BigMarker", Hardpoint = HardpointType.Tool, Shape = bigMarkerShape, Durability = 1000000, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); + // S8 fix batch: an L-tromino (3 of a 2x2's 4 cells) -- its centroid ((0,0)+(1,0)+(0,1))/3 = (1/3,1/3) is + // neither any one of its own cells nor the bounding box's centre (.5,.5), so a fixture built from it can + // tell a true centroid apart from both. + var lMarkerShape = new Shape(2, 2) { [new int2(0, 0)] = true, [new int2(1, 0)] = true, [new int2(0, 1)] = true }; + cache.Upsert(new GearData + { + Name = "LMarker", Hardpoint = HardpointType.Tool, Shape = lMarkerShape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); @@ -165,7 +200,7 @@ public sealed class FireControlCut12Tests : IDisposable // is replayable byte-for-byte across runs. A GUID-suffixed name here (this file's own earlier defect) // reseeded the die on every run, which is exactly what made TurningArmourIntoTheShotTakesItOnTheArmour // flaky (F2, Soul's fix batch, 2026-09-24). - var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut12", Owner = null }, null); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem { @@ -212,6 +247,18 @@ public sealed class FireControlCut12Tests : IDisposable Assert.Equal(4, bigMarkerCells.Length); // fixture precondition: the full 2x2 footprint landed } + EquippedItem lMarker = null; + int2[] lMarkerCells = null; + if (equipLMarker) + { + var lm = Make("LMarker", lot++, 1000000); + Assert.True(target.TryFindSpace(lm, out var lmPos)); + Assert.True(target.TryEquip(lm, lmPos)); + lMarker = target.Equipment.Single(x => x.EquippableItem == lm); + lMarkerCells = hullShape.Coordinates.Where(v => target.GearOccupancy[v.x, v.y] == lMarker).ToArray(); + Assert.Equal(3, lMarkerCells.Length); // fixture precondition: the full 3-cell L footprint landed + } + if (equipShield) { var reactor = Make("Reactor", lot++, 10); @@ -247,7 +294,8 @@ public sealed class FireControlCut12Tests : IDisposable return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, - HullData = hullData, Markers = markers, BigMarker = bigMarker, BigMarkerCells = bigMarkerCells + HullData = hullData, Markers = markers, BigMarker = bigMarker, BigMarkerCells = bigMarkerCells, + LMarker = lMarker, LMarkerCells = lMarkerCells }; } @@ -397,6 +445,73 @@ public sealed class FireControlCut12Tests : IDisposable Assert.Equal(expected, p, 4); } + // :~483 fix batch (Hands, 2026-09-25): `if (p <= 0f) return 0f;` in CommitProbability is a guard against + // building a silhouette for a shot that cannot hit -- Stryker's own `p < 0` boundary mutant (excluding only + // the single value p == 0 exactly) survived because nothing exercised the guard at exactly 0. + // 12.3 fix batch correction: the previous version of this test set `shot.PFire = 0f` on a pending shot by + // hand, then asserted `sil.Intervals == null` -- a shape assertion on CommitProbability's own struct + // default, not a behaviour a bug could actually violate (any caller reading `sil` after a real miss reads + // the same default regardless of whether a Silhouette was built and discarded). Made behavioural per the + // 12.2 postmortem: PFire == 0 is now produced through the real gate (closing the shooter's own + // VisibleEntities, the same way S4's InvisibleTargetStillResolvesAtItsRealFlightTime does), and the claim + // "no Silhouette was ever built" is checked the only way that is actually true of a function's own + // behaviour: it allocates nothing. The `p < 0` mutant lets p == 0 fall through to build a real Silhouette + // (Array.Sort, ResolveAimPoint, an owned Interval[]), which is not zero-allocation -- this test kills it + // the same way S3's own zero-allocation pins do, not by re-reading a field the mutant never touches. + [Fact] + public void ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette() + { + var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f); + e.Shooter.VisibleEntities.Remove(e.Target); // closes the real gate -- PFire == 0 through the actual path + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Equal(0f, shot.PFire); // fixture precondition: the gate is actually closed + + for (var i = 0; i < 50; i++) FireControl.CommitProbability(shot, e.Zone.Time, out _); // JIT warm-up + + var before = GC.GetAllocatedBytesForCurrentThread(); + var p = FireControl.CommitProbability(shot, e.Zone.Time, out var sil); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0f, p); + Assert.Equal(0, sil.Count); + Assert.Equal(0, allocated); // no Silhouette was built: Array.Sort/ResolveAimPoint never ran + + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + e.Zone.Update(.01f); + Assert.NotNull(outcome); + Assert.False(outcome.Hit, "fixture: PFire 0 must commit as a clean miss"); + } + + // S4 fix batch (Hands, 2026-09-25): F8 (Soul's earlier fix batch, 2026-09-24) moved range/flightTime + // computation ahead of the visibility gate, so Fire's own flight time reflects the real distance even when + // PFire prices the shot at 0 -- but nothing behavioural pinned that. N4 (Soul's mutation set: "range after + // visibility gate", reverting F8) survived every existing test. A shot at a target the shooter cannot + // currently see must still resolve, as a miss (PFire gates it to 0), at the arrival time the real + // range/velocity imply -- not immediately (range 0) and not never (flight time 0 means instant resolve). + [Fact] + public void InvisibleTargetStillResolvesAtItsRealFlightTime() + { + var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f, velocity: 20); // range 100 -> 5s flight + e.Shooter.VisibleEntities.Remove(e.Target); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Equal(0f, pending.PFire); // fixture precondition: the visibility gate is actually closed + var expectedArrival = pending.FireTime + 100f / 20f; // the real range (100) over the real velocity (20) + Assert.Equal(expectedArrival, pending.ArrivalTime, 3); + + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + e.Zone.Update(4.9f); + Assert.Null(outcome); // not yet: N4's bug (range 0) would have resolved this instantly + e.Zone.Update(.2f); // past the real 5s arrival + Assert.NotNull(outcome); + Assert.False(outcome.Hit, "fixture: PFire 0 (not visible) must resolve as a miss"); + } + // Stryker survivors: ResolveAimPoint's fallback (aimed but currently occupying zero cells -> hull centre // of mass) and its multi-cell average were both untested -- every existing marker fixture uses the // catalog's 1x1 "Marker" stub, where sum/length and sum*length agree at length 1, and no fixture ever @@ -457,6 +572,42 @@ public sealed class FireControlCut12Tests : IDisposable $"empirical mean lateral offset {empiricalMeanLateral:F3} does not track the centroid's own lane {expectedA:F3}"); } + // S8 fix batch (Hands, 2026-09-25): the BigMarker test above only ever exercises a 2x2 (square) footprint, + // whose true centroid and bounding-box centre are the same point -- N11 (aim point = bounding-box centre + // instead of the cell average) survived because nothing could tell the two apart. An L-tromino (3 of a + // 2x2's 4 cells) makes them different points: this fixture pins the aim point against the exact centroid + // the test computes itself, and separately checks it against the wrong (bounding-box) candidate to prove + // the fixture actually distinguishes them. + [Fact] + public void AimingAtAnLShapedItemCentresOnItsCentroidNotItsBoundingBox() + { + // A diagonal facing (not axis-aligned) puts the L-shape's actual asymmetry -- its centroid sits off + // the bounding-box centre along the diagonal, not along either grid axis -- fully onto the lateral + // axis, instead of losing most of it to the axis a purely axis-aligned facing would leave unmeasured. + var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: normalize(float2(1, 1)), equipLMarker: true); + Assert.True(e.Shooter.TrySelectTargetItem(e.LMarker)); + + var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + var bearing = normalize(e.Target.ToSchematic(travelDirection)); + var ell = float2(-bearing.y, bearing.x); + var centroid = e.LMarkerCells.Aggregate(float2.zero, (t, c) => t + (float2) c) / e.LMarkerCells.Length; + var boundingBoxCentre = (float2(e.LMarkerCells.Min(c => c.x), e.LMarkerCells.Min(c => c.y)) + + float2(e.LMarkerCells.Max(c => c.x), e.LMarkerCells.Max(c => c.y))) / 2f; + Assert.True(lengthsq(centroid - boundingBoxCentre) > .01f, "fixture: the L-shape's centroid and bounding-box centre must actually differ"); + + var expectedA = dot(centroid, ell); + var wrongA = dot(boundingBoxCentre, ell); + Assert.True(Math.Abs(expectedA - wrongA) > .15f, "fixture: the two candidate aim points must project to distinguishably different lateral values"); + + var outcomes = FireMany(e, 3000); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 300, $"expected a healthy number of hits, got {hits.Count}"); + + var empiricalMeanLateral = hits.Average(o => (double) o.Lateral); + Assert.True(Math.Abs(empiricalMeanLateral - expectedA) < .1, + $"empirical mean lateral offset {empiricalMeanLateral:F3} (bounding-box centre would predict {wrongA:F3}) does not track the true centroid's own lane {expectedA:F3}"); + } + // BroadsideIsEasierThanHeadOn: kills the bearing being ignored and the old max(W,H) bound. A 2x12 hull is // much easier to hit broadside (its long axis facing the shot) than head-on (its short axis facing it). [Fact] @@ -527,74 +678,240 @@ public sealed class FireControlCut12Tests : IDisposable Assert.True(outcome.Cell.y >= 5.5f, $"expected every hit to land on the bow row (y>=5.5), got cell {outcome.Cell}"); } - // F1 (Soul's fix batch, 2026-09-24): LateralDraw's clamp used to close the top of the shadow interval - // ([Lo, Hi] instead of the spec's half-open [Lo, Hi)). Soul enumerated all 2^24 NextFloat outputs against - // this exact geometry (TurningArmourIntoTheShotTakesItOnTheArmour's own 3x11 hull, bearing (0,-1), - // Precision .4 -> interval [-0.5, 2.5]) and found 3 values of u that drew s == Hi == 2.5 exactly, at which - // Lane's own half-open filter (`s >= centre + h` continues, :731) then found no candidate cell at all, - // returning an empty lane for a shot that had already passed its roll (R3 violation) -- the reflection - // call below reproduces that exact draw. u=0 is the analogous exact-Lo case (the first NextFloat output). + // S1 (Soul's second pass, 2026-09-25): at a non-axis-aligned bearing, a lateral draw exactly at an + // interval's Lo used to touch only a cell's corner -- Lane's own shadow prefilter admitted the cell but the + // independent SlabAlongB slab test rejected it on float rounding, Lane returned zero cells, and Commit's + // own guard threw InvalidOperationException out of Zone.Update (R3 violation: a hit that already passed + // its roll must always land on metal). Soul reproduced this live, not through reflection into a private + // method: zone name Cut12-edge-68442709 (StableHash seeds Zone.CombatSeed, so this exact name reproduces + // the exact die roll), a 7x9 hull, Precision 2, target facing 1 degree, shot 1. Fixed by deleting the + // second, disagreeing gate (this fix batch's FireControl.cs change) -- this pins the exact repro against a + // real Commit, not a reflection call, and checks the placement lands where the roll priced it. [Fact] - public void LateralDrawIsHalfOpenAtTheShadowsTopEdge() + public void ShotAtBearingLoTouchingOnlyACellCornerHitsMetal() { - var hull = new HullData { Name = "H", HullType = HullType.Ship, Shape = SolidShape(3, 11) }; - var bearing = float2(0, -1); - const float precision = .4f; - var sil = FireControl.Silhouette(null, hull, null, bearing, precision); - Assert.Equal(1, sil.Count); // fixture precondition: one interval, matching Soul's own probe - Assert.Equal(-0.5f, sil.Intervals[0].Lo, 3); - Assert.Equal(2.5f, sil.Intervals[0].Hi, 3); + var e = Build(TestSettings(), SolidShape(7, 9), precision: 2f, targetFacing: Deg(1), zoneName: "Cut12-edge-68442709"); - var lateralDraw = (Func) typeof(FireControl) - .GetMethod("LateralDraw", BindingFlags.NonPublic | BindingFlags.Static) - .CreateDelegate(typeof(Func)); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); - void AssertLandsOnMetal(float u, string label) - { - var s = lateralDraw(sil, u); - var buffer = new LaneCell[hull.Shape.Coordinates.Length]; - var count = FireControl.Lane(hull, bearing, s, buffer); - Assert.True(count > 0, $"{label}: u={u:R} drew s={s:R} (interval [{sil.Intervals[0].Lo},{sil.Intervals[0].Hi}]) -- Lane found no occupied cell for a hit that already passed its roll"); - Assert.True(hull.Shape[buffer[0].Cell], $"{label}: Lane's own first cell {buffer[0].Cell} is not on the hull's schematic"); - } + Assert.NotNull(outcome); + Assert.True(outcome.Hit, "fixture: this exact seed must roll a hit (this is the repro, not a general property)"); + Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, not on the hull's own schematic"); - AssertLandsOnMetal(0.9999998f, "at the top edge (u drives s to exactly Hi)"); - AssertLandsOnMetal(0f, "at the bottom edge (u drives s to exactly Lo)"); + var ell = float2(-outcome.Bearing.y, outcome.Bearing.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + var centre = dot((float2) outcome.Cell, ell); + Assert.True(outcome.Lateral >= centre - h && outcome.Lateral < centre + h, + $"hit cell {outcome.Cell}'s own projected lateral interval [{centre - h},{centre + h}) does not contain the drawn Lateral {outcome.Lateral}"); } - // Entity.ApplyHit's own penetration march, replicated independently (not by calling ApplyHit or reading - // its source beyond the documented convention 12.2 explicitly leaves untouched: cell centres at - // +.5, .5-unit steps, hullData.Shape[int2(point)] as the stop condition). Used to compute, from a frozen - // Bearing, the full set of cells a march would consume -- independent of which bearing (committed or - // live) the test feeds it. - private static HashSet ExpectedMarchCells(HullData hull, int2 cell, float2 bearing, float penetration) + // S1: the same class of empty-lane crash on the ruling's own TurningArmourIntoTheShotTakesItOnTheArmour + // geometry (3x11 hull, velocity 20, Precision .4, the fire-then-turn sequence that fixture uses) -- + // Soul's search found a zone name (Cut12-edge-64078070) whose draw lands on an interval edge after the + // target turns its bow into the shot. + [Fact] + public void ShotAtBearingLoOnTurningArmourHitsMetal() { - var cells = new HashSet { cell }; - if (penetration <= .5f) return cells; - var vector = normalize(bearing); - var point = (float2) cell + float2(.5f); - var distance = 0f; - while (distance < penetration && hull.Shape[int2(point)]) + var e = Build(TestSettings(), SolidShape(3, 11), precision: .4f, velocity: 20, zoneName: "Cut12-edge-64078070"); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + var travelDirection = pending.TravelDirection; + + // At Fire: bow faces the same way the shot travels (stern exposed). Before commit: turn the bow to + // face the incoming shot head-on, TurningArmourIntoTheShotTakesItOnTheArmour's own sequence. + e.Target.Direction = travelDirection; + e.Zone.Update(1f); + e.Target.Direction = -travelDirection; + + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + e.Zone.Update(4f); // past commit (4.5s), short of arrival (5s) + e.Zone.Update(2f); // past arrival + + Assert.NotNull(outcome); + Assert.True(outcome.Hit, "fixture: this exact seed/sequence must roll a hit (this is the repro, not a general property)"); + Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, not on the hull's own schematic"); + Assert.True(outcome.Cell.y >= 5.5f, $"expected the hit to land on the bow row (y>=5.5), got cell {outcome.Cell}"); + + var ell = float2(-outcome.Bearing.y, outcome.Bearing.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + var centre = dot((float2) outcome.Cell, ell); + Assert.True(outcome.Lateral >= centre - h && outcome.Lateral < centre + h, + $"hit cell {outcome.Cell}'s own projected lateral interval [{centre - h},{centre + h}) does not contain the drawn Lateral {outcome.Lateral}"); + } + + // S1 sweep: across many bearings and precisions, on hulls including a non-convex (holed) one, Lane must + // find at least one occupied cell for every lateral offset at an interval's own Lo, and just below its Hi + // -- the exact edges LateralDraw's own half-open clamp can produce (u=0 draws Lo; u near 1 draws just + // below Hi). An empty lane at either edge is precisely the class of crash S1 fixed; this is the general + // property the two named-seed repros above are specific instances of. + [Fact] + public void LaneNeverEmptiesAtAnIntervalEdge() + { + var shapes = new[] { SolidShape(7, 9), SolidShape(3, 11), HoledShape(7, 7) }; + var precisions = new[] { .3f, .5f, 1f, 2f }; + var failures = new List(); + + foreach (var shape in shapes) { - distance += .5f; - cells.Add(int2(point)); - point += vector * .5f; + var hull = new HullData { Name = "Sweep", HullType = HullType.Ship, Shape = shape }; + var buffer = new LaneCell[shape.Coordinates.Length]; + for (var deg = 0; deg < 360; deg += 3) + { + var b = Deg(deg); + foreach (var precision in precisions) + { + var sil = FireControl.Silhouette(null, hull, null, b, precision); + for (var k = 0; k < sil.Count; k++) + { + var iv = sil.Intervals[k]; + if (FireControl.Lane(hull, b, iv.Lo, buffer) == 0) + failures.Add($"{shape.Width}x{shape.Height} deg={deg} p={precision} k={k} at Lo={iv.Lo:R}"); + var justBelowHi = Math.Max(iv.Lo, iv.Hi - 1e-4f - 1e-5f); + if (FireControl.Lane(hull, b, justBelowHi, buffer) == 0) + failures.Add($"{shape.Width}x{shape.Height} deg={deg} p={precision} k={k} just below Hi={iv.Hi:R}"); + } + } + } } - return cells; + + Assert.True(failures.Count == 0, $"{failures.Count} empty lanes at an interval edge, e.g.: {string.Join("; ", failures.Take(5))}"); + } + + // Carried into 12.3's brief (12.2 Soul pass 3): pins Lane's own seam contract directly, against a 2x1 hull + // -- the smallest fixture where a seam actually falls between two occupied cells. At b=(0,1), ell=(-1,0), + // so cell (0,0)'s shadow is [-.5,.5) and cell (1,0)'s is [.5,1.5): s=-0.5 must land only in (0,0)'s column, + // and s=0.5 (the shared seam, exclusive on (0,0)'s side and where (1,0)'s own interval starts) must find + // NOTHING here, because this fixture's hull is only 1 cell wide at y=0 -- there is no (1,0). Mutation: the + // `s >= lateral.Hi` admission test at FireControl.cs:~817 flipped to `s > lateral.Hi` would let s=0.5 + // through as still touching (0,0) (0.5 is not > 0.5), which on a hull that DID have a second column would + // interleave the two columns' lanes at the seam -- reproduced directly here as "an empty lane's cell count + // is wrong", not by requiring the second column to exist. + [Fact] + public void LaneSeamIsHalfOpen() + { + var shape = new Shape(1, 1) { [new int2(0, 0)] = true }; + var hull = new HullData { Name = "Seam", HullType = HullType.Ship, Shape = shape }; + var b = float2(0, 1); + var buffer = new LaneCell[shape.Coordinates.Length]; + + var atLo = FireControl.Lane(hull, b, -0.5f, buffer); + Assert.Equal(1, atLo); + Assert.Equal(new int2(0, 0), buffer[0].Cell); + + var atHi = FireControl.Lane(hull, b, 0.5f, buffer); + Assert.Equal(0, atHi); // the seam itself: half-open on the Hi side, so this cell's own shadow excludes it + } + + // Carried into 12.3's brief (12.2 Soul pass 3): AlongBearing/SlabAxis had no test of their own VALUES -- + // every mutant there survived in 12.2 because only buffer[0]'s Cell was ever read (S1's own shadow + // prefilter, Extent, decided admission; AlongBearing's entry/exit were computed but unchecked). 12.3's own + // walk (FireControl.Apply) reads Entry for penetration and ordering, so a wrong entry/exit here is now a + // wrong damage cell, not dead arithmetic. + // F10 fix batch (Soul, 2026-09-25): the previous, single-bearing version of this test wrote out SlabAxis's + // OWN threshold (`1e-9f`) and clamp a second time, so a mutant that only moved that threshold (C7b, 1e-9 to + // .2) agreed with both copies identically and survived. This version never special-cases a near-zero + // bearing component at all: IEEE division by a signed zero already yields the correctly signed infinity, so + // an axis with no motion is unconstrained without a hand-written epsilon to disagree with SlabAxis's own. + // Checked at several bearings, including one within about 11 degrees of an axis -- inside SlabAxis's + // mutated .2f threshold but nowhere near the real 1e-9f one, so C7b now has to answer for a visibly wrong + // entry/exit instead of an unstressed one. + [Fact] + public void AlongBearingReturnsTheExactSlabIntersectionAtSeveralBearings() + { + var shape = SolidShape(5, 5); + var hull = new HullData { Name = "Diagonal", HullType = HullType.Ship, Shape = shape }; + + (double Entry, double Exit) ExactSlabIndependent(int2 c, double bx, double by, double ellx, double elly, double s) + { + var valX = s * ellx; var valY = s * elly; + var t1X = (c.x - .5 - valX) / bx; var t2X = (c.x + .5 - valX) / bx; + var t1Y = (c.y - .5 - valY) / by; var t2Y = (c.y + .5 - valY) / by; + var entry = Math.Max(Math.Min(t1X, t2X), Math.Min(t1Y, t2Y)); + var exit = Math.Min(Math.Max(t1X, t2X), Math.Max(t1Y, t2Y)); + return (entry, exit); + } + + void Check(float2 bRaw, float s) + { + var b = normalize(bRaw); + var ell = float2(-b.y, b.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2.0; // Extent's own half-width, written out independently + var expectedCells = shape.Coordinates + .Select(c => (Cell: c, Centre: (double) dot((float2) c, ell))) + .Where(t => s >= t.Centre - h && s < t.Centre + h) // Extent's own half-open shadow + .Select(t => t.Cell) + .OrderBy(c => dot((float2) c, b)) + .ThenBy(c => c.x).ThenBy(c => c.y) + .ToList(); + Assert.True(expectedCells.Count >= 3, $"fixture: b={b.x:R},{b.y:R} s={s} must cross several cells, got {expectedCells.Count}"); + + var buffer = new LaneCell[shape.Coordinates.Length]; + var count = FireControl.Lane(hull, b, s, buffer); + var actualCells = Enumerable.Range(0, count).Select(i => buffer[i].Cell).ToList(); + + Assert.Equal(expectedCells, actualCells); // same cells, same order -- entry ordering and contiguity agree + + for (var i = 0; i < count; i++) + { + var (expectedEntry, expectedExit) = ExactSlabIndependent(buffer[i].Cell, b.x, b.y, ell.x, ell.y, s); + Assert.Equal(expectedEntry, buffer[i].Entry, 3); + Assert.Equal(expectedExit, buffer[i].Exit, 3); + } + } + + Check(float2(2, 1), 0.13f); // neither axis-aligned nor 45 degrees (the original fixture) + Check(float2(1, .15f), .3f); // ~8.5 degrees off the x-axis -- by sits inside C7b's mutated .2f threshold + Check(float2(.15f, 1), -.4f); // the same, off the y-axis -- bx sits inside C7b's mutated .2f threshold + } + + // Cut 12.3: replaces ExpectedMarchCells, which reproduced Entity.ApplyHit's own deleted 0.5-step march -- + // that rule is gone with the function. The rule this now pins is the NEW thing FireControl.Apply's lane + // walk adds on top of Lane (Lane's own cell set/order/entry values are independently pinned elsewhere, by + // AlongBearingReturnsTheExactSlabIntersectionAtSeveralBearings and the LaneNeverEmptiesAtAnIntervalEdge sweep): the + // penetration cutoff. So this calls the real, already-verified FireControl.Lane for the walked cells and + // their entry parameters, then applies the cutoff independently -- kept only while within `penetration` + // cells of the first (impact) cell's own entry; the impact cell is always kept, matching the deleted + // `> .5f` threshold's intent at zero cost. + private static List ExpectedLaneCells(HullData hull, float2 bearing, float lateral, float penetration) + { + var buffer = new LaneCell[hull.Shape.Coordinates.Length]; + var walked = FireControl.Lane(hull, bearing, lateral, buffer); + + var result = new List(); + if (walked == 0) return result; + var impactEntry = buffer[0].Entry; + for (var i = 0; i < walked; i++) + { + if (i > 0 && buffer[i].Entry - impactEntry >= penetration) break; + result.Add(buffer[i].Cell); + } + return result; } // TurningAfterCommitChangesNothing (R4, closes L464/L465): after commit, turning the target and moving the // shooter must not change where the damage actually lands -- Apply reads no live position or facing. - // F5 (Soul's fix batch, 2026-09-24): the original fixture fired with Penetration 0, so ApplyHit's march - // (Entity.cs, gated at `penetration > .5f`) never ran and never actually read the bearing Apply passed it - // -- a mutant that fed Apply a live (post-turn) bearing instead of the committed one survived (M2a) because - // nothing downstream of Cell ever consumed Bearing. Penetration 3 marches several cells deep, so the full - // damaged-cell set is asserted against the committed Bearing/Lateral, not the live one. + // F5 (Soul's fix batch, 2026-09-24): the original fixture fired with Penetration 0, so the penetration march + // never ran and never actually read the bearing Apply passed it -- a mutant that fed Apply a live + // (post-turn) bearing instead of the committed one survived (M2a) because nothing downstream of Cell ever + // consumed Bearing. Penetration 3 walks the lane several cells deep (Cut 12.3: FireControl.Apply's own lane + // walk, replacing the deleted Entity.ApplyHit march), so the full damaged-cell set is asserted against the + // committed Bearing/Lateral, not the live one. + // Cut 12.3 fix: this fixture's hull carries the flat 1000 armour every Build() call authors, and the Gun's + // own Damage is a fixed 5 -- fine for the old even-split model (every cell in the march took an + // undetectable-but-nonzero 5/N share), fatal for the new sequential one, where the very first (impact) cell + // alone would absorb the whole 5 and leave nothing for the rest of a several-cell-deep lane to show. Lowered + // to 0.05 per cell here so 5 damage plainly carries the full penetration-3 span. [Fact] public void TurningAfterCommitChangesNothing() { var settings = TestSettings(); var e = Build(settings, SolidShape(7, 7), precision: .5f, velocity: 20, penetration: 3); // 5s flight, commits at 4.5s + foreach (var v in e.HullData.Shape.Coordinates) { e.Target.Armor[v.x, v.y] = 0.05f; e.Target.MaxArmor[v.x, v.y] = 0.05f; } ShotOutcome committed = null; using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); @@ -612,7 +929,7 @@ public sealed class FireControlCut12Tests : IDisposable var expectedCell = NearestOccupiedCellAtLateral(e.HullData, committed.Bearing, committed.Lateral); Assert.Equal(expectedCell, committed.Cell); // sanity: production's own Lane agrees with the independent recompute - var expectedFromCommitted = ExpectedMarchCells(e.HullData, committed.Cell, committed.Bearing, 3f); + var expectedFromCommitted = ExpectedLaneCells(e.HullData, committed.Bearing, committed.Lateral, 3f).ToHashSet(); var before = (float[,]) e.Target.Armor.Clone(); @@ -628,14 +945,14 @@ public sealed class FireControlCut12Tests : IDisposable // hand-rolled copy of the frame math. var liveTravelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); var wrongBearing = normalize(e.Target.ToSchematic(liveTravelDirection)); - var expectedFromLive = ExpectedMarchCells(e.HullData, committed.Cell, wrongBearing, 3f); + var expectedFromLive = ExpectedLaneCells(e.HullData, wrongBearing, committed.Lateral, 3f).ToHashSet(); Assert.NotEqual(expectedFromCommitted, expectedFromLive); e.Zone.Update(2f); // past arrival (5s) foreach (var cell in expectedFromCommitted) Assert.True(e.Target.Armor[cell.x, cell.y] < before[cell.x, cell.y], - $"cell {cell} is on the committed march and must have taken damage, regardless of the post-commit turn"); + $"cell {cell} is on the committed lane and must have taken damage, regardless of the post-commit turn"); foreach (var cell in expectedFromLive) if (!expectedFromCommitted.Contains(cell)) @@ -803,14 +1120,28 @@ public sealed class FireControlCut12Tests : IDisposable Assert.True(Math.Abs(empiricalShareA - expectedShareA) < .05, $"prong A's empirical share {empiricalShareA:F3} does not match its analytic share {expectedShareA:F3}"); - // Within-prong shape, not just the aggregate share: prong A is x in {0,1}, projected lateral centres - // 0 and -1 respectively (ell=(-1,0)); a=-3 sits closer to x=1's centre (-1) than x=0's (0), so a real - // Gaussian shape (not a coin flip within the prong, and not "first cell only") puts strictly more mass - // on x=1 than x=0. + // S2 fix batch (Hands, 2026-09-25): within-prong SHAPE, pinned by ratio, not just direction. Prong A is + // x in {0,1}, projected lateral centres 0 and -1 respectively (ell=(-1,0)), a=-3. The old + // "atNearColumn > atFarColumn" check survived M12/M12b (an entire second silhouette built at sigma + // floor .5 or at sigma*sqrt(2)) and the sigma/sqrt(2) Stryker mutants at :~753/:~750, because every one + // of those wrong sigmas still puts more mass on the nearer column -- direction alone can't tell a + // sigma of 1.5 from a sigma of 1.5*sqrt(2). The RATIO can: Soul's own figures are ~2.9 at the true + // sigma against ~1.73 at sigma*sqrt(2), well separated at 8000 shots. The near and far columns' own raw + // (unmerged) intervals are exactly the two ends of `raw` for prong A, reused here rather than + // recomputed. + var nearInterval = raw.First(iv => Math.Abs(iv.Lo - (-1.5f)) < 1e-3f); // x=1: [-1.5,-0.5) + var farInterval = raw.First(iv => Math.Abs(iv.Lo - (-0.5f)) < 1e-3f); // x=0: [-0.5,0.5) + var nearMass = Mass(nearInterval); + var farMass = Mass(farInterval); + var analyticRatio = nearMass / farMass; + Assert.True(analyticRatio > 2.3, $"fixture: analytic near/far ratio ({analyticRatio:F3}) must be well clear of the wrong-sigma ratio (~1.73) to actually distinguish the two"); + var atNearColumn = hits.Count(o => o.Cell.x == 1); var atFarColumn = hits.Count(o => o.Cell.x == 0); - Assert.True(atNearColumn > atFarColumn, - $"expected the column nearer the aim point (x=1, {atNearColumn} hits) to outweigh the far one (x=0, {atFarColumn} hits)"); + Assert.True(atNearColumn > 0 && atFarColumn > 0, $"expected hits on both columns, got near={atNearColumn} far={atFarColumn}"); + var empiricalRatio = atNearColumn / (double) atFarColumn; + Assert.True(Math.Abs(empiricalRatio - analyticRatio) < .6, + $"empirical near/far ratio {empiricalRatio:F3} ({atNearColumn}/{atFarColumn}) does not track the analytic ratio {analyticRatio:F3} -- a sigma perturbed by sqrt(2) would give ~1.73 instead"); } // HitsLandOnTheFacingEdge supersedes EveryHitLandsOnMetal: on a concave (holed) hull, every hit's Cell is @@ -893,10 +1224,15 @@ public sealed class FireControlCut12Tests : IDisposable // a target jinked off its fire-time projection so pDeviation is strictly less than 1) and checks the // EMPIRICAL hit fraction over the real Commit path against the HUD's own forecast, with a tolerance sized // from the sample (3 sigma of a Bernoulli(p, n)). + // S7 fix batch (Hands, 2026-09-25): Tracking is now authored through Build's own `tracking` parameter (one + // authoring path for every frozen shooter stat, matching Precision/Accuracy) instead of patching + // PendingShot.Tracking after every Fire -- the operator's own rule (2026-09-22) is that a fixture should + // author its state up front, not reach into a frozen field post hoc. An authored Tracking of 6 against this + // 5x7 hull and jink gives pDeviation ~= .33, strictly between 0 and 1, with no patching. [Fact] public void TheHudEstimateIsTheCommitPrice() { - var e = Build(TestSettings(), SolidShape(5, 7), precision: .5f, velocity: 40, spread: 6f); // 2.5s flight, commits at 2.0s + var e = Build(TestSettings(), SolidShape(5, 7), precision: .5f, velocity: 40, spread: 6f, tracking: 6f); // 2.5s flight, commits at 2.0s var origin = e.Target.Position; const int shots = 800; @@ -906,16 +1242,8 @@ public sealed class FireControlCut12Tests : IDisposable e.Target.Position = origin; // reset before each Fire so every shot freezes the same FireTargetPosition var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.001f); - // The fixture's own Targeting system freezes Tracking = 100000 (full-strength isolation, Build's - // own convention) -- against that, a jink small enough to keep the target on this 5x7 hull barely - // registers (pDeviation ~= .99996). Lower the frozen Tracking after the fact, the same way - // CommitProbabilityIsTheExactProductOfItsFourFactors overrides a frozen field, so the jink below - // produces a pDeviation strictly between 0 and 1. var index = e.Zone.PendingShots.FindIndex(s => s.ShotId == shotId); - var shot = e.Zone.PendingShots[index]; - shot.Tracking = 6f; - e.Zone.PendingShots[index] = shot; - pendingSnapshots.Add(shot); + pendingSnapshots.Add(e.Zone.PendingShots[index]); } // One jink, after every shot is queued, before any of them commit -- pDeviation < 1 for all of them, @@ -994,4 +1322,69 @@ public sealed class FireControlCut12Tests : IDisposable Assert.True(AbsorbsFrom(float2(0, -1))); // stern toward the shot Assert.True(AbsorbsFrom(float2(1, 0))); // beam } + + // S3 fix batch (Hands, 2026-09-25): Lane's own comment ("never allocates on its own") and Silhouette's + // pooled path are pinned directly, instead of trusting the comment. Measured (Release, LonginusX-shaped + // fixture unavailable to this test file -- a 7x9 solid hull is used instead): Lane and Silhouette (given a + // caller-supplied buffer) allocate exactly 0 bytes over 1000 calls, confirming the S3 change itself + // (Array.Sort(T[], int, int) resolving Interval/LaneCell's own IComparable at JIT time, not the + // (T[], int, int, IComparer) overload that wrapped even a cached singleton comparer in a fresh delegate + // per call). A full gated-in HitProbability call is NOT zero-allocation (measured ~120 B/call, Release) -- + // 12.3 fix batch correction: that cost is exactly three GetBehavior calls (Accuracy, + // Resolution, Precision, ~40 B each), each walking Entity.Equipment (a ReactiveCollection, heap-allocated + // enumerator per foreach) -- NOT `Behaviors` (a plain array, the earlier wording here was wrong; see the + // corrected comment on Forecast). It predates this cut and is out of its scope. The loose `< 500_000` bound + // below is a regression trip-wire, not a proof: a reintroduced per-call IComparer wrapper (~184 B/call) + // would pass it easily. The two zero-allocation asserts just above are what actually catch that regression + // -- Lane and Silhouette are exactly where such a wrapper would reappear -- so the bound here only needs to + // catch something far larger, such as a reintroduced unpooled Interval[]/LaneCell[] on this path. + [Fact] + public void LaneAndSilhouetteAllocateNothingGivenAPooledBuffer() + { + var e = Build(TestSettings(), SolidShape(7, 9), precision: .5f); + var hull = e.HullData; + var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + var bearing = normalize(e.Target.ToSchematic(travelDirection)); + var sil = FireControl.Silhouette(e.Target, hull, null, bearing, .5f); + var laneBuffer = new LaneCell[hull.Shape.Coordinates.Length]; + var intervalBuffer = new Interval[hull.Shape.Coordinates.Length]; + + for (var i = 0; i < 100; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); // JIT warm-up + for (var i = 0; i < 100; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); + + // Steady state, not first-use: under a whole-suite run the runtime's background tier-up is delayed by the + // other tests' JIT activity, so a one-off allocation (measured: 1232 bytes over 1000 calls, in 1 of 12 + // full-suite runs and 0 of 100 isolated runs) can land inside a single window. That is a cost paid once. + // A per-call allocation, the regression this test guards, lands in EVERY round, so the smallest round + // over a bounded number of rounds is the steady-state allocation. + long SmallestRoundAllocation(Action round) + { + var smallest = long.MaxValue; + for (var r = 0; r < 20 && smallest != 0; r++) + { + var start = GC.GetAllocatedBytesForCurrentThread(); + round(); + smallest = Math.Min(smallest, GC.GetAllocatedBytesForCurrentThread() - start); + } + return smallest; + } + + var laneAllocated = SmallestRoundAllocation(() => { for (var i = 0; i < 1000; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); }); + Assert.True(laneAllocated == 0, $"1000 Lane calls allocated {laneAllocated} bytes in every round; \"never allocates\" is not true"); + + var silhouetteAllocated = SmallestRoundAllocation(() => { for (var i = 0; i < 1000; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); }); + Assert.True(silhouetteAllocated == 0, $"1000 buffered Silhouette calls allocated {silhouetteAllocated} bytes in every round"); + + // Recorded, not asserted zero: the full gated-in HitProbability call still allocates, from Entity's own + // collection enumeration (GetBehavior over ReactiveCollection), not from anything this fix batch + // touches. A loose upper bound catches a real regression (e.g. a reintroduced comparer wrapper) without + // pretending Entity's own cost is Lane's or Silhouette's to fix here. + for (var i = 0; i < 100; i++) FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + var before = GC.GetAllocatedBytesForCurrentThread(); + var sum = 0f; + for (var i = 0; i < 1000; i++) sum += FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + var hitProbabilityAllocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(sum > 0f, "fixture: this must be a real, gated-in call, not a gated-out one"); + Assert.True(hitProbabilityAllocated < 500_000, $"1000 gated-in HitProbability calls allocated {hitProbabilityAllocated} bytes -- well above Entity's own ~120 B/call baseline; likely a reintroduced Lane/Silhouette allocation"); + } } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index 2fbc5e52d..5ee04751a 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -247,9 +247,10 @@ public sealed class FireControlCut4Tests : IDisposable Assert.Equal(1, resolvedCount); } - // ---- Cut 4's splash rule: FireControl.Splash is an unconditional area effect (no roll), directional per - // target's own hull facing. Tested directly against the function, no weapon/gear catalog needed -- Entity. - // MapEntity (called from the Entity constructor) is enough to give a Ship its Armor/GearOccupancy/Hull. ---- + // ---- Cut 4's blast rule (12.4(b) renames FireControl.Splash to Detonate, and areas replace the + // directional half-hull split): FireControl.Detonate is an unconditional area effect, no roll. Tested + // directly against the function, no weapon/gear catalog needed -- Entity.MapEntity (called from the Entity + // constructor) is enough to give a Ship its Armor/GearOccupancy/Hull. ---- private ProvenanceLedger _splashLedger; @@ -279,10 +280,10 @@ public sealed class FireControlCut4Tests : IDisposable return ship; } - // Splash is one rule, not per-effect: every entity within radius takes damage, one outside radius takes + // A blast is one rule, not per-effect: every entity within radius takes damage, one outside radius takes // none. Mutation: damage only the nearest entity in range instead of every one. [Fact] - public void SplashHitsEveryEntityInRadius() + public void ABlastHitsEveryEntityItCovers() { var (items, zone) = BuildSplashZone(TestSettings()); var near = AddShip(items, zone, float3(10, 0, 0), float2(0, 1), 1); @@ -293,39 +294,15 @@ public sealed class FireControlCut4Tests : IDisposable var farBefore = far.Hull.Durability; var outBefore = outOfRange.Hull.Durability; - FireControl.Splash(zone, float3.zero, radius: 30, damage: 50, damageType: DamageType.Kinetic); + FireControl.Detonate(zone, float2.zero, radius: 30, damage: 50, damageType: DamageType.Kinetic); Assert.True(near.Hull.Durability < nearBefore); Assert.True(far.Hull.Durability < farBefore); Assert.Equal(outBefore, outOfRange.Hull.Durability); } - // Splash is directional over each target's own hull: cells facing the blast take it, cells facing away do - // not. Mutation: damage the whole shape regardless of direction. - [Fact] - public void SplashIsDirectional() - { - // Nonzero armor so an undamaged cell is distinguishable from a damaged one -- with armor 0 every cell - // starts and stays at 0 regardless of which half took the schematic hit. - var (items, zone) = BuildSplashZone(TestSettings(), armor: 10); - // Facing +Z (the default); the blast sits behind the ship along -Z, so the near half of the hull - // (negative-Z cells, closest to the blast) should take damage and the far half (positive-Z) should not. - var ship = AddShip(items, zone, float3(0, 0, 10), float2(0, 1), 1); - - FireControl.Splash(zone, float3(0, 0, 0), radius: 50, damage: 1000, damageType: DamageType.Kinetic); - - var hullData = (HullData) items.GetData(ship.Hull); - var center = hullData.Shape.CenterOfMass; - var nearCellDamaged = false; - var farCellUntouched = false; - foreach (var v in hullData.Shape.Coordinates) - { - var offset = (float2) v - center; - if (offset.y < -.5f && ship.Armor[v.x, v.y] < ship.MaxArmor[v.x, v.y]) nearCellDamaged = true; - if (offset.y > .5f && ship.Armor[v.x, v.y] >= ship.MaxArmor[v.x, v.y]) farCellUntouched = true; - } - - Assert.True(nearCellDamaged); - Assert.True(farCellUntouched); - } + // Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in + // FireControlCut124Tests.cs succeeds this test (SplashIsDirectional) and Cut11Tests' + // SplashDamagesTheSideTheBlastCameFrom -- a blast is now an area (12.4(b)), not a directional half-hull + // split, so "which cells take it" is pinned by the disc's own coverage, not a facing-rotated half-shape. } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index 48dccca21..e1366721e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -499,12 +499,16 @@ public sealed class FireControlCut5Tests : IDisposable Assert.Equal(0, resolvedCount); } - // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification): FireControl.Splash's shield - // branch must actually break an unabsorbed shield, not merely route the remainder to the hull. Mutation: - // delete `shield.Break()` from Splash -- the target's hull still takes the blast (SplashHitsEveryEntityInRadius - // stays green), but the shield itself never registers the overwhelming hit and keeps absorbing on schedule. + // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification), rewritten to Detonate (12.4(b), + // "Retargeted, because Absorb is deleted" / Q12-9 = A): the blast's shield branch must actually break an + // unabsorbed shield, not merely route the remainder to the hull. A radius this much larger than the hull + // covers essentially the whole disc's damage on the target's own cells, overwhelming a 5-capacity reserve + // however the exact per-cell shares fall -- the expected amount is not loosened, it was never asserted + // precisely here (only that the shield breaks). Mutation: delete `shield.Break()` from Detonate -- the + // target's hull still takes the blast (ABlastHitsEveryEntityItCovers stays green), but the shield itself + // never registers the overwhelming hit and keeps absorbing on schedule. [Fact] - public void SplashBreaksUnabsorbedShield() + public void ABlastBreaksUnabsorbedShield() { var e = Build(TestSettings(), damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0, equipShield: true, shieldCapacity: 5); @@ -512,7 +516,10 @@ public sealed class FireControlCut5Tests : IDisposable Assert.False(e.Target.Shield.Broken); Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 500)); - FireControl.Splash(e.Zone, e.Target.Position, radius: 50, damage: 500, damageType: DamageType.Kinetic); + // radius 1, centred on the target's own position (its hull's centre of mass): well inside the 5x5 + // solid hull's interior, so the disc never spills off the hull and the covered share equals the whole + // damage exactly, the same amount Splash always charged. + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 500, damageType: DamageType.Kinetic); Assert.True(e.Target.Shield.Broken); } @@ -614,12 +621,12 @@ public sealed class FireControlCut5Tests : IDisposable var e = Build(TestSettings(), damage: 10000, velocity: 0, accuracy: 1, resolution: 1, spread: 0, hullDurability: 5, armor: 0); // one hit is lethal - Assert.Contains(e.Target, e.Zone.Entities); + SafeAssert.In(e.Zone, e.Target); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.1f); Assert.True(e.Target.Hull.Durability < .01f); - Assert.DoesNotContain(e.Target, e.Zone.Entities); + SafeAssert.NotIn(e.Zone, e.Target); } // ---- 5.7: delete the two carried-and-unread fields (Soul finding 12). ---- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 72421af4e..788655802 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -78,7 +78,7 @@ public sealed class FireControlCut6Tests : IDisposable GameplaySettings settings, float damage = 10, float range = 1000, float velocity = 0, float spread = 0, float accuracy = 1, float resolution = 1, float tracking = 100000, - float targetRange = 100, bool airburst = false, float airburstRange = 0) + float targetRange = 100, WeaponFuse? fuse = null, float blastRadius = 0) { var hullShape = SolidShape(5, 5); var hullData = new HullData @@ -91,14 +91,15 @@ public sealed class FireControlCut6Tests : IDisposable _openCaches.Add(cache); cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); cache.Upsert(hullData); - // Cut 6b, 6.2: a real WeaponItemData (not the bare GearData earlier cuts' fixtures use) -- it is the - // one catalog type carrying WeaponModifiers (the Airburst flag) and, since this cut, AirburstRange. + // Cut 6b, 6.2 (renamed 12.4(a)): a real WeaponItemData (not the bare GearData earlier cuts' fixtures + // use) -- it is the one catalog type carrying Fuse and BlastRadius. WeaponModifiers no longer decides + // behaviour (Q12-6), so the fixture does not bother setting it. cache.Upsert(new WeaponItemData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, - WeaponModifiers = airburst ? WeaponModifiers.Airburst : WeaponModifiers.None, - AirburstRange = airburstRange, + Fuse = fuse, + BlastRadius = blastRadius, Behaviors = { new InstantWeaponData { Damage = Constant(damage), Range = Constant(range), MinRange = Constant(0), @@ -234,19 +235,20 @@ public sealed class FireControlCut6Tests : IDisposable // ---- 6.2: airburst resolves in the simulation (Soul finding 5). ---- - // An airburst shot resolves via Splash, and Splash does not gate on the discrete hit roll at all -- it - // damages everything in radius unconditionally (Cut 4's rule). Accuracy 0 pins PBase (and so Commit's hit) - // to false with no dependence on the random draw, so a normal discrete shot would apply no damage - // whatsoever (Apply's own `if (!shot.Outcome.Hit) return;`). An airburst shot must still deal its area - // damage. Mutation: call Apply as well as Splash -- harmless here (Apply still no-ops on a miss), so this - // test alone does not catch that mutation; AirburstAndDiscreteNeverDoubleUp below does, with a guaranteed - // hit where an extra Apply call is not a no-op. + // A proximity-fuse shot detonates unconditionally, at arrival, with no roll (12.4(b)'s "Which model a shot + // uses") -- it damages everything in radius regardless of the discrete hit roll (Cut 4's rule, kept). + // Accuracy 0 pins PBase (and so Commit's hit) to false with no dependence on the random draw, so a direct + // hit would apply no damage whatsoever (Apply's own gate on shot.Outcome.Hit). A proximity shot must still + // deal its area damage. Mutation: route a proximity fuse through the direct-hit branch instead -- harmless + // here (that branch still no-ops on a miss), so this test alone does not catch that mutation; + // AirburstAndDiscreteNeverDoubleUp below does, with a guaranteed hit where an extra direct-hit application + // is not a no-op. [Fact] - public void AirburstSplashesEvenOnAGuaranteedMiss() + public void ProximityDetonatesEvenOnAGuaranteedMiss() { var settings = TestSettings(); var e = Build(settings, damage: 500, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000, - targetRange: 50, airburst: true, airburstRange: 100); + targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); var before = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -255,49 +257,52 @@ public sealed class FireControlCut6Tests : IDisposable Assert.True(e.Target.Hull.Durability < before, "An airburst shot must splash regardless of the discrete hit roll."); } - // The double-application Soul was told to hunt for: with a guaranteed hit (accuracy 1), an airburst shot's - // total damage must equal a bare Splash call's own damage -- not more. Mutation: call Apply as well as - // Splash at arrival -- Apply's own discrete cell hit would land on top of Splash's area damage, so the - // airburst shot's total damage exceeds a lone Splash call's. + // The double-application Soul was told to hunt for: with a guaranteed hit (accuracy 1), a proximity shot's + // total damage must equal a bare Detonate call's own damage -- not more. Mutation: route the shot through + // the direct-hit branch as well as Detonate at arrival -- the direct-hit cell would land on top of + // Detonate's area damage, so the shot's total damage exceeds a lone Detonate call's. [Fact] public void AirburstAndDiscreteNeverDoubleUp() { var settings = TestSettings(); - var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, - targetRange: 50, airburst: true, airburstRange: 100); + // 12.4(b)'s area model spreads damage over pi*r^2, so a radius this much larger than the 5x5 target + // needs enough damage that the covered share still clears DamageHull's .1f threshold (matching + // ProximityDetonatesEvenOnAGuaranteedMiss's own damage, above). + var e = Build(settings, damage: 500, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, + targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); var before = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); var actualDelta = before - e.Target.Hull.Durability; - // A fresh, identically-shaped target, damaged by one bare Splash call with the same parameters + // A fresh, identically-shaped target, damaged by one bare Detonate call with the same parameters // FireControl.Fire freezes for this weapon (BurstPosition, for a stationary velocity-0 target, is // exactly its own position -- PredictedIntercept falls back to target.Position below weapon.Velocity's // .01f floor). - var e2 = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, - targetRange: 50, airburst: true, airburstRange: 100); + var e2 = Build(settings, damage: 500, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, + targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); var before2 = e2.Target.Hull.Durability; - FireControl.Splash(e2.Zone, e2.Target.Position, 100, 10, DamageType.Kinetic); + FireControl.Detonate(e2.Zone, e2.Target.Position.xz, 100, 500, DamageType.Kinetic); var expectedDelta = before2 - e2.Target.Hull.Durability; Assert.True(expectedDelta > 0); Assert.Equal(expectedDelta, actualDelta, 3); } - // The other side: a non-airburst shot must never splash, even one that lands exactly on its own frozen - // burst position at zero range (a stationary target, so BurstPosition == the target's own position at - // arrival) -- the zero-radius edge case a careless "splash unconditionally" mutant would still pass - // through, since distance 0 is never greater than radius 0. Accuracy 0 pins the discrete roll to a - // guaranteed miss, so the correct path (Apply, gated on Outcome.Hit) deals no damage at all. Mutation: - // splash unconditionally at arrival instead of gating on BurstRadius -- Splash does not check Hit, so it - // would still deal damage even though this shot never carried the Airburst flag. + // The other side: a shot with no fuse must never detonate, even one whose frozen burst position (unread, + // for a null-fuse shot) would land exactly at zero range on a stationary target -- the zero-radius edge + // case a careless "detonate unconditionally" mutant would still pass through, since distance 0 is never + // greater than radius 0. Accuracy 0 pins the discrete roll to a guaranteed miss, so the correct path + // (Apply's null-fuse branch, gated on Outcome.Hit) deals no damage at all. Mutation: detonate unconditionally + // at arrival instead of switching on the frozen Fuse -- Detonate does not check Hit, so it would still deal + // damage even though this shot never froze a fuse. [Fact] - public void NonAirburstNeverSplashes() + public void NoFuseNeverDetonates() { var settings = TestSettings(); var e = Build(settings, damage: 1000, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000, - targetRange: 50, airburst: false); + targetRange: 50); var before = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -306,6 +311,31 @@ public sealed class FireControlCut6Tests : IDisposable Assert.Equal(before, e.Target.Hull.Durability); } + // ---- Cut 12.4(a): Fire decides "detonates" once, from the weapon's own data fields, never from a label + // (Q12-6, "WeaponModifiers are labels, not behaviour"). A fuse without a radius is inert: it freezes null, + // not the weapon's Fuse. ---- + + // Mutation: freeze `weaponItemData.Fuse` unconditionally instead of gating it on `blastRadius > 0f` -- a + // weapon that carries a fuse but no radius would then freeze a fuse it should not, and Apply's future + // fuse switch (12.4(b)) would try to detonate a shot with nothing to detonate with. + [Fact] + public void FireFreezesFuseOnlyWhenBlastRadiusIsPositive() + { + var settings = TestSettings(); + + var withRadius = Build(settings, fuse: WeaponFuse.Delayed, blastRadius: 40); + FireControl.Fire(withRadius.Weapon, withRadius.WeaponItem, withRadius.Shooter); + var shotWithRadius = withRadius.Zone.PendingShots[0]; + Assert.Equal(WeaponFuse.Delayed, shotWithRadius.Fuse); + Assert.Equal(40f, shotWithRadius.BlastRadius); + + var withoutRadius = Build(settings, fuse: WeaponFuse.Delayed, blastRadius: 0); + FireControl.Fire(withoutRadius.Weapon, withoutRadius.WeaponItem, withoutRadius.Shooter); + var shotWithoutRadius = withoutRadius.Zone.PendingShots[0]; + Assert.Null(shotWithoutRadius.Fuse); + Assert.Equal(0f, shotWithoutRadius.BlastRadius); + } + // ---- negative: ItemManager.Random no longer appears in FireControl.cs; Airburst no longer appears // anywhere under Assets/Scripts/Gameplay (Projectile.cs's fields and ProjectileManager.cs's write-back are // deleted, not merely unused). Enforced by tests/mutation_tests_fire_control_cut6.sh's negative-grep step, diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index 81256f3ec..96fb785d1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -293,10 +293,10 @@ public sealed class FireControlCut8Tests : IDisposable e.Zone.Update(.1f); Assert.True(e.Target.Hull.Durability < .01f); - Assert.DoesNotContain(e.Target, e.Zone.Entities); + SafeAssert.NotIn(e.Zone, e.Target); Assert.False(e.Target.Active); Assert.Empty(e.Target.EntityInfoGathered); - Assert.Empty(e.Target.VisibleEntities); + Assert.True(e.Target.VisibleEntities.Count == 0, "the target's visible set must be empty"); } // ---- Ordering risk (8.1's own callout): EntityInstance's loot-drop subscription (EntityInstance.cs:292-325) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 2300108fd..d824d4278 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -428,7 +428,7 @@ public sealed class FireControlCut9Tests : IDisposable e.Shooter.EntityInfoGathered[e.Target] = info; var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); - Assert.True(hud.Visible && hud.InRange && hud.Locked && hud.InArc, $"info {info}: precondition, every gate open"); + Assert.True(hud.Designated && hud.InArc, $"info {info}: precondition, every gate open"); Assert.True(hot > 0f, $"info {info}, Precision {precision}: precondition, a shot that can land"); Assert.Equal(hot, hud.Accuracy * hud.PSensor * hud.PSpread * hud.POnHull, 5); sawPartialSpread |= hud.PSpread < .99f; @@ -445,7 +445,7 @@ public sealed class FireControlCut9Tests : IDisposable } e.Target.Position = origin + float3(0, 0, 5000); - Closed("out of range", d => d.InRange); + Closed("out of range", d => d.Designated); e.Target.Position = origin + float3(0, 0, -100); Closed("behind the mount", d => d.InArc); @@ -455,13 +455,13 @@ public sealed class FireControlCut9Tests : IDisposable e.Target.Position = float3(600, 0, 500); var far = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); Assert.Equal(500f, far.Range, 3); - Assert.True(far.InRange && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin"); + Assert.True(far.Designated && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin"); checkedConfigurations++; e.Shooter.Position = origin; e.Target.Position = origin + float3(0, 0, 100); e.Shooter.VisibleEntities.Remove(e.Target); - Closed("not visible", d => d.Visible); + Closed("not visible", d => d.Designated); Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget); Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null)); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs new file mode 100644 index 000000000..7d79a99b1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -0,0 +1,789 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Operator rulings 2026-09-30 (fuse batch 5): AI and turrets decide per weapon and a fused weapon needs a +// designated target; the HUD forecast shows a fused weapon's outcome and reads the same decision Fire flies; a +// refused round is free, for a discrete weapon and for a beam. Builds its own fixture, the convention every +// fire-control test file follows: its shooter carries several mounts and can hold either kind of weapon. +public sealed class FireControlPerWeaponTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolperweapon-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlPerWeaponTests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings(float minHit) => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = minHit, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Gun + { + public EquippedItem Item; + public Weapon Weapon; + } + + private sealed class Rig + { + public Zone Zone; + public Ship Shooter; + public Ship Target; + public List Guns = new List(); + public Gun Gun => Guns[0]; + public Gun Decoy; + } + + // Mount 0 (None, arc 0) bears on a target dead ahead; (Clockwise, arc 0) is a side mount the default 170 + // degree arc cannot bring to bear on it. Fused: Proximity with `blast`, which is also its arming distance: blast 30 + // arms at 30, so a Range of 29.5 refuses and 40 fires. + private Rig Build( + (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, + float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, + bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false, bool charged = false, bool auto = false) + { + mounts ??= new[] { (ItemRotation.None, 0f) }; + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000 + }; + for (var i = 0; i < mounts.Length; i++) + shooterHullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(), + Rotation = mounts[i].Rotation, FiringArc = mounts[i].Arc + }); + // A second weapon type on its own mount (a group of its own, since groups are per item type), dead ahead + // and unfused, weaker than the gun: what a group pick falls back to when the gun's group is refused. + if (decoy) + shooterHullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(mounts.Length, 0), Shape = new Shape(), + Rotation = ItemRotation.None, FiringArc = 0f + }); + + WeaponData behavior; + if (beam) + behavior = new ConstantWeaponData(); + else if (lockWeapon) + behavior = new LockWeaponData + { + LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(0), DirectionImpact = Constant(1), Decay = Constant(1), + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1) + }; + else if (charged) + behavior = new ChargedWeaponData + { + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst, + ChargeTime = Constant(1), ChargeEnergy = Constant(0), ChargeHeat = Constant(5000), CanFireEarly = true + }; + else if (auto) + behavior = new AutoWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; + else + behavior = new InstantWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; + behavior.Damage = Constant(100); + behavior.Range = Constant(range); + behavior.MinRange = Constant(0); + behavior.Velocity = Constant(0); + behavior.Spread = Constant(0); + behavior.DamageSpread = Constant(0); + behavior.Penetration = Constant(0); + behavior.Energy = Constant(energy); + behavior.Heat = Constant(heat); + behavior.Visibility = Constant(visibility); + behavior.MagazineSize = magazine; + behavior.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blast, + Behaviors = { behavior } + }); + cache.Upsert(new WeaponItemData + { + Name = "Decoy", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = + { + new InstantWeaponData + { + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), Damage = Constant(50), Range = Constant(100), + MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), + Energy = Constant(0), Heat = Constant(0), Visibility = Constant(0), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } + } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData { Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Turret", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TurretControllerData() } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, TestSettings(minHit), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var lot = 1; + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var rig = new Rig(); + for (var i = 0; i < mounts.Length; i++) + { + var gun = Make("Gun", lot++, 1); + Assert.True(shooter.TryEquip(gun, new int2(i, 0))); + var item = shooter.Equipment.Single(x => x.EquippableItem == gun); + rig.Guns.Add(new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }); + } + if (decoy) + { + var d = Make("Decoy", lot++, 1); + Assert.True(shooter.TryEquip(d, new int2(mounts.Length, 0))); + var item = shooter.Equipment.Single(x => x.EquippableItem == d); + rig.Decoy = new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }; + } + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + if (true) + { + var reactor = Make("Reactor", lot++, 10); + Assert.True(shooter.TryFindSpace(reactor, out var rpos)); + Assert.True(shooter.TryEquip(reactor, rpos)); + } + if (turret) + { + var t = Make("Turret", lot++, 1); + Assert.True(shooter.TryFindSpace(t, out var upos)); + Assert.True(shooter.TryEquip(t, upos)); + } + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3(-53, 0, -101); + target.Position = shooter.Position + float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(1f); + + rig.Zone = zone; + rig.Shooter = shooter; + rig.Target = target; + return rig; + } + + private static void Aim(Rig r, float2 direction) => r.Shooter.LookDirection = float3(direction.x, 0, direction.y); + + private static readonly (ItemRotation, float)[] SideThenForward = { (ItemRotation.Clockwise, 0f), (ItemRotation.None, 0f) }; + private static readonly (ItemRotation, float)[] SideMount = { (ItemRotation.Clockwise, 0f) }; + + // ==== Ruling 1: per weapon, and a fused weapon needs a designated target ==== + + // A weapon group shares a trigger, not a verdict: two guns of one type, the first on a side mount that cannot + // bear on the target and the second dead ahead. Only the second fires. Kills: the group's verdict read off its + // first weapon (Combat's old `testWeapon`), which silences the group. + [Fact] + public void CombatDecidesEachWeaponOfAGroupForItself() + { + var r = Build(SideThenForward); + var group = r.Shooter.WeaponGroups.Single(g => g.weapons.Count == 2).weapons; + Assert.True(ReferenceEquals(group[0], r.Guns[0].Weapon), "fixture: the side mount leads the group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Guns[0].Weapon.Firing, "the side-mounted gun cannot bear"); + Assert.True(r.Guns[1].Weapon.Firing, "the forward gun bears and is worth firing"); + } + + // A fused weapon fires whenever its target is designated, whatever the arc: the same group, now fused, fires + // both guns, the side mount included. Kills: Combat reading HitProbability (zero out of arc) for a fused weapon. + [Fact] + public void CombatFiresAFusedWeaponAtADesignatedTargetOutOfArc() + { + var r = Build(SideThenForward, fuse: WeaponFuse.Proximity, blast: 4f); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.True(r.Guns[0].Weapon.Firing, "a designated target is enough, arc or no"); + Assert.True(r.Guns[1].Weapon.Firing); + } + + // The turret makes the same per-weapon decision. Kills: TurretController reading HitProbability, or its own + // range test, for a fused weapon. + [Fact] + public void ATurretFiresAFusedWeaponAtADesignatedTargetOutOfArc() + { + var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, turret: true); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc"); + } + + public enum Withheld { NoTarget, OutOfRange, NotVisible, Unlocked } + + // The AI never fires a fused weapon at nothing: a missing target, one out of range, one not visible and one + // an unlocked LockWeapon holds no lock on are each no reason to fish. The direct-hit control fires (true) on + // the same rig once the target is valid. Kills: `designated` dropped from the fused branch (null included). + [Theory] + [InlineData(Withheld.NoTarget)] + [InlineData(Withheld.OutOfRange)] + [InlineData(Withheld.NotVisible)] + [InlineData(Withheld.Unlocked)] + public void AnAgentNeverFiresAFusedWeaponAtNothing(Withheld reason) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 4f, range: 100f, + targetRange: reason == Withheld.OutOfRange ? 150f : 60f, lockWeapon: reason == Withheld.Unlocked); + var target = r.Target; + if (reason == Withheld.NoTarget) target = null; + if (reason == Withheld.NotVisible) r.Shooter.VisibleEntities.Remove(r.Target); + + Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target)); + } + + [Fact] + public void AnAgentFiresAFusedWeaponAtAValidTargetWhateverItsHitChance() + { + // Accuracy 0 prices every direct hit at zero; the fused weapon is unmoved. + var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, accuracy: 0f); + + Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // A weapon that fires at what it hits keeps the AgentMinHitProbability threshold, per weapon. + // Kills: the threshold dropped for a direct-hit weapon, or applied to a fused one. + [Theory] + [InlineData(.5f, .2f, true)] + [InlineData(.5f, .9f, false)] + public void AnAgentKeepsTheHitProbabilityThresholdForADirectHitWeapon(float accuracy, float minHit, bool fires) + { + var r = Build(accuracy: accuracy, minHit: minHit); + var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); + Assert.True(p > .3f && p < .7f, $"fixture: the shot must price near the accuracy, priced {p}"); + + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // ==== Ruling 2: the HUD forecast is Fire's own decision ==== + + // Inspect's Outcome and BurstReach against what Fire then does with the same rig: Refused iff Fire returns 0, + // and a Burst's reach is the planar distance to the burst point Fire froze. Rows: a target beyond the arming + // distance (burst at its range), one inside it (pushed out to 30), no target (max range), out of arc, a + // Range that cannot reach the arming distance, and a weapon without a fuse. + // Kills: an Inspect that computes its own reach (ignoring the arming push or the Range clamp). + [Theory] + [InlineData(60f, 100f, false, false, FireOutcome.Burst, 60f)] + [InlineData(20f, 100f, false, false, FireOutcome.Burst, 30f)] + [InlineData(60f, 40f, true, false, FireOutcome.Burst, 40f)] + [InlineData(60f, 100f, false, true, FireOutcome.Burst, 60f)] + [InlineData(60f, 29.5f, false, false, FireOutcome.Refused, 0f)] + [InlineData(60f, 29.5f, true, false, FireOutcome.Refused, 0f)] + public void TheForecastIsTheOutcomeFireDelivers(float targetRange, float range, bool noTarget, bool outOfArc, FireOutcome outcome, float reach) + { + var r = Build(outOfArc ? SideMount : null, fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: targetRange); + if (noTarget) r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Shooter.Target.Value); + var shotId = FireControl.Fire(r.Gun.Weapon, r.Gun.Item, r.Shooter); + + Assert.Equal(outcome, d.Outcome); + Assert.Equal(outcome != FireOutcome.Refused, shotId != 0); + if (outcome == FireOutcome.Refused) return; + Assert.Equal(reach, d.BurstReach, 2); + var shot = SafeAssert.OnlyShot(r.Zone); + Assert.Equal(d.BurstReach, length((shot.BurstPosition - shot.FireOrigin).xz), 2); + } + + // A weapon without a fuse forecasts a direct hit and keeps its percentages. Kills: Solve calling everything a burst. + [Fact] + public void AWeaponWithoutAFuseForecastsADirectHit() + { + var r = Build(); + + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Target); + + Assert.Equal(FireOutcome.Direct, d.Outcome); + Assert.True(d.PBase > 0f); + } + + // ==== Ruling 3: a refused round is free ==== + + private sealed class Costs + { + public int Ammo; + public float ChargeSpent; + public int Sounds; + public int Wears; + public float Heat; + public bool Visible; + public int Announced; + public int Shots; + public bool CanFire; + } + + // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, + // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. + private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false, bool auto = false) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single, auto: auto); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon); + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_fire" } } }; + var costs = new Costs(); + r.Gun.Item.AudioEvents.Subscribe(_ => costs.Sounds++); + r.Zone.ShotCommitted.Subscribe(_ => costs.Shots++); // a fused round with no flight commits and resolves in its own tick + r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) costs.Wears++; }); + weapon.OnFire += _ => costs.Announced++; + capacitor.AddCharge(1000f); // a full buffer, so the first round can be paid for + var chargeBefore = capacitor.Charge; + var heatBefore = r.Gun.Item.Temperature; + var ammoBefore = weapon.Ammo; + Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine"); + + // The Range the previous tick's Execute left, before this tick refreshes it: a power-dependent Range that + // has since fallen. Set through the property's own setter, so the trigger sees a Range the round will not. + if (staleRange) typeof(Weapon).GetProperty("Range").GetSetMethod(true).Invoke(weapon, new object[] { 100f }); + if (auto) + { + // AutoWeapon's own trigger: a held weapon whose cooldown has run out retriggers inside Execute. + typeof(Weapon).GetField("_firing", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, true); + typeof(InstantWeapon).GetField("_cooldown", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, -1f); + } + else if (viaTrigger) weapon.Activate(); + else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); + r.Zone.Update(.01f); + + costs.Ammo = ammoBefore - weapon.Ammo; + costs.ChargeSpent = chargeBefore - capacitor.Charge; + costs.Heat = r.Gun.Item.Temperature - heatBefore; + costs.Visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + costs.CanFire = weapon.CanFire; + return costs; + } + + // Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that + // never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no + // visibility, no shot. `single` is a weapon that pays once per burst, at its first unrefused round rather than at each; + // `viaTrigger` false authorises the burst directly (the way a trigger that passed leaves it) on a weapon whose + // Range is short, so the Trigger check cannot be what stops the round and Execute must refuse it itself. + // Kills, one per row of the assertions: a spend that precedes the refusal (Execute or Trigger), an + // announcement, a sound, wear, heat or visibility left ahead of the `continue`. + [Theory] + [InlineData(false, true)] + [InlineData(true, true)] + [InlineData(false, false)] + public void ARefusedDiscreteRoundCostsNothing(bool single, bool viaTrigger) + { + var fired = Pull(40f, single, viaTrigger); + Assert.True(fired.Shots == 1, $"shots {fired.Shots}"); + Assert.True(fired.Ammo == 1, $"ammo spent {fired.Ammo}"); + Assert.True(fired.ChargeSpent > 1f, "a fired round spends its energy"); + Assert.Equal(1, fired.Sounds); + Assert.True(fired.Wears > 0); + Assert.True(fired.Heat > 0f); + Assert.True(fired.Visible); + Assert.Equal(1, fired.Announced); + + var refused = Pull(29.5f, single, viaTrigger); + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Ammo); + Assert.Equal(0f, refused.ChargeSpent); + Assert.Equal(0, refused.Sounds); + Assert.Equal(0, refused.Wears); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused round adds no heat ({refused.Heat} vs {fired.Heat})"); + Assert.False(refused.Visible); + Assert.Equal(0, refused.Announced); + } + + // The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws + // nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of + // that, asks the bus for nothing, and never starts (ARefusedBeamNeverStarts). + // Kills: ConstantWeapon spending (power request, wear, heat, visibility) before the refusal, or not + // consulting FireControl at all. + private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range) + { + var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ConstantWeapon) r.Gun.Weapon; + var wears = 0; + var stops = 0; + var shots = 0; + r.Zone.ShotResolved.Subscribe(_ => shots++); + r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) wears++; }); + weapon.OnStopFiring += () => stops++; + var heatBefore = r.Gun.Item.Temperature; + + weapon.Activate(); + var request = weapon.PowerRequest(.1f); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + + var visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + return (shots, wears, request, visible, r.Gun.Item.Temperature - heatBefore, stops, weapon.Firing); + } + + [Fact] + public void ARefusedBeamCostsNothing() + { + var fired = Burn(40f); + Assert.True(fired.Shots > 0, "control: the beam rolls shots"); + Assert.True(fired.Wears > 0); + Assert.True(fired.Request > 0f); + Assert.True(fired.Visible); + Assert.True(fired.Heat > 0f); + + var refused = Burn(29.5f); + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Wears); + Assert.Equal(0f, refused.Request); + Assert.False(refused.Visible); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused beam adds no heat ({refused.Heat} vs {fired.Heat})"); + Assert.True(refused.Stops == 0 && !refused.Firing, "a refused beam never starts, so it never has to stop"); + } + + // A beam is a stream of rounds, so a refused beam never starts: OnStartFiring drives the beam's visuals and + // audio, and starting it for Execute to stop a tick later would flash a beam that fires nothing. The control + // (Range 40) starts once and keeps firing. Kills: Activate that starts a beam without asking FireControl. + [Theory] + [InlineData(40f, 1)] + [InlineData(29.5f, 0)] + public void ARefusedBeamNeverStarts(float range, int starts) + { + var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ConstantWeapon) r.Gun.Weapon; + var started = 0; + weapon.OnStartFiring += () => started++; + + weapon.Activate(); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + + Assert.Equal(starts, started); + Assert.Equal(starts == 1, weapon.Firing); + } + + // A refused weapon is not one an agent can use: a fused weapon whose arming distance (30) exceeds its Range + // (29.5) is not fired at a target inside that Range, where the same weapon with Range to spare is. + // Kills: AgentFires answering "designated" without asking whether Solve refuses. + [Theory] + [InlineData(29.5f, false)] + [InlineData(40f, true)] + public void AnAgentNeverFiresARefusedFusedWeapon(float range, bool fires) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: 20f); + + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // Combat's group pick counts only weapons that can fire: the refused gun's group out-damages the decoy's, so a + // pick that counted it would select a group whose weapons are then never fired, and the decoy would stay silent. + // Kills: Combat counting a refused weapon's damage toward its group. + [Fact] + public void CombatDoesNotPickAGroupWhoseWeaponIsRefused() + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, targetRange: 20f, decoy: true); + Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the refused gun is the stronger group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Gun.Weapon.Firing, "a refused weapon is never activated"); + Assert.True(r.Decoy.Weapon.Firing, "the group that can fire is the one picked"); + } + + // Combat's group pick counts only weapons whose target is designated: the stronger gun's Range (10) does not reach the + // target (20), so its group is not picked and the decoy's, which does reach, is. Kills: Combat asking + // Designated nothing (a group of weapons that cannot reach the target selected). + [Fact] + public void CombatDoesNotPickAGroupWhoseWeaponCannotReachTheTarget() + { + var r = Build(range: 10f, targetRange: 20f, decoy: true); + Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the short gun is the stronger group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Gun.Weapon.Firing, "a weapon that cannot reach the target is never activated"); + Assert.True(r.Decoy.Weapon.Firing, "the group that reaches is the one picked"); + } + + // Combat's range test is Designated's, not a second one: a target exactly at max range is in reach (the old + // private test was strict). Kills: a range comparison of Combat's own. + [Fact] + public void CombatCountsATargetExactlyAtMaxRangeAsInReach() + { + var r = Build(range: 60f, targetRange: 60f); + // The agent is driven directly, not through the zone, so the shooter stays exactly at max range: the first + // update leaves the root state for combat, the second is combat deciding. + var agent = new Minion(r.Shooter); + agent.Update(.01f); + agent.Update(.01f); + + Assert.True(r.Gun.Weapon.Firing); + } + + // The trigger's threshold is inclusive: a shot priced exactly at AgentMinHitProbability is worth taking, and + // one priced a hair under it is not. Kills: `>` for `>=`. + [Fact] + public void AnAgentFiresAtExactlyTheHitProbabilityThreshold() + { + var probe = Build(accuracy: .5f); + var price = FireControl.HitProbability(probe.Gun.Weapon, probe.Shooter, probe.Target); + Assert.True(price > 0f); + + var atThreshold = Build(accuracy: .5f, minHit: price); + var overThreshold = Build(accuracy: .5f, minHit: MathF.BitIncrement(price)); + + Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target)); + Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target)); + } + + // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid + // (SingleAmmoBurst) at its first round that is not refused, judged with the Range that round flies with. + // The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. The refusal + // starts no cooldown either (operator ruling 2026-09-30): the weapon can fire again at once, while the + // control (Range 40) fired and is cooling down. Covers the player's trigger and AutoWeapon's own. + // Kills: paying for the burst at the trigger; starting the cooldown at the trigger (Trigger or AutoWeapon). + [Theory] + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(false, true)] + public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing(bool single, bool auto) + { + var fired = Pull(40f, single, viaTrigger: true, staleRange: true, auto: auto); + Assert.Equal(1, fired.Shots); + Assert.False(fired.CanFire, "control: a fired burst starts the cooldown"); + + var refused = Pull(29.5f, single, viaTrigger: true, staleRange: true, auto: auto); + + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Ammo); + Assert.Equal(0f, refused.ChargeSpent); + Assert.Equal(0, refused.Announced); + Assert.True(refused.CanFire, "a burst that fired nothing leaves no cooldown"); + } + + // A refused charged weapon never starts charging: no charge heat, no audio, no start event, and it can be + // triggered again at once. The control (Range 40) charges, sounds and heats. + // Kills: ChargedWeapon.Activate that does not ask FireControl. + private (int Starts, int Sounds, float Heat, bool Charging) Charge(float range) + { + var r = Build(charged: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ChargedWeapon) r.Gun.Weapon; + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_charge_play" } } }; + var starts = 0; + var sounds = 0; + weapon.OnStartCharging += () => starts++; + r.Gun.Item.AudioEvents.Subscribe(_ => sounds++); + var heatBefore = r.Gun.Item.Temperature; + + weapon.Activate(); + for (var i = 0; i < 3; i++) r.Zone.Update(.1f); + + return (starts, sounds, r.Gun.Item.Temperature - heatBefore, weapon.Progress > 0f); + } + + [Fact] + public void ARefusedChargedWeaponNeverStartsCharging() + { + var fired = Charge(40f); + Assert.Equal(1, fired.Starts); + Assert.True(fired.Sounds > 0, "control: charging sounds"); + Assert.True(fired.Heat > 0f, "control: charging heats"); + + var refused = Charge(29.5f); + Assert.Equal(0, refused.Starts); + Assert.Equal(0, refused.Sounds); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused weapon adds no charge heat ({refused.Heat} vs {fired.Heat})"); + } + // A triggered burst has not started its cooldown, but it is not ready either: pulled again before its first round + // flies it would restart the burst. Kills: CanFire that reads the cooldown alone. + [Fact] + public void ATriggeredBurstIsNotReadyBeforeItsFirstRound() + { + var r = Build(range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + r.Zone.Update(.01f); // resolves the gun's stats, which size the burst + Assert.True(weapon.CanFire); + + weapon.Activate(); + + Assert.False(weapon.CanFire); + } + + // A charge released with a burst pending has not started its cooldown, but it is not ready either: pressed + // again before that burst's first round it would start a second charge over it. Kills: ChargedWeapon.Activate + // that reads the cooldown alone instead of CanFire. + [Fact] + public void AChargedWeaponPressedAgainBeforeItsReleasedRoundFliesStartsNoSecondCharge() + { + var r = Build(charged: true, range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ChargedWeapon) r.Gun.Weapon; + var starts = 0; + weapon.OnStartCharging += () => starts++; + r.Zone.Update(.01f); // resolves the stats + weapon.Activate(); + for (var i = 0; i < 15; i++) r.Zone.Update(.1f); + Assert.Equal(1, starts); + + weapon.Deactivate(); + weapon.Activate(); + r.Zone.Update(.1f); + + Assert.Equal(1, starts); + Assert.False((bool) typeof(ChargedWeapon).GetField("_charging", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon)); + } + + // Holding the trigger fires once per Cooldown: the round that starts the cooldown pays for the tick it flew in. + // Cooldown 1 at .25 s ticks is a shot every 5 ticks, 12 in 15 s. And a burst whose every round is refused + // leaves no cooldown: the same held trigger is ready every tick. Kills: a first round that starts the cooldown + // at 1 after the tick's decrement (a shot every 6 ticks, 10 in 15 s); a cooldown left by a refused burst. + [Theory] + [InlineData(100f, 12)] + [InlineData(29.5f, 0)] + public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + var fired = 0; + weapon.OnFire += _ => fired++; + r.Zone.Update(.25f); // resolves the stats + var readyTicks = 0; + + for (var i = 0; i < 60; i++) + { + weapon.Activate(); + r.Zone.Update(.25f); + if (weapon.CanFire) readyTicks++; + } + + Assert.Equal(shots, fired); + if (shots == 0) Assert.Equal(60, readyTicks); + } + + // Kills: Trigger that leaves _burstStarted true from the previous burst, so the next burst reads as started + // (and ready) before its first round has flown. + [Fact] + public void ABurstTriggeredAfterAFiredOneIsNotReadyBeforeItsFirstRound() + { + var r = Build(range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + r.Zone.Update(.25f); + weapon.Activate(); + r.Zone.Update(.25f); + Assert.False(weapon.CanFire, "control: the first burst fired and is cooling"); + for (var i = 0; i < 10 && !weapon.CanFire; i++) r.Zone.Update(.25f); + Assert.True(weapon.CanFire, "fixture: the cooldown expired"); + + weapon.Activate(); + + Assert.False(weapon.CanFire); + } +} diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 6c01aafc9..31e390abe 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -32,7 +32,7 @@ public sealed class IffAndCombatTests : IDisposable cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, - Behaviors = { new InstantWeaponData() } + Behaviors = { new InstantWeaponData { Count = new PerformanceStat { Min = 1, Max = 1 }, Cooldown = new PerformanceStat { Min = 1, Max = 1 } } } }); cache.FlushAsync().Wait(); } @@ -248,9 +248,11 @@ public sealed class IffAndCombatTests : IDisposable shooter.Target.Value = target; shooter.SetIff(target, false); // explicitly neutral + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); + weapon.Execute(.01f); - Assert.Equal(0f, weapon.Progress); // Trigger() was safed: no cooldown/burst was started + Assert.True(weapon.CanFire); // Trigger() was safed: no burst fired, so no cooldown } [Fact] @@ -264,9 +266,11 @@ public sealed class IffAndCombatTests : IDisposable shooter.Target.Value = target; shooter.SetIff(target, true); + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); + weapon.Execute(.01f); - Assert.Equal(1f, weapon.Progress); // Trigger() ran: cooldown/burst started + Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } [Fact] @@ -277,9 +281,11 @@ public sealed class IffAndCombatTests : IDisposable var shooter = NewShip(items, zone, new Faction { Name = "Shooter" }, equipGun: true); var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); + weapon.Execute(.01f); - Assert.Equal(1f, weapon.Progress); + Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } [Fact] diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs new file mode 100644 index 000000000..b0a125ddf --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -0,0 +1,902 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using Random = CultMath.Random; + +// Cut 1 (docs/locomotion-cut.md, "Restore the legacy thruster ship hulls to the live catalog"). Verifies the +// content restore-hulls landed, against the real shipped catalog, read-only: the hulls exist with the expected +// thruster hardpoint counts, every thruster hardpoint accepts a catalog thruster design through TryEquip at its +// own position, and the ship then moves and turns for a few ticks under MovementDirection/LookDirection on the +// pre-Cut-3 mixer -- proof of the restored content, not of a controller this cut does not touch. +public sealed class RestoredHullsTests +{ + // Soul's finding #1 (this cut's fix batch 3): a hand-set GameplaySettings fixture left thermal radiation and + // conduction at their bare-class defaults (1/1/1) against the shipped 3 / 1e-8 / 0.01 + // (Assets/Resources/Settings.asset), so equipped parts froze toward 0 K, performance collapsed to 0 from + // cold, and TorqueMultiplier defaulting to 0 on top of that made every thruster's yaw contribution silently + // zero (Ship.cs's mixer applies Torque*Thrust*TorqueMultiplier/Mass) -- velocity moved but Direction never + // turned, which looks exactly like a content defect instead of a fixture gap. AuthoredSettings + // (tools/AetherDb/AuthoredSettings.cs) is the same headless YAML reader tools/AetherDb already uses to + // inspect Settings.asset outside Unity; this loads the real GameplaySettings the same way instead of + // hand-copying its fields a second time. + private static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"); + + // Same composition FireControlCut7Tests.OpenReadOnlyRealCatalog uses: a registry scoped to the shipped + // assembly's own [CultDocument] types, so this test's own process-wide type registration never pollutes the + // real catalog's validation the way the shared, AppDomain-scanning CultCache() default would. + private static CultCache OpenReadOnlyRealCatalog(string catalogPath) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + var cache = new CultCache(registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes); + return cache; + } + + private static string FindRepoRoot() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) + return dir.FullName; + throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); + } + + private static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc")); + + // Shared tutorial-galaxy construction settings (Assets/Resources/Settings.asset's own values), factored out + // so every test that needs a real tutorial Galaxy -- not just EntranceZoneAlwaysGetsADockedStationAcrossSeeds + // -- builds it the same way instead of re-inlining the same ~20 fields. + private static ZoneGenerationSettings TutorialZoneSettings() => new ZoneGenerationSettings + { + PlanetSafetyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 2.5f, Constant = 0 }, + MassFloor = 1, SunMass = 5000, GasGiantMass = 1000, PlanetMass = 100, + SatellitePasses = 5, SatelliteCreationMassFloor = 100, SatelliteCreationProbability = .25f, + BinaryCreationProbability = .1f, RosetteProbability = .1f, + ZoneRadius = new ExponentialLerp { Exponent = 1.5f, Minimum = 1000, Maximum = 10000 }, + ZoneMass = new ExponentialLerp { Exponent = 1.5f, Minimum = 10000, Maximum = 500000 }, + SubZoneCount = new ExponentialLerp { Exponent = 1.5f, Minimum = 0, Maximum = 8 }, + ZoneBoundaryRadius = .9f, BeltProbability = .25f, BeltMassCeiling = 500, + AsteroidBeltWidth = new ExponentialCurve { Exponent = .666f, Multiplier = 5, Constant = 50 }, + AsteroidCount = new ExponentialCurve { Exponent = .5f, Multiplier = 1, Constant = 11 }, + AsteroidRotationSpeed = new ExponentialLerp { Exponent = 2, Minimum = .1f, Maximum = .5f }, + SunColorSaturation = .85f, SunSecondaryColorDistance = .33f, SunLightSaturation = .5f, SunFogTintSaturation = .55f, + GasGiantBandCount = new ExponentialLerp { Exponent = 2, Minimum = 5, Maximum = 8 }, + GasGiantBandColorSeparation = .25f, GasGiantBandAltColorChance = .25f, + GasGiantBandSaturation = new ExponentialLerp { Exponent = .5f, Minimum = 0, Maximum = .8f }, + GasGiantBandBrightness = new ExponentialLerp { Exponent = .25f, Minimum = .25f, Maximum = 1 }, + NameData = new string[0], + }; + + private static TutorialGenerationSettings TutorialGalaxySettings() => new TutorialGenerationSettings + { + ProtagonistFaction = "Miss", AntagonistFaction = "Zhe", BufferFaction = "Luc", + NeutralFactions = new[] { "Aero", "Finch" }, QuestFaction = "Adras", LinkDensity = .5f, ZoneCount = 64, + }; + + private static SectorBackgroundSettings TutorialBackgroundSettings() => new SectorBackgroundSettings + { + NoiseAmplitude = 1, NoiseOffset = .3f, NoiseGain = .7f, NoiseLacunarity = 2, NoiseFrequency = .1f, + NoisePosition = 536.5106f, CloudExponent = 10, CloudAmplitude = .01f, + }; + + private static NameGeneratorSettings TutorialNameSettings() => + new NameGeneratorSettings { NameGeneratorMinLength = 5, NameGeneratorMaxLength = 10, NameGeneratorOrder = 3 }; + + // A station's orbit is a copy of its source planet's own (Parent, Distance) (ZoneGenerator.CreateLagrangeOrbit), + // so it always matches that one planet. It matches MORE than one planet only when the source planet shares its + // distance with a sibling under the same parent -- exactly what ZoneGenerator's potentialLagrangePoints filter + // excludes. An entrance station whose orbit matches more than one planet orbit this way could therefore only + // have come from the widened candidate set, never from the ordinary (non-rosette) Lagrange selection. + // + // Soul's finding (this cut's fix batch 3): counting planet orbits as pack.Orbits.Count minus the + // OrbitalEntityPack count (the previous version of this method) over-counts whenever a station or turret + // loadout generator returns null -- CreateLagrangeOrbit already appended that orbit to pack.Orbits before the + // null check (ZoneGenerator.cs) skips adding the entity, so the subtraction no longer matches. Deriving the + // set from pack.Planets' own Orbit refs instead (Census.cs's own approach) sidesteps that entirely: every + // real planet, including an ordinary rosette member, names its own orbit directly. Only a rosette ROOT is + // excluded this way (it gets no BodyData/PlanetData entry at all), which is fine here -- a root never shares + // its own Parent's distance with itself, so it was never a sibling candidate for `orbit` in the first place. + private static bool IsRosetteMember(CultCache cache, ZonePack pack, OrbitData orbit) + { + if (!orbit.Parent.IsSet()) return false; + var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).ToArray(); + return planetOrbits.Count(o => o.Parent.IsSet() && o.Parent.Key.Equals(orbit.Parent.Key) && abs(o.Distance - orbit.Distance) < .1f) > 1; + } + + // ZoneGenerator.GenerateZone writes OrbitData/BodyData (run-store types, AetheriaStores.RunTypes), so a + // catalog-only cache has no home for them. A scratch run file, deleted after the seed that used it. + private static CultCache OpenReadOnlyRealCatalogWithScratchRun(string catalogPath, string runPath) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + var cache = new CultCache(registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(runPath), AetheriaStores.RunTypes); + return cache; + } + + // Headless asset-reference rule: every hull's Prefab, every ThrusterData.ParticlesPrefab, and every + // non-empty Schematic names a GUID that actually exists under Assets/ (a .meta file carries it). Unity's + // EngineAssetCheck enforces this too, but only in-editor; this is the same check run headless against the + // shipped catalog, so a fabricated or dropped GUID fails a build that never opens Unity. Soul's own probe + // (commit message, this cut's fix batch) found Djinni.Prefab fabricated and all five restored thrusters' + // ParticlesPrefab null; this test is that probe, committed so it can be rerun and mutated. + [Fact] + public void EveryAssetReferenceResolvesToAMetaGuid() + { + var assetGuids = new HashSet(Directory.EnumerateFiles(Path.Combine(FindRepoRoot(), "Assets"), "*.meta", SearchOption.AllDirectories) + .Select(f => File.ReadLines(f).FirstOrDefault(l => l.StartsWith("guid: "))?.Substring("guid: ".Length).Trim()) + .Where(g => g != null)); + + using var cache = OpenCatalog(); + var missing = new List(); + foreach (var hull in cache.GetAll()) + if (!assetGuids.Contains(hull.Prefab ?? "")) + missing.Add($"{hull.Name}.Prefab = {hull.Prefab ?? "null"}"); + foreach (var design in cache.GetAll()) + { + foreach (var thruster in design.Behaviors.OfType()) + if (!assetGuids.Contains(thruster.ParticlesPrefab ?? "")) + missing.Add($"{design.Name}.ParticlesPrefab = {thruster.ParticlesPrefab ?? "null"}"); + if (!string.IsNullOrEmpty(design.Schematic) && !assetGuids.Contains(design.Schematic)) + missing.Add($"{design.Name}.Schematic = {design.Schematic}"); + } + + Assert.True(missing.Count == 0, "Dangling asset references:\n" + string.Join("\n", missing)); + } + + [Theory] + [InlineData("Longinus", 4)] + [InlineData("Djinni", 8)] + public void RestoredHullExistsWithExpectedThrusterHardpointCount(string name, int expectedThrusterHardpoints) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + Assert.NotNull(hull); + Assert.Equal(HullType.Ship, hull.HullType); + Assert.Equal(expectedThrusterHardpoints, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); + } + + [Theory] + [InlineData("Longinus")] + [InlineData("Djinni")] + public void EveryThrusterHardpointAcceptsACatalogThrusterAtItsOwnPosition(string name) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + var thrusterDesigns = cache.GetAll().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray(); + Assert.NotEmpty(thrusterDesigns); + + var settings = Settings(); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; + var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); + + var lot = 2; + foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) + { + var cells = hardpoint.Shape.Coordinates.Length; + var design = thrusterDesigns.FirstOrDefault(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == cells); + Assert.True(design != null, $"{name}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} has no fitting catalog thruster design (needs {cells} cells)."); + var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++ }; + Assert.True(ship.TryEquip(gearItem, hardpoint.Position), + $"{name}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} refused {design.Name} via TryEquip."); + } + } + + // "The ship then moves and turns... for a few ticks" (docs/locomotion-cut.md verification list). Equips + // every thruster hardpoint with a fitting design, then drives MovementDirection/LookDirection the same way + // ActionGameManager's player-input path does (docs' own consumer audit) and checks Entity.Velocity and + // Entity.Direction both move over a few ticks on the current (pre-Cut-3) mixer. + [Theory] + [InlineData("Longinus")] + [InlineData("Djinni")] + public void RestoredHullMovesAndTurnsUnderMovementAndLookDirection(string name) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + var thrusterDesigns = cache.GetAll().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray(); + + var settings = Settings(); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; + var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); + + var lot = 2; + foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) + { + var cells = hardpoint.Shape.Coordinates.Length; + var design = thrusterDesigns.First(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == cells); + var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++ }; + Assert.True(ship.TryEquip(gearItem, hardpoint.Position)); + } + + // A reactor: Victoire (one of Cut 1's own restored thrusters, docs/locomotion-cut.md's own note on it) + // carries a real EnergyUsage draw unlike its four siblings, so an unpowered ship reads as though only + // some fitting thrusters produce thrust -- an artifact of which design TryEquip's own hardpoint search + // happens to land on, not of the restored content. Every hull here also has a Reactor hardpoint; a real + // ship always carries one, so this fixture should too. + var reactorHardpoint = hull.Hardpoints.FirstOrDefault(h => h.Type == HardpointType.Reactor); + if (reactorHardpoint != null) + { + var reactorDesign = cache.GetAll().First(g => g.Hardpoint == HardpointType.Reactor && + g.Shape.FitsWithin(reactorHardpoint.Shape, reactorHardpoint.Rotation, out _) && g.Shape.Coordinates.Length == reactorHardpoint.Shape.Coordinates.Length); + var reactorItem = new EquippableItem { Data = cache.RefOf(reactorDesign), Durability = reactorDesign.Durability, Lot = lot++ }; + Assert.True(ship.TryEquip(reactorItem, reactorHardpoint.Position)); + } + + zone.Entities.Add(ship); + ship.Position = float3.zero; + ship.Activate(); + zone.Update(0f); // warm-up: resolves equipment stats before the driven ticks read them + + var startVelocity = ship.Velocity; + var startDirection = ship.Direction; + + ship.MovementDirection = float2(0, 1); // full forward, same axis ActionGameManager.Input.Player.Move drives + ship.LookDirection = float3(1, 0, 0); // off-axis target heading, so the mixer's yaw thrusters have work to do + for (var i = 0; i < 10; i++) zone.Update(1f / 60f); + + Assert.NotEqual(startVelocity, ship.Velocity); + Assert.NotEqual(startDirection, ship.Direction); + } + + // Operator ruling, 2026-09-25 (docs/locomotion-cut.md, "Tutorial station"): "What we're testing is going to + // be the tutorial level, right? Let's drop a station in there." The tutorial's entrance zone always gets a + // generated faction station with a docking bay, deterministically -- not merely likely. This REPLACES + // TutorialZoneGenerationCanProduceADockedStation, which never used the tutorial construction path at all: it + // built an ad hoc single-zone SavedGame/Galaxy and asserted a docking bay showed up in at least one of 25 + // zone-name variants, which is a claim about the BACKGROUND station path in general, not about the entrance + // zone Galaxy.cs actually picks (lowest-influence, unowned -- the one case ZoneGenerator.cs's own stationCount + // roll produces 0 on essentially every seed, docs' own probe: 30 of 30). This test builds the real tutorial + // Galaxy exactly as Assets/Resources/Settings.asset configures it and ActionGameManager.StartGame -> + // PopulateLevel(CurrentGalaxy.Entrance) generates it, across seeds, and requires a docked station in every + // run. It must fail without ZoneGenerator's tutorial-entrance override. + // + // Also requires that the NORMAL case (a zone with a real Lagrange candidate, the common case across 25 seeds) + // seats its station on a genuine, non-rosette Lagrange orbit rather than the widened fallback -- the widened + // candidate set is for the rare zone with none, not a replacement for ordinary placement. + [Fact] + public void EntranceZoneAlwaysGetsADockedStationAcrossSeeds() + { + var zoneSettings = TutorialZoneSettings(); + var tutorialSettings = TutorialGalaxySettings(); + var background = TutorialBackgroundSettings(); + var names = TutorialNameSettings(); + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + + // Galaxy zone-graph generation has a pre-existing, seed-dependent flakiness of its own (a disconnected + // zone graph throws inside the Galaxy constructor on roughly 1 seed in 15, confirmed unrelated to this + // cut: it reproduces identically against the pre-fix-batch catalog). That failure belongs to Galaxy.cs, + // not to ZoneGenerator's entrance-station override this test is pinning, so it is skipped rather than + // asserted on; every seed whose Galaxy actually builds must still get a docked entrance station. + var succeeded = 0; + var sawGenuineLagrangeOrbit = false; + for (uint seed = 1; seed <= 25; seed++) + { + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-station-{Guid.NewGuid():N}.cc"); + try + { + // Galaxy mutates Faction.InfluenceDistance in place; a fresh catalog open per seed keeps runs independent. + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + Galaxy galaxy; + try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } + catch (Exception) { continue; } + succeeded++; + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var dockedStation = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + Assert.True(dockedStation != null, $"seed {seed}: the tutorial entrance zone generated no station with a docking bay."); + + if (!IsRosetteMember(cache, pack, cache.Get(dockedStation.Orbit))) + sawGenuineLagrangeOrbit = true; + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result."); + Assert.True(sawGenuineLagrangeOrbit, "none of the 25 seeds seated the entrance station on a genuine (non-rosette) Lagrange orbit."); + } + + // Cut 1 fix batch 3 (F6, N1): a per-seed strengthening of the "across seeds" test above. That test only + // requires a genuine Lagrange orbit at least once in 25 seeds; it says nothing about a seed where a genuine + // candidate DID exist but the station landed on the widened fallback anyway (which the widening condition's + // own isTutorialEntrance-and-empty-candidates guard should make impossible). This pins the normal case + // directly, seed by seed: whenever the entrance zone actually has a genuine (non-rosette) Lagrange candidate, + // its station must be seated on one. + [Fact] + public void EntranceStationSitsOnAGenuineLagrangeOrbitWheneverOneExists() + { + var zoneSettings = TutorialZoneSettings(); + var tutorialSettings = TutorialGalaxySettings(); + var background = TutorialBackgroundSettings(); + var names = TutorialNameSettings(); + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + + var succeeded = 0; + var sawGenuineCandidate = false; + for (uint seed = 1; seed <= 25; seed++) + { + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-normalcase-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + Galaxy galaxy; + try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } + catch (Exception) { continue; } + succeeded++; + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray(); + var hasGenuineCandidate = planetOrbits.Any(o => planetOrbits.Count(c => c.Parent.Key.Equals(o.Parent.Key) && abs(c.Distance - o.Distance) < .1f) == 1); + if (!hasGenuineCandidate) continue; + sawGenuineCandidate = true; + + var dockedStation = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + Assert.True(dockedStation != null, $"seed {seed}: the tutorial entrance zone generated no station with a docking bay."); + Assert.False(IsRosetteMember(cache, pack, cache.Get(dockedStation.Orbit)), + $"seed {seed}: a genuine Lagrange candidate existed, but the station sat on the widened (rosette) fallback anyway."); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result."); + Assert.True(sawGenuineCandidate, "none of the 25 seeds had a genuine Lagrange candidate at all; too few runs to trust this result."); + } + + // Cut 1 fix batch 3 (F5): the throw ZoneGenerator.cs's own entrance-station override raises + // (ZoneGenerator.cs's "every planet in the zone is a parentless root" case, fixed in this same batch, F3) is + // reachable through authored settings, not just a hypothetical: a real seed-1 tutorial entrance with + // RosetteProbability 0 (no rosettes to widen into) and MassFloor far above every possible child mass (no + // satellite ever clears the floor, so every planet stays a parentless root) leaves the entrance with no + // candidate orbit at all, even after widening. + [Fact] + public void ThrowReachableWithAuthoredSettings() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-throw-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var zoneSettings = TutorialZoneSettings(); + zoneSettings.RosetteProbability = 0; + zoneSettings.MassFloor = 1e9f; + + Assert.Throws(() => + ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true)); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 3 (F6, N3): a multi-root, heavily-widened entrance via authored settings -- RosetteProbability + // 1 (every subzone becomes a rosette) and a fixed SubZoneCount of 8 (several independent rosette systems, each + // its own root, never each other's sibling) with satellite/binary creation turned off so as few non-rosette + // orbits form as this generator's own unconditional "captured planet" step (ZoneGenerator.cs's ExpandRosette + // caller always adds at least one) allows. Verified directly against seed 1's real entrance: this leaves 14 + // distinct parents among its planets, not the single root a boring system would have. Whatever candidate + // selection actually runs on a topology this fragmented -- ordinary or widened -- every resulting station + // orbit must still be a real, positioned orbit: a set Parent and a positive Distance, never the zero-distance, + // parentless orbit the pre-F4 fallback used to synthesize. + [Fact] + public void MultiRootEntranceStationOrbitsAreAlwaysParentedWithPositiveDistance() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-multiroot-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var zoneSettings = TutorialZoneSettings(); + zoneSettings.RosetteProbability = 1; + zoneSettings.SubZoneCount = new ExponentialLerp { Exponent = 1, Minimum = 8, Maximum = 8 }; + zoneSettings.SatelliteCreationProbability = 0; + zoneSettings.BinaryCreationProbability = 0; + + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var roots = pack.Planets.Select(p => cache.Get(p).Orbit) + .Select(cache.Get) + .Select(o => o.Parent.IsSet() ? o.Parent.Key.Value : "(root)") + .Distinct().Count(); + Assert.True(roots > 1, $"this fixture no longer produces a multi-root entrance (only {roots} distinct parent); it no longer tests the fragmented-topology case."); + + var stations = pack.Entities.OfType() + .Where(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station).ToArray(); + Assert.NotEmpty(stations); + foreach (var station in stations) + { + var orbit = cache.Get(station.Orbit); + Assert.True(orbit.Parent.IsSet(), "a station's orbit has no Parent."); + Assert.True(orbit.Distance > 0, $"a station's orbit Distance is not positive: {orbit.Distance}."); + } + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 3 (F6, N9): a station-offset pin. CreateLagrangeOrbit (ZoneGenerator.cs) builds the + // station's orbit as a COPY of its source planet's (Parent, Distance) but offsets Phase by + // PI/3 * sign(random...) -- a real angular displacement, not the same orbital slot. Must fail if that offset + // is ever dropped (a mutant that seats the station directly on the planet's own orbit object): the station's + // Phase must differ from every planet's Phase among those sharing its own (Parent, Distance), across seeds. + [Fact] + public void EntranceStationPhaseDiffersFromEveryPlanetAtItsOwnDistanceAcrossSeeds() + { + var zoneSettings = TutorialZoneSettings(); + var tutorialSettings = TutorialGalaxySettings(); + var background = TutorialBackgroundSettings(); + var names = TutorialNameSettings(); + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + + var checkedAny = false; + for (uint seed = 1; seed <= 25; seed++) + { + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-offset-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + Galaxy galaxy; + try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } + catch (Exception) { continue; } + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var dockedStation = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + if (dockedStation == null) continue; + var stationOrbit = cache.Get(dockedStation.Orbit); + if (!stationOrbit.Parent.IsSet()) continue; + + var siblingPlanets = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)) + .Where(o => o.Parent.IsSet() && o.Parent.Key.Equals(stationOrbit.Parent.Key) && abs(o.Distance - stationOrbit.Distance) < .1f) + .ToArray(); + if (siblingPlanets.Length == 0) continue; + checkedAny = true; + + foreach (var planetOrbit in siblingPlanets) + Assert.True(abs(planetOrbit.Phase - stationOrbit.Phase) > 1e-3f, + $"seed {seed}: the docked station's Phase ({stationOrbit.Phase}) matches a planet's own Phase at the same (Parent, Distance) -- it is sitting on the planet's own orbital slot instead of an offset one."); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + Assert.True(checkedAny, "no seed produced a docked entrance station with an identifiable source planet at its own (Parent, Distance); too few runs to trust this result."); + } + + // Speed cap (docs/locomotion-cut.md Cut 1 verification): Longinus's restored VelocityLimitData caps it at + // the legacy TopSpeed, 100 (100..100) -- asserted as the literal from the legacy record rather than read + // back out of the same catalog value being tested, so a mutated TopSpeed (e.g. 1000) can't also move the + // bound this test checks against. + // + // Cut 1 fix batch 3 (Soul's finding #1): the comment this replaces claimed the run went "long enough to + // approach its drag/thrust balance well past 100" while forcing OverrideShutdown on the hull and both + // firing thrusters for the whole scenario -- a fixture compensating for RestoredHullsTests.Settings() (then + // hand-set, thermal radiation/conduction frozen at 1/1/1 against the shipped 3 / 1e-8 / 0.01) making every + // part freeze toward 0 K and shut down within about a second on real physics. Settings() now loads the real + // GameplaySettings (AuthoredSettings, above), so OverrideShutdown is no longer needed to keep the ship + // flying: a real, unforced Longinus holds thrust (and the hull's own VelocityLimit) running well past the + // window this test drives. + // + // Real settings also retired the old +-10 tolerance's premise. On real physics the mixer's own tick order + // (Ship.cs: a tick's thrust-driven velocity is checked against VelocityLimit's cap on the FOLLOWING tick) + // settles into a steady state at the cap plus one tick's own forward acceleration, not at the literal cap -- + // measured here at ~103.3 for this seed's generated loadout, not the ~110 an unconstrained +-10 tolerance + // would also accept. +4 keeps that real headroom on the ceiling side. The floor is tighter (-3): a + // materially nerfed cap (Soul's own catalog mutant, TopSpeed 100 -> 92) still produces the same kind of + // steady state, just a lower one (~95), which -3 catches but +-10 or a +4 ceiling would not. + // + // Must fail when VelocityLimitData is removed (docs' own probe: Longinus reaches 122 without it), when + // TopSpeed is mutated away from 100, and when VelocityLimit's own trigger check is loosened (a "+6 slack" + // mutant pushes this same steady state to ~109, over the +4 ceiling). + [Fact] + public void LonginusNeverExceedsItsRestoredTopSpeedUnderFullThrust() + { + const float LegacyTopSpeed = 100f; // Longinus's legacy-record TopSpeed (100..100) + + using var cache = OpenCatalog(); + var hull = cache.GetByName("Longinus"); + var settings = Settings(); + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + // ItemManager.Random defaults to a DateTime.Now-seeded instance; without pinning it here, the item + // qualities LoadoutGenerator/EntitySerializer roll through it (not through the `random` below, which + // only drives which designs get picked) would make this test's own measured speed non-deterministic. + items.Random = new Random(1u); + var random = new Random(1); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate == hull); + Assert.NotNull(pack); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var ship = (Ship) EntitySerializer.Unpack(items, zone, pack); + + zone.Entities.Add(ship); + ship.Position = float3.zero; + ship.Activate(); + zone.Update(0f); + + ship.MovementDirection = float2(0, 1); // full forward + ship.LookDirection = float3(ship.Direction.x, 0, ship.Direction.y); // straight line: hold the ship's own heading + + // Soul measured this loadout's forward thrusters crossing 386 K (thermal shutdown) around 7.3 s under + // real settings; 5 s keeps every tick inside the window where thrust runs continuously and unforced, so + // the cap -- not a coasting or shut-down ship -- is what the assertions below are pinning. + const int ticks = 60 * 5; + var lastSpeed = 0f; + for (var i = 0; i < ticks; i++) + { + zone.Update(1f / 60f); + var speed = length(ship.Velocity); + lastSpeed = speed; + Assert.True(speed <= LegacyTopSpeed + 4f, $"tick {i}: speed {speed} exceeded the legacy top speed {LegacyTopSpeed} by more than the real tick-order headroom."); + } + + Assert.True(lastSpeed >= LegacyTopSpeed - 3f, $"final speed {lastSpeed} fell too far below the legacy top speed {LegacyTopSpeed}; the cap looks nerfed."); + } + + // Loadout generation (docs/locomotion-cut.md Cut 1 verification): LoadoutGenerator, filtered to each + // restored hull, must fill every one of its thruster hardpoints -- the check that the restored designs and + // their FactionProductData/roles are authored correctly, not just present. + [Theory] + [InlineData("Longinus")] + [InlineData("Djinni")] + public void LoadoutGeneratorFillsEveryThrusterHardpointAcrossSeeds(string name) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + var thrusterHardpoints = hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster).ToArray(); + var settings = Settings(); + + for (uint seed = 1; seed <= 10; seed++) + { + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var random = new Random(seed); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate == hull); + Assert.True(pack != null, $"{name}: seed {seed} produced no loadout."); + + foreach (var hardpoint in thrusterHardpoints) + { + var filled = pack.Equipment.Any(e => e.position.Equals(hardpoint.Position) && + (cache.Get(e.item.Data) as GearData)?.Hardpoint == HardpointType.Thruster); + Assert.True(filled, $"{name}: seed {seed}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} got no thruster."); + } + } + } + + // Builds the real tutorial galaxy for `seed`, then re-targets its Entrance to `entranceZoneName` via the + // public SavedGame/SavedZone round trip (RunSave's own shape: Galaxy -> SavedZone[] -> Galaxy(cache, + // SavedGame, log)), without touching Galaxy's private Entrance backing field by reflection. Zone content + // (planets) is keyed off each zone's own Name/Position, not off which zone happens to be Entrance, so the + // re-targeted galaxy's chosen zone generates identically to the original -- letting a test pick, by name, any + // real zone from a real galaxy (e.g. one already known to have no Lagrange candidate) and make it the Entrance. + private static Galaxy BuildGalaxyWithForcedEntrance(CultCache cache, uint seed, string entranceZoneName) + { + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); + var target = galaxy.Zones.Single(z => z.Name == entranceZoneName); + + var factions = galaxy.Factions; + var savedZones = galaxy.Zones.Select(zone => new SavedZone + { + Name = zone.Name, + Position = zone.Position, + AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(), + Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(), + Contents = zone.PackedContents, + Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner), + }).ToArray(); + var keys = savedZones.Select((_, i) => new CultRecordKey($"restoredhullstests-forced-entrance-zone-{i}")).ToArray(); + cache.Commit(batch => { for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), savedZones[i], keys[i]); }); + + var game = new SavedGame + { + Zones = keys.Select(k => new CultRecordRef(k)).ToArray(), + Factions = factions.Select(f => cache.RefOf(f)).ToArray(), + HomeZones = galaxy.HomeZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)), + BossZones = galaxy.BossZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)), + Entrance = Array.IndexOf(galaxy.Zones, target), + Exit = -1, + Relationships = factions.Select(f => galaxy.FactionRelationships[f]).ToArray(), + DiscoveredZones = Array.Empty(), + Background = galaxy.Background, + IsTutorial = true, + }; + return new Galaxy(cache, game, _ => { }); + } + + // Cut 1 fix batch 2 (Soul's finding #2): the tutorial entrance's widened candidate set. Seed 3's real tutorial + // galaxy has a zone, "EAC-7089", whose planets are entirely one rosette with no captured satellites at all: 4 + // planets sharing one Distance under one Parent, and nothing else -- potentialLagrangePoints is empty for it + // (verified directly against this exact fixture, not carried over from Soul's own forced-fallback probe, whose + // own candidate check undercounted planet orbits by using pack.Planets.Count -- which excludes Empty rosette + // roots -- and so misidentified some zones, including its originally-reported "EAC-3208", as candidate-less + // when they were not). Re-targeted here to be the Entrance through BuildGalaxyWithForcedEntrance above. Its + // docked station can therefore only have come from the widened candidate set (every parented planet orbit, + // not just non-rosette ones), never from a synthesized, unparented fallback orbit. Pins: still docked, and + // turrets sit on a real orbit (a real Parent, a finite Phase). + // + // Cut 1 fix batch 3 (F7): the Distance check this comment used to describe as "near their station" was + // vacuous -- PlaceTurret (ZoneGenerator.cs) sets a turret orbit's Distance from `orbit.Distance` directly, + // unconditionally, the same as CreateLagrangeOrbit does for the station itself, so every turret's Distance + // equals the station's by construction regardless of placement; the check could never fail. What actually + // makes a turret "near" its station is Phase: PlaceTurrets' own `dist = t/2` gives each turret a small + // angular nudge (`20 * dist / orbit.Distance`) off the station's own Phase, not a placement anywhere else on + // the orbit. Replaced with a bound on that real offset, generous over PlaceTurrets' own formula. + [Fact] + public void TutorialEntranceWithNoLagrangeCandidateStillSeatsAStationWithFiniteNearbyTurrets() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-widened-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = BuildGalaxyWithForcedEntrance(cache, seed: 3u, "EAC-7089"); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, galaxy.Entrance, isTutorial: true); + + var station = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + Assert.True(station != null, "the known no-Lagrange-candidate entrance zone generated no docked station."); + var stationOrbit = cache.Get(station.Orbit); + + // Confirms this fixture still exercises the widened path (a regression here would mean the fixture no + // longer probes what this test claims to probe). + Assert.True(IsRosetteMember(cache, pack, stationOrbit), "the entrance station no longer sits on a rosette-only orbit -- this fixture no longer tests the widened candidate path."); + + var turrets = pack.Entities.OfType() + .Where(e => (cache.Get(e.Hull.Data) as HullData)?.HullType != HullType.Station).ToArray(); + Assert.NotEmpty(turrets); + // Generous over PlaceTurrets' own max |distanceMultiplier| ((turrets.Length - 1) / 2): a real bound, + // not the vacuous Distance-always-matches check this replaces. + var maxPhaseOffset = 20f * turrets.Length / stationOrbit.Distance; + foreach (var turret in turrets) + { + var turretOrbit = cache.Get(turret.Orbit); + Assert.True(turretOrbit.Parent.IsSet(), "a turret's orbit has no Parent."); + Assert.False(float.IsNaN(turretOrbit.Phase) || float.IsInfinity(turretOrbit.Phase), $"a turret's orbit Phase is not finite: {turretOrbit.Phase}."); + var phaseDelta = abs(turretOrbit.Phase - stationOrbit.Phase); + Assert.True(phaseDelta <= maxPhaseOffset, + $"a turret's Phase ({turretOrbit.Phase}) is not near the station's own Phase ({stationOrbit.Phase}): delta {phaseDelta} > {maxPhaseOffset}."); + } + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 2 (Soul's finding #2, leak check): the entrance override must not leak to (a) other + // tutorial zones or (b) a non-tutorial game's own entrance zone. Both are pinned against a seed-1 galaxy -- + // the widened-candidate test above uses a different fixture, seed 3's "EAC-7089" -- so this test and the one + // below it stand or fall on this one known fixture, not that one. + [Fact] + public void EntranceOverrideDoesNotLeakToOtherTutorialZonesOrNonTutorialGames() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-leak-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + int StationCount(GalaxyZone zone, bool isTutorial) => + ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, zone, isTutorial) + .Entities.OfType() + .Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); + + // (a) A non-entrance tutorial zone that naturally rolls zero stations keeps that roll under + // isTutorial: true -- the floor must not leak past the galaxy.Entrance reference check. + var otherZone = galaxy.Zones.Single(z => z.Name == "EAC-2733"); + Assert.NotSame(galaxy.Entrance, otherZone); + Assert.Equal(0, StationCount(otherZone, isTutorial: true)); + + // (b) The entrance zone itself, generated for a non-tutorial game (isTutorial: false), keeps its + // natural roll too -- the floor must not leak past the isTutorial flag either. + Assert.Equal(0, StationCount(galaxy.Entrance, isTutorial: false)); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 2 (Soul's finding #2, baseStationCount): the entrance override raises stationCount (the + // orbit-selection floor) but must not also raise the enemyCount roll, which is keyed to baseStationCount + // (the pre-override value) precisely so the forced station doesn't buy the zone an extra enemy. Seed 1's + // entrance zone rolls baseStationCount 0 (confirmed via the isTutorial: false StationCount == 0 above) and + // the override raises stationCount to 1; this pins the resulting ship count at the value baseStationCount + // produces. It must fail if the enemy-count formula is changed to add stationCount instead (it would add a + // ship instead of zero). + [Fact] + public void ForcedTutorialEntranceStationDoesNotInflateEnemyCount() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-enemycount-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, galaxy.Entrance, isTutorial: true); + var stationCount = pack.Entities.OfType().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); + // Soul's finding (this cut's fix batch 3): pack.Entities.OfType().Count() also counts the + // zone's neutral wanderers (ZoneGenerator.cs's own EligibleWandererFactions spawn, keyed to + // ZoneGenerationSettings.NeutralWandererCount -- 2 by TutorialZoneSettings' own default, unrelated to + // this fixture's own enemy roll), so the old assertion of exactly 2 was pinning the wanderer count, + // not the enemy count baseStationCount is supposed to gate. The nearest faction here is the same one + // ZoneGenerator computes internally (galaxy.HomeZones' own distance-to-zone ordering); every ShipPack + // names its faction directly (EntityPack.Faction), so filtering on it separates the two spawns. + var nearestFaction = galaxy.Factions.MinBy(f => galaxy.HomeZones[f].Distance[galaxy.Entrance]); + var enemyShipCount = pack.Entities.OfType().Count(s => cache.Get(s.Faction) == nearestFaction); + + Assert.Equal(1, stationCount); // the override did raise the station count (from a natural roll of 0) + Assert.Equal(0, enemyShipCount); // the enemy roll stayed keyed to the pre-override baseStationCount of 0 + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 3 (F6, N2): the entrance-only widening must not leak to a non-entrance zone that would + // otherwise get zero stations. Seed 3's "EAC-7089" (the same no-Lagrange fixture the widened-candidate test + // above uses, this time NOT retargeted as Entrance) rolls a real, nonzero station count on its own -- forced + // here by boosting one faction's presence there (HomeZone distance 0, InfluenceDistance 50, so the roll's + // floor(rand * (factionPresence+1)) is overwhelmingly likely nonzero) via the same public SavedGame round + // trip BuildGalaxyWithForcedEntrance uses, not by touching ZoneGenerator's private roll. Verified directly: + // temporarily widening every zone (dropping ZoneGenerator's isTutorialEntrance guard) turns this fixture's + // station count from 0 into 2, so a natural nonzero roll really is hiding behind the empty candidate set. + // Must fail if that guard is dropped for real. + private static Galaxy BuildGalaxyWithBoostedPresence(CultCache cache, uint seed, string zoneName) + { + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); + var target = galaxy.Zones.Single(z => z.Name == zoneName); + + var factions = galaxy.Factions; + var savedZones = galaxy.Zones.Select(zone => new SavedZone + { + Name = zone.Name, + Position = zone.Position, + AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(), + Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(), + Contents = zone.PackedContents, + Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner), + }).ToArray(); + var keys = savedZones.Select((_, i) => new CultRecordKey($"restoredhullstests-boosted-presence-zone-{i}")).ToArray(); + cache.Commit(batch => { for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), savedZones[i], keys[i]); }); + + var homeZones = galaxy.HomeZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)); + var boosted = Enumerable.Range(0, factions.Length).OrderByDescending(i => factions[i].InfluenceDistance).First(); + homeZones[boosted] = Array.IndexOf(galaxy.Zones, target); + factions[boosted].InfluenceDistance = 50; + + var game = new SavedGame + { + Zones = keys.Select(k => new CultRecordRef(k)).ToArray(), + Factions = factions.Select(f => cache.RefOf(f)).ToArray(), + HomeZones = homeZones, + BossZones = galaxy.BossZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)), + Entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance), + Exit = -1, + Relationships = factions.Select(f => galaxy.FactionRelationships[f]).ToArray(), + DiscoveredZones = Array.Empty(), + Background = galaxy.Background, + IsTutorial = true, + }; + return new Galaxy(cache, game, _ => { }); + } + + [Fact] + public void WideningDoesNotLeakToANonEntranceNoLagrangeZoneThatRollsAStation() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-widen-leak-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = BuildGalaxyWithBoostedPresence(cache, seed: 3u, "EAC-7089"); + var zone = galaxy.Zones.Single(z => z.Name == "EAC-7089"); + Assert.NotSame(galaxy.Entrance, zone); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, zone, isTutorial: true); + + // Confirms this fixture still has no genuine (non-rosette) Lagrange candidate -- a regression here + // would mean the fixture no longer probes what this test claims to probe. + var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray(); + var hasGenuineCandidate = planetOrbits.Any(o => planetOrbits.Count(c => c.Parent.Key.Equals(o.Parent.Key) && abs(c.Distance - o.Distance) < .1f) == 1); + Assert.False(hasGenuineCandidate, "the entrance-adjacent fixture zone now has a genuine Lagrange candidate; it no longer tests the no-Lagrange leak path."); + + var stationCount = pack.Entities.OfType().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); + Assert.Equal(0, stationCount); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Soul's finding #3 (asset identity): the existence-only check (EveryAssetReferenceResolvesToAMetaGuid) passes + // even when Djinni.Prefab is swapped for Longinus's -- both are real GUIDs somewhere under Assets/. This pins + // the actual identity: each restored reference names the specific legacy-record file it is supposed to, not + // merely some meta file that happens to exist. Must fail under Soul's own "djprefab" mutation (Djinni.Prefab + // set to Longinus's GUID). + [Fact] + public void RestoredAssetReferencesNameTheirOwnLegacyFileNotJustAnyExistingGuid() + { + string GuidOf(string assetPathUnderRepoRoot) + { + var meta = Path.Combine(FindRepoRoot(), assetPathUnderRepoRoot + ".meta"); + return File.ReadLines(meta).First(l => l.StartsWith("guid: ")).Substring("guid: ".Length).Trim(); + } + + using var cache = OpenCatalog(); + + var djinniPrefabGuid = GuidOf("Assets/Content/Prefabs/Ships/Djinni.prefab"); + var djinni = cache.GetByName("Djinni"); + Assert.Equal(djinniPrefabGuid, djinni.Prefab); + + // Every restored thruster's particle effect is Thruster 1.prefab, by the legacy record (soul-dump.txt). + var thruster1Guid = GuidOf("Assets/Content/Prefabs/Thrusters/Thruster 1.prefab"); + var restoredThrusterNames = new[] { "deep space burnout", "Large Drive", "Victoire", "Small Drive", "Talaria", "RevvITup 2.0", "Medium Drive" }; + foreach (var name in restoredThrusterNames) + { + var design = cache.GetAll().First(g => g.Name == name); + var thrusterBehavior = design.Behaviors.OfType().Single(); + Assert.Equal(thruster1Guid, thrusterBehavior.ParticlesPrefab); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/SafeAssert.cs b/tests/Aetheria.Shared.Tests/SafeAssert.cs new file mode 100644 index 000000000..2ab52d6bd --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SafeAssert.cs @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using Xunit; + +// Assertions over collections of entities and shots. xunit formats a collection's contents into its failure +// message, and an Entity, a PendingShot or a ShotOutcome drags the whole zone graph in with it: the formatter +// recurses until the test host dies of a stack overflow, which a mutation tool scores as a kill of every test +// that was running. A failure here must be an ordinary red naming the test, so these compare counts and +// identities and never hand xunit an entity. +internal static class SafeAssert +{ + public static void NoShots(Zone zone) => + Assert.True(zone.PendingShots.Count == 0, $"{zone.PendingShots.Count} shot(s) still pending"); + + public static PendingShot OnlyShot(Zone zone) + { + Assert.True(zone.PendingShots.Count == 1, $"expected exactly one pending shot, found {zone.PendingShots.Count}"); + return zone.PendingShots[0]; + } + + public static T Only(List list) + { + Assert.True(list.Count == 1, $"expected exactly one {typeof(T).Name}, found {list.Count}"); + return list[0]; + } + + public static void Empty(List list) => Assert.True(list.Count == 0, $"expected no {typeof(T).Name}, found {list.Count}"); + + public static void In(Zone zone, Entity entity, string why = "entity should be in the zone") => + Assert.True(zone.Entities.Contains(entity), why); + + public static void NotIn(Zone zone, Entity entity, string why = "entity should have left the zone") => + Assert.False(zone.Entities.Contains(entity), why); +}