Merge master (fire control 12) into codex/moddable-ships
This commit is contained in:
commit
18e12d81ce
37 changed files with 4947 additions and 395 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
21
CLAUDE.md
Normal 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
|
|
@ -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":{}}
|
||||
|
|
@ -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":{}}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
172
docs/merge-to-master-checklist.md
Normal file
172
docs/merge-to-master-checklist.md
Normal 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
674
docs/scenarios-cut.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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). ----
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
789
tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs
Normal file
789
tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
37
tests/Aetheria.Shared.Tests/SafeAssert.cs
Normal file
37
tests/Aetheria.Shared.Tests/SafeAssert.cs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue