Merge master (fire control 12) into codex/moddable-ships

This commit is contained in:
Meta Vens 2026-09-30 02:21:09 +02:00
commit 18e12d81ce
37 changed files with 4947 additions and 395 deletions

File diff suppressed because it is too large Load diff

View file

@ -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}

View file

@ -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"": ""<Keyboard>/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
/// </summary>
public InputAction @TargetNearest => m_Wrapper.m_Player_TargetNearest;
/// <summary>
/// Provides access to the underlying input action "Player/CycleTargetItem".
/// </summary>
public InputAction @CycleTargetItem => m_Wrapper.m_Player_CycleTargetItem;
/// <summary>
/// Provides access to the underlying input action "Player/ToggleHeatsinks".
/// </summary>
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
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
void OnTargetNearest(InputAction.CallbackContext context);
/// <summary>
/// Method invoked when associated input action "Cycle Target Item" is either <see cref="UnityEngine.InputSystem.InputAction.started" />, <see cref="UnityEngine.InputSystem.InputAction.performed" /> or <see cref="UnityEngine.InputSystem.InputAction.canceled" />.
/// </summary>
/// <seealso cref="UnityEngine.InputSystem.InputAction.started" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.performed" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
void OnCycleTargetItem(InputAction.CallbackContext context);
/// <summary>
/// Method invoked when associated input action "Toggle Heatsinks" is either <see cref="UnityEngine.InputSystem.InputAction.started" />, <see cref="UnityEngine.InputSystem.InputAction.performed" /> or <see cref="UnityEngine.InputSystem.InputAction.canceled" />.
/// </summary>
/// <seealso cref="UnityEngine.InputSystem.InputAction.started" />

View file

@ -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<EquippableItemData>()
.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;
}

View file

@ -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)
{
@ -336,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);

View file

@ -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<Transform>();
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<Transform>();
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);

View file

@ -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<GuidedProjectile>();
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!");
}

View file

@ -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<EquippableItemData>())
{
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<ConsumableItemData>())
{
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<EquippableItemData>()) CultRecordRefs.Validate(data);
foreach (var data in cache.GetAll<ConsumableItemData>()) CultRecordRefs.Validate(data);
return cache;
}
catch

View file

@ -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();

View file

@ -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);

View file

@ -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();

View file

@ -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.

View file

@ -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();
}

View file

@ -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)
{

View file

@ -516,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<Cockpit>()!=null).Select(_ => CauseOfDeath.CockpitDestroyed));
.Merge(ItemDestroyed.Where(i=>i.GetBehavior<Cockpit>()!=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;
}

View file

@ -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;
@ -253,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;
@ -341,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
@ -361,40 +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 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. Fire 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 weaponItemData = item.Data as WeaponItemData;
var blastRadius = weaponItemData?.BlastRadius > 0f ? weaponItemData.BlastRadius.Value : 0f;
var fuse = blastRadius > 0f ? weaponItemData.Fuse : null;
var burstPosition = blastRadius > 0f && target != null ? PredictedIntercept(weapon, source, target) : targetPosition;
var shot = new PendingShot
{
ShotId = zone.NextShotId(),
Source = source,
Target = target,
Target = engaged,
Weapon = item,
Aimed = source.ResolvedTargetItem,
Damage = damageOverride ?? weapon.Damage,
@ -408,10 +569,14 @@ 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,
Fuse = fuse,
BlastRadius = blastRadius,
@ -423,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;
@ -435,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;
}
@ -455,8 +626,11 @@ 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)
@ -465,9 +639,11 @@ public static class FireControl
// 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.
Apply(shot);
zone.ShotResolved.OnNext(shot.Outcome);
// 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);
}
}
}
@ -513,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
@ -558,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<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
try
{
@ -592,7 +772,87 @@ public static class FireControl
}
}
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<LaneCell>.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<LaneCell>.Shared.Return(buffer);
}
}
// R4: the commit is authoritative and immutable from here on -- this only performs what Commit already
@ -648,18 +908,22 @@ public static class FireControl
// 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(PendingShot shot)
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.Proximity:
// Detonates at shot.BurstPosition.xz, which PredictedIntercept froze at Fire (today's airburst
// rule, unchanged) -- no roll, and no gate on shot.Outcome.Hit.
Detonate(shot.Source.Zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType);
return;
case WeaponFuse.Contact:
case WeaponFuse.Delayed:
ApplyBlastHit(shot);
@ -774,7 +1038,10 @@ public static class FireControl
// 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.
var worldPoint = target.ToWorldPoint(schematicPoint);
// 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);
}
@ -1025,7 +1292,7 @@ public static class FireControl
return walked;
}
private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now)
private static ShotOutcome MakeOutcome(PendingShot shot, ShotResult result, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now)
{
return new ShotOutcome
{
@ -1033,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,
@ -1073,23 +1340,20 @@ public static class FireControl
// DamageHull call.
public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType)
{
// F3 (Soul, Cut 12.3 fold-in): Detonate cannot be reached with a nonpositive radius. Fire only ever
// freezes a Fuse when BlastRadius > 0 (12.4(a)), so this guard matters only for a direct caller (a
// test, or Mine.Explode with a zero BlastRange).
if (radius <= 0f) return;
foreach (var entity 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 cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize;
var hullData = entity.ItemManager.GetData(entity.Hull) as HullData;
var shape = hullData.Shape;
// Candidates: |Position.xz - P| <= radius + half the hull's own diagonal, in world units -- catches
// a long hull whose end lies inside the blast even though its centre does not.
var toEntity = length(entity.Position.xz - worldPlanar);
var halfDiagonal = .5f * length(float2(shape.Width, shape.Height)) * cellSize;
if (toEntity > radius + halfDiagonal) continue;
var centre = entity.ToSchematicPoint(worldPlanar);
var rCells = radius / cellSize;
var normaliser = PI * rCells * rCells;
@ -1097,17 +1361,18 @@ public static class FireControl
// 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;
var covered = new List<(int2 Cell, float Share)>();
List<(int2 Cell, float Share)> covered = null;
var totalShare = 0f;
for (var i = 0; i < coords.Length; i++)
{
var overlap = CircleSquareOverlap(centre, rCells, coords[i]);
if (overlap <= 0f) continue;
var share = damage * overlap / normaliser;
covered.Add((coords[i], share));
(covered ??= new List<(int2 Cell, float Share)>()).Add((coords[i], share));
totalShare += share;
}
if (covered.Count == 0) continue;
// 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;
@ -1209,8 +1474,11 @@ public static class FireControl
var mid = .5f * (a + b);
var h = sqrt(max(0f, r * r - mid * mid));
var upperIsDisc = h < yhi;
var lowerIsDisc = -h > ylo;
// 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;
@ -1363,24 +1631,10 @@ 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
}
// 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
// entry (ties broken by dot(cell, b), then cell index for full determinism), then walks forward while
// consecutive cells are contiguous (`next.entry <= current.exit + 1e-4`) -- the first gap ends the walk,
// the same rule Entity.cs's old march kept. A direct hit starts outside the hull (t -> -infinity), so the
// 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)
// 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;
@ -1402,7 +1656,29 @@ public static class FireControl
}
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
// entry (ties broken by dot(cell, b), then cell index for full determinism), then walks forward while
// consecutive cells are contiguous (`next.entry <= current.exit + 1e-4`) -- the first gap ends the walk,
// the same rule Entity.cs's old march kept. A direct hit starts outside the hull (t -> -infinity), so the
// 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 n = CellsOnLine(hull, b, s, buffer);
var walked = n > 0 ? 1 : 0;
for (var i = 1; i < n; i++)
{
@ -1491,12 +1767,15 @@ public struct PendingShot
// 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 (Fire decides "detonates" once); it is unread until
// 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 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;
@ -1507,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;
@ -1530,6 +1835,10 @@ 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
@ -1596,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
@ -1611,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
}

View file

@ -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;
}
private List<Task> BeltUpdates = new List<Task>();
public float Time

View file

@ -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);
}

View file

@ -373,8 +373,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();

21
CLAUDE.md Normal file
View file

@ -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.

File diff suppressed because one or more lines are too long

View file

@ -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":{}}

View file

@ -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":{}}

View file

@ -2601,6 +2601,102 @@ the lanes that reach it, a pool of one lane included. The survey and the sequent
### 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

View file

@ -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`.

674
docs/scenarios-cut.md Normal file
View file

@ -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 <name>` | `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:<Name>`, 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:<Name>` (`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:<Slug>` | 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:<Slug>:<At as UTC ticks>` | 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<ScenarioEntity>`).
- `ScenarioShip`: `Loadout` (`CultRecordRef<Loadout>`), `Position` (`float2`, zone xz), `Direction` (`float2`),
`Cargo` (`List<CultRecordRef<EquippableItemData>>`, 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<Scenario>`, 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 <rev>`). `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 <slug> <pass|fail|partial|blocked> --revision <sha> [--note <text>]`. 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.

View file

@ -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

View file

@ -305,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. ----

File diff suppressed because it is too large Load diff

View file

@ -1352,22 +1352,35 @@ public sealed class FireControlCut12Tests : IDisposable
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);
var before = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 1000; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer);
var laneAllocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(laneAllocated == 0, $"1000 Lane calls allocated {laneAllocated} bytes; \"never allocates\" is not true");
// 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;
}
before = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 1000; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer);
var silhouetteAllocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(silhouetteAllocated == 0, $"1000 buffered Silhouette calls allocated {silhouetteAllocated} bytes");
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<T> over ReactiveCollection<T>), 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);
before = GC.GetAllocatedBytesForCurrentThread();
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;

View file

@ -621,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). ----

View file

@ -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)

View file

@ -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));

View file

@ -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<CultCache> _openCaches = new List<CultCache>();
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<Gun> Guns = new List<Gun>();
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<ItemData>(items.ItemData.GetByName<ItemData>(name)), Durability = durability, Lot = lot
};
var lot = 1;
var shooterHullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("ShooterHull"));
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("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);
}
}

View file

@ -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]

View file

@ -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<T>(List<T> list)
{
Assert.True(list.Count == 1, $"expected exactly one {typeof(T).Name}, found {list.Count}");
return list[0];
}
public static void Empty<T>(List<T> 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);
}