diff --git a/.gitignore b/.gitignore index 0524e8cb8..2cbd223f9 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ Vault/Aetheria/.obsidian/plugins/copilot/data.json # come back. .scratch-* scratch-* +tests/_scratch_* .codex/ .codex-tmp/ .evepkg/ @@ -108,3 +109,6 @@ scratch-* .nuget-sandbox/ .dotnet-sandbox/ .appdata-sandbox/ + +# Stryker.NET mutation reports (regenerated per run) +StrykerOutput/ diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc index 168f00ca4..91f465eca 100644 Binary files a/.voidbot/state/aetheria.cc and b/.voidbot/state/aetheria.cc differ diff --git a/Aetheria.Shared/Aetheria.Shared.csproj b/Aetheria.Shared/Aetheria.Shared.csproj index 5274558da..43d1c257d 100644 --- a/Aetheria.Shared/Aetheria.Shared.csproj +++ b/Aetheria.Shared/Aetheria.Shared.csproj @@ -24,7 +24,7 @@ - + diff --git a/Assets/AddressableAssetsData.meta b/Assets/AddressableAssetsData.meta new file mode 100644 index 000000000..9dbcc00b9 --- /dev/null +++ b/Assets/AddressableAssetsData.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: afcac10c400670e449f5fc5036bc8f33 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/AddressableAssetsData/AddressableAssetSettings.asset b/Assets/AddressableAssetsData/AddressableAssetSettings.asset new file mode 100644 index 000000000..719891e8c --- /dev/null +++ b/Assets/AddressableAssetsData/AddressableAssetSettings.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38a6368e658b1ccee606d9b3438397bdb4d89ddf6377036038607d7858d7d5c7 +size 3996 diff --git a/Assets/Resources/Icons.asset.meta b/Assets/AddressableAssetsData/AddressableAssetSettings.asset.meta similarity index 79% rename from Assets/Resources/Icons.asset.meta rename to Assets/AddressableAssetsData/AddressableAssetSettings.asset.meta index 7c6fa0e74..5602a5206 100644 --- a/Assets/Resources/Icons.asset.meta +++ b/Assets/AddressableAssetsData/AddressableAssetSettings.asset.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 50ac72e1e2474804287a2d4f1693f9b8 +guid: 9afa7f178037c2542b634eefddca5a76 NativeFormatImporter: externalObjects: {} mainObjectFileID: 11400000 diff --git a/Assets/AddressableAssetsData/AssetGroupTemplates.meta b/Assets/AddressableAssetsData/AssetGroupTemplates.meta new file mode 100644 index 000000000..7a2f7153d --- /dev/null +++ b/Assets/AddressableAssetsData/AssetGroupTemplates.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2634fb27aecdd0848b0d29dcb1c046eb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/AddressableAssetsData/AssetGroupTemplates/Packed Assets.asset b/Assets/AddressableAssetsData/AssetGroupTemplates/Packed Assets.asset new file mode 100644 index 000000000..e4a47d270 --- /dev/null +++ b/Assets/AddressableAssetsData/AssetGroupTemplates/Packed Assets.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0b10af898b793f07c9ef59370539c48f4199a2fb8f21362ff1633e58bc7a457 +size 2527 diff --git a/Assets/Resources/Atmosphere Settings.asset.meta b/Assets/AddressableAssetsData/AssetGroupTemplates/Packed Assets.asset.meta similarity index 79% rename from Assets/Resources/Atmosphere Settings.asset.meta rename to Assets/AddressableAssetsData/AssetGroupTemplates/Packed Assets.asset.meta index a300d1102..8a838116f 100644 --- a/Assets/Resources/Atmosphere Settings.asset.meta +++ b/Assets/AddressableAssetsData/AssetGroupTemplates/Packed Assets.asset.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 8e73e89daf00b8447b2410427953df25 +guid: 28a572c267988bc4dbf1f42e4e5904df NativeFormatImporter: externalObjects: {} mainObjectFileID: 11400000 diff --git a/Assets/AddressableAssetsData/AssetGroups.meta b/Assets/AddressableAssetsData/AssetGroups.meta new file mode 100644 index 000000000..682421143 --- /dev/null +++ b/Assets/AddressableAssetsData/AssetGroups.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 82bd9e776cbf4834f88206161b46a8ae +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/AddressableAssetsData/AssetGroups/Default Local Group.asset b/Assets/AddressableAssetsData/AssetGroups/Default Local Group.asset new file mode 100644 index 000000000..da8a310d4 --- /dev/null +++ b/Assets/AddressableAssetsData/AssetGroups/Default Local Group.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c1ff5e018d8debd96ac8d0d4ebf9fe267c5afc4e3546370ca5b883c582e5c85 +size 1036 diff --git a/Assets/Resources/Test Galaxy.asset.meta b/Assets/AddressableAssetsData/AssetGroups/Default Local Group.asset.meta similarity index 79% rename from Assets/Resources/Test Galaxy.asset.meta rename to Assets/AddressableAssetsData/AssetGroups/Default Local Group.asset.meta index 1d0fc56f2..fdb0709c6 100644 --- a/Assets/Resources/Test Galaxy.asset.meta +++ b/Assets/AddressableAssetsData/AssetGroups/Default Local Group.asset.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 70345ecc9f78fcb47b1f6f5afed2681a +guid: 7633ca2c7c5d68748b1143acbc5d3055 NativeFormatImporter: externalObjects: {} mainObjectFileID: 11400000 diff --git a/Assets/AddressableAssetsData/AssetGroups/Schemas.meta b/Assets/AddressableAssetsData/AssetGroups/Schemas.meta new file mode 100644 index 000000000..2b0be3a48 --- /dev/null +++ b/Assets/AddressableAssetsData/AssetGroups/Schemas.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 76d1075a6428daf46ae7e6c4435679eb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_BundledAssetGroupSchema.asset b/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_BundledAssetGroupSchema.asset new file mode 100644 index 000000000..13b1f9447 --- /dev/null +++ b/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_BundledAssetGroupSchema.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccaf616e92fc06a3f6eb4420903cb6140d00a073fed24a4e71048dcc34780dbd +size 1741 diff --git a/Assets/Resources/Elysium.asset.meta b/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_BundledAssetGroupSchema.asset.meta similarity index 79% rename from Assets/Resources/Elysium.asset.meta rename to Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_BundledAssetGroupSchema.asset.meta index c135d349b..1abdbc35d 100644 --- a/Assets/Resources/Elysium.asset.meta +++ b/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_BundledAssetGroupSchema.asset.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 8525ebaeff3478247bbeb4d8eb71ef35 +guid: e100c3f53063351499abbf5ce47e9352 NativeFormatImporter: externalObjects: {} mainObjectFileID: 11400000 diff --git a/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_ContentUpdateGroupSchema.asset b/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_ContentUpdateGroupSchema.asset new file mode 100644 index 000000000..2915adef7 --- /dev/null +++ b/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_ContentUpdateGroupSchema.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8df0cf714a3c70db9a868f3b157aad194effd553a638028740b1367d34e31b07 +size 636 diff --git a/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_ContentUpdateGroupSchema.asset.meta b/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_ContentUpdateGroupSchema.asset.meta new file mode 100644 index 000000000..279281f3e --- /dev/null +++ b/Assets/AddressableAssetsData/AssetGroups/Schemas/Default Local Group_ContentUpdateGroupSchema.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 21b66eceac2d0e3458024bf6683bb82a +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/AddressableAssetsData/DataBuilders.meta b/Assets/AddressableAssetsData/DataBuilders.meta new file mode 100644 index 000000000..1ace8ff26 --- /dev/null +++ b/Assets/AddressableAssetsData/DataBuilders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 45fe9f1406ef90144ba81be38ff309a5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/AddressableAssetsData/DataBuilders/BuildScriptFastMode.asset b/Assets/AddressableAssetsData/DataBuilders/BuildScriptFastMode.asset new file mode 100644 index 000000000..9c8bc0286 --- /dev/null +++ b/Assets/AddressableAssetsData/DataBuilders/BuildScriptFastMode.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec8fa6f7f04687b1ea742863ca9312cc48c78708d3bd04f5a80ee1c4289fa550 +size 905 diff --git a/Assets/AddressableAssetsData/DataBuilders/BuildScriptFastMode.asset.meta b/Assets/AddressableAssetsData/DataBuilders/BuildScriptFastMode.asset.meta new file mode 100644 index 000000000..d16838a9b --- /dev/null +++ b/Assets/AddressableAssetsData/DataBuilders/BuildScriptFastMode.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a411416dac5dbce4b998447a1d0d6425 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedMode.asset b/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedMode.asset new file mode 100644 index 000000000..89776d393 --- /dev/null +++ b/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedMode.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca98b03b4bd46044981893af422aad5ec947f6b4923d5e7d732074ac0cf2d58c +size 909 diff --git a/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedMode.asset.meta b/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedMode.asset.meta new file mode 100644 index 000000000..93ca759df --- /dev/null +++ b/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedMode.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 49b1641b1c4ec2049871e35911caab38 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedPlayMode.asset b/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedPlayMode.asset new file mode 100644 index 000000000..2fb87b085 --- /dev/null +++ b/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedPlayMode.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40aec021cfef83513030f6622569c31b4dc2eed74b6024c9ea8762ee981fd3bd +size 917 diff --git a/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedPlayMode.asset.meta b/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedPlayMode.asset.meta new file mode 100644 index 000000000..57a61f36f --- /dev/null +++ b/Assets/AddressableAssetsData/DataBuilders/BuildScriptPackedPlayMode.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cad6f30924c70ae44832566c0439562d +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/AddressableAssetsData/DefaultObject.asset b/Assets/AddressableAssetsData/DefaultObject.asset new file mode 100644 index 000000000..164322b0d --- /dev/null +++ b/Assets/AddressableAssetsData/DefaultObject.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccdbb9beb096e5204d816c17a38b52a4362af46f754793ca72616cf68cc311de +size 563 diff --git a/Assets/AddressableAssetsData/DefaultObject.asset.meta b/Assets/AddressableAssetsData/DefaultObject.asset.meta new file mode 100644 index 000000000..3528cc5f0 --- /dev/null +++ b/Assets/AddressableAssetsData/DefaultObject.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fcb9b650d8f542f4cb0bb445384274c8 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Content.meta b/Assets/Content.meta new file mode 100644 index 000000000..5436eaecf --- /dev/null +++ b/Assets/Content.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 20272a470f27e964d9c77b7b0300226e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Prefabs.meta b/Assets/Content/Prefabs.meta similarity index 100% rename from Assets/Resources/Prefabs.meta rename to Assets/Content/Prefabs.meta diff --git a/Assets/Resources/Prefabs/Influence.prefab b/Assets/Content/Prefabs/Influence.prefab similarity index 100% rename from Assets/Resources/Prefabs/Influence.prefab rename to Assets/Content/Prefabs/Influence.prefab diff --git a/Assets/Resources/Prefabs/Influence.prefab.meta b/Assets/Content/Prefabs/Influence.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Influence.prefab.meta rename to Assets/Content/Prefabs/Influence.prefab.meta diff --git a/Assets/Resources/Prefabs/Lightning.prefab b/Assets/Content/Prefabs/Lightning.prefab similarity index 100% rename from Assets/Resources/Prefabs/Lightning.prefab rename to Assets/Content/Prefabs/Lightning.prefab diff --git a/Assets/Resources/Prefabs/Lightning.prefab.meta b/Assets/Content/Prefabs/Lightning.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Lightning.prefab.meta rename to Assets/Content/Prefabs/Lightning.prefab.meta diff --git a/Assets/Resources/Prefabs/PingEffect.prefab b/Assets/Content/Prefabs/PingEffect.prefab similarity index 100% rename from Assets/Resources/Prefabs/PingEffect.prefab rename to Assets/Content/Prefabs/PingEffect.prefab diff --git a/Assets/Resources/Prefabs/PingEffect.prefab.meta b/Assets/Content/Prefabs/PingEffect.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/PingEffect.prefab.meta rename to Assets/Content/Prefabs/PingEffect.prefab.meta diff --git a/Assets/Resources/Prefabs/Shield.prefab b/Assets/Content/Prefabs/Shield.prefab similarity index 100% rename from Assets/Resources/Prefabs/Shield.prefab rename to Assets/Content/Prefabs/Shield.prefab diff --git a/Assets/Resources/Prefabs/Shield.prefab.meta b/Assets/Content/Prefabs/Shield.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Shield.prefab.meta rename to Assets/Content/Prefabs/Shield.prefab.meta diff --git a/Assets/Resources/Prefabs/Ships.meta b/Assets/Content/Prefabs/Ships.meta similarity index 100% rename from Assets/Resources/Prefabs/Ships.meta rename to Assets/Content/Prefabs/Ships.meta diff --git a/Assets/Resources/Prefabs/Ships/Djinni.prefab b/Assets/Content/Prefabs/Ships/Djinni.prefab similarity index 100% rename from Assets/Resources/Prefabs/Ships/Djinni.prefab rename to Assets/Content/Prefabs/Ships/Djinni.prefab diff --git a/Assets/Resources/Prefabs/Ships/Djinni.prefab.meta b/Assets/Content/Prefabs/Ships/Djinni.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Ships/Djinni.prefab.meta rename to Assets/Content/Prefabs/Ships/Djinni.prefab.meta diff --git a/Assets/Resources/Prefabs/Ships/Longinus.prefab b/Assets/Content/Prefabs/Ships/Longinus.prefab similarity index 100% rename from Assets/Resources/Prefabs/Ships/Longinus.prefab rename to Assets/Content/Prefabs/Ships/Longinus.prefab diff --git a/Assets/Resources/Prefabs/Ships/Longinus.prefab.meta b/Assets/Content/Prefabs/Ships/Longinus.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Ships/Longinus.prefab.meta rename to Assets/Content/Prefabs/Ships/Longinus.prefab.meta diff --git a/Assets/Resources/Prefabs/Stations.meta b/Assets/Content/Prefabs/Stations.meta similarity index 100% rename from Assets/Resources/Prefabs/Stations.meta rename to Assets/Content/Prefabs/Stations.meta diff --git a/Assets/Resources/Prefabs/Stations/AsteroidOutpost.prefab b/Assets/Content/Prefabs/Stations/AsteroidOutpost.prefab similarity index 100% rename from Assets/Resources/Prefabs/Stations/AsteroidOutpost.prefab rename to Assets/Content/Prefabs/Stations/AsteroidOutpost.prefab diff --git a/Assets/Resources/Prefabs/Stations/AsteroidOutpost.prefab.meta b/Assets/Content/Prefabs/Stations/AsteroidOutpost.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Stations/AsteroidOutpost.prefab.meta rename to Assets/Content/Prefabs/Stations/AsteroidOutpost.prefab.meta diff --git a/Assets/Resources/Prefabs/Stations/PlanetOutpost.prefab b/Assets/Content/Prefabs/Stations/PlanetOutpost.prefab similarity index 100% rename from Assets/Resources/Prefabs/Stations/PlanetOutpost.prefab rename to Assets/Content/Prefabs/Stations/PlanetOutpost.prefab diff --git a/Assets/Resources/Prefabs/Stations/PlanetOutpost.prefab.meta b/Assets/Content/Prefabs/Stations/PlanetOutpost.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Stations/PlanetOutpost.prefab.meta rename to Assets/Content/Prefabs/Stations/PlanetOutpost.prefab.meta diff --git a/Assets/Resources/Prefabs/Stations/Zenith.prefab b/Assets/Content/Prefabs/Stations/Zenith.prefab similarity index 100% rename from Assets/Resources/Prefabs/Stations/Zenith.prefab rename to Assets/Content/Prefabs/Stations/Zenith.prefab diff --git a/Assets/Resources/Prefabs/Stations/Zenith.prefab.meta b/Assets/Content/Prefabs/Stations/Zenith.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Stations/Zenith.prefab.meta rename to Assets/Content/Prefabs/Stations/Zenith.prefab.meta diff --git a/Assets/Resources/Prefabs/Thrusters.meta b/Assets/Content/Prefabs/Thrusters.meta similarity index 100% rename from Assets/Resources/Prefabs/Thrusters.meta rename to Assets/Content/Prefabs/Thrusters.meta diff --git a/Assets/Resources/Prefabs/Thrusters/Aether Drive.prefab b/Assets/Content/Prefabs/Thrusters/Aether Drive.prefab similarity index 100% rename from Assets/Resources/Prefabs/Thrusters/Aether Drive.prefab rename to Assets/Content/Prefabs/Thrusters/Aether Drive.prefab diff --git a/Assets/Resources/Prefabs/Thrusters/Aether Drive.prefab.meta b/Assets/Content/Prefabs/Thrusters/Aether Drive.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Thrusters/Aether Drive.prefab.meta rename to Assets/Content/Prefabs/Thrusters/Aether Drive.prefab.meta diff --git a/Assets/Resources/Prefabs/Thrusters/Thruster 1 1.prefab b/Assets/Content/Prefabs/Thrusters/Thruster 1 1.prefab similarity index 100% rename from Assets/Resources/Prefabs/Thrusters/Thruster 1 1.prefab rename to Assets/Content/Prefabs/Thrusters/Thruster 1 1.prefab diff --git a/Assets/Resources/Prefabs/Thrusters/Thruster 1 1.prefab.meta b/Assets/Content/Prefabs/Thrusters/Thruster 1 1.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Thrusters/Thruster 1 1.prefab.meta rename to Assets/Content/Prefabs/Thrusters/Thruster 1 1.prefab.meta diff --git a/Assets/Resources/Prefabs/Thrusters/Thruster 1.prefab b/Assets/Content/Prefabs/Thrusters/Thruster 1.prefab similarity index 100% rename from Assets/Resources/Prefabs/Thrusters/Thruster 1.prefab rename to Assets/Content/Prefabs/Thrusters/Thruster 1.prefab diff --git a/Assets/Resources/Prefabs/Thrusters/Thruster 1.prefab.meta b/Assets/Content/Prefabs/Thrusters/Thruster 1.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Thrusters/Thruster 1.prefab.meta rename to Assets/Content/Prefabs/Thrusters/Thruster 1.prefab.meta diff --git a/Assets/Resources/Prefabs/Tractor Beam.prefab b/Assets/Content/Prefabs/Tractor Beam.prefab similarity index 100% rename from Assets/Resources/Prefabs/Tractor Beam.prefab rename to Assets/Content/Prefabs/Tractor Beam.prefab diff --git a/Assets/Resources/Prefabs/Tractor Beam.prefab.meta b/Assets/Content/Prefabs/Tractor Beam.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Tractor Beam.prefab.meta rename to Assets/Content/Prefabs/Tractor Beam.prefab.meta diff --git a/Assets/Resources/Prefabs/Turrets.meta b/Assets/Content/Prefabs/Turrets.meta similarity index 100% rename from Assets/Resources/Prefabs/Turrets.meta rename to Assets/Content/Prefabs/Turrets.meta diff --git a/Assets/Resources/Prefabs/Turrets/Turret.prefab b/Assets/Content/Prefabs/Turrets/Turret.prefab similarity index 100% rename from Assets/Resources/Prefabs/Turrets/Turret.prefab rename to Assets/Content/Prefabs/Turrets/Turret.prefab diff --git a/Assets/Resources/Prefabs/Turrets/Turret.prefab.meta b/Assets/Content/Prefabs/Turrets/Turret.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Turrets/Turret.prefab.meta rename to Assets/Content/Prefabs/Turrets/Turret.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons.meta b/Assets/Content/Prefabs/Weapons.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons.meta rename to Assets/Content/Prefabs/Weapons.meta diff --git a/Assets/Resources/Prefabs/Weapons/AC5.prefab b/Assets/Content/Prefabs/Weapons/AC5.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/AC5.prefab rename to Assets/Content/Prefabs/Weapons/AC5.prefab diff --git a/Assets/Resources/Prefabs/Weapons/AC5.prefab.meta b/Assets/Content/Prefabs/Weapons/AC5.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/AC5.prefab.meta rename to Assets/Content/Prefabs/Weapons/AC5.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/AU.meta b/Assets/Content/Prefabs/Weapons/AU.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/AU.meta rename to Assets/Content/Prefabs/Weapons/AU.meta diff --git a/Assets/Resources/Prefabs/Weapons/AU/Earp.prefab b/Assets/Content/Prefabs/Weapons/AU/Earp.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/AU/Earp.prefab rename to Assets/Content/Prefabs/Weapons/AU/Earp.prefab diff --git a/Assets/Resources/Prefabs/Weapons/AU/Earp.prefab.meta b/Assets/Content/Prefabs/Weapons/AU/Earp.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/AU/Earp.prefab.meta rename to Assets/Content/Prefabs/Weapons/AU/Earp.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/AU/Leonid.prefab b/Assets/Content/Prefabs/Weapons/AU/Leonid.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/AU/Leonid.prefab rename to Assets/Content/Prefabs/Weapons/AU/Leonid.prefab diff --git a/Assets/Resources/Prefabs/Weapons/AU/Leonid.prefab.meta b/Assets/Content/Prefabs/Weapons/AU/Leonid.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/AU/Leonid.prefab.meta rename to Assets/Content/Prefabs/Weapons/AU/Leonid.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Adrasteia.meta b/Assets/Content/Prefabs/Weapons/Adrasteia.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Adrasteia.meta rename to Assets/Content/Prefabs/Weapons/Adrasteia.meta diff --git a/Assets/Resources/Prefabs/Weapons/Adrasteia/ColdFire.prefab b/Assets/Content/Prefabs/Weapons/Adrasteia/ColdFire.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Adrasteia/ColdFire.prefab rename to Assets/Content/Prefabs/Weapons/Adrasteia/ColdFire.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Adrasteia/ColdFire.prefab.meta b/Assets/Content/Prefabs/Weapons/Adrasteia/ColdFire.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Adrasteia/ColdFire.prefab.meta rename to Assets/Content/Prefabs/Weapons/Adrasteia/ColdFire.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Alakrita.meta b/Assets/Content/Prefabs/Weapons/Alakrita.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Alakrita.meta rename to Assets/Content/Prefabs/Weapons/Alakrita.meta diff --git a/Assets/Resources/Prefabs/Weapons/Alakrita/Spectra.prefab b/Assets/Content/Prefabs/Weapons/Alakrita/Spectra.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Alakrita/Spectra.prefab rename to Assets/Content/Prefabs/Weapons/Alakrita/Spectra.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Alakrita/Spectra.prefab.meta b/Assets/Content/Prefabs/Weapons/Alakrita/Spectra.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Alakrita/Spectra.prefab.meta rename to Assets/Content/Prefabs/Weapons/Alakrita/Spectra.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Auto Shotgun.prefab b/Assets/Content/Prefabs/Weapons/Auto Shotgun.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Auto Shotgun.prefab rename to Assets/Content/Prefabs/Weapons/Auto Shotgun.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Auto Shotgun.prefab.meta b/Assets/Content/Prefabs/Weapons/Auto Shotgun.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Auto Shotgun.prefab.meta rename to Assets/Content/Prefabs/Weapons/Auto Shotgun.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Charged Shotgun.prefab b/Assets/Content/Prefabs/Weapons/Charged Shotgun.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Charged Shotgun.prefab rename to Assets/Content/Prefabs/Weapons/Charged Shotgun.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Charged Shotgun.prefab.meta b/Assets/Content/Prefabs/Weapons/Charged Shotgun.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Charged Shotgun.prefab.meta rename to Assets/Content/Prefabs/Weapons/Charged Shotgun.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/ColferV.meta b/Assets/Content/Prefabs/Weapons/ColferV.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV.meta rename to Assets/Content/Prefabs/Weapons/ColferV.meta diff --git a/Assets/Resources/Prefabs/Weapons/ColferV/6k Shooter.prefab b/Assets/Content/Prefabs/Weapons/ColferV/6k Shooter.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV/6k Shooter.prefab rename to Assets/Content/Prefabs/Weapons/ColferV/6k Shooter.prefab diff --git a/Assets/Resources/Prefabs/Weapons/ColferV/6k Shooter.prefab.meta b/Assets/Content/Prefabs/Weapons/ColferV/6k Shooter.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV/6k Shooter.prefab.meta rename to Assets/Content/Prefabs/Weapons/ColferV/6k Shooter.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/ColferV/Cannonball.prefab b/Assets/Content/Prefabs/Weapons/ColferV/Cannonball.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV/Cannonball.prefab rename to Assets/Content/Prefabs/Weapons/ColferV/Cannonball.prefab diff --git a/Assets/Resources/Prefabs/Weapons/ColferV/Cannonball.prefab.meta b/Assets/Content/Prefabs/Weapons/ColferV/Cannonball.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV/Cannonball.prefab.meta rename to Assets/Content/Prefabs/Weapons/ColferV/Cannonball.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/ColferV/DeathCluster.prefab b/Assets/Content/Prefabs/Weapons/ColferV/DeathCluster.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV/DeathCluster.prefab rename to Assets/Content/Prefabs/Weapons/ColferV/DeathCluster.prefab diff --git a/Assets/Resources/Prefabs/Weapons/ColferV/DeathCluster.prefab.meta b/Assets/Content/Prefabs/Weapons/ColferV/DeathCluster.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV/DeathCluster.prefab.meta rename to Assets/Content/Prefabs/Weapons/ColferV/DeathCluster.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/ColferV/Emperor.prefab b/Assets/Content/Prefabs/Weapons/ColferV/Emperor.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV/Emperor.prefab rename to Assets/Content/Prefabs/Weapons/ColferV/Emperor.prefab diff --git a/Assets/Resources/Prefabs/Weapons/ColferV/Emperor.prefab.meta b/Assets/Content/Prefabs/Weapons/ColferV/Emperor.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV/Emperor.prefab.meta rename to Assets/Content/Prefabs/Weapons/ColferV/Emperor.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/ColferV/ShootYerEyeOut.prefab b/Assets/Content/Prefabs/Weapons/ColferV/ShootYerEyeOut.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV/ShootYerEyeOut.prefab rename to Assets/Content/Prefabs/Weapons/ColferV/ShootYerEyeOut.prefab diff --git a/Assets/Resources/Prefabs/Weapons/ColferV/ShootYerEyeOut.prefab.meta b/Assets/Content/Prefabs/Weapons/ColferV/ShootYerEyeOut.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/ColferV/ShootYerEyeOut.prefab.meta rename to Assets/Content/Prefabs/Weapons/ColferV/ShootYerEyeOut.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Constant Laser.prefab b/Assets/Content/Prefabs/Weapons/Constant Laser.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Constant Laser.prefab rename to Assets/Content/Prefabs/Weapons/Constant Laser.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Constant Laser.prefab.meta b/Assets/Content/Prefabs/Weapons/Constant Laser.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Constant Laser.prefab.meta rename to Assets/Content/Prefabs/Weapons/Constant Laser.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/DME.meta b/Assets/Content/Prefabs/Weapons/DME.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/DME.meta rename to Assets/Content/Prefabs/Weapons/DME.meta diff --git a/Assets/Resources/Prefabs/Weapons/DME/bananasplit.prefab b/Assets/Content/Prefabs/Weapons/DME/bananasplit.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/DME/bananasplit.prefab rename to Assets/Content/Prefabs/Weapons/DME/bananasplit.prefab diff --git a/Assets/Resources/Prefabs/Weapons/DME/bananasplit.prefab.meta b/Assets/Content/Prefabs/Weapons/DME/bananasplit.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/DME/bananasplit.prefab.meta rename to Assets/Content/Prefabs/Weapons/DME/bananasplit.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/DME/prettypretty.prefab b/Assets/Content/Prefabs/Weapons/DME/prettypretty.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/DME/prettypretty.prefab rename to Assets/Content/Prefabs/Weapons/DME/prettypretty.prefab diff --git a/Assets/Resources/Prefabs/Weapons/DME/prettypretty.prefab.meta b/Assets/Content/Prefabs/Weapons/DME/prettypretty.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/DME/prettypretty.prefab.meta rename to Assets/Content/Prefabs/Weapons/DME/prettypretty.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/DME/railagainst.prefab b/Assets/Content/Prefabs/Weapons/DME/railagainst.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/DME/railagainst.prefab rename to Assets/Content/Prefabs/Weapons/DME/railagainst.prefab diff --git a/Assets/Resources/Prefabs/Weapons/DME/railagainst.prefab.meta b/Assets/Content/Prefabs/Weapons/DME/railagainst.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/DME/railagainst.prefab.meta rename to Assets/Content/Prefabs/Weapons/DME/railagainst.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/DME/scorchedvoid.prefab b/Assets/Content/Prefabs/Weapons/DME/scorchedvoid.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/DME/scorchedvoid.prefab rename to Assets/Content/Prefabs/Weapons/DME/scorchedvoid.prefab diff --git a/Assets/Resources/Prefabs/Weapons/DME/scorchedvoid.prefab.meta b/Assets/Content/Prefabs/Weapons/DME/scorchedvoid.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/DME/scorchedvoid.prefab.meta rename to Assets/Content/Prefabs/Weapons/DME/scorchedvoid.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Flak Cannon.prefab b/Assets/Content/Prefabs/Weapons/Flak Cannon.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Flak Cannon.prefab rename to Assets/Content/Prefabs/Weapons/Flak Cannon.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Flak Cannon.prefab.meta b/Assets/Content/Prefabs/Weapons/Flak Cannon.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Flak Cannon.prefab.meta rename to Assets/Content/Prefabs/Weapons/Flak Cannon.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Flamethrower.prefab b/Assets/Content/Prefabs/Weapons/Flamethrower.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Flamethrower.prefab rename to Assets/Content/Prefabs/Weapons/Flamethrower.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Flamethrower.prefab.meta b/Assets/Content/Prefabs/Weapons/Flamethrower.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Flamethrower.prefab.meta rename to Assets/Content/Prefabs/Weapons/Flamethrower.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Lightning Rifle.prefab b/Assets/Content/Prefabs/Weapons/Lightning Rifle.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Lightning Rifle.prefab rename to Assets/Content/Prefabs/Weapons/Lightning Rifle.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Lightning Rifle.prefab.meta b/Assets/Content/Prefabs/Weapons/Lightning Rifle.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Lightning Rifle.prefab.meta rename to Assets/Content/Prefabs/Weapons/Lightning Rifle.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Lightsail.meta b/Assets/Content/Prefabs/Weapons/Lightsail.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Lightsail.meta rename to Assets/Content/Prefabs/Weapons/Lightsail.meta diff --git a/Assets/Resources/Prefabs/Weapons/Lightsail/ClearPath.prefab b/Assets/Content/Prefabs/Weapons/Lightsail/ClearPath.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Lightsail/ClearPath.prefab rename to Assets/Content/Prefabs/Weapons/Lightsail/ClearPath.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Lightsail/ClearPath.prefab.meta b/Assets/Content/Prefabs/Weapons/Lightsail/ClearPath.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Lightsail/ClearPath.prefab.meta rename to Assets/Content/Prefabs/Weapons/Lightsail/ClearPath.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Lucent.meta b/Assets/Content/Prefabs/Weapons/Lucent.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Lucent.meta rename to Assets/Content/Prefabs/Weapons/Lucent.meta diff --git a/Assets/Resources/Prefabs/Weapons/Lucent/RainbowLite.prefab b/Assets/Content/Prefabs/Weapons/Lucent/RainbowLite.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Lucent/RainbowLite.prefab rename to Assets/Content/Prefabs/Weapons/Lucent/RainbowLite.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Lucent/RainbowLite.prefab.meta b/Assets/Content/Prefabs/Weapons/Lucent/RainbowLite.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Lucent/RainbowLite.prefab.meta rename to Assets/Content/Prefabs/Weapons/Lucent/RainbowLite.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Lucent/TaranisLG.prefab b/Assets/Content/Prefabs/Weapons/Lucent/TaranisLG.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Lucent/TaranisLG.prefab rename to Assets/Content/Prefabs/Weapons/Lucent/TaranisLG.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Lucent/TaranisLG.prefab.meta b/Assets/Content/Prefabs/Weapons/Lucent/TaranisLG.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Lucent/TaranisLG.prefab.meta rename to Assets/Content/Prefabs/Weapons/Lucent/TaranisLG.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/MIRV-1.prefab b/Assets/Content/Prefabs/Weapons/MIRV-1.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/MIRV-1.prefab rename to Assets/Content/Prefabs/Weapons/MIRV-1.prefab diff --git a/Assets/Resources/Prefabs/Weapons/MIRV-1.prefab.meta b/Assets/Content/Prefabs/Weapons/MIRV-1.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/MIRV-1.prefab.meta rename to Assets/Content/Prefabs/Weapons/MIRV-1.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/MIRV-2.prefab b/Assets/Content/Prefabs/Weapons/MIRV-2.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/MIRV-2.prefab rename to Assets/Content/Prefabs/Weapons/MIRV-2.prefab diff --git a/Assets/Resources/Prefabs/Weapons/MIRV-2.prefab.meta b/Assets/Content/Prefabs/Weapons/MIRV-2.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/MIRV-2.prefab.meta rename to Assets/Content/Prefabs/Weapons/MIRV-2.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/MIRV-3.prefab b/Assets/Content/Prefabs/Weapons/MIRV-3.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/MIRV-3.prefab rename to Assets/Content/Prefabs/Weapons/MIRV-3.prefab diff --git a/Assets/Resources/Prefabs/Weapons/MIRV-3.prefab.meta b/Assets/Content/Prefabs/Weapons/MIRV-3.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/MIRV-3.prefab.meta rename to Assets/Content/Prefabs/Weapons/MIRV-3.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Micromissile.prefab b/Assets/Content/Prefabs/Weapons/Micromissile.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Micromissile.prefab rename to Assets/Content/Prefabs/Weapons/Micromissile.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Micromissile.prefab.meta b/Assets/Content/Prefabs/Weapons/Micromissile.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Micromissile.prefab.meta rename to Assets/Content/Prefabs/Weapons/Micromissile.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Mine Launcher.prefab b/Assets/Content/Prefabs/Weapons/Mine Launcher.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Mine Launcher.prefab rename to Assets/Content/Prefabs/Weapons/Mine Launcher.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Mine Launcher.prefab.meta b/Assets/Content/Prefabs/Weapons/Mine Launcher.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Mine Launcher.prefab.meta rename to Assets/Content/Prefabs/Weapons/Mine Launcher.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/NiteLife.meta b/Assets/Content/Prefabs/Weapons/NiteLife.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/NiteLife.meta rename to Assets/Content/Prefabs/Weapons/NiteLife.meta diff --git a/Assets/Resources/Prefabs/Weapons/NiteLife/ChargeBlast SG.prefab b/Assets/Content/Prefabs/Weapons/NiteLife/ChargeBlast SG.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/NiteLife/ChargeBlast SG.prefab rename to Assets/Content/Prefabs/Weapons/NiteLife/ChargeBlast SG.prefab diff --git a/Assets/Resources/Prefabs/Weapons/NiteLife/ChargeBlast SG.prefab.meta b/Assets/Content/Prefabs/Weapons/NiteLife/ChargeBlast SG.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/NiteLife/ChargeBlast SG.prefab.meta rename to Assets/Content/Prefabs/Weapons/NiteLife/ChargeBlast SG.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/NiteLife/ChargeBlast+-.prefab b/Assets/Content/Prefabs/Weapons/NiteLife/ChargeBlast+-.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/NiteLife/ChargeBlast+-.prefab rename to Assets/Content/Prefabs/Weapons/NiteLife/ChargeBlast+-.prefab diff --git a/Assets/Resources/Prefabs/Weapons/NiteLife/ChargeBlast+-.prefab.meta b/Assets/Content/Prefabs/Weapons/NiteLife/ChargeBlast+-.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/NiteLife/ChargeBlast+-.prefab.meta rename to Assets/Content/Prefabs/Weapons/NiteLife/ChargeBlast+-.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/NiteLife/FastBlast+-.prefab b/Assets/Content/Prefabs/Weapons/NiteLife/FastBlast+-.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/NiteLife/FastBlast+-.prefab rename to Assets/Content/Prefabs/Weapons/NiteLife/FastBlast+-.prefab diff --git a/Assets/Resources/Prefabs/Weapons/NiteLife/FastBlast+-.prefab.meta b/Assets/Content/Prefabs/Weapons/NiteLife/FastBlast+-.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/NiteLife/FastBlast+-.prefab.meta rename to Assets/Content/Prefabs/Weapons/NiteLife/FastBlast+-.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Pulse Laser.prefab b/Assets/Content/Prefabs/Weapons/Pulse Laser.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Pulse Laser.prefab rename to Assets/Content/Prefabs/Weapons/Pulse Laser.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Pulse Laser.prefab.meta b/Assets/Content/Prefabs/Weapons/Pulse Laser.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Pulse Laser.prefab.meta rename to Assets/Content/Prefabs/Weapons/Pulse Laser.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Railgun.prefab b/Assets/Content/Prefabs/Weapons/Railgun.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Railgun.prefab rename to Assets/Content/Prefabs/Weapons/Railgun.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Railgun.prefab.meta b/Assets/Content/Prefabs/Weapons/Railgun.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Railgun.prefab.meta rename to Assets/Content/Prefabs/Weapons/Railgun.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Regular Missile.prefab b/Assets/Content/Prefabs/Weapons/Regular Missile.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Regular Missile.prefab rename to Assets/Content/Prefabs/Weapons/Regular Missile.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Regular Missile.prefab.meta b/Assets/Content/Prefabs/Weapons/Regular Missile.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Regular Missile.prefab.meta rename to Assets/Content/Prefabs/Weapons/Regular Missile.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Revolver.prefab b/Assets/Content/Prefabs/Weapons/Revolver.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Revolver.prefab rename to Assets/Content/Prefabs/Weapons/Revolver.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Revolver.prefab.meta b/Assets/Content/Prefabs/Weapons/Revolver.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Revolver.prefab.meta rename to Assets/Content/Prefabs/Weapons/Revolver.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/SRM.prefab b/Assets/Content/Prefabs/Weapons/SRM.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/SRM.prefab rename to Assets/Content/Prefabs/Weapons/SRM.prefab diff --git a/Assets/Resources/Prefabs/Weapons/SRM.prefab.meta b/Assets/Content/Prefabs/Weapons/SRM.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/SRM.prefab.meta rename to Assets/Content/Prefabs/Weapons/SRM.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Small Laser.prefab b/Assets/Content/Prefabs/Weapons/Small Laser.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Small Laser.prefab rename to Assets/Content/Prefabs/Weapons/Small Laser.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Small Laser.prefab.meta b/Assets/Content/Prefabs/Weapons/Small Laser.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Small Laser.prefab.meta rename to Assets/Content/Prefabs/Weapons/Small Laser.prefab.meta diff --git a/Assets/Resources/Prefabs/Weapons/Tesla Gun.prefab b/Assets/Content/Prefabs/Weapons/Tesla Gun.prefab similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Tesla Gun.prefab rename to Assets/Content/Prefabs/Weapons/Tesla Gun.prefab diff --git a/Assets/Resources/Prefabs/Weapons/Tesla Gun.prefab.meta b/Assets/Content/Prefabs/Weapons/Tesla Gun.prefab.meta similarity index 100% rename from Assets/Resources/Prefabs/Weapons/Tesla Gun.prefab.meta rename to Assets/Content/Prefabs/Weapons/Tesla Gun.prefab.meta diff --git a/Assets/Resources/Schematics.meta b/Assets/Content/Schematics.meta similarity index 100% rename from Assets/Resources/Schematics.meta rename to Assets/Content/Schematics.meta diff --git a/Assets/Resources/Schematics/djinni_schematic_transparent.png b/Assets/Content/Schematics/djinni_schematic_transparent.png similarity index 100% rename from Assets/Resources/Schematics/djinni_schematic_transparent.png rename to Assets/Content/Schematics/djinni_schematic_transparent.png diff --git a/Assets/Resources/Schematics/djinni_schematic_transparent.png.meta b/Assets/Content/Schematics/djinni_schematic_transparent.png.meta similarity index 100% rename from Assets/Resources/Schematics/djinni_schematic_transparent.png.meta rename to Assets/Content/Schematics/djinni_schematic_transparent.png.meta diff --git a/Assets/Resources/Schematics/schema_Longinus.png b/Assets/Content/Schematics/schema_Longinus.png similarity index 100% rename from Assets/Resources/Schematics/schema_Longinus.png rename to Assets/Content/Schematics/schema_Longinus.png diff --git a/Assets/Resources/Schematics/schema_Longinus.png.meta b/Assets/Content/Schematics/schema_Longinus.png.meta similarity index 100% rename from Assets/Resources/Schematics/schema_Longinus.png.meta rename to Assets/Content/Schematics/schema_Longinus.png.meta diff --git a/Assets/Content/Sprites.meta b/Assets/Content/Sprites.meta new file mode 100644 index 000000000..b24da7940 --- /dev/null +++ b/Assets/Content/Sprites.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 30e828fca27d36b449d7bc337d6a20d5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Sprites/Icons.meta b/Assets/Content/Sprites/Icons.meta similarity index 100% rename from Assets/Resources/Sprites/Icons.meta rename to Assets/Content/Sprites/Icons.meta diff --git a/Assets/Resources/Sprites/Icons/Boiling Thermometer.png b/Assets/Content/Sprites/Icons/Boiling Thermometer.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Boiling Thermometer.png rename to Assets/Content/Sprites/Icons/Boiling Thermometer.png diff --git a/Assets/Resources/Sprites/Icons/Boiling Thermometer.png.meta b/Assets/Content/Sprites/Icons/Boiling Thermometer.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Boiling Thermometer.png.meta rename to Assets/Content/Sprites/Icons/Boiling Thermometer.png.meta diff --git a/Assets/Resources/Sprites/Icons/Box.png b/Assets/Content/Sprites/Icons/Box.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Box.png rename to Assets/Content/Sprites/Icons/Box.png diff --git a/Assets/Resources/Sprites/Icons/Box.png.meta b/Assets/Content/Sprites/Icons/Box.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Box.png.meta rename to Assets/Content/Sprites/Icons/Box.png.meta diff --git a/Assets/Resources/Sprites/Icons/Circle.png b/Assets/Content/Sprites/Icons/Circle.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Circle.png rename to Assets/Content/Sprites/Icons/Circle.png diff --git a/Assets/Resources/Sprites/Icons/Circle.png.meta b/Assets/Content/Sprites/Icons/Circle.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Circle.png.meta rename to Assets/Content/Sprites/Icons/Circle.png.meta diff --git a/Assets/Resources/Sprites/Icons/Fire_Tank.png b/Assets/Content/Sprites/Icons/Fire_Tank.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Fire_Tank.png rename to Assets/Content/Sprites/Icons/Fire_Tank.png diff --git a/Assets/Resources/Sprites/Icons/Fire_Tank.png.meta b/Assets/Content/Sprites/Icons/Fire_Tank.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Fire_Tank.png.meta rename to Assets/Content/Sprites/Icons/Fire_Tank.png.meta diff --git a/Assets/Resources/Sprites/Icons/Home.png b/Assets/Content/Sprites/Icons/Home.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Home.png rename to Assets/Content/Sprites/Icons/Home.png diff --git a/Assets/Resources/Sprites/Icons/Home.png.meta b/Assets/Content/Sprites/Icons/Home.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Home.png.meta rename to Assets/Content/Sprites/Icons/Home.png.meta diff --git a/Assets/Resources/Sprites/Icons/Infinity.png b/Assets/Content/Sprites/Icons/Infinity.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Infinity.png rename to Assets/Content/Sprites/Icons/Infinity.png diff --git a/Assets/Resources/Sprites/Icons/Infinity.png.meta b/Assets/Content/Sprites/Icons/Infinity.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Infinity.png.meta rename to Assets/Content/Sprites/Icons/Infinity.png.meta diff --git a/Assets/Resources/Sprites/Icons/Lightning Bolt.png b/Assets/Content/Sprites/Icons/Lightning Bolt.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Lightning Bolt.png rename to Assets/Content/Sprites/Icons/Lightning Bolt.png diff --git a/Assets/Resources/Sprites/Icons/Lightning Bolt.png.meta b/Assets/Content/Sprites/Icons/Lightning Bolt.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Lightning Bolt.png.meta rename to Assets/Content/Sprites/Icons/Lightning Bolt.png.meta diff --git a/Assets/Resources/Sprites/Icons/Logos.meta b/Assets/Content/Sprites/Icons/Logos.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Logos.meta rename to Assets/Content/Sprites/Icons/Logos.meta diff --git a/Assets/Resources/Sprites/Icons/Logos/colferv.png b/Assets/Content/Sprites/Icons/Logos/colferv.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Logos/colferv.png rename to Assets/Content/Sprites/Icons/Logos/colferv.png diff --git a/Assets/Resources/Sprites/Icons/Logos/colferv.png.meta b/Assets/Content/Sprites/Icons/Logos/colferv.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Logos/colferv.png.meta rename to Assets/Content/Sprites/Icons/Logos/colferv.png.meta diff --git a/Assets/Resources/Sprites/Icons/Logos/finch.png b/Assets/Content/Sprites/Icons/Logos/finch.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Logos/finch.png rename to Assets/Content/Sprites/Icons/Logos/finch.png diff --git a/Assets/Resources/Sprites/Icons/Logos/finch.png.meta b/Assets/Content/Sprites/Icons/Logos/finch.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Logos/finch.png.meta rename to Assets/Content/Sprites/Icons/Logos/finch.png.meta diff --git a/Assets/Resources/Sprites/Icons/Logos/lucent.png b/Assets/Content/Sprites/Icons/Logos/lucent.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Logos/lucent.png rename to Assets/Content/Sprites/Icons/Logos/lucent.png diff --git a/Assets/Resources/Sprites/Icons/Logos/lucent.png.meta b/Assets/Content/Sprites/Icons/Logos/lucent.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Logos/lucent.png.meta rename to Assets/Content/Sprites/Icons/Logos/lucent.png.meta diff --git a/Assets/Resources/Sprites/Icons/Logos/miss_terri.png b/Assets/Content/Sprites/Icons/Logos/miss_terri.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Logos/miss_terri.png rename to Assets/Content/Sprites/Icons/Logos/miss_terri.png diff --git a/Assets/Resources/Sprites/Icons/Logos/miss_terri.png.meta b/Assets/Content/Sprites/Icons/Logos/miss_terri.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Logos/miss_terri.png.meta rename to Assets/Content/Sprites/Icons/Logos/miss_terri.png.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse.meta b/Assets/Content/Sprites/Icons/Mouse.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse.meta rename to Assets/Content/Sprites/Icons/Mouse.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse.png b/Assets/Content/Sprites/Icons/Mouse.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse.png rename to Assets/Content/Sprites/Icons/Mouse.png diff --git a/Assets/Resources/Sprites/Icons/Mouse.png.meta b/Assets/Content/Sprites/Icons/Mouse.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse.png.meta rename to Assets/Content/Sprites/Icons/Mouse.png.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse.png b/Assets/Content/Sprites/Icons/Mouse/Mouse.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse.png rename to Assets/Content/Sprites/Icons/Mouse/Mouse.png diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse.png.meta b/Assets/Content/Sprites/Icons/Mouse/Mouse.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse.png.meta rename to Assets/Content/Sprites/Icons/Mouse/Mouse.png.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Left_fill.png b/Assets/Content/Sprites/Icons/Mouse/Mouse_Left_fill.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Left_fill.png rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Left_fill.png diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Left_fill.png.meta b/Assets/Content/Sprites/Icons/Mouse/Mouse_Left_fill.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Left_fill.png.meta rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Left_fill.png.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Left_outline.png b/Assets/Content/Sprites/Icons/Mouse/Mouse_Left_outline.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Left_outline.png rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Left_outline.png diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Left_outline.png.meta b/Assets/Content/Sprites/Icons/Mouse/Mouse_Left_outline.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Left_outline.png.meta rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Left_outline.png.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Middle_fill.png b/Assets/Content/Sprites/Icons/Mouse/Mouse_Middle_fill.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Middle_fill.png rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Middle_fill.png diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Middle_fill.png.meta b/Assets/Content/Sprites/Icons/Mouse/Mouse_Middle_fill.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Middle_fill.png.meta rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Middle_fill.png.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Middle_outline.png b/Assets/Content/Sprites/Icons/Mouse/Mouse_Middle_outline.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Middle_outline.png rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Middle_outline.png diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Middle_outline.png.meta b/Assets/Content/Sprites/Icons/Mouse/Mouse_Middle_outline.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Middle_outline.png.meta rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Middle_outline.png.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Right_fill.png b/Assets/Content/Sprites/Icons/Mouse/Mouse_Right_fill.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Right_fill.png rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Right_fill.png diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Right_fill.png.meta b/Assets/Content/Sprites/Icons/Mouse/Mouse_Right_fill.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Right_fill.png.meta rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Right_fill.png.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Right_outline.png b/Assets/Content/Sprites/Icons/Mouse/Mouse_Right_outline.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Right_outline.png rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Right_outline.png diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Right_outline.png.meta b/Assets/Content/Sprites/Icons/Mouse/Mouse_Right_outline.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Right_outline.png.meta rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Right_outline.png.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Side_fill.png b/Assets/Content/Sprites/Icons/Mouse/Mouse_Side_fill.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Side_fill.png rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Side_fill.png diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Side_fill.png.meta b/Assets/Content/Sprites/Icons/Mouse/Mouse_Side_fill.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Side_fill.png.meta rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Side_fill.png.meta diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Side_outline.png b/Assets/Content/Sprites/Icons/Mouse/Mouse_Side_outline.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Side_outline.png rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Side_outline.png diff --git a/Assets/Resources/Sprites/Icons/Mouse/Mouse_Side_outline.png.meta b/Assets/Content/Sprites/Icons/Mouse/Mouse_Side_outline.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Mouse/Mouse_Side_outline.png.meta rename to Assets/Content/Sprites/Icons/Mouse/Mouse_Side_outline.png.meta diff --git a/Assets/Resources/Sprites/Icons/Resize.png b/Assets/Content/Sprites/Icons/Resize.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Resize.png rename to Assets/Content/Sprites/Icons/Resize.png diff --git a/Assets/Resources/Sprites/Icons/Resize.png.meta b/Assets/Content/Sprites/Icons/Resize.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Resize.png.meta rename to Assets/Content/Sprites/Icons/Resize.png.meta diff --git a/Assets/Resources/Sprites/Icons/Resize1.png b/Assets/Content/Sprites/Icons/Resize1.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Resize1.png rename to Assets/Content/Sprites/Icons/Resize1.png diff --git a/Assets/Resources/Sprites/Icons/Resize1.png.meta b/Assets/Content/Sprites/Icons/Resize1.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Resize1.png.meta rename to Assets/Content/Sprites/Icons/Resize1.png.meta diff --git a/Assets/Resources/Sprites/Icons/Rotate.png b/Assets/Content/Sprites/Icons/Rotate.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Rotate.png rename to Assets/Content/Sprites/Icons/Rotate.png diff --git a/Assets/Resources/Sprites/Icons/Rotate.png.meta b/Assets/Content/Sprites/Icons/Rotate.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Rotate.png.meta rename to Assets/Content/Sprites/Icons/Rotate.png.meta diff --git a/Assets/Resources/Sprites/Icons/Shield.png b/Assets/Content/Sprites/Icons/Shield.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Shield.png rename to Assets/Content/Sprites/Icons/Shield.png diff --git a/Assets/Resources/Sprites/Icons/Shield.png.meta b/Assets/Content/Sprites/Icons/Shield.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Shield.png.meta rename to Assets/Content/Sprites/Icons/Shield.png.meta diff --git a/Assets/Resources/Sprites/Icons/Shield2.png b/Assets/Content/Sprites/Icons/Shield2.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Shield2.png rename to Assets/Content/Sprites/Icons/Shield2.png diff --git a/Assets/Resources/Sprites/Icons/Shield2.png.meta b/Assets/Content/Sprites/Icons/Shield2.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Shield2.png.meta rename to Assets/Content/Sprites/Icons/Shield2.png.meta diff --git a/Assets/Resources/Sprites/Icons/Shield2_no.png b/Assets/Content/Sprites/Icons/Shield2_no.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Shield2_no.png rename to Assets/Content/Sprites/Icons/Shield2_no.png diff --git a/Assets/Resources/Sprites/Icons/Shield2_no.png.meta b/Assets/Content/Sprites/Icons/Shield2_no.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Shield2_no.png.meta rename to Assets/Content/Sprites/Icons/Shield2_no.png.meta diff --git a/Assets/Resources/Sprites/Icons/Storage.png b/Assets/Content/Sprites/Icons/Storage.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Storage.png rename to Assets/Content/Sprites/Icons/Storage.png diff --git a/Assets/Resources/Sprites/Icons/Storage.png.meta b/Assets/Content/Sprites/Icons/Storage.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Storage.png.meta rename to Assets/Content/Sprites/Icons/Storage.png.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked.meta b/Assets/Content/Sprites/Icons/Stroked.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked.meta rename to Assets/Content/Sprites/Icons/Stroked.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked/Arrow.png b/Assets/Content/Sprites/Icons/Stroked/Arrow.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Arrow.png rename to Assets/Content/Sprites/Icons/Stroked/Arrow.png diff --git a/Assets/Resources/Sprites/Icons/Stroked/Arrow.png.meta b/Assets/Content/Sprites/Icons/Stroked/Arrow.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Arrow.png.meta rename to Assets/Content/Sprites/Icons/Stroked/Arrow.png.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked/Moon.png b/Assets/Content/Sprites/Icons/Stroked/Moon.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Moon.png rename to Assets/Content/Sprites/Icons/Stroked/Moon.png diff --git a/Assets/Resources/Sprites/Icons/Stroked/Moon.png.meta b/Assets/Content/Sprites/Icons/Stroked/Moon.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Moon.png.meta rename to Assets/Content/Sprites/Icons/Stroked/Moon.png.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked/Planet.png b/Assets/Content/Sprites/Icons/Stroked/Planet.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Planet.png rename to Assets/Content/Sprites/Icons/Stroked/Planet.png diff --git a/Assets/Resources/Sprites/Icons/Stroked/Planet.png.meta b/Assets/Content/Sprites/Icons/Stroked/Planet.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Planet.png.meta rename to Assets/Content/Sprites/Icons/Stroked/Planet.png.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked/Satellite.png b/Assets/Content/Sprites/Icons/Stroked/Satellite.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Satellite.png rename to Assets/Content/Sprites/Icons/Stroked/Satellite.png diff --git a/Assets/Resources/Sprites/Icons/Stroked/Satellite.png.meta b/Assets/Content/Sprites/Icons/Stroked/Satellite.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Satellite.png.meta rename to Assets/Content/Sprites/Icons/Stroked/Satellite.png.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked/Ship.png b/Assets/Content/Sprites/Icons/Stroked/Ship.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Ship.png rename to Assets/Content/Sprites/Icons/Stroked/Ship.png diff --git a/Assets/Resources/Sprites/Icons/Stroked/Ship.png.meta b/Assets/Content/Sprites/Icons/Stroked/Ship.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Ship.png.meta rename to Assets/Content/Sprites/Icons/Stroked/Ship.png.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked/Sun.png b/Assets/Content/Sprites/Icons/Stroked/Sun.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Sun.png rename to Assets/Content/Sprites/Icons/Stroked/Sun.png diff --git a/Assets/Resources/Sprites/Icons/Stroked/Sun.png.meta b/Assets/Content/Sprites/Icons/Stroked/Sun.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Sun.png.meta rename to Assets/Content/Sprites/Icons/Stroked/Sun.png.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked/Wormhole.png b/Assets/Content/Sprites/Icons/Stroked/Wormhole.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Wormhole.png rename to Assets/Content/Sprites/Icons/Stroked/Wormhole.png diff --git a/Assets/Resources/Sprites/Icons/Stroked/Wormhole.png.meta b/Assets/Content/Sprites/Icons/Stroked/Wormhole.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/Wormhole.png.meta rename to Assets/Content/Sprites/Icons/Stroked/Wormhole.png.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked/gasgiant.png b/Assets/Content/Sprites/Icons/Stroked/gasgiant.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/gasgiant.png rename to Assets/Content/Sprites/Icons/Stroked/gasgiant.png diff --git a/Assets/Resources/Sprites/Icons/Stroked/gasgiant.png.meta b/Assets/Content/Sprites/Icons/Stroked/gasgiant.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/gasgiant.png.meta rename to Assets/Content/Sprites/Icons/Stroked/gasgiant.png.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked/orbital.png b/Assets/Content/Sprites/Icons/Stroked/orbital.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/orbital.png rename to Assets/Content/Sprites/Icons/Stroked/orbital.png diff --git a/Assets/Resources/Sprites/Icons/Stroked/orbital.png.meta b/Assets/Content/Sprites/Icons/Stroked/orbital.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/orbital.png.meta rename to Assets/Content/Sprites/Icons/Stroked/orbital.png.meta diff --git a/Assets/Resources/Sprites/Icons/Stroked/zone.png b/Assets/Content/Sprites/Icons/Stroked/zone.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/zone.png rename to Assets/Content/Sprites/Icons/Stroked/zone.png diff --git a/Assets/Resources/Sprites/Icons/Stroked/zone.png.meta b/Assets/Content/Sprites/Icons/Stroked/zone.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Stroked/zone.png.meta rename to Assets/Content/Sprites/Icons/Stroked/zone.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech.meta b/Assets/Content/Sprites/Icons/Tech.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech.meta rename to Assets/Content/Sprites/Icons/Tech.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Cloud.png b/Assets/Content/Sprites/Icons/Tech/Cloud.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Cloud.png rename to Assets/Content/Sprites/Icons/Tech/Cloud.png diff --git a/Assets/Resources/Sprites/Icons/Tech/Cloud.png.meta b/Assets/Content/Sprites/Icons/Tech/Cloud.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Cloud.png.meta rename to Assets/Content/Sprites/Icons/Tech/Cloud.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Cooler.png b/Assets/Content/Sprites/Icons/Tech/Cooler.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Cooler.png rename to Assets/Content/Sprites/Icons/Tech/Cooler.png diff --git a/Assets/Resources/Sprites/Icons/Tech/Cooler.png.meta b/Assets/Content/Sprites/Icons/Tech/Cooler.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Cooler.png.meta rename to Assets/Content/Sprites/Icons/Tech/Cooler.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Final - Reactor.png b/Assets/Content/Sprites/Icons/Tech/Final - Reactor.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Final - Reactor.png rename to Assets/Content/Sprites/Icons/Tech/Final - Reactor.png diff --git a/Assets/Resources/Sprites/Icons/Tech/Final - Reactor.png.meta b/Assets/Content/Sprites/Icons/Tech/Final - Reactor.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Final - Reactor.png.meta rename to Assets/Content/Sprites/Icons/Tech/Final - Reactor.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Fire.png b/Assets/Content/Sprites/Icons/Tech/Fire.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Fire.png rename to Assets/Content/Sprites/Icons/Tech/Fire.png diff --git a/Assets/Resources/Sprites/Icons/Tech/Fire.png.meta b/Assets/Content/Sprites/Icons/Tech/Fire.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Fire.png.meta rename to Assets/Content/Sprites/Icons/Tech/Fire.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Fxperiment Turbine.png b/Assets/Content/Sprites/Icons/Tech/Fxperiment Turbine.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Fxperiment Turbine.png rename to Assets/Content/Sprites/Icons/Tech/Fxperiment Turbine.png diff --git a/Assets/Resources/Sprites/Icons/Tech/Fxperiment Turbine.png.meta b/Assets/Content/Sprites/Icons/Tech/Fxperiment Turbine.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Fxperiment Turbine.png.meta rename to Assets/Content/Sprites/Icons/Tech/Fxperiment Turbine.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Heat.png b/Assets/Content/Sprites/Icons/Tech/Heat.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Heat.png rename to Assets/Content/Sprites/Icons/Tech/Heat.png diff --git a/Assets/Resources/Sprites/Icons/Tech/Heat.png.meta b/Assets/Content/Sprites/Icons/Tech/Heat.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Heat.png.meta rename to Assets/Content/Sprites/Icons/Tech/Heat.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Laser.png b/Assets/Content/Sprites/Icons/Tech/Laser.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Laser.png rename to Assets/Content/Sprites/Icons/Tech/Laser.png diff --git a/Assets/Resources/Sprites/Icons/Tech/Laser.png.meta b/Assets/Content/Sprites/Icons/Tech/Laser.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Laser.png.meta rename to Assets/Content/Sprites/Icons/Tech/Laser.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Reactor.png b/Assets/Content/Sprites/Icons/Tech/Reactor.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Reactor.png rename to Assets/Content/Sprites/Icons/Tech/Reactor.png diff --git a/Assets/Resources/Sprites/Icons/Tech/Reactor.png.meta b/Assets/Content/Sprites/Icons/Tech/Reactor.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Reactor.png.meta rename to Assets/Content/Sprites/Icons/Tech/Reactor.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Spiral.png b/Assets/Content/Sprites/Icons/Tech/Spiral.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Spiral.png rename to Assets/Content/Sprites/Icons/Tech/Spiral.png diff --git a/Assets/Resources/Sprites/Icons/Tech/Spiral.png.meta b/Assets/Content/Sprites/Icons/Tech/Spiral.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Spiral.png.meta rename to Assets/Content/Sprites/Icons/Tech/Spiral.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_01.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_01.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_01.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_01.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_01.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_01.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_01.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_01.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_02.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_02.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_02.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_02.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_02.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_02.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_02.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_02.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_03.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_03.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_03.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_03.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_03.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_03.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_03.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_03.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_04.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_04.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_04.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_04.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_04.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_04.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_04.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_04.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_05.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_05.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_05.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_05.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_05.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_05.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_05.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_05.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_06.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_06.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_06.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_06.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_06.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_06.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_06.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_06.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_07.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_07.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_07.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_07.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_07.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_07.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_07.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_07.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_08.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_08.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_08.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_08.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_08.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_08.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_08.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_08.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_09.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_09.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_09.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_09.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_09.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_09.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_09.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_09.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_10.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_10.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_10.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_10.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_10.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_10.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_10.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_10.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_100.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_100.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_100.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_100.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_100.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_100.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_100.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_100.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_101.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_101.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_101.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_101.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_101.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_101.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_101.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_101.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_102.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_102.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_102.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_102.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_102.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_102.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_102.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_102.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_103.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_103.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_103.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_103.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_103.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_103.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_103.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_103.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_104.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_104.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_104.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_104.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_104.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_104.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_104.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_104.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_105.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_105.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_105.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_105.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_105.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_105.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_105.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_105.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_106.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_106.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_106.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_106.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_106.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_106.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_106.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_106.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_107.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_107.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_107.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_107.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_107.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_107.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_107.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_107.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_108.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_108.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_108.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_108.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_108.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_108.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_108.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_108.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_109.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_109.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_109.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_109.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_109.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_109.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_109.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_109.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_11.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_11.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_11.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_11.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_11.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_11.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_11.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_11.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_110.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_110.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_110.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_110.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_110.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_110.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_110.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_110.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_111.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_111.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_111.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_111.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_111.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_111.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_111.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_111.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_12.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_12.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_12.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_12.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_12.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_12.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_12.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_12.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_13.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_13.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_13.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_13.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_13.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_13.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_13.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_13.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_14.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_14.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_14.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_14.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_14.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_14.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_14.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_14.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_15.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_15.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_15.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_15.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_15.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_15.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_15.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_15.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_16.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_16.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_16.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_16.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_16.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_16.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_16.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_16.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_17.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_17.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_17.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_17.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_17.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_17.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_17.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_17.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_18.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_18.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_18.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_18.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_18.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_18.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_18.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_18.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_19.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_19.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_19.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_19.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_19.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_19.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_19.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_19.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_20.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_20.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_20.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_20.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_20.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_20.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_20.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_20.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_21.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_21.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_21.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_21.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_21.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_21.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_21.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_21.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_22.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_22.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_22.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_22.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_22.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_22.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_22.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_22.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_23.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_23.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_23.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_23.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_23.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_23.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_23.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_23.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_24.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_24.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_24.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_24.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_24.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_24.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_24.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_24.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_25.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_25.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_25.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_25.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_25.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_25.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_25.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_25.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_26.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_26.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_26.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_26.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_26.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_26.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_26.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_26.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_27.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_27.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_27.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_27.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_27.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_27.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_27.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_27.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_28.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_28.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_28.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_28.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_28.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_28.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_28.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_28.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_29.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_29.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_29.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_29.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_29.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_29.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_29.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_29.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_30.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_30.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_30.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_30.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_30.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_30.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_30.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_30.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_31.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_31.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_31.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_31.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_31.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_31.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_31.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_31.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_32.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_32.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_32.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_32.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_32.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_32.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_32.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_32.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_33.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_33.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_33.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_33.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_33.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_33.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_33.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_33.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_34.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_34.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_34.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_34.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_34.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_34.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_34.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_34.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_35.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_35.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_35.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_35.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_35.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_35.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_35.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_35.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_36.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_36.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_36.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_36.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_36.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_36.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_36.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_36.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_37.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_37.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_37.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_37.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_37.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_37.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_37.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_37.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_38.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_38.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_38.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_38.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_38.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_38.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_38.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_38.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_39.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_39.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_39.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_39.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_39.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_39.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_39.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_39.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_40.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_40.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_40.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_40.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_40.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_40.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_40.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_40.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_41.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_41.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_41.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_41.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_41.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_41.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_41.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_41.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_42.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_42.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_42.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_42.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_42.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_42.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_42.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_42.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_43.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_43.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_43.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_43.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_43.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_43.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_43.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_43.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_44.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_44.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_44.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_44.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_44.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_44.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_44.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_44.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_45.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_45.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_45.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_45.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_45.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_45.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_45.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_45.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_46.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_46.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_46.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_46.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_46.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_46.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_46.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_46.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_47.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_47.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_47.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_47.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_47.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_47.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_47.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_47.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_48.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_48.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_48.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_48.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_48.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_48.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_48.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_48.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_49.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_49.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_49.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_49.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_49.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_49.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_49.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_49.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_50.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_50.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_50.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_50.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_50.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_50.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_50.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_50.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_51.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_51.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_51.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_51.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_51.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_51.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_51.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_51.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_52.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_52.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_52.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_52.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_52.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_52.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_52.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_52.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_53.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_53.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_53.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_53.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_53.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_53.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_53.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_53.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_54.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_54.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_54.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_54.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_54.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_54.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_54.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_54.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_55.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_55.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_55.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_55.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_55.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_55.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_55.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_55.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_56.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_56.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_56.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_56.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_56.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_56.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_56.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_56.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_57.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_57.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_57.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_57.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_57.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_57.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_57.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_57.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_58.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_58.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_58.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_58.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_58.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_58.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_58.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_58.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_59.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_59.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_59.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_59.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_59.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_59.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_59.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_59.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_60.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_60.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_60.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_60.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_60.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_60.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_60.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_60.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_61.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_61.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_61.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_61.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_61.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_61.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_61.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_61.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_62.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_62.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_62.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_62.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_62.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_62.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_62.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_62.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_63.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_63.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_63.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_63.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_63.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_63.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_63.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_63.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_64.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_64.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_64.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_64.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_64.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_64.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_64.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_64.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_65.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_65.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_65.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_65.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_65.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_65.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_65.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_65.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_66.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_66.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_66.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_66.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_66.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_66.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_66.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_66.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_67.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_67.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_67.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_67.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_67.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_67.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_67.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_67.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_68.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_68.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_68.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_68.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_68.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_68.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_68.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_68.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_69.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_69.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_69.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_69.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_69.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_69.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_69.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_69.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_70.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_70.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_70.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_70.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_70.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_70.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_70.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_70.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_71.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_71.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_71.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_71.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_71.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_71.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_71.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_71.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_72.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_72.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_72.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_72.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_72.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_72.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_72.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_72.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_73.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_73.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_73.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_73.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_73.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_73.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_73.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_73.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_74.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_74.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_74.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_74.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_74.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_74.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_74.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_74.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_75.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_75.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_75.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_75.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_75.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_75.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_75.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_75.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_76.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_76.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_76.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_76.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_76.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_76.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_76.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_76.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_77.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_77.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_77.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_77.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_77.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_77.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_77.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_77.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_78.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_78.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_78.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_78.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_78.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_78.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_78.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_78.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_79.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_79.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_79.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_79.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_79.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_79.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_79.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_79.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_80.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_80.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_80.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_80.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_80.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_80.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_80.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_80.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_81.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_81.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_81.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_81.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_81.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_81.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_81.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_81.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_82.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_82.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_82.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_82.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_82.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_82.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_82.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_82.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_83.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_83.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_83.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_83.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_83.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_83.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_83.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_83.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_84.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_84.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_84.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_84.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_84.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_84.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_84.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_84.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_85.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_85.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_85.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_85.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_85.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_85.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_85.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_85.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_86.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_86.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_86.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_86.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_86.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_86.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_86.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_86.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_87.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_87.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_87.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_87.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_87.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_87.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_87.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_87.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_88.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_88.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_88.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_88.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_88.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_88.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_88.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_88.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_89.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_89.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_89.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_89.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_89.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_89.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_89.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_89.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_90.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_90.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_90.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_90.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_90.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_90.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_90.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_90.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_91.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_91.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_91.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_91.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_91.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_91.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_91.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_91.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_92.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_92.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_92.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_92.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_92.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_92.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_92.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_92.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_93.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_93.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_93.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_93.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_93.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_93.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_93.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_93.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_94.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_94.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_94.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_94.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_94.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_94.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_94.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_94.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_95.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_95.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_95.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_95.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_95.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_95.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_95.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_95.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_96.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_96.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_96.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_96.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_96.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_96.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_96.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_96.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_97.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_97.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_97.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_97.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_97.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_97.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_97.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_97.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_98.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_98.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_98.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_98.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_98.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_98.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_98.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_98.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_99.PNG b/Assets/Content/Sprites/Icons/Tech/Tex_skill_99.PNG similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_99.PNG rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_99.PNG diff --git a/Assets/Resources/Sprites/Icons/Tech/Tex_skill_99.PNG.meta b/Assets/Content/Sprites/Icons/Tech/Tex_skill_99.PNG.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/Tex_skill_99.PNG.meta rename to Assets/Content/Sprites/Icons/Tech/Tex_skill_99.PNG.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/ballistic.png b/Assets/Content/Sprites/Icons/Tech/ballistic.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/ballistic.png rename to Assets/Content/Sprites/Icons/Tech/ballistic.png diff --git a/Assets/Resources/Sprites/Icons/Tech/ballistic.png.meta b/Assets/Content/Sprites/Icons/Tech/ballistic.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/ballistic.png.meta rename to Assets/Content/Sprites/Icons/Tech/ballistic.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/chevron-truncated.png b/Assets/Content/Sprites/Icons/Tech/chevron-truncated.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/chevron-truncated.png rename to Assets/Content/Sprites/Icons/Tech/chevron-truncated.png diff --git a/Assets/Resources/Sprites/Icons/Tech/chevron-truncated.png.meta b/Assets/Content/Sprites/Icons/Tech/chevron-truncated.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/chevron-truncated.png.meta rename to Assets/Content/Sprites/Icons/Tech/chevron-truncated.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/compoundObject.png b/Assets/Content/Sprites/Icons/Tech/compoundObject.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/compoundObject.png rename to Assets/Content/Sprites/Icons/Tech/compoundObject.png diff --git a/Assets/Resources/Sprites/Icons/Tech/compoundObject.png.meta b/Assets/Content/Sprites/Icons/Tech/compoundObject.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/compoundObject.png.meta rename to Assets/Content/Sprites/Icons/Tech/compoundObject.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/fighter.png b/Assets/Content/Sprites/Icons/Tech/fighter.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/fighter.png rename to Assets/Content/Sprites/Icons/Tech/fighter.png diff --git a/Assets/Resources/Sprites/Icons/Tech/fighter.png.meta b/Assets/Content/Sprites/Icons/Tech/fighter.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/fighter.png.meta rename to Assets/Content/Sprites/Icons/Tech/fighter.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/final missile.png b/Assets/Content/Sprites/Icons/Tech/final missile.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/final missile.png rename to Assets/Content/Sprites/Icons/Tech/final missile.png diff --git a/Assets/Resources/Sprites/Icons/Tech/final missile.png.meta b/Assets/Content/Sprites/Icons/Tech/final missile.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/final missile.png.meta rename to Assets/Content/Sprites/Icons/Tech/final missile.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/invisible.png b/Assets/Content/Sprites/Icons/Tech/invisible.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/invisible.png rename to Assets/Content/Sprites/Icons/Tech/invisible.png diff --git a/Assets/Resources/Sprites/Icons/Tech/invisible.png.meta b/Assets/Content/Sprites/Icons/Tech/invisible.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/invisible.png.meta rename to Assets/Content/Sprites/Icons/Tech/invisible.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/meteor.png b/Assets/Content/Sprites/Icons/Tech/meteor.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/meteor.png rename to Assets/Content/Sprites/Icons/Tech/meteor.png diff --git a/Assets/Resources/Sprites/Icons/Tech/meteor.png.meta b/Assets/Content/Sprites/Icons/Tech/meteor.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/meteor.png.meta rename to Assets/Content/Sprites/Icons/Tech/meteor.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/mini missile.png b/Assets/Content/Sprites/Icons/Tech/mini missile.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/mini missile.png rename to Assets/Content/Sprites/Icons/Tech/mini missile.png diff --git a/Assets/Resources/Sprites/Icons/Tech/mini missile.png.meta b/Assets/Content/Sprites/Icons/Tech/mini missile.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/mini missile.png.meta rename to Assets/Content/Sprites/Icons/Tech/mini missile.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/satellite.png b/Assets/Content/Sprites/Icons/Tech/satellite.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/satellite.png rename to Assets/Content/Sprites/Icons/Tech/satellite.png diff --git a/Assets/Resources/Sprites/Icons/Tech/satellite.png.meta b/Assets/Content/Sprites/Icons/Tech/satellite.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/satellite.png.meta rename to Assets/Content/Sprites/Icons/Tech/satellite.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/storm.png b/Assets/Content/Sprites/Icons/Tech/storm.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/storm.png rename to Assets/Content/Sprites/Icons/Tech/storm.png diff --git a/Assets/Resources/Sprites/Icons/Tech/storm.png.meta b/Assets/Content/Sprites/Icons/Tech/storm.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/storm.png.meta rename to Assets/Content/Sprites/Icons/Tech/storm.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/technical thinking.png b/Assets/Content/Sprites/Icons/Tech/technical thinking.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/technical thinking.png rename to Assets/Content/Sprites/Icons/Tech/technical thinking.png diff --git a/Assets/Resources/Sprites/Icons/Tech/technical thinking.png.meta b/Assets/Content/Sprites/Icons/Tech/technical thinking.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/technical thinking.png.meta rename to Assets/Content/Sprites/Icons/Tech/technical thinking.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/tools3.png b/Assets/Content/Sprites/Icons/Tech/tools3.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/tools3.png rename to Assets/Content/Sprites/Icons/Tech/tools3.png diff --git a/Assets/Resources/Sprites/Icons/Tech/tools3.png.meta b/Assets/Content/Sprites/Icons/Tech/tools3.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/tools3.png.meta rename to Assets/Content/Sprites/Icons/Tech/tools3.png.meta diff --git a/Assets/Resources/Sprites/Icons/Tech/wing1.png b/Assets/Content/Sprites/Icons/Tech/wing1.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/wing1.png rename to Assets/Content/Sprites/Icons/Tech/wing1.png diff --git a/Assets/Resources/Sprites/Icons/Tech/wing1.png.meta b/Assets/Content/Sprites/Icons/Tech/wing1.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Tech/wing1.png.meta rename to Assets/Content/Sprites/Icons/Tech/wing1.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons.meta b/Assets/Content/Sprites/Icons/Weapons.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons.meta rename to Assets/Content/Sprites/Icons/Weapons.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/CalliberL.png b/Assets/Content/Sprites/Icons/Weapons/CalliberL.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/CalliberL.png rename to Assets/Content/Sprites/Icons/Weapons/CalliberL.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/CalliberL.png.meta b/Assets/Content/Sprites/Icons/Weapons/CalliberL.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/CalliberL.png.meta rename to Assets/Content/Sprites/Icons/Weapons/CalliberL.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/CalliberM.png b/Assets/Content/Sprites/Icons/Weapons/CalliberM.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/CalliberM.png rename to Assets/Content/Sprites/Icons/Weapons/CalliberM.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/CalliberM.png.meta b/Assets/Content/Sprites/Icons/Weapons/CalliberM.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/CalliberM.png.meta rename to Assets/Content/Sprites/Icons/Weapons/CalliberM.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/CalliberS.png b/Assets/Content/Sprites/Icons/Weapons/CalliberS.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/CalliberS.png rename to Assets/Content/Sprites/Icons/Weapons/CalliberS.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/CalliberS.png.meta b/Assets/Content/Sprites/Icons/Weapons/CalliberS.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/CalliberS.png.meta rename to Assets/Content/Sprites/Icons/Weapons/CalliberS.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/CalliberXL.png b/Assets/Content/Sprites/Icons/Weapons/CalliberXL.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/CalliberXL.png rename to Assets/Content/Sprites/Icons/Weapons/CalliberXL.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/CalliberXL.png.meta b/Assets/Content/Sprites/Icons/Weapons/CalliberXL.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/CalliberXL.png.meta rename to Assets/Content/Sprites/Icons/Weapons/CalliberXL.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/EMPS.png b/Assets/Content/Sprites/Icons/Weapons/EMPS.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/EMPS.png rename to Assets/Content/Sprites/Icons/Weapons/EMPS.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/EMPS.png.meta b/Assets/Content/Sprites/Icons/Weapons/EMPS.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/EMPS.png.meta rename to Assets/Content/Sprites/Icons/Weapons/EMPS.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/ESD.png b/Assets/Content/Sprites/Icons/Weapons/ESD.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/ESD.png rename to Assets/Content/Sprites/Icons/Weapons/ESD.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/ESD.png.meta b/Assets/Content/Sprites/Icons/Weapons/ESD.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/ESD.png.meta rename to Assets/Content/Sprites/Icons/Weapons/ESD.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionAB.png b/Assets/Content/Sprites/Icons/Weapons/FunctionAB.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionAB.png rename to Assets/Content/Sprites/Icons/Weapons/FunctionAB.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionAB.png.meta b/Assets/Content/Sprites/Icons/Weapons/FunctionAB.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionAB.png.meta rename to Assets/Content/Sprites/Icons/Weapons/FunctionAB.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionAP.png b/Assets/Content/Sprites/Icons/Weapons/FunctionAP.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionAP.png rename to Assets/Content/Sprites/Icons/Weapons/FunctionAP.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionAP.png.meta b/Assets/Content/Sprites/Icons/Weapons/FunctionAP.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionAP.png.meta rename to Assets/Content/Sprites/Icons/Weapons/FunctionAP.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionBF.png b/Assets/Content/Sprites/Icons/Weapons/FunctionBF.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionBF.png rename to Assets/Content/Sprites/Icons/Weapons/FunctionBF.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionBF.png.meta b/Assets/Content/Sprites/Icons/Weapons/FunctionBF.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionBF.png.meta rename to Assets/Content/Sprites/Icons/Weapons/FunctionBF.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionCF.png b/Assets/Content/Sprites/Icons/Weapons/FunctionCF.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionCF.png rename to Assets/Content/Sprites/Icons/Weapons/FunctionCF.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionCF.png.meta b/Assets/Content/Sprites/Icons/Weapons/FunctionCF.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionCF.png.meta rename to Assets/Content/Sprites/Icons/Weapons/FunctionCF.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionI.png b/Assets/Content/Sprites/Icons/Weapons/FunctionI.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionI.png rename to Assets/Content/Sprites/Icons/Weapons/FunctionI.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionI.png.meta b/Assets/Content/Sprites/Icons/Weapons/FunctionI.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionI.png.meta rename to Assets/Content/Sprites/Icons/Weapons/FunctionI.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionNE.png b/Assets/Content/Sprites/Icons/Weapons/FunctionNE.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionNE.png rename to Assets/Content/Sprites/Icons/Weapons/FunctionNE.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionNE.png.meta b/Assets/Content/Sprites/Icons/Weapons/FunctionNE.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionNE.png.meta rename to Assets/Content/Sprites/Icons/Weapons/FunctionNE.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionRF.png b/Assets/Content/Sprites/Icons/Weapons/FunctionRF.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionRF.png rename to Assets/Content/Sprites/Icons/Weapons/FunctionRF.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/FunctionRF.png.meta b/Assets/Content/Sprites/Icons/Weapons/FunctionRF.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/FunctionRF.png.meta rename to Assets/Content/Sprites/Icons/Weapons/FunctionRF.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/Icons.psd b/Assets/Content/Sprites/Icons/Weapons/Icons.psd similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/Icons.psd rename to Assets/Content/Sprites/Icons/Weapons/Icons.psd diff --git a/Assets/Resources/Sprites/Icons/Weapons/Icons.psd.meta b/Assets/Content/Sprites/Icons/Weapons/Icons.psd.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/Icons.psd.meta rename to Assets/Content/Sprites/Icons/Weapons/Icons.psd.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/JET.png b/Assets/Content/Sprites/Icons/Weapons/JET.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/JET.png rename to Assets/Content/Sprites/Icons/Weapons/JET.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/JET.png.meta b/Assets/Content/Sprites/Icons/Weapons/JET.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/JET.png.meta rename to Assets/Content/Sprites/Icons/Weapons/JET.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/L.png b/Assets/Content/Sprites/Icons/Weapons/L.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/L.png rename to Assets/Content/Sprites/Icons/Weapons/L.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/L.png.meta b/Assets/Content/Sprites/Icons/Weapons/L.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/L.png.meta rename to Assets/Content/Sprites/Icons/Weapons/L.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/Long Range.png b/Assets/Content/Sprites/Icons/Weapons/Long Range.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/Long Range.png rename to Assets/Content/Sprites/Icons/Weapons/Long Range.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/Long Range.png.meta b/Assets/Content/Sprites/Icons/Weapons/Long Range.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/Long Range.png.meta rename to Assets/Content/Sprites/Icons/Weapons/Long Range.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/M.png b/Assets/Content/Sprites/Icons/Weapons/M.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/M.png rename to Assets/Content/Sprites/Icons/Weapons/M.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/M.png.meta b/Assets/Content/Sprites/Icons/Weapons/M.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/M.png.meta rename to Assets/Content/Sprites/Icons/Weapons/M.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/MINE.png b/Assets/Content/Sprites/Icons/Weapons/MINE.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/MINE.png rename to Assets/Content/Sprites/Icons/Weapons/MINE.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/MINE.png.meta b/Assets/Content/Sprites/Icons/Weapons/MINE.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/MINE.png.meta rename to Assets/Content/Sprites/Icons/Weapons/MINE.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/MIRV.png b/Assets/Content/Sprites/Icons/Weapons/MIRV.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/MIRV.png rename to Assets/Content/Sprites/Icons/Weapons/MIRV.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/MIRV.png.meta b/Assets/Content/Sprites/Icons/Weapons/MIRV.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/MIRV.png.meta rename to Assets/Content/Sprites/Icons/Weapons/MIRV.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/MM.png b/Assets/Content/Sprites/Icons/Weapons/MM.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/MM.png rename to Assets/Content/Sprites/Icons/Weapons/MM.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/MM.png.meta b/Assets/Content/Sprites/Icons/Weapons/MM.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/MM.png.meta rename to Assets/Content/Sprites/Icons/Weapons/MM.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/Medium Range.png b/Assets/Content/Sprites/Icons/Weapons/Medium Range.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/Medium Range.png rename to Assets/Content/Sprites/Icons/Weapons/Medium Range.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/Medium Range.png.meta b/Assets/Content/Sprites/Icons/Weapons/Medium Range.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/Medium Range.png.meta rename to Assets/Content/Sprites/Icons/Weapons/Medium Range.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/Melee Range.png b/Assets/Content/Sprites/Icons/Weapons/Melee Range.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/Melee Range.png rename to Assets/Content/Sprites/Icons/Weapons/Melee Range.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/Melee Range.png.meta b/Assets/Content/Sprites/Icons/Weapons/Melee Range.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/Melee Range.png.meta rename to Assets/Content/Sprites/Icons/Weapons/Melee Range.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/PPC.png b/Assets/Content/Sprites/Icons/Weapons/PPC.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PPC.png rename to Assets/Content/Sprites/Icons/Weapons/PPC.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/PPC.png.meta b/Assets/Content/Sprites/Icons/Weapons/PPC.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PPC.png.meta rename to Assets/Content/Sprites/Icons/Weapons/PPC.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/PrefixC.png b/Assets/Content/Sprites/Icons/Weapons/PrefixC.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PrefixC.png rename to Assets/Content/Sprites/Icons/Weapons/PrefixC.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/PrefixC.png.meta b/Assets/Content/Sprites/Icons/Weapons/PrefixC.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PrefixC.png.meta rename to Assets/Content/Sprites/Icons/Weapons/PrefixC.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/PrefixCha.png b/Assets/Content/Sprites/Icons/Weapons/PrefixCha.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PrefixCha.png rename to Assets/Content/Sprites/Icons/Weapons/PrefixCha.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/PrefixCha.png.meta b/Assets/Content/Sprites/Icons/Weapons/PrefixCha.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PrefixCha.png.meta rename to Assets/Content/Sprites/Icons/Weapons/PrefixCha.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/PrefixD.png b/Assets/Content/Sprites/Icons/Weapons/PrefixD.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PrefixD.png rename to Assets/Content/Sprites/Icons/Weapons/PrefixD.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/PrefixD.png.meta b/Assets/Content/Sprites/Icons/Weapons/PrefixD.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PrefixD.png.meta rename to Assets/Content/Sprites/Icons/Weapons/PrefixD.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/PrefixG.png b/Assets/Content/Sprites/Icons/Weapons/PrefixG.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PrefixG.png rename to Assets/Content/Sprites/Icons/Weapons/PrefixG.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/PrefixG.png.meta b/Assets/Content/Sprites/Icons/Weapons/PrefixG.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PrefixG.png.meta rename to Assets/Content/Sprites/Icons/Weapons/PrefixG.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/PrefixS.png b/Assets/Content/Sprites/Icons/Weapons/PrefixS.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PrefixS.png rename to Assets/Content/Sprites/Icons/Weapons/PrefixS.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/PrefixS.png.meta b/Assets/Content/Sprites/Icons/Weapons/PrefixS.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/PrefixS.png.meta rename to Assets/Content/Sprites/Icons/Weapons/PrefixS.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/Short Range.png b/Assets/Content/Sprites/Icons/Weapons/Short Range.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/Short Range.png rename to Assets/Content/Sprites/Icons/Weapons/Short Range.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/Short Range.png.meta b/Assets/Content/Sprites/Icons/Weapons/Short Range.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/Short Range.png.meta rename to Assets/Content/Sprites/Icons/Weapons/Short Range.png.meta diff --git a/Assets/Resources/Sprites/Icons/Weapons/XPS.png b/Assets/Content/Sprites/Icons/Weapons/XPS.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/XPS.png rename to Assets/Content/Sprites/Icons/Weapons/XPS.png diff --git a/Assets/Resources/Sprites/Icons/Weapons/XPS.png.meta b/Assets/Content/Sprites/Icons/Weapons/XPS.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Weapons/XPS.png.meta rename to Assets/Content/Sprites/Icons/Weapons/XPS.png.meta diff --git a/Assets/Resources/Sprites/Icons/Worried Flame Stroked.png b/Assets/Content/Sprites/Icons/Worried Flame Stroked.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Worried Flame Stroked.png rename to Assets/Content/Sprites/Icons/Worried Flame Stroked.png diff --git a/Assets/Resources/Sprites/Icons/Worried Flame Stroked.png.meta b/Assets/Content/Sprites/Icons/Worried Flame Stroked.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Worried Flame Stroked.png.meta rename to Assets/Content/Sprites/Icons/Worried Flame Stroked.png.meta diff --git a/Assets/Resources/Sprites/Icons/Worried Flame.png b/Assets/Content/Sprites/Icons/Worried Flame.png similarity index 100% rename from Assets/Resources/Sprites/Icons/Worried Flame.png rename to Assets/Content/Sprites/Icons/Worried Flame.png diff --git a/Assets/Resources/Sprites/Icons/Worried Flame.png.meta b/Assets/Content/Sprites/Icons/Worried Flame.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/Worried Flame.png.meta rename to Assets/Content/Sprites/Icons/Worried Flame.png.meta diff --git a/Assets/Resources/Sprites/Icons/add.png b/Assets/Content/Sprites/Icons/add.png similarity index 100% rename from Assets/Resources/Sprites/Icons/add.png rename to Assets/Content/Sprites/Icons/add.png diff --git a/Assets/Resources/Sprites/Icons/add.png.meta b/Assets/Content/Sprites/Icons/add.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/add.png.meta rename to Assets/Content/Sprites/Icons/add.png.meta diff --git a/Assets/Resources/Sprites/Icons/asterisk.png b/Assets/Content/Sprites/Icons/asterisk.png similarity index 100% rename from Assets/Resources/Sprites/Icons/asterisk.png rename to Assets/Content/Sprites/Icons/asterisk.png diff --git a/Assets/Resources/Sprites/Icons/asterisk.png.meta b/Assets/Content/Sprites/Icons/asterisk.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/asterisk.png.meta rename to Assets/Content/Sprites/Icons/asterisk.png.meta diff --git a/Assets/Resources/Sprites/Icons/book.png b/Assets/Content/Sprites/Icons/book.png similarity index 100% rename from Assets/Resources/Sprites/Icons/book.png rename to Assets/Content/Sprites/Icons/book.png diff --git a/Assets/Resources/Sprites/Icons/book.png.meta b/Assets/Content/Sprites/Icons/book.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/book.png.meta rename to Assets/Content/Sprites/Icons/book.png.meta diff --git a/Assets/Resources/Sprites/Icons/boss.png b/Assets/Content/Sprites/Icons/boss.png similarity index 100% rename from Assets/Resources/Sprites/Icons/boss.png rename to Assets/Content/Sprites/Icons/boss.png diff --git a/Assets/Resources/Sprites/Icons/boss.png.meta b/Assets/Content/Sprites/Icons/boss.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/boss.png.meta rename to Assets/Content/Sprites/Icons/boss.png.meta diff --git a/Assets/Resources/Sprites/Icons/check.png b/Assets/Content/Sprites/Icons/check.png similarity index 100% rename from Assets/Resources/Sprites/Icons/check.png rename to Assets/Content/Sprites/Icons/check.png diff --git a/Assets/Resources/Sprites/Icons/check.png.meta b/Assets/Content/Sprites/Icons/check.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/check.png.meta rename to Assets/Content/Sprites/Icons/check.png.meta diff --git a/Assets/Resources/Sprites/Icons/cross.png b/Assets/Content/Sprites/Icons/cross.png similarity index 100% rename from Assets/Resources/Sprites/Icons/cross.png rename to Assets/Content/Sprites/Icons/cross.png diff --git a/Assets/Resources/Sprites/Icons/cross.png.meta b/Assets/Content/Sprites/Icons/cross.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/cross.png.meta rename to Assets/Content/Sprites/Icons/cross.png.meta diff --git a/Assets/Resources/Sprites/Icons/distance.png b/Assets/Content/Sprites/Icons/distance.png similarity index 100% rename from Assets/Resources/Sprites/Icons/distance.png rename to Assets/Content/Sprites/Icons/distance.png diff --git a/Assets/Resources/Sprites/Icons/distance.png.meta b/Assets/Content/Sprites/Icons/distance.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/distance.png.meta rename to Assets/Content/Sprites/Icons/distance.png.meta diff --git a/Assets/Resources/Sprites/Icons/edit.png b/Assets/Content/Sprites/Icons/edit.png similarity index 100% rename from Assets/Resources/Sprites/Icons/edit.png rename to Assets/Content/Sprites/Icons/edit.png diff --git a/Assets/Resources/Sprites/Icons/edit.png.meta b/Assets/Content/Sprites/Icons/edit.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/edit.png.meta rename to Assets/Content/Sprites/Icons/edit.png.meta diff --git a/Assets/Resources/Sprites/Icons/enter.png b/Assets/Content/Sprites/Icons/enter.png similarity index 100% rename from Assets/Resources/Sprites/Icons/enter.png rename to Assets/Content/Sprites/Icons/enter.png diff --git a/Assets/Resources/Sprites/Icons/enter.png.meta b/Assets/Content/Sprites/Icons/enter.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/enter.png.meta rename to Assets/Content/Sprites/Icons/enter.png.meta diff --git a/Assets/Resources/Sprites/Icons/exit.png b/Assets/Content/Sprites/Icons/exit.png similarity index 100% rename from Assets/Resources/Sprites/Icons/exit.png rename to Assets/Content/Sprites/Icons/exit.png diff --git a/Assets/Resources/Sprites/Icons/exit.png.meta b/Assets/Content/Sprites/Icons/exit.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/exit.png.meta rename to Assets/Content/Sprites/Icons/exit.png.meta diff --git a/Assets/Resources/Sprites/Icons/flash.png b/Assets/Content/Sprites/Icons/flash.png similarity index 100% rename from Assets/Resources/Sprites/Icons/flash.png rename to Assets/Content/Sprites/Icons/flash.png diff --git a/Assets/Resources/Sprites/Icons/flash.png.meta b/Assets/Content/Sprites/Icons/flash.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/flash.png.meta rename to Assets/Content/Sprites/Icons/flash.png.meta diff --git a/Assets/Resources/Sprites/Icons/hexempty.png b/Assets/Content/Sprites/Icons/hexempty.png similarity index 100% rename from Assets/Resources/Sprites/Icons/hexempty.png rename to Assets/Content/Sprites/Icons/hexempty.png diff --git a/Assets/Resources/Sprites/Icons/hexempty.png.meta b/Assets/Content/Sprites/Icons/hexempty.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/hexempty.png.meta rename to Assets/Content/Sprites/Icons/hexempty.png.meta diff --git a/Assets/Resources/Sprites/Icons/hexfilled.png b/Assets/Content/Sprites/Icons/hexfilled.png similarity index 100% rename from Assets/Resources/Sprites/Icons/hexfilled.png rename to Assets/Content/Sprites/Icons/hexfilled.png diff --git a/Assets/Resources/Sprites/Icons/hexfilled.png.meta b/Assets/Content/Sprites/Icons/hexfilled.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/hexfilled.png.meta rename to Assets/Content/Sprites/Icons/hexfilled.png.meta diff --git a/Assets/Resources/Sprites/Icons/hexpartial.png b/Assets/Content/Sprites/Icons/hexpartial.png similarity index 100% rename from Assets/Resources/Sprites/Icons/hexpartial.png rename to Assets/Content/Sprites/Icons/hexpartial.png diff --git a/Assets/Resources/Sprites/Icons/hexpartial.png.meta b/Assets/Content/Sprites/Icons/hexpartial.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/hexpartial.png.meta rename to Assets/Content/Sprites/Icons/hexpartial.png.meta diff --git a/Assets/Resources/Sprites/Icons/hexstroke.png b/Assets/Content/Sprites/Icons/hexstroke.png similarity index 100% rename from Assets/Resources/Sprites/Icons/hexstroke.png rename to Assets/Content/Sprites/Icons/hexstroke.png diff --git a/Assets/Resources/Sprites/Icons/hexstroke.png.meta b/Assets/Content/Sprites/Icons/hexstroke.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/hexstroke.png.meta rename to Assets/Content/Sprites/Icons/hexstroke.png.meta diff --git a/Assets/Resources/Sprites/Icons/person.png b/Assets/Content/Sprites/Icons/person.png similarity index 100% rename from Assets/Resources/Sprites/Icons/person.png rename to Assets/Content/Sprites/Icons/person.png diff --git a/Assets/Resources/Sprites/Icons/person.png.meta b/Assets/Content/Sprites/Icons/person.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/person.png.meta rename to Assets/Content/Sprites/Icons/person.png.meta diff --git a/Assets/Resources/Sprites/Icons/pod.png b/Assets/Content/Sprites/Icons/pod.png similarity index 100% rename from Assets/Resources/Sprites/Icons/pod.png rename to Assets/Content/Sprites/Icons/pod.png diff --git a/Assets/Resources/Sprites/Icons/pod.png.meta b/Assets/Content/Sprites/Icons/pod.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/pod.png.meta rename to Assets/Content/Sprites/Icons/pod.png.meta diff --git a/Assets/Resources/Sprites/Icons/remove.png b/Assets/Content/Sprites/Icons/remove.png similarity index 100% rename from Assets/Resources/Sprites/Icons/remove.png rename to Assets/Content/Sprites/Icons/remove.png diff --git a/Assets/Resources/Sprites/Icons/remove.png.meta b/Assets/Content/Sprites/Icons/remove.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/remove.png.meta rename to Assets/Content/Sprites/Icons/remove.png.meta diff --git a/Assets/Resources/Sprites/Icons/resizeCursor.png b/Assets/Content/Sprites/Icons/resizeCursor.png similarity index 100% rename from Assets/Resources/Sprites/Icons/resizeCursor.png rename to Assets/Content/Sprites/Icons/resizeCursor.png diff --git a/Assets/Resources/Sprites/Icons/resizeCursor.png.meta b/Assets/Content/Sprites/Icons/resizeCursor.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/resizeCursor.png.meta rename to Assets/Content/Sprites/Icons/resizeCursor.png.meta diff --git a/Assets/Resources/Sprites/Icons/rightArrow.png b/Assets/Content/Sprites/Icons/rightArrow.png similarity index 100% rename from Assets/Resources/Sprites/Icons/rightArrow.png rename to Assets/Content/Sprites/Icons/rightArrow.png diff --git a/Assets/Resources/Sprites/Icons/rightArrow.png.meta b/Assets/Content/Sprites/Icons/rightArrow.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/rightArrow.png.meta rename to Assets/Content/Sprites/Icons/rightArrow.png.meta diff --git a/Assets/Resources/Sprites/Icons/solar.png b/Assets/Content/Sprites/Icons/solar.png similarity index 100% rename from Assets/Resources/Sprites/Icons/solar.png rename to Assets/Content/Sprites/Icons/solar.png diff --git a/Assets/Resources/Sprites/Icons/solar.png.meta b/Assets/Content/Sprites/Icons/solar.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/solar.png.meta rename to Assets/Content/Sprites/Icons/solar.png.meta diff --git a/Assets/Resources/Sprites/Icons/station1.png b/Assets/Content/Sprites/Icons/station1.png similarity index 100% rename from Assets/Resources/Sprites/Icons/station1.png rename to Assets/Content/Sprites/Icons/station1.png diff --git a/Assets/Resources/Sprites/Icons/station1.png.meta b/Assets/Content/Sprites/Icons/station1.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/station1.png.meta rename to Assets/Content/Sprites/Icons/station1.png.meta diff --git a/Assets/Resources/Sprites/Icons/station2.png b/Assets/Content/Sprites/Icons/station2.png similarity index 100% rename from Assets/Resources/Sprites/Icons/station2.png rename to Assets/Content/Sprites/Icons/station2.png diff --git a/Assets/Resources/Sprites/Icons/station2.png.meta b/Assets/Content/Sprites/Icons/station2.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/station2.png.meta rename to Assets/Content/Sprites/Icons/station2.png.meta diff --git a/Assets/Resources/Sprites/Icons/station3.png b/Assets/Content/Sprites/Icons/station3.png similarity index 100% rename from Assets/Resources/Sprites/Icons/station3.png rename to Assets/Content/Sprites/Icons/station3.png diff --git a/Assets/Resources/Sprites/Icons/station3.png.meta b/Assets/Content/Sprites/Icons/station3.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/station3.png.meta rename to Assets/Content/Sprites/Icons/station3.png.meta diff --git a/Assets/Resources/Sprites/Icons/visible.png b/Assets/Content/Sprites/Icons/visible.png similarity index 100% rename from Assets/Resources/Sprites/Icons/visible.png rename to Assets/Content/Sprites/Icons/visible.png diff --git a/Assets/Resources/Sprites/Icons/visible.png.meta b/Assets/Content/Sprites/Icons/visible.png.meta similarity index 100% rename from Assets/Resources/Sprites/Icons/visible.png.meta rename to Assets/Content/Sprites/Icons/visible.png.meta diff --git a/Assets/Resources/Aetheria.inputactions b/Assets/Resources/Aetheria.inputactions index c5e3153a6..86044799b 100644 --- a/Assets/Resources/Aetheria.inputactions +++ b/Assets/Resources/Aetheria.inputactions @@ -68,6 +68,15 @@ "interactions": "", "initialStateCheck": false }, + { + "name": "Cycle Target Item", + "type": "Button", + "id": "3f9a2f0a-6e3f-4a53-9f5e-1a9c1e6d9f7b", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, { "name": "Toggle Heatsinks", "type": "Button", @@ -307,6 +316,17 @@ "isComposite": false, "isPartOfComposite": false }, + { + "name": "", + "id": "8c2d5e77-4b18-4f0b-9a2e-6d3f7c1a5e90", + "path": "/j", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Cycle Target Item", + "isComposite": false, + "isPartOfComposite": false + }, { "name": "", "id": "71ac6088-1e18-4810-86be-1179918dd4a2", diff --git a/Assets/Resources/Atmosphere Settings.asset b/Assets/Resources/Atmosphere Settings.asset deleted file mode 100644 index 0c1666d82..000000000 --- a/Assets/Resources/Atmosphere Settings.asset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0bf219df0dc34bce2b328d76e131f8d65ffe3dbf358a06f2fae7939c626e66b9 -size 778 diff --git a/Assets/Resources/Elysium.asset b/Assets/Resources/Elysium.asset deleted file mode 100644 index 6a6b24ef1..000000000 --- a/Assets/Resources/Elysium.asset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f64780cfd1dc5669c875a6238a1449a676954f45fa8020952c10134f3b3a51cd -size 8449 diff --git a/Assets/Resources/Icons.asset b/Assets/Resources/Icons.asset deleted file mode 100644 index b29c50170..000000000 --- a/Assets/Resources/Icons.asset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:74c677e6a1ce2a0add82cc80500cd6eaed3d936425f84973f252666fb1c93715 -size 545 diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index b2e5d2548..b2a9b0ed2 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:249f9067115f21c8042320e9180813532073083eea698e4e6e373ba88d2a5b5a -size 13366 +oid sha256:347752dd66c3d250eddde8f6f78a17d7369d562bd698a0c8099e941f7c0825c8 +size 13163 diff --git a/Assets/Resources/Test Galaxy.asset b/Assets/Resources/Test Galaxy.asset deleted file mode 100644 index b36307abc..000000000 --- a/Assets/Resources/Test Galaxy.asset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0ff123261102a2504a5a79e662d367e0010a2b9735dbfb1bd7593db1a99b1d2d -size 7085 diff --git a/Assets/Resources/Volume Settings.asset b/Assets/Resources/Volume Settings.asset deleted file mode 100644 index 9ad525705..000000000 --- a/Assets/Resources/Volume Settings.asset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0db95af06408123b378864d5737781b666afe55c72899fc6084e7ba44be534d8 -size 1418 diff --git a/Assets/Resources/Volume Settings.asset.meta b/Assets/Resources/Volume Settings.asset.meta deleted file mode 100644 index e8387e451..000000000 --- a/Assets/Resources/Volume Settings.asset.meta +++ /dev/null @@ -1,10 +0,0 @@ -fileFormatVersion: 2 -guid: a15886e93ac3e7847861da392782f13d -timeCreated: 1524043690 -licenseType: Free -NativeFormatImporter: - externalObjects: {} - mainObjectFileID: 11400000 - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index c4bd71fcf..1ef50e0ea 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -436,6 +436,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -457,9 +459,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &23420360 MeshFilter: @@ -701,6 +705,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -722,9 +728,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &45636168 MeshFilter: @@ -1137,6 +1145,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -1597,6 +1606,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -1821,6 +1831,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -2497,6 +2508,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -2518,9 +2531,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &195655953 MeshFilter: @@ -2885,6 +2900,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -3410,6 +3426,7 @@ MonoBehaviour: InfiniteAmmoIcon: {fileID: 2044077503} CooldownFill: {fileID: 757740693} HeatFill: {fileID: 2060807771} + SelectedIndicator: {fileID: 0} --- !u!114 &232470601 MonoBehaviour: m_ObjectHideFlags: 0 @@ -3835,6 +3852,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -4117,6 +4135,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -4138,9 +4158,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &259676750 MeshFilter: @@ -4843,6 +4865,7 @@ RectTransform: m_Children: - {fileID: 2352149240727418666} - {fileID: 410701128} + - {fileID: 568886950} - {fileID: 1558520822} - {fileID: 1396909587} - {fileID: 1556666354} @@ -5153,6 +5176,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -5289,6 +5313,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -5695,6 +5720,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -7714,6 +7740,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -8358,6 +8385,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -8732,6 +8760,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -8753,9 +8783,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &542243549 MeshFilter: @@ -9080,6 +9112,82 @@ RectTransform: type: 3} m_PrefabInstance: {fileID: 2279630454421103832} m_PrefabAsset: {fileID: 0} +--- !u!1 &568886949 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 568886950} + - component: {fileID: 568886952} + - component: {fileID: 568886951} + m_Layer: 5 + m_Name: Debug Info Panel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &568886950 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1731988778} + m_Father: {fileID: 346000247} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 16, y: -800} + m_SizeDelta: {x: 220, y: 120} + m_Pivot: {x: 0, y: 0} +--- !u!114 &568886951 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 2100000, guid: 46238d909c2092c4eb873278d84c6ce8, type: 2} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 0 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 84d126f984de5fd42ac66361c00f830c, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 0.5 +--- !u!222 &568886952 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_CullTransparentMesh: 0 --- !u!1 &569895571 GameObject: m_ObjectHideFlags: 0 @@ -10233,9 +10341,6 @@ MonoBehaviour: TintDisplay: {fileID: 218215088} InfluenceDisplay: {fileID: 1390179643} Scale: 1 - Position: - x: 0 - y: 0 IconSize: 32 --- !u!1001 &642590526 PrefabInstance: @@ -11058,6 +11163,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -11079,9 +11186,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &677960094 MeshFilter: @@ -11394,6 +11503,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -11512,6 +11622,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -11533,9 +11645,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &708399897 MeshFilter: @@ -12046,6 +12160,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13167,6 +13282,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13434,6 +13550,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13738,6 +13855,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14206,6 +14324,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14343,6 +14462,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14510,6 +14630,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -14531,9 +14653,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &926083405 MeshFilter: @@ -14699,6 +14823,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14927,6 +15052,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -15094,6 +15220,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -15115,9 +15243,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &936330464 MeshFilter: @@ -15244,6 +15374,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -15265,9 +15397,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &945347194 MeshFilter: @@ -16545,9 +16679,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Settings: {fileID: 11400000, guid: cd2344e2001a37544aab0960c164d4ff, type: 2} - Sensitivity: - x: -0.001 - y: 0.001 Credits: 100000000 TargetSpottedBlinkFrequency: 20 TargetSpottedBlinkOffset: -0.25 @@ -16594,6 +16725,7 @@ MonoBehaviour: HitMarkerDuration: 0.25 SchematicDisplay: {fileID: 7628189704109037584} TargetSchematicDisplay: {fileID: 410701129} + DebugInfoText: {fileID: 1731988779} TargetIndicator: {fileID: 1556666357} TargetHitpointsFill: {fileID: 1691348884} TargetVisibilityFill: {fileID: 1715434759} @@ -16997,6 +17129,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -17431,6 +17564,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -17567,6 +17701,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -18347,6 +18482,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -18759,6 +18895,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -19045,6 +19182,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -19066,9 +19205,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1163671429 MeshFilter: @@ -21015,6 +21156,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -21635,6 +21777,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -21656,9 +21800,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1390575965 MeshFilter: @@ -21855,6 +22001,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22054,6 +22201,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22240,6 +22388,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22746,6 +22895,7 @@ MonoBehaviour: InfiniteAmmoIcon: {fileID: 0} CooldownFill: {fileID: 0} HeatFill: {fileID: 0} + SelectedIndicator: {fileID: 0} --- !u!114 &1462704894 MonoBehaviour: m_ObjectHideFlags: 0 @@ -23011,6 +23161,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -23237,6 +23388,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -23258,9 +23411,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1476765551 MeshFilter: @@ -23428,6 +23583,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -23824,6 +23980,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -24490,6 +24647,11 @@ PrefabInstance: propertyPath: m_firstOverflowCharacterIndex value: 0 objectReference: {fileID: 0} + - target: {fileID: 1713457305180270767, guid: 2758dfa2744d7ad4d8877545189d116e, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 2297648055057363568, guid: 2758dfa2744d7ad4d8877545189d116e, type: 3} propertyPath: m_Pivot.x @@ -24931,6 +25093,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -25211,6 +25374,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -25232,9 +25397,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1566772338 MeshFilter: @@ -25567,6 +25734,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 50 m_lineSpacingMax: 0 @@ -25710,6 +25878,11 @@ PrefabInstance: propertyPath: m_firstOverflowCharacterIndex value: 0 objectReference: {fileID: 0} + - target: {fileID: 1713457305180270767, guid: 2758dfa2744d7ad4d8877545189d116e, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 2297648055057363568, guid: 2758dfa2744d7ad4d8877545189d116e, type: 3} propertyPath: m_Pivot.x @@ -26204,6 +26377,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -26715,6 +26889,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -26962,6 +27137,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -26983,9 +27160,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1713745167 MeshFilter: @@ -27473,6 +27652,151 @@ MonoBehaviour: TabButtons: {fileID: 1783772015} ActiveTabColor: {r: 1, g: 0.44583336, b: 0.050000012, a: 1} InactiveTabColor: {r: 0.6981132, g: 0.6981132, b: 0.6981132, a: 1} +--- !u!1 &1731988777 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1731988778} + - component: {fileID: 1731988780} + - component: {fileID: 1731988779} + m_Layer: 5 + m_Name: Debug Info Text + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1731988778 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 568886950} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 2, y: 2} + m_SizeDelta: {x: -20, y: -20} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1731988779 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: 'New TextNew TextNew Text + + New TextNew TextNew TextNew Text + + New + TextNew Text + + New TextNew Text' + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 53555f7a2d5e745428813f1b1118734f, type: 2} + m_sharedMaterial: {fileID: -2804126062856497447, guid: 53555f7a2d5e745428813f1b1118734f, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 1, g: 1, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 15.85 + m_fontSizeBase: 12 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 8 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 1 + m_VerticalAlignment: 1024 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_characterHorizontalScale: 1 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!222 &1731988780 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_CullTransparentMesh: 1 --- !u!1 &1733988258 GameObject: m_ObjectHideFlags: 0 @@ -27569,6 +27893,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -27928,6 +28253,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -28979,6 +29305,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -29000,9 +29328,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1857285688 MeshFilter: @@ -30080,6 +30410,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31056,6 +31387,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31112,6 +31444,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -31133,9 +31467,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!1 &2095699056 GameObject: @@ -31368,6 +31704,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31708,6 +32045,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -31729,9 +32068,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &2117796868 MeshFilter: @@ -31892,6 +32233,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -32028,6 +32370,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33170,6 +33513,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33270,6 +33614,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33590,6 +33935,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33719,6 +34065,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33819,6 +34166,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34030,6 +34378,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34369,6 +34718,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34506,6 +34856,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34625,6 +34976,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34780,6 +35132,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34917,6 +35270,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35222,6 +35576,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35358,6 +35713,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35457,6 +35813,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35631,6 +35988,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35787,6 +36145,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37131,6 +37490,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37577,6 +37937,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37888,6 +38249,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 diff --git a/Assets/Scenes/FieldShieldTest.unity b/Assets/Scenes/FieldShieldTest.unity index b1b2e50b9..51c915b7f 100644 --- a/Assets/Scenes/FieldShieldTest.unity +++ b/Assets/Scenes/FieldShieldTest.unity @@ -13,7 +13,7 @@ OcclusionCullingSettings: --- !u!104 &2 RenderSettings: m_ObjectHideFlags: 0 - serializedVersion: 9 + serializedVersion: 10 m_Fog: 0 m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} m_FogMode: 3 @@ -38,13 +38,12 @@ RenderSettings: m_ReflectionIntensity: 1 m_CustomReflection: {fileID: 0} m_Sun: {fileID: 0} - m_IndirectSpecularColor: {r: 0.023674645, g: 0.04639011, b: 0.122535154, a: 1} m_UseRadianceAmbientProbe: 0 --- !u!157 &3 LightmapSettings: m_ObjectHideFlags: 0 - serializedVersion: 12 - m_GIWorkflowMode: 1 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 m_GISettings: serializedVersion: 2 m_BounceScale: 1 @@ -67,9 +66,6 @@ LightmapSettings: m_LightmapParameters: {fileID: 0} m_LightmapsBakeMode: 1 m_TextureCompression: 1 - m_FinalGather: 0 - m_FinalGatherFiltering: 1 - m_FinalGatherRayCount: 256 m_ReflectionCompression: 2 m_MixedBakeMode: 2 m_BakeBackend: 1 @@ -104,7 +100,7 @@ NavMeshSettings: serializedVersion: 2 m_ObjectHideFlags: 0 m_BuildSettings: - serializedVersion: 2 + serializedVersion: 3 agentTypeID: 0 agentRadius: 0.5 agentHeight: 2 @@ -117,7 +113,7 @@ NavMeshSettings: cellSize: 0.16666667 manualTileSize: 0 tileSize: 256 - accuratePlacement: 0 + buildHeightMesh: 0 maxJobWorkers: 0 preserveTilesOutsideBounds: 0 debug: @@ -155,7 +151,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 5 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} @@ -296,7 +291,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 3 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -308,6 +302,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 815358592} m_Modifications: - target: {fileID: 257019179550429270, guid: 426f9dcb510530d4dbfa678c9f727ec1, @@ -360,6 +355,11 @@ PrefabInstance: propertyPath: m_text value: Aetheric Field Tester objectReference: {fileID: 0} + - target: {fileID: 7131821417072393581, guid: 426f9dcb510530d4dbfa678c9f727ec1, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 7131821417165215474, guid: 426f9dcb510530d4dbfa678c9f727ec1, type: 3} propertyPath: m_Enabled @@ -481,6 +481,9 @@ PrefabInstance: value: Properties objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 426f9dcb510530d4dbfa678c9f727ec1, type: 3} --- !u!114 &401779935 stripped MonoBehaviour: @@ -505,6 +508,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 815358592} m_Modifications: - target: {fileID: 9194050151789067659, guid: 5c3a7b786419db74a8c1d17039fd4f67, @@ -663,6 +667,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 5c3a7b786419db74a8c1d17039fd4f67, type: 3} --- !u!224 &415327856 stripped RectTransform: @@ -713,7 +720,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1112533349} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 180} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -812,13 +818,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 699508825} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_RootOrder: 5 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &815358588 GameObject: @@ -896,7 +902,9 @@ Canvas: m_OverrideSorting: 0 m_OverridePixelPerfect: 0 m_SortingBucketNormalizedSize: 0 + m_VertexColorAlwaysGammaSpace: 0 m_AdditionalShaderChannelsFlag: 25 + m_UpdateRectTransformForStandalone: 0 m_SortingLayerID: 0 m_SortingOrder: 0 m_TargetDisplay: 0 @@ -919,7 +927,6 @@ RectTransform: - {fileID: 415327856} - {fileID: 87891009} m_Father: {fileID: 0} - m_RootOrder: 3 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} @@ -931,6 +938,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 0} m_Modifications: - target: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, @@ -1008,7 +1016,39 @@ PrefabInstance: propertyPath: m_LocalEulerAnglesHint.z value: 0 objectReference: {fileID: 0} + - target: {fileID: 2212592724187910978, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + propertyPath: m_Enabled + value: 0 + objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026487} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026488} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026489} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026490} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026492} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026491} m_SourcePrefab: {fileID: 100100000, guid: fe47fadcdb5ae5c4caca516b4af6fde6, type: 3} --- !u!1 &909026486 stripped GameObject: @@ -1024,9 +1064,17 @@ MeshCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 909026486} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 4 + serializedVersion: 5 m_Convex: 1 m_CookingOptions: 30 m_Mesh: {fileID: -5495902117074765545, guid: dd6644c1aa6c16c47bb9fea9822ce805, type: 3} @@ -1043,11 +1091,9 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Camera: {fileID: 1153356615} + FrontTwist: 0 + RearTwist: 0 TestMagnitude: 0.5 - Throttle: - x: 0 - y: 0 - ThrottleDecay: 5 FlowSpeed: 2 FlowSpeedThrottleExponent: 1 WaveThrottleExponent: 0 @@ -1084,8 +1130,8 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 52538a3309c36fc4583c964a1c0b705f, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: --- !u!114 &909026490 MonoBehaviour: m_ObjectHideFlags: 0 @@ -1096,8 +1142,40 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: d8f4aa77146e46a2a4b28d9fefb98496, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: +--- !u!114 &909026491 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 909026486} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fdca6a832d764b6e89fc83fdcf7b42d7, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldInterceptor + Envelope: {fileID: 909026492} + PanelPrototype: {fileID: 1087039186} + MaxLivePanels: 12 + PanelRadius: 2 + EnergyScale: 1 + ReuseFraction: 1 + DeathMargin: 0.25 + PatternByDamageType: +--- !u!114 &909026492 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 909026486} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e25c67ee83c01364fb121caeda952606, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldEnvelope --- !u!1 &1012179276 GameObject: m_ObjectHideFlags: 0 @@ -1138,13 +1216,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1012179276} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_RootOrder: 4 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1012179280 MonoBehaviour: @@ -1186,11 +1264,14 @@ MonoBehaviour: type: 3} m_DeselectOnBackgroundClick: 1 m_PointerBehavior: 0 + m_CursorLockBehavior: 0 + m_ScrollDeltaPerTick: 6 --- !u!1001 &1080184620 PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 0} m_Modifications: - target: {fileID: 105552333169325610, guid: 4a3db6090789ca24cb57c43666754017, @@ -1275,7 +1356,101 @@ PrefabInstance: objectReference: {fileID: 0} m_RemovedComponents: - {fileID: 7577392600424441103, guid: 4a3db6090789ca24cb57c43666754017, type: 3} + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 4a3db6090789ca24cb57c43666754017, type: 3} +--- !u!1 &1087039185 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1087039188} + - component: {fileID: 1087039187} + - component: {fileID: 1087039186} + m_Layer: 0 + m_Name: Panel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!114 &1087039186 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 1281fe44325f9b548aa42dc331b17266, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::Prototype +--- !u!114 &1087039187 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c1b180b85c234a4488cf0662b75ac77f, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldField.ShieldPanel + tiling: 3 + panelRadius: 5 + cellSize: 0.35 + mergeRhombs: 1 + takeDual: 0 + seed: 1337 + sim: {fileID: 7200000, guid: 1d17c100121b5ba47af9c75de451a2f1, type: 3} + panelShader: {fileID: 4800000, guid: 6a7f034931432c2418a0f0f0b9af0b93, type: 3} + growDuration: 0.35 + wavefrontSpeed: 8 + delayJitter: 0.06 + waveSpeed2: 26 + damping: 1.6 + stressClamp: 4 + substeps: 6 + fixedSubstepDt: 0.0025 + echoDelaySteps: 5 + echoReflect: -0.65 + tensileStrength: 0.9 + temperInit: 0.55 + temperErosion: 2.2 + storedEnergyGain: 0.8 + damageGain: 6 + breakAt: 0.75 + crackStart: 0.18 + breakBudgetFraction: 0.02 + radialKick: 1.6 + lateralKick: 2.2 + normalKick: 1.1 + spinKick: 7 + shardLife: 2.5 + shardDrag: 0.7 + maxShards: 6 + conduitGain: 2.5 +--- !u!4 &1087039188 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1112013867 GameObject: m_ObjectHideFlags: 0 @@ -1300,6 +1475,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1112013867} + serializedVersion: 2 m_LocalRotation: {x: 0.05948526, y: -0.910067, z: 0.14047916, w: 0.38536373} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -1307,7 +1483,6 @@ Transform: m_Children: - {fileID: 1153356611} m_Father: {fileID: 0} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 17.55, y: -134.1, z: 0} --- !u!114 &1112013869 MonoBehaviour: @@ -1353,7 +1528,6 @@ RectTransform: m_Children: - {fileID: 602735389} m_Father: {fileID: 815358592} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 1, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1387,13 +1561,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1153356610} + serializedVersion: 2 m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} m_LocalPosition: {x: 0, y: 0, z: -50} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1112013868} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1153356612 MonoBehaviour: @@ -1492,9 +1666,17 @@ Camera: m_projectionMatrixMode: 1 m_GateFitMode: 2 m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 m_SensorSize: {x: 36, y: 24} m_LensShift: {x: 0, y: 0} - m_FocalLength: 50 m_NormalizedViewPortRect: serializedVersion: 2 x: 0 @@ -1564,9 +1746,17 @@ BoxCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1424996955} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 1 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Size: {x: 1, y: 1, z: 1} m_Center: {x: 0, y: 0, z: 0} --- !u!4 &1424996958 @@ -1576,13 +1766,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1424996955} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: -1.4908371, y: -143.55145, z: 0.8193531} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1618381683 GameObject: @@ -1617,7 +1807,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1691,3 +1880,15 @@ CanvasRenderer: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1618381683} m_CullTransparentMesh: 1 +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 909026485} + - {fileID: 1424996958} + - {fileID: 1112013868} + - {fileID: 815358592} + - {fileID: 1012179279} + - {fileID: 699508827} + - {fileID: 1080184620} + - {fileID: 1087039188} diff --git a/Assets/Scripts/AetheriaInput.cs b/Assets/Scripts/AetheriaInput.cs index c6eb618c2..938444fef 100644 --- a/Assets/Scripts/AetheriaInput.cs +++ b/Assets/Scripts/AetheriaInput.cs @@ -162,6 +162,16 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable ""initialStateCheck"": false, ""priority"": 0 }, + { + ""name"": ""Cycle Target Item"", + ""type"": ""Button"", + ""id"": ""3f9a2f0a-6e3f-4a53-9f5e-1a9c1e6d9f7b"", + ""expectedControlType"": ""Button"", + ""processors"": """", + ""interactions"": """", + ""initialStateCheck"": false, + ""priority"": 0 + }, { ""name"": ""Toggle Heatsinks"", ""type"": ""Button"", @@ -409,6 +419,17 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable ""isComposite"": false, ""isPartOfComposite"": false }, + { + ""name"": """", + ""id"": ""8c2d5e77-4b18-4f0b-9a2e-6d3f7c1a5e90"", + ""path"": ""/j"", + ""interactions"": """", + ""processors"": """", + ""groups"": ""Keyboard&Mouse"", + ""action"": ""Cycle Target Item"", + ""isComposite"": false, + ""isPartOfComposite"": false + }, { ""name"": """", ""id"": ""71ac6088-1e18-4810-86be-1179918dd4a2"", @@ -1178,6 +1199,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable m_Player_TargetPrevious = m_Player.FindAction("Target Previous", throwIfNotFound: true); m_Player_TargetNext = m_Player.FindAction("Target Next", throwIfNotFound: true); m_Player_TargetNearest = m_Player.FindAction("Target Nearest", throwIfNotFound: true); + m_Player_CycleTargetItem = m_Player.FindAction("Cycle Target Item", throwIfNotFound: true); m_Player_ToggleHeatsinks = m_Player.FindAction("Toggle Heatsinks", throwIfNotFound: true); m_Player_ToggleShield = m_Player.FindAction("Toggle Shield", throwIfNotFound: true); m_Player_ToggleStance = m_Player.FindAction("Toggle Stance", throwIfNotFound: true); @@ -1294,6 +1316,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable private readonly InputAction m_Player_TargetPrevious; private readonly InputAction m_Player_TargetNext; private readonly InputAction m_Player_TargetNearest; + private readonly InputAction m_Player_CycleTargetItem; private readonly InputAction m_Player_ToggleHeatsinks; private readonly InputAction m_Player_ToggleShield; private readonly InputAction m_Player_ToggleStance; @@ -1342,6 +1365,10 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// public InputAction @TargetNearest => m_Wrapper.m_Player_TargetNearest; /// + /// Provides access to the underlying input action "Player/CycleTargetItem". + /// + public InputAction @CycleTargetItem => m_Wrapper.m_Player_CycleTargetItem; + /// /// Provides access to the underlying input action "Player/ToggleHeatsinks". /// public InputAction @ToggleHeatsinks => m_Wrapper.m_Player_ToggleHeatsinks; @@ -1420,6 +1447,9 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable @TargetNearest.started += instance.OnTargetNearest; @TargetNearest.performed += instance.OnTargetNearest; @TargetNearest.canceled += instance.OnTargetNearest; + @CycleTargetItem.started += instance.OnCycleTargetItem; + @CycleTargetItem.performed += instance.OnCycleTargetItem; + @CycleTargetItem.canceled += instance.OnCycleTargetItem; @ToggleHeatsinks.started += instance.OnToggleHeatsinks; @ToggleHeatsinks.performed += instance.OnToggleHeatsinks; @ToggleHeatsinks.canceled += instance.OnToggleHeatsinks; @@ -1476,6 +1506,9 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable @TargetNearest.started -= instance.OnTargetNearest; @TargetNearest.performed -= instance.OnTargetNearest; @TargetNearest.canceled -= instance.OnTargetNearest; + @CycleTargetItem.started -= instance.OnCycleTargetItem; + @CycleTargetItem.performed -= instance.OnCycleTargetItem; + @CycleTargetItem.canceled -= instance.OnCycleTargetItem; @ToggleHeatsinks.started -= instance.OnToggleHeatsinks; @ToggleHeatsinks.performed -= instance.OnToggleHeatsinks; @ToggleHeatsinks.canceled -= instance.OnToggleHeatsinks; @@ -1964,6 +1997,13 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// void OnTargetNearest(InputAction.CallbackContext context); /// + /// Method invoked when associated input action "Cycle Target Item" is either , or . + /// + /// + /// + /// + void OnCycleTargetItem(InputAction.CallbackContext context); + /// /// Method invoked when associated input action "Toggle Heatsinks" is either , or . /// /// diff --git a/Assets/Scripts/Editor/CultCacheDrawers.cs b/Assets/Scripts/Editor/CultCacheDrawers.cs index 75fe59ccd..c446212db 100644 --- a/Assets/Scripts/Editor/CultCacheDrawers.cs +++ b/Assets/Scripts/Editor/CultCacheDrawers.cs @@ -101,7 +101,7 @@ public sealed class InspectableSchematicShapeDrawer : ICultInspectorDrawer var shape = value as Shape ?? new Shape(); var item = inspector.Record as EquippableItemData; var hull = item as HullData; - var schematic = string.IsNullOrEmpty(item?.Schematic) ? null : AssetDatabase.LoadAssetAtPath(item.Schematic); + var schematic = string.IsNullOrEmpty(item?.Schematic) ? null : AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(item.Schematic)); EditorGUILayout.LabelField(label); using (new EditorGUILayout.HorizontalScope()) diff --git a/Assets/Scripts/Editor/EngineAssetCheck.cs b/Assets/Scripts/Editor/EngineAssetCheck.cs new file mode 100644 index 000000000..59eca26c8 --- /dev/null +++ b/Assets/Scripts/Editor/EngineAssetCheck.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using MessagePack; +using UnityEditor; +using UnityEditor.AddressableAssets; +using UnityEngine; +using Object = UnityEngine.Object; + +// docs/addressables-cut.md §2.5: validates every [CultInspectorAssetGuid] string member stored in +// the catalog. Read-only: it opens the catalog through AetheriaStores.Open exactly like the game +// does, and never writes. No repair path — a failure names the record, member, GUID and path. +public static class EngineAssetCheck +{ + // One row per attributed member (docs/addressables-cut.md §2.5 point 4). A member found on a + // stored document but missing here fails the check, so a new field can't skip validation. + private delegate bool Contract(object owner, Type ownerType, Object asset, out string reason); + + private static readonly Dictionary ConsumerContracts = new Dictionary + { + ["Icon"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), + ["ActionBarIcon"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), + ["Schematic"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), + ["Logo"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), + ["Prefab"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract(asset as GameObject, out reason), + ["ParticlesPrefab"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract(asset as GameObject, out reason), + ["Particles"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract(asset as GameObject, out reason), + ["EffectPrefab"] = EffectPrefabContract, + }; + + // Menu entry for interactive use; -executeMethod EngineAssetCheck.Run for batchmode/CI, exit 1 on failure. + [MenuItem("Aetheria/Validate Engine Asset References")] + public static void Run() + { + var failures = RunCheck(out var checkedCount); + foreach (var failure in failures) Debug.LogError(failure); + Debug.Log(failures.Count == 0 + ? $"EngineAssetCheck: {checkedCount} stored reference(s) OK." + : $"EngineAssetCheck: {failures.Count} failure(s) of {checkedCount} checked."); + + if (Application.isBatchMode) + EditorApplication.Exit(failures.Count == 0 ? 0 : 1); + } + + // Returns one failure line per bad stored reference; empty when everything resolves. Exposed for the + // mutation tests (tests/mutation_tests.py) driving this same method through a batchmode -executeMethod. + public static List RunCheck(out int checkedCount) + { + var failures = new List(); + checkedCount = 0; + + var root = Directory.GetParent(Application.dataPath)!.FullName; + var catalogPath = Path.Combine(root, "GameData", "Aetheria.cc"); + var cache = AetheriaStores.Open(catalogPath); + try + { + foreach (var stored in cache.AllStoredDocuments.ToArray()) + { + var docType = stored.Descriptor.DocumentType; + var document = stored.Document; + var name = NameOf(document, docType); + var seen = new HashSet(); + + foreach (var (owner, field) in WalkAssetGuidFields(document, seen)) + { + var key = (string)field.GetValue(owner); + if (string.IsNullOrEmpty(key)) continue; + checkedCount++; + + var label = $"{docType.Name} \"{name}\" .{field.Name} = \"{key}\""; + + if (!CultAssetGuidKey.TryParse(key, out var guid, out var subAssetName)) + { + failures.Add($"{label}: not a valid Addressables key (guid or guid[subAsset])"); + continue; + } + if (guid.Length != 32 || !guid.All(IsLowerHex)) + { + failures.Add($"{label}: GUID \"{guid}\" is not 32 lowercase hex characters"); + continue; + } + + var path = AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrEmpty(path) || !File.Exists(Path.Combine(root, path))) + { + failures.Add($"{label}: GUID {guid} names no existing file"); + continue; + } + + var entry = AddressableAssetSettingsDefaultObject.Settings?.FindAssetEntry(guid, true); + if (entry == null) + { + failures.Add($"{label}: {path} ({guid}) is not addressable"); + continue; + } + + if (!ConsumerContracts.TryGetValue(field.Name, out var contract)) + { + failures.Add($"{label}: no consumer contract registered for member \"{field.Name}\"; add one to EngineAssetCheck.ConsumerContracts"); + continue; + } + + Object asset = string.IsNullOrEmpty(subAssetName) + ? AssetDatabase.LoadAssetAtPath(path) + : AssetDatabase.LoadAllAssetsAtPath(path).FirstOrDefault(a => a.name == subAssetName); + if (asset == null) + { + failures.Add($"{label}: {(string.IsNullOrEmpty(subAssetName) ? path : path + "[" + subAssetName + "]")} did not load"); + continue; + } + + if (!contract(owner, owner.GetType(), asset, out var reason)) + failures.Add($"{label}: {reason}"); + } + } + + return failures; + } + finally + { + cache.Dispose(); + } + } + + private static bool TypeContract(Object asset, out string reason) where T : Object + { + if (!(asset is T)) { reason = $"asset is a {asset.GetType().Name}, not a {typeof(T).Name}"; return false; } + reason = null; + return true; + } + + private static bool ComponentContract(GameObject gameObject, out string reason) where T : Component + { + if (gameObject == null) { reason = "asset is not a GameObject"; return false; } + if (gameObject.GetComponent() == null) { reason = $"GameObject has no {typeof(T).Name}"; return false; } + reason = null; + return true; + } + + private static bool EffectPrefabContract(object owner, Type ownerType, Object asset, out string reason) + { + var gameObject = asset as GameObject; + if (typeof(InstantWeaponData).IsAssignableFrom(ownerType)) return ComponentContract(gameObject, out reason); + if (typeof(ConstantWeaponData).IsAssignableFrom(ownerType)) return ComponentContract(gameObject, out reason); + reason = $"unrecognised WeaponData subtype {ownerType.Name} for EffectPrefab"; + return false; + } + + private static bool IsLowerHex(char c) => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + + private static string NameOf(object document, Type docType) + { + var nameField = docType.GetField("Name", BindingFlags.Public | BindingFlags.Instance); + return nameField?.GetValue(document) as string ?? "(unnamed)"; + } + + // Same shape as AetherDb's DanglingRefs / the migration's WalkAssetPathFields: plain fields, list + // elements, and nested [MessagePackObject]/[Union] values (a HullData's Behaviors list, say). + private static IEnumerable<(object Owner, FieldInfo Field)> WalkAssetGuidFields(object value, HashSet seen) + { + if (value == null || !seen.Add(value)) yield break; + foreach (var field in value.GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (field.FieldType == typeof(string)) + { + if (field.GetCustomAttribute() != null) + yield return (value, field); + continue; + } + + var member = field.GetValue(value); + if (member == null) continue; + + if (member is IDictionary dictionary) + { + foreach (var key in dictionary.Keys) + { + foreach (var nested in WalkAssetGuidFields(key, seen)) yield return nested; + foreach (var nested in WalkAssetGuidFields(dictionary[key], seen)) yield return nested; + } + } + else if (member is IList list && !(member is Array { Rank: > 1 })) + { + foreach (var element in list) + if (element != null && IsSerializedObject(element.GetType())) + foreach (var nested in WalkAssetGuidFields(element, seen)) yield return nested; + } + else if (IsSerializedObject(member.GetType())) + { + foreach (var nested in WalkAssetGuidFields(member, seen)) yield return nested; + } + } + } + + private static bool IsSerializedObject(Type type) + { + for (var t = type; t != null && t != typeof(object); t = t.BaseType) + if (t.IsDefined(typeof(MessagePackObjectAttribute), false) || t.IsDefined(typeof(UnionAttribute), false)) + return true; + return false; + } +} diff --git a/Assets/Scripts/Editor/EngineAssetCheck.cs.meta b/Assets/Scripts/Editor/EngineAssetCheck.cs.meta new file mode 100644 index 000000000..8445541bc --- /dev/null +++ b/Assets/Scripts/Editor/EngineAssetCheck.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 66eb9b3ae90c36b4e920ed38d0dc0036 \ No newline at end of file diff --git a/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs new file mode 100644 index 000000000..58e53a1ad --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs @@ -0,0 +1,143 @@ +using System; +using System.Linq; +using ShieldField; +using UnityEditor; +using UnityEditor.Rendering; +using UnityEngine; + +namespace Aetheria.EditorTools +{ + /// + /// Batchmode probe for the shield-panel Cut 1 landing (docs/shield-panel-cut.md). + /// No NUnit coverage exists for these files (Tests.asmdef cannot see + /// Assembly-CSharp — see the map's Q7), so this is the check that stands in + /// for a unit test on two Cut 1 rules that a compile pass alone cannot prove: + /// + /// 1. The shader and compute shader carry zero compile errors on built-in. + /// 2. Penrose cell count actually scales with panelRadius (D18) instead of + /// being a fixed function of the (now-removed) authored iteration count. + /// + /// Run via: Unity -batchmode -projectPath <repo> -executeMethod + /// Aetheria.EditorTools.ShieldPanelCut1Verify.Run -quit -logFile <path> + /// Exits with a non-zero code if either rule is violated, so the log alone is + /// not the only signal — a CI runner can gate on the exit code too. + /// + public static class ShieldPanelCut1Verify + { + public static void Run() + { + bool ok = true; + ok &= CheckShaderMessages(); + ok &= CheckComputeMessages(); + ok &= CheckPenroseScalesWithRadius(); + + Debug.Log(ok + ? "[ShieldPanelCut1Verify] PASS" + : "[ShieldPanelCut1Verify] FAIL — see errors above"); + + EditorApplication.Exit(ok ? 0 : 1); + } + + static bool CheckShaderMessages() + { + var shader = AssetDatabase.LoadAssetAtPath( + "Assets/Shaders/Compute/ShieldField/ShieldPanel.shader"); + if (shader == null) + { + Debug.LogError("[ShieldPanelCut1Verify] ShieldPanel.shader not found."); + return false; + } + + bool ok = true; + + // The first query can observe the shader mid-compile and return a stale/empty + // message set; warm it up before trusting the result. + _ = ShaderUtil.GetShaderMessageCount(shader); + _ = ShaderUtil.ShaderHasError(shader); + var shaderMsgs = ShaderUtil.GetShaderMessages(shader); + + foreach (var m in shaderMsgs) + { + if (m.severity == ShaderCompilerMessageSeverity.Error) + { + Debug.LogError($"[ShieldPanelCut1Verify] Shader error: {m.message} ({m.file}:{m.line})"); + ok = false; + } + else + { + Debug.Log($"[ShieldPanelCut1Verify] Shader warning: {m.message} ({m.file}:{m.line})"); + } + } + + if (ShaderUtil.ShaderHasError(shader)) + { + Debug.LogError("[ShieldPanelCut1Verify] ShieldPanel.shader has compile errors (ShaderHasError)."); + ok = false; + } + + Debug.Log($"[ShieldPanelCut1Verify] shader supported={!ShaderUtil.ShaderHasError(shader)} messages={shaderMsgs.Length}"); + return ok; + } + + static bool CheckComputeMessages() + { + var cs = AssetDatabase.LoadAssetAtPath( + "Assets/Shaders/Compute/ShieldField/ShieldSim.compute"); + if (cs == null) + { + Debug.LogError("[ShieldPanelCut1Verify] ShieldSim.compute not found."); + return false; + } + + bool ok = true; + var msgs = ShaderUtil.GetComputeShaderMessages(cs); + foreach (var m in msgs) + { + if (m.severity == ShaderCompilerMessageSeverity.Error) + { + Debug.LogError($"[ShieldPanelCut1Verify] Compute shader error: {m.message} ({m.file}:{m.line})"); + ok = false; + } + } + Debug.Log($"[ShieldPanelCut1Verify] compute messages={msgs.Length}, errors={msgs.Count(m => m.severity == ShaderCompilerMessageSeverity.Error)}"); + return ok; + } + + static bool CheckPenroseScalesWithRadius() + { + const float cellSize = 0.35f; + float[] radii = { 1.5f, 10f }; + int[] penroseCounts = new int[radii.Length]; + int[] hexCounts = new int[radii.Length]; + + for (int i = 0; i < radii.Length; i++) + { + var pSoup = Tilings.Generate(TilingKind.PenroseP3, radii[i], cellSize, 1337); + var pMerged = TilingBuilder.MergeRhombs(pSoup); + var pData = TilingBuilder.Build(pMerged, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f), + clipRadius: radii[i], seed: 1337); + penroseCounts[i] = pData.CellCount; + + var hSoup = Tilings.Generate(TilingKind.Hex, radii[i], cellSize, 1337); + var hData = TilingBuilder.Build(hSoup, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f), + clipRadius: radii[i], seed: 1337); + hexCounts[i] = hData.CellCount; + + Debug.Log($"[ShieldPanelCut1Verify] radius={radii[i]} cellSize={cellSize} " + + $"PenroseP3(merged) cells={penroseCounts[i]} Hex cells={hexCounts[i]}"); + } + + bool ok = penroseCounts[0] != penroseCounts[1]; + if (!ok) + Debug.LogError($"[ShieldPanelCut1Verify] D18 not fixed: Penrose cell count identical " + + $"({penroseCounts[0]}) at radius {radii[0]} and {radii[1]}."); + else if (penroseCounts[1] < penroseCounts[0]) + Debug.LogError($"[ShieldPanelCut1Verify] D18 regression: Penrose cell count DECREASED " + + $"from {penroseCounts[0]} (r={radii[0]}) to {penroseCounts[1]} (r={radii[1]})."); + else + Debug.Log("[ShieldPanelCut1Verify] D18 fixed: Penrose cell count increases with panelRadius."); + + return ok && penroseCounts[1] > penroseCounts[0]; + } + } +} diff --git a/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta new file mode 100644 index 000000000..a09fb26c3 --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 22124e7322748c84a80cc32b7d601bce \ No newline at end of file diff --git a/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs new file mode 100644 index 000000000..55634b84e --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs @@ -0,0 +1,327 @@ +using System; +using UnityEditor; +using UnityEngine; + +namespace Aetheria.EditorTools +{ + /// + /// Batchmode probe for the shield-panel Cut 2 landing (docs/shield-panel-cut.md): + /// ShieldEnvelope collapsing the two implicit ellipsoids (FieldDriver's transform, the Shield + /// prefab instance's transform) into one reader. Same reason Cut 1 has one (the map's Q7): + /// Tests.asmdef cannot see Assembly-CSharp, so this batchmode probe stands in for NUnit + /// coverage. + /// + /// Cut 2 does not edit any prefab or scene (Q5: the operator rigs prefabs/scenes by hand), so + /// this cannot literally load Longinus.prefab/Djinni.prefab and find a ShieldEnvelope on them + /// yet. Instead it builds synthetic transforms carrying the exact override values the map's + /// §1 table recorded for Longinus and Djinni (scale, position, rotation) plus the real + /// Icosphere collider mesh those ships' Shield objects actually use, and runs the map's own + /// verification rule against those: ProjectToSurface lands exactly on the ellipsoid, and + /// SurfaceNormal matches a finite-difference gradient of the implicit surface to within 1e-3, + /// checked on Longinus's 4:1-aspect envelope specifically (a sphere could not distinguish a + /// correct normal from a plain normalize(p)). + /// + /// Run via: Unity -batchmode -projectPath <repo> -executeMethod + /// Aetheria.EditorTools.ShieldPanelCut2Verify.Run -quit -logFile <path> + /// + public static class ShieldPanelCut2Verify + { + const string Tag = "[ShieldPanelCut2Verify]"; + + public static void Run() + { + bool ok = true; + GameObject longinus = null, djinni = null, cubesphere = null; + try + { + ok &= CheckIcosphereExtents(); + + longinus = BuildEnvelope( + "LonginusShieldProbe", + new Vector3(395.3487f, 235.47029f, 990.37805f), + new Vector3(0f, 0.73f, 1.32f), + Quaternion.identity, + "Assets/Models/Icosphere.fbx"); + djinni = BuildEnvelope( + "DjinniShieldProbe", + new Vector3(857.9317f, 424.00012f, 1036.2562f), + new Vector3(0f, 1.75f, -1.06f), + new Quaternion(0.08715578f, 0f, 0f, 0.9961947f), + "Assets/Models/Icosphere.fbx"); + cubesphere = BuildEnvelope( + "FieldShieldTestProbe", + new Vector3(4f, 3f, 12f), + Vector3.zero, + Quaternion.identity, + null); // no mesh -- matches the cubesphere's collider extents of (1,1,1) exactly + + ok &= CheckEllipsoidSurface(longinus, "Longinus"); + ok &= CheckEllipsoidSurface(djinni, "Djinni"); + ok &= CheckEllipsoidSurface(cubesphere, "FieldShieldTest cubesphere"); + + ok &= CheckNormalMatchesGradient(longinus, "Longinus"); + ok &= CheckNormalMatchesGradient(djinni, "Djinni"); + + // The sphere-vs-ellipsoid distinction the map's whole Cut 2 rests on: on + // Longinus's 4:1 envelope, the true normal and normalize(p) must visibly differ. + ok &= CheckNormalDiffersFromNormalizeP(longinus, "Longinus"); + + // Radii must reflect the measured mesh factor (per-axis; the Icosphere is not a + // perfect cube of extents -- see IcosphereExtents above), not a guessed flat 0.5. + ok &= CheckRadiiUsesMeasuredExtents(longinus, new Vector3(395.3487f, 235.47029f, 990.37805f), IcosphereExtents, "Longinus"); + ok &= CheckRadiiUsesMeasuredExtents(cubesphere, new Vector3(4f, 3f, 12f), Vector3.one, "FieldShieldTest cubesphere"); + + // Pin the fallback: a carrier with NO ShieldEnvelope component must produce exactly + // the pre-Cut-2 hit direction/position, for both FieldDriver's and ShieldManager's + // formulas. This is what a Longinus/Djinni prefab looks like before the operator + // adds the component (Q5) -- the fallback is not a courtesy, it is load-bearing for + // every ship in the game until then. + ok &= CheckLegacyFallbackFormulas(); + } + catch (Exception e) + { + Debug.LogError($"{Tag} exception: {e}"); + ok = false; + } + finally + { + if (longinus != null) UnityEngine.Object.DestroyImmediate(longinus); + if (djinni != null) UnityEngine.Object.DestroyImmediate(djinni); + if (cubesphere != null) UnityEngine.Object.DestroyImmediate(cubesphere); + } + + Debug.Log(ok ? $"{Tag} PASS" : $"{Tag} FAIL — see errors above"); + EditorApplication.Exit(ok ? 0 : 1); + } + + // Measured once via a throwaway batchmode probe (deleted): the Icosphere collider mesh's + // bounds.extents at import scale. Only y and z happen to land on exactly 0.01 -- x is + // slightly smaller (a base icosahedron's vertices are not all equidistant from its own AABB + // faces on every axis, so its bounding box is not a perfect cube even though the shape is + // "spherical"). This is exactly why ShieldEnvelope reads the mesh's actual bounds rather + // than assuming a round number on every axis: the real collider is not perfectly isotropic. + static readonly Vector3 IcosphereExtents = new Vector3(0.0095106f, 0.01f, 0.01f); + + static bool CheckIcosphereExtents() + { + var mesh = AssetDatabase.LoadAssetAtPath("Assets/Models/Icosphere.fbx"); + if (mesh == null) + { + Debug.LogError($"{Tag} Icosphere.fbx mesh not found."); + return false; + } + var e = mesh.bounds.extents; + bool ok = Vector3.Distance(e, IcosphereExtents) < 1e-4f; + if (!ok) + Debug.LogError($"{Tag} Icosphere.fbx bounds.extents is {e:F7}, expected {IcosphereExtents:F7} " + + "(measured once via batchmode; if the asset legitimately changed, update this constant " + + "and re-derive ShieldEnvelope's documented factor)."); + else + Debug.Log($"{Tag} Icosphere.fbx bounds.extents={e:F7} (as measured)."); + return ok; + } + + static GameObject BuildEnvelope(string name, Vector3 scale, Vector3 position, Quaternion rotation, string meshPath) + { + var go = new GameObject(name); + go.transform.position = position; + go.transform.rotation = rotation; + go.transform.localScale = scale; + if (meshPath != null) + { + var mesh = AssetDatabase.LoadAssetAtPath(meshPath); + var mc = go.AddComponent(); + mc.sharedMesh = mesh; + } + go.AddComponent(); + return go; + } + + static bool CheckEllipsoidSurface(GameObject envelopeObj, string label) + { + var envelope = envelopeObj.GetComponent(); + var center = envelope.Center; + var radii = envelope.Radii; + bool ok = true; + var rng = new System.Random(1337); + for (int i = 0; i < 20; i++) + { + var probe = center + new Vector3( + (float)(rng.NextDouble() * 4 - 2) * radii.x, + (float)(rng.NextDouble() * 4 - 2) * radii.y, + (float)(rng.NextDouble() * 4 - 2) * radii.z); + if (probe == center) probe += Vector3.right * 0.001f; + + var surface = envelope.ProjectToSurface(probe); + var local = envelopeObj.transform.InverseTransformPoint(surface); + var extents = LocalExtentsOf(envelopeObj); + double sum = Sq(local.x / extents.x) + Sq(local.y / extents.y) + Sq(local.z / extents.z); + if (Math.Abs(sum - 1.0) > 1e-3) + { + Debug.LogError($"{Tag} {label}: ProjectToSurface point {i} is off the ellipsoid, sum(p_i/r_i)^2={sum:F6} (want 1)."); + ok = false; + } + } + if (ok) Debug.Log($"{Tag} {label}: 20/20 ProjectToSurface probes land on the ellipsoid."); + return ok; + } + + static bool CheckNormalMatchesGradient(GameObject envelopeObj, string label) + { + var envelope = envelopeObj.GetComponent(); + bool ok = true; + var rng = new System.Random(2026); + const float h = 1e-3f; + for (int i = 0; i < 10; i++) + { + var dir = new Vector3((float)rng.NextDouble() - 0.5f, (float)rng.NextDouble() - 0.5f, (float)rng.NextDouble() - 0.5f).normalized; + var probe = envelope.Center + envelopeObj.transform.rotation * Vector3.Scale(dir, envelope.Radii * 3f); + var surface = envelope.ProjectToSurface(probe); + var analytic = envelope.SurfaceNormal(surface); + + // Finite-difference gradient of F(x) = sum((local_i)/extents_i)^2 - 1 in world space. + var extents = LocalExtentsOf(envelopeObj); + Func f = worldP => + { + var l = envelopeObj.transform.InverseTransformPoint(worldP); + return Sq(l.x / extents.x) + Sq(l.y / extents.y) + Sq(l.z / extents.z); + }; + var grad = new Vector3( + (float)((f(surface + Vector3.right * h) - f(surface - Vector3.right * h)) / (2 * h)), + (float)((f(surface + Vector3.up * h) - f(surface - Vector3.up * h)) / (2 * h)), + (float)((f(surface + Vector3.forward * h) - f(surface - Vector3.forward * h)) / (2 * h))); + var fd = grad.normalized; + + var dot = Vector3.Dot(analytic, fd); + if (dot < 1 - 1e-3f) + { + Debug.LogError($"{Tag} {label}: SurfaceNormal probe {i} disagrees with the finite-difference " + + $"gradient, dot={dot:F6} (want ~1). analytic={analytic} fd={fd}"); + ok = false; + } + } + if (ok) Debug.Log($"{Tag} {label}: 10/10 SurfaceNormal probes match the finite-difference gradient within 1e-3."); + return ok; + } + + static bool CheckNormalDiffersFromNormalizeP(GameObject envelopeObj, string label) + { + var envelope = envelopeObj.GetComponent(); + // normalize(p) and the true gradient normal coincide at every point lying exactly on a + // principal axis (a pole) -- both directions collapse to that axis there, for any radii. + // They diverge everywhere else, more so the more the axes differ. Longinus's radii are + // ~3.95/2.35/9.90 (x/y/z), so a direction mixing the short x axis and the long z axis, + // off every pole, is where the two disagree most. + var localDir = new Vector3(0.8f, 0f, 0.6f).normalized; + var probe = envelope.Center + envelopeObj.transform.rotation * Vector3.Scale(localDir, envelope.Radii * 3f); + var surface = envelope.ProjectToSurface(probe); + var trueNormal = envelope.SurfaceNormal(surface); + var wrongNormal = (surface - envelope.Center).normalized; + var dot = Vector3.Dot(trueNormal, wrongNormal); + + // On this envelope the two are safely coincident only at the poles; off-axis they must + // differ by more than a token amount, or SurfaceNormal has silently degenerated into + // normalize(p) again. + bool ok = dot < 0.999f; + if (!ok) + Debug.LogError($"{Tag} {label}: SurfaceNormal equals normalize(p) (dot={dot:F6}) on a 4:1 " + + "ellipsoid's flank -- this is the exact wrong-answer mutation the map warns about."); + else + Debug.Log($"{Tag} {label}: SurfaceNormal correctly diverges from normalize(p) on the flank (dot={dot:F6})."); + return ok; + } + + static bool CheckRadiiUsesMeasuredExtents(GameObject envelopeObj, Vector3 lossyScale, Vector3 extentFactor, string label) + { + var envelope = envelopeObj.GetComponent(); + var expected = Vector3.Scale(lossyScale, extentFactor); + var actual = envelope.Radii; + bool ok = Vector3.Distance(expected, actual) < 1e-2f; + if (!ok) + Debug.LogError($"{Tag} {label}: Radii={actual:F6} does not match lossyScale*measuredExtents={expected:F6}. " + + $"actualLossyScale={envelopeObj.transform.lossyScale:F6} localScale={envelopeObj.transform.localScale:F6}"); + else + Debug.Log($"{Tag} {label}: Radii={actual:F6} matches lossyScale*measuredExtents."); + return ok; + } + + /// + /// Pins ShieldEnvelope's two static fallback entry points (SurfaceDirection, + /// LegacyLocalSurfacePoint) against an INDEPENDENT re-derivation of the exact pre-Cut-2 + /// formulas -- not a call into the production code under test -- on a carrier with no + /// ShieldEnvelope component and a non-trivial transform (nonzero position, non-identity + /// rotation), so the check is not vacuous the way it would be at the origin with identity + /// rotation. This is the regression the coordinator flagged: ShieldManager's fallback used + /// to silently change behaviour (hard-fail to Vector3.forward) instead of reproducing the + /// deleted formula, which would have shown every ship's shield hits in the wrong place + /// until the operator hand-edits Longinus.prefab/Djinni.prefab. + /// + static bool CheckLegacyFallbackFormulas() + { + var go = new GameObject("LegacyFallbackProbe"); + bool ok = true; + try + { + go.transform.position = new Vector3(5f, -2f, 13f); + go.transform.rotation = Quaternion.Euler(15f, 40f, -8f); + go.transform.localScale = new Vector3(4f, 3f, 12f); + // Deliberately no MeshCollider/MeshFilter/ShieldEnvelope on this object -- this is + // exactly what Longinus.prefab's "Shield" object looks like before the operator + // rigs it (Q5), and what FieldShieldTest's cubesphere looked like before Cut 2. + + var probePoints = new[] + { + go.transform.position + new Vector3(10f, 2f, -6f), + go.transform.position + new Vector3(-3f, 8f, 20f), + go.transform.position + new Vector3(0.4f, -0.1f, 0.2f), + }; + + foreach (var worldPoint in probePoints) + { + // Independent re-derivation of ShieldManager's deleted formula + // (normalize(shield.transform.InverseTransformPoint(point))). + var expectedDirection = go.transform.InverseTransformPoint(worldPoint).normalized; + var actualDirection = ShieldEnvelope.SurfaceDirection(go.transform, worldPoint); + if (Vector3.Distance(expectedDirection, actualDirection) > 1e-5f) + { + Debug.LogError($"{Tag} SurfaceDirection(transform, p) fallback diverged from " + + $"ShieldManager's deleted formula: expected={expectedDirection:F6} actual={actualDirection:F6}"); + ok = false; + } + + // Independent re-derivation of FieldDriver.AddHit's deleted formula + // (normalize(InverseTransformPoint(p)) * transform.localScale). + var expectedLocalSurface = Vector3.Scale( + go.transform.InverseTransformPoint(worldPoint).normalized, go.transform.localScale); + var actualLocalSurface = ShieldEnvelope.LegacyLocalSurfacePoint(go.transform, worldPoint); + if (Vector3.Distance(expectedLocalSurface, actualLocalSurface) > 1e-3f) + { + Debug.LogError($"{Tag} LegacyLocalSurfacePoint(transform, p) fallback diverged from " + + $"FieldDriver's deleted formula: expected={expectedLocalSurface:F6} actual={actualLocalSurface:F6}"); + ok = false; + } + } + } + finally + { + UnityEngine.Object.DestroyImmediate(go); + } + + if (ok) + Debug.Log($"{Tag} Legacy fallback statics (SurfaceDirection, LegacyLocalSurfacePoint) match " + + "the pre-Cut-2 formulas exactly, for a carrier with no ShieldEnvelope component and a " + + "non-trivial transform."); + return ok; + } + + static Vector3 LocalExtentsOf(GameObject envelopeObj) + { + var meshCollider = envelopeObj.GetComponent(); + if (meshCollider != null && meshCollider.sharedMesh != null) + return meshCollider.sharedMesh.bounds.extents; + return Vector3.one; + } + + static double Sq(double x) => x * x; + } +} diff --git a/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta new file mode 100644 index 000000000..6adb5a959 --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc6b6136927b78c45a78cfc0694ce93a \ No newline at end of file diff --git a/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs new file mode 100644 index 000000000..aa6f4b151 --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs @@ -0,0 +1,579 @@ +using System.Reflection; +using CultMath.UnityBridge; +using ShieldField; +using UnityEditor; +using UnityEngine; + +namespace Aetheria.EditorTools +{ + /// + /// Batchmode probe for the shield-panel Cut 3 landing (docs/shield-panel-cut.md): the + /// interceptor presenter (ShieldInterceptor) and the panel pool. Tests.asmdef cannot see + /// Assembly-CSharp (the map's Q7), so this stands in for NUnit coverage exactly as Cut 1 and + /// Cut 2's probes do, and it runs the rules those probes cannot reach: same-frame injection + /// (D17/D20), reuse-strikes-the-existing-panel, pool exhaustion, and the D21 Penrose/Hex + /// density ratio. + /// + /// Unlike Cut 1/2 this exercises actual MonoBehaviour simulation state (GraphicsBuffer + /// dispatches), which needs GPU access -- run WITHOUT -nographics, unlike the two probes before + /// it. ShieldPanel deliberately has no [ExecuteAlways] (Cut 1 removed it, D13), so Update() + /// never runs on its own in edit mode; this probe steps the simulation itself by invoking the + /// private Update() method directly; it is Application.isPlaying == false throughout, so + /// ShieldPanel's own dt fallback (1/60s, fixed) is exactly what's exercised. + /// + /// Run via: Unity -batchmode -projectPath <repo> -executeMethod + /// Aetheria.EditorTools.ShieldPanelCut3Verify.Run -logFile <path> + /// (no -quit while iterating: EditorApplication.Exit both reports the code and quits.) + /// + public static class ShieldPanelCut3Verify + { + static readonly MethodInfo PanelUpdate = + typeof(ShieldPanel).GetMethod("Update", BindingFlags.NonPublic | BindingFlags.Instance); + static readonly MethodInfo PanelOnEnable = + typeof(ShieldPanel).GetMethod("OnEnable", BindingFlags.NonPublic | BindingFlags.Instance); + static readonly MethodInfo PanelOnDisable = + typeof(ShieldPanel).GetMethod("OnDisable", BindingFlags.NonPublic | BindingFlags.Instance); + static readonly FieldInfo PanelPending = + typeof(ShieldPanel).GetField("pending", BindingFlags.NonPublic | BindingFlags.Instance); + + // pending's element type (PendingHit) is a private nested struct, so this reads it through + // the non-generic ICollection Queue also implements -- Count needs no knowledge of T. + static int PendingCount(ShieldPanel panel) + => ((System.Collections.ICollection)PanelPending.GetValue(panel)).Count; + + public static void Run() + { + // This probe dispatches the panel's compute kernels, so it needs a graphics device. + // Under -batchmode -nographics the kernels cannot be looked up and every check dies + // with "Kernel 'KInit' not found", which reads like a broken shader and is not one: + // run this probe without -nographics. Verified 2026-09-18, both ways. + // SystemInfo.supportsComputeShaders still reports true against the null device, so the + // device type is what actually answers this (checked 2026-09-18). + if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null) + { + Debug.LogError("[ShieldPanelCut3Verify] FAIL: no graphics device in this session. " + + "This probe needs a graphics device; rerun batchmode without -nographics."); + EditorApplication.Exit(1); + return; + } + + bool ok = true; + ok &= CheckSpawnFrameInjectionAbsorbs(); + ok &= CheckReuseStrikesExistingPanel(); + ok &= CheckHitOutsideRadiusDoesNotReachPanel(); + ok &= CheckPoolExhaustion(); + ok &= CheckNullGuardsDoNotThrow(); + ok &= CheckPoolReuseDoesNotRebuild(); + ok &= CheckPenroseDensity(); + + Debug.Log(ok + ? "[ShieldPanelCut3Verify] PASS" + : "[ShieldPanelCut3Verify] FAIL — see errors above"); + + EditorApplication.Exit(ok ? 0 : 1); + } + + // ============================================================== + static void StepPanel(ShieldPanel panel) => PanelUpdate.Invoke(panel, null); + + static (GameObject envelopeGO, ShieldEnvelope envelope, GameObject protoGO, ShieldInterceptor interceptor) + BuildRig(int maxLive = 12, float panelRadius = 2f, float reuseFraction = 1f) + { + var sim = AssetDatabase.LoadAssetAtPath( + "Assets/Shaders/Compute/ShieldField/ShieldSim.compute"); + var shader = AssetDatabase.LoadAssetAtPath( + "Assets/Shaders/Compute/ShieldField/ShieldPanel.shader"); + + // Uniform-scale sphere at the origin: no MeshCollider/MeshFilter, so ShieldEnvelope + // takes its documented no-mesh fallback (_localExtents = Vector3.one) rather than + // needing a real hull mesh -- this probe is about the interceptor's plumbing, and + // Cut 2's own probe already pins the mesh-extents/normal arithmetic. + var envelopeGO = new GameObject("Cut3Probe_Envelope"); + envelopeGO.transform.localScale = Vector3.one * 5f; + var envelope = envelopeGO.AddComponent(); + + var protoGO = new GameObject("Cut3Probe_PanelPrototype"); + var panel = protoGO.AddComponent(); + // Assigned after AddComponent so the OnEnable this triggers (sim/panelShader still + // null at that point) is a no-op -- Rebuild() below is the real, deliberate build, + // exactly as Cut 4's rig depends on the prototype's authored fields being set before + // its first real build, not on whatever OnEnable saw at GameObject-creation time. + panel.tiling = TilingKind.Hex; + panel.panelRadius = panelRadius; + panel.cellSize = 0.5f; + panel.sim = sim; + panel.panelShader = shader; + panel.Rebuild(); + var proto = protoGO.AddComponent(); + + var interceptorGO = new GameObject("Cut3Probe_Interceptor"); + var interceptor = interceptorGO.AddComponent(); + interceptor.Envelope = envelope; + interceptor.PanelPrototype = proto; + interceptor.MaxLivePanels = maxLive; + interceptor.PanelRadius = panelRadius; + interceptor.ReuseFraction = reuseFraction; + + return (envelopeGO, envelope, protoGO, interceptor); + } + + static void DestroyRig((GameObject envelopeGO, ShieldEnvelope envelope, GameObject protoGO, ShieldInterceptor interceptor) rig) + { + // The prototype's own pooled instances are children of protoGO's parent (null here, i.e. + // scene root) per Prototype.Instantiate's `SetParent(transform.parent, ...)` -- find and + // destroy them too so one check's panels can't be mistaken for the next check's. + foreach (var go in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + UnityEngine.Object.DestroyImmediate(go.gameObject); + UnityEngine.Object.DestroyImmediate(rig.envelopeGO); + UnityEngine.Object.DestroyImmediate(rig.interceptor.gameObject); + } + + static AbsorbEvent Absorb(Vector3 worldPos, Vector3 worldDir, float energy = 5f, + DamageType type = DamageType.Kinetic) + => new AbsorbEvent(worldPos.ToCultMath(), worldDir.ToCultMath(), energy, type); + + // ============================================================== + // D17/D20: a panel struck in its own spawn frame must absorb that hit, not swallow it. + // ============================================================== + static bool CheckSpawnFrameInjectionAbsorbs() + { + var rig = BuildRig(); + try + { + rig.interceptor.Absorb(Absorb(new Vector3(0, 0, 20), new Vector3(0, 0, -1))); + if (rig.interceptor.LiveCount != 1) + { + Debug.LogError($"[ShieldPanelCut3Verify] spawn-frame: expected 1 live panel, got {rig.interceptor.LiveCount}."); + return false; + } + + var panel = rig.interceptor.DebugPanelAt(0); + // The strike lands at the panel's own local origin (ShieldInterceptor poses the + // panel's transform AT the intercept point before calling Hit with that same + // point), so the nearest cell to local (0,0) is the one that must show injection. + int nearest = NearestCell(panel, Vector2.zero); + + // Exactly one Update() call: the frame the hit was queued in. + StepPanel(panel); + + var v = new float[panel.DebugCellCount]; + panel.DebugVField.GetData(v); + if (Mathf.Abs(v[nearest]) < 1e-6f) + { + Debug.LogError($"[ShieldPanelCut3Verify] D17 not fixed: cell {nearest}'s wave velocity is " + + $"{v[nearest]:E3} after the spawn-frame hit -- the growth gate swallowed it."); + return false; + } + + // D20: the panel's own arm clock must keep the gate open on the NEXT frame too, not + // just the spawn frame. KWaveStep zeroes both _UOut and _V outright for any cell + // whose growth is still < 0.6 (ShieldSim.compute), so under the pre-Cut-3 bug + // (_PanelSpawnTime hardcoded to 0f) growth gets reset to 0 by frame 1's own KUpdate + // and frame 2 is gated shut again -- a fresh injection landing on frame 2 can only + // push v MORE negative (compression) than whatever frame 1 left behind; the bug + // instead zeroes it outright. Decay alone (the fix, with no second hit) could only + // shrink |v| toward zero, never make it more negative, so this is a clean + // discriminator between "still gated" and "correctly still open". + float vBeforeSecondHit = v[nearest]; + panel.Hit(panel.transform.position, new Vector3(0, 0, -1), 5f); + StepPanel(panel); + var v2 = new float[panel.DebugCellCount]; + panel.DebugVField.GetData(v2); + if (!(v2[nearest] < vBeforeSecondHit - 1e-4f)) + { + Debug.LogError($"[ShieldPanelCut3Verify] D20 not fixed: a second hit queued for frame 2 did not " + + $"inject (v {vBeforeSecondHit:E3} -> {v2[nearest]:E3}) -- growth likely regressed " + + "back below the gate between frame 1 and frame 2."); + return false; + } + + Debug.Log($"[ShieldPanelCut3Verify] D17/D20 OK: frame 1 |v|={Mathf.Abs(v[nearest]):E3}, " + + $"frame 2 (after a second queued hit) v={v2[nearest]:E3} -- the gate stayed open."); + return true; + } + finally { DestroyRig(rig); } + } + + static int NearestCell(ShieldPanel panel, Vector2 localPoint) + { + int best = 0; + float bestD2 = float.MaxValue; + for (int i = 0; i < panel.DebugCellCount; i++) + { + float d2 = (panel.DebugCellCenter(i) - localPoint).sqrMagnitude; + if (d2 < bestD2) { bestD2 = d2; best = i; } + } + return best; + } + + // ============================================================== + // Reuse: a second hit inside a living panel strikes it rather than spawning a new one, and + // the strike actually reaches the material (temper falls), not merely "panel count stayed + // the same" -- a silently dropped second event would pass a panel-count-only check too. + // ============================================================== + static bool CheckReuseStrikesExistingPanel() + { + var rig = BuildRig(maxLive: 4); + try + { + var p1 = new Vector3(0, 0, 20); + var p2 = new Vector3(0.3f, 0, 20); // projects close to p1 -- inside PanelRadius(2)*ReuseFraction(1) + var pFar = new Vector3(20, 0, 0); // projects to (5,0,0) -- ~7 units from (0,0,5), well past the reuse radius + + rig.interceptor.Absorb(Absorb(p1, new Vector3(0, 0, -1))); + if (rig.interceptor.LiveCount != 1) + { + Debug.LogError($"[ShieldPanelCut3Verify] reuse step 1: expected 1 live panel, got {rig.interceptor.LiveCount}."); + return false; + } + var panel = rig.interceptor.DebugPanelAt(0); + + // Step until the struck region is past the growth threshold and the first hit's + // temper erosion has had time to register (growDuration default 0.35s @ 1/60 ~= 21 + // frames; erosion is also gated to whichever frames catch the wave in tension -- + // KFracture's `if (u <= 0) return` skips compression frames entirely -- so give it + // several oscillation cycles, not just enough frames to grow). + for (int i = 0; i < 40; i++) StepPanel(panel); + + // The map's own wording: compare the panel's MINIMUM temper, not one hand-picked + // cell's -- a single cell can sit in a compression phase at the instant we sample + // even though the strike clearly reached the material (KFracture only erodes temper + // on a tension frame), so pinning one cell is a false-negative trap. The minimum + // across the whole panel is what the map's reuse test actually asks for. + float temperAfterFirst = MinTemper(panel); + + rig.interceptor.Absorb(Absorb(p2, new Vector3(0, 0, -1))); + if (rig.interceptor.LiveCount != 1) + { + Debug.LogError($"[ShieldPanelCut3Verify] reuse step 2: a nearby second hit spawned a new panel " + + $"(live count {rig.interceptor.LiveCount}) instead of reusing the existing one."); + return false; + } + if (rig.interceptor.DebugPanelAt(0) != panel) + { + Debug.LogError("[ShieldPanelCut3Verify] reuse step 2: live count stayed 1 but the panel identity changed."); + return false; + } + + for (int i = 0; i < 40; i++) StepPanel(panel); + float temperAfterSecond = MinTemper(panel); + + if (!(temperAfterSecond < temperAfterFirst - 1e-5f)) + { + Debug.LogError($"[ShieldPanelCut3Verify] reuse step 2: min temper did not fall from the second hit " + + $"({temperAfterFirst:F4} -> {temperAfterSecond:F4}) -- the strike reached the panel " + + "but not the material."); + return false; + } + + rig.interceptor.Absorb(Absorb(pFar, new Vector3(-1, 0, 0))); + if (rig.interceptor.LiveCount != 2) + { + Debug.LogError($"[ShieldPanelCut3Verify] reuse step 3: a far strike should spawn a second panel " + + $"(live count {rig.interceptor.LiveCount}, expected 2)."); + return false; + } + + Debug.Log($"[ShieldPanelCut3Verify] reuse OK: temper {temperAfterFirst:F4} -> {temperAfterSecond:F4} on reuse; " + + "a far strike opened a second panel."); + return true; + } + finally { DestroyRig(rig); } + } + + // ============================================================== + // Operator's play report (docs/shield-panel-cut.md), playing FieldShieldTest: "clicking + // outside the hex still makes it tremble and shatter". The rule this pins: a hit outside a + // panel's own radius must not reach that panel at all -- no tremble, no temper loss, no + // break -- whether it arrives as a direct Hit() call or is routed there by the interceptor's + // reuse search; the existing panel is left exactly as it was, and a genuinely-outside click + // opens its own panel instead. + // ============================================================== + static bool CheckHitOutsideRadiusDoesNotReachPanel() + { + var rig = BuildRig(maxLive: 4, panelRadius: 2f); + try + { + rig.interceptor.Absorb(Absorb(new Vector3(0, 0, 20), Vector3.back)); + if (rig.interceptor.LiveCount != 1) + { + Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: expected 1 live panel after the first strike, got {rig.interceptor.LiveCount}."); + return false; + } + var panel = rig.interceptor.DebugPanelAt(0); + for (int i = 0; i < 40; i++) StepPanel(panel); + + // §2.4/D10's own comment: breakBudgetFraction paces the fracture cascade over many + // frames on purpose, so min temper keeps drifting downward for a while even with zero + // NEW hits -- comparing temper before/after more stepping would be measuring that + // drift, not this rule. What must be verified deterministically instead: no PendingHit + // was ever enqueued for the rejected strike (Hit() returns before Enqueue), and the + // panel's own state buffer does not move at all across a call that does no stepping. + if (PendingCount(panel) != 0) + { + Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: pending queue not drained before the probe " + + $"({PendingCount(panel)} left) -- test setup is unreliable."); + return false; + } + float temperBefore = MinTemper(panel); + + // Mutation target: ShieldPanel.ContainsWorldPoint / Hit's early-out. A point at local + // (5,0,0) is 5 units from panel-local origin against panelRadius 2 -- well outside. + Vector3 farAsWorld = panel.transform.TransformPoint(new Vector3(5f, 0f, 0f)); + panel.Hit(farAsWorld, Vector3.back, 500f); + if (PendingCount(panel) != 0) + { + Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: a direct Hit() 5 units outside panelRadius " + + $"(2) still enqueued a PendingHit -- Hit() is not gating on ContainsWorldPoint."); + return false; + } + float temperAfterDirectFarHit = MinTemper(panel); + if (temperAfterDirectFarHit != temperBefore) + { + Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: a rejected Hit() still changed panel state " + + $"({temperBefore:F6} -> {temperAfterDirectFarHit:F6}) with no Update() in between."); + return false; + } + + // Mutation target: ShieldInterceptor.FindReusable. A clearly-outside click routed + // through Absorb() must open its own panel, not disturb the existing one -- again + // checked with no intervening StepPanel, so any change can only be this call's doing. + rig.interceptor.Absorb(Absorb(new Vector3(20, 0, 0), Vector3.left)); + if (rig.interceptor.LiveCount != 2) + { + Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: a clearly-outside click should open its own " + + $"panel (live count {rig.interceptor.LiveCount}, expected 2)."); + return false; + } + if (rig.interceptor.DebugPanelAt(0) != panel) + { + Debug.LogError("[ShieldPanelCut3Verify] out-of-radius: the original panel's identity changed from an out-of-radius strike."); + return false; + } + if (PendingCount(panel) != 0) + { + Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: a far Absorb() enqueued a PendingHit on the " + + "original panel instead of routing to a new one."); + return false; + } + float temperAfterFarAbsorb = MinTemper(panel); + if (temperAfterFarAbsorb != temperBefore) + { + Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: routing a far Absorb() changed the original " + + $"panel's state ({temperBefore:F6} -> {temperAfterFarAbsorb:F6}) instead of leaving it untouched."); + return false; + } + + Debug.Log($"[ShieldPanelCut3Verify] out-of-radius OK: a direct Hit() and a routed Absorb() outside " + + $"panelRadius both enqueued nothing on the existing panel (temper held at {temperBefore:F6}), " + + "and the far click opened its own panel."); + return true; + } + finally { DestroyRig(rig); } + } + + static float MinTemper(ShieldPanel panel) + { + var raw = new CellStateRaw[panel.DebugCellCount]; + panel.DebugStateBuffer.GetData(raw); + float min = float.MaxValue; + foreach (var s in raw) min = Mathf.Min(min, s.temper); + return min; + } + + // Mirrors ShieldField.CellState's layout for GraphicsBuffer.GetData -- kept local to the + // probe rather than reusing ShieldField.CellState directly so a future field reorder in the + // production struct (which IS this probe's business to catch) shows up as a stride/offset + // mismatch here rather than the probe silently reading through the same accidental typo. + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + struct CellStateRaw + { + public Vector3 vel; public float spin; public Vector3 pos; public float rot; + public float damage; public float breakTime; public float glow; public float growth; + public float temper; public float released; public float peakTension; public float pad2; + } + + // ============================================================== + // Pool exhaustion: MaxLivePanels+3 widely-separated strikes must never exceed the cap, and + // must not drop a strike -- the oldest live panel is recycled in place. + // ============================================================== + static bool CheckPoolExhaustion() + { + const int cap = 3; + var rig = BuildRig(maxLive: cap, panelRadius: 1f); + try + { + // Spread strikes around the equator, far enough apart (PanelRadius*3+) that none reuse. + for (int i = 0; i < cap; i++) + { + float a = i * 137.5f * Mathf.Deg2Rad; // irrational-ish spacing, no two ever coincide + var dir = new Vector3(Mathf.Cos(a), 0, Mathf.Sin(a)); + rig.interceptor.Absorb(Absorb(dir * 20f, -dir)); + } + if (rig.interceptor.LiveCount != cap) + { + Debug.LogError($"[ShieldPanelCut3Verify] exhaustion: expected {cap} live panels before exhaustion, got {rig.interceptor.LiveCount}."); + return false; + } + + long before = System.GC.GetAllocatedBytesForCurrentThread(); + for (int i = cap; i < cap + 3; i++) + { + float a = i * 137.5f * Mathf.Deg2Rad; + var dir = new Vector3(Mathf.Cos(a), 0, Mathf.Sin(a)); + rig.interceptor.Absorb(Absorb(dir * 20f, -dir)); + } + long after = System.GC.GetAllocatedBytesForCurrentThread(); + + if (rig.interceptor.LiveCount != cap) + { + Debug.LogError($"[ShieldPanelCut3Verify] exhaustion: live count is {rig.interceptor.LiveCount} " + + $"after exceeding MaxLivePanels ({cap}); the cap did not hold."); + return false; + } + + // Soft budget, not a hard zero: JIT/editor background allocation noise is real, but + // three GraphicsBuffer.SetData-only re-arms plus list bookkeeping should be a small, + // fixed number of bytes, not the kilobytes a Rebuild (or a per-hit array/list + // allocation regression) would cost. + long perStrike = (after - before) / 3; + if (perStrike > 4096) + { + Debug.LogError($"[ShieldPanelCut3Verify] exhaustion: ~{perStrike} B allocated per strike after " + + "the pool filled -- §3's 'no allocation at interception' looks violated."); + return false; + } + + Debug.Log($"[ShieldPanelCut3Verify] exhaustion OK: live count capped at {cap}, ~{perStrike} B/strike once full."); + return true; + } + finally { DestroyRig(rig); } + } + + // ============================================================== + static bool CheckNullGuardsDoNotThrow() + { + var go = new GameObject("Cut3Probe_NullGuard"); + try + { + var interceptor = go.AddComponent(); + try + { + interceptor.Absorb(Absorb(Vector3.forward * 10, Vector3.back)); + } + catch (System.Exception ex) + { + Debug.LogError($"[ShieldPanelCut3Verify] null-guard: Absorb with no Envelope/PanelPrototype threw {ex}."); + return false; + } + Debug.Log("[ShieldPanelCut3Verify] null-guard OK: Absorb with no Envelope/PanelPrototype did not throw."); + return true; + } + finally { UnityEngine.Object.DestroyImmediate(go); } + } + + // ============================================================== + // The pooling fix this cut depends on: a panel returned to the pool and reused must NOT + // rebuild its tiling (§2.4's whole 123-2686x argument for prebuilding). + // ============================================================== + static bool CheckPoolReuseDoesNotRebuild() + { + var rig = BuildRig(maxLive: 4); + try + { + rig.interceptor.Absorb(Absorb(new Vector3(0, 0, 20), Vector3.back)); + var panel = rig.interceptor.DebugPanelAt(0); + int rebuildsAfterSpawn = panel.DebugRebuildCount; + + // ReturnToPool() is called on the INSTANCE (it hands itself to its own + // _originalPrototype's pool); Instantiate() must be called on the ORIGINAL prototype + // -- Prototype.Instantiate's free list lives on whichever object it's called on, + // and only the original's is ever populated by ReturnToPool. + panel.GetComponent().ReturnToPool(); + var reused = rig.interceptor.PanelPrototype.Instantiate(); + + if (!ReferenceEquals(reused, panel)) + { + Debug.LogError("[ShieldPanelCut3Verify] pool-reuse: Instantiate() after ReturnToPool returned a " + + "different instance than the one just returned -- the free list isn't LIFO-of-one as expected."); + return false; + } + if (panel.DebugRebuildCount != rebuildsAfterSpawn) + { + Debug.LogError($"[ShieldPanelCut3Verify] pool-reuse: RebuildCount went {rebuildsAfterSpawn} -> " + + $"{panel.DebugRebuildCount} across a ReturnToPool/Instantiate cycle -- OnEnable is " + + "rebuilding on every reuse instead of only once."); + return false; + } + + // The check above is necessary but not sufficient: SetActive(false)/(true) is not + // guaranteed to fire OnDisable/OnEnable synchronously within this same call stack in + // every context this probe can run in (observed directly -- the very first spawn's + // OnEnable does not run synchronously inside Object.Instantiate here, which is why + // ResetSim() is self-healing rather than relying on it). So drive OnEnable/OnDisable + // themselves, directly, to pin the rule those methods actually encode regardless of + // when/whether Unity's own SetActive plumbing calls them in a given context. + int rebuildsBeforeDirect = panel.DebugRebuildCount; + PanelOnDisable.Invoke(panel, null); + PanelOnEnable.Invoke(panel, null); + if (panel.DebugRebuildCount != rebuildsBeforeDirect || panel.DebugCellCount == 0) + { + Debug.LogError($"[ShieldPanelCut3Verify] pool-reuse: a direct OnDisable()/OnEnable() cycle " + + $"changed RebuildCount ({rebuildsBeforeDirect} -> {panel.DebugRebuildCount}) or " + + $"cleared the tiling (cellCount={panel.DebugCellCount}) -- OnEnable is rebuilding " + + "(or OnDisable is releasing) on every reuse instead of only once."); + return false; + } + + Debug.Log("[ShieldPanelCut3Verify] pool-reuse OK: no Rebuild across a return/reuse cycle."); + return true; + } + finally { DestroyRig(rig); } + } + + // ============================================================== + // D21: Penrose's achievable cell counts are phi-quantised, but must land within a small + // factor of Hex's at the same radius/cellSize, not an order of magnitude denser. + // ============================================================== + static bool CheckPenroseDensity() + { + const float cellSize = 0.35f; + // The ruling (R5/D21) fixes the MECHANISM -- nearest achievable edge length, not + // nearest achievable Hex-equivalent cell count -- and each Penrose substitution changes + // density by phi^2 (~2.618x), so landing within that step of Hex's count at an arbitrary + // radius/cellSize is already the honest ceiling for "nearest edge length", not a tuning + // knob. Measured after the fix (see the Cut 3 report): 2.12x-4.70x across r in + // {1.5,3,5,10} at cellSize 0.35, against the pre-fix 24x/8x order-of-magnitude bug this + // rule replaces. 5x is comfortably past that measured range and still an order of + // magnitude tighter than what D21 opened with. + const float smallFactor = 5f; + float[] radii = { 1.5f, 3f, 5f, 10f }; + bool ok = true; + + for (int i = 0; i < radii.Length; i++) + { + float r = radii[i]; + var pSoup = Tilings.Generate(TilingKind.PenroseP3, r, cellSize, 1337); + var pMerged = TilingBuilder.MergeRhombs(pSoup); + var pData = TilingBuilder.Build(pMerged, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f), clipRadius: r, seed: 1337); + + var hSoup = Tilings.Generate(TilingKind.Hex, r, cellSize, 1337); + var hData = TilingBuilder.Build(hSoup, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f), clipRadius: r, seed: 1337); + + float ratio = (float)pData.CellCount / hData.CellCount; + Debug.Log($"[ShieldPanelCut3Verify] D21 radius={r} cellSize={cellSize} " + + $"PenroseP3(merged) cells={pData.CellCount} Hex cells={hData.CellCount} ratio={ratio:F2}"); + + if (ratio > smallFactor || ratio < 1f / smallFactor) + { + Debug.LogError($"[ShieldPanelCut3Verify] D21 not within a small factor at radius {r}: " + + $"Penrose/Hex ratio {ratio:F2} is outside [1/{smallFactor:F0}, {smallFactor:F0}]."); + ok = false; + } + } + + if (ok) Debug.Log("[ShieldPanelCut3Verify] D21 OK: Penrose stays within a small factor of Hex at every radius."); + return ok; + } + } +} diff --git a/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs.meta b/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs.meta new file mode 100644 index 000000000..d3bb24fa4 --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eb4633f8820c41cba7ed651b672b2696 diff --git a/Assets/Scripts/Editor/ShieldPanelCut5Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut5Verify.cs new file mode 100644 index 000000000..f3a3ee352 --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut5Verify.cs @@ -0,0 +1,550 @@ +using System.Linq; +using System.Reflection; +using ShieldField; +using UnityEditor; +using UnityEngine; + +namespace Aetheria.EditorTools +{ + /// + /// Batchmode probe for the shield-panel Cut 5 landing (docs/shield-panel-cut.md): R7 (the rim + /// is held, not free — the `openW` ghost term is gone), D9 (v was unclamped and pinned u in + /// compression above ~energy 60), D10 (KFracture ran once per frame but integrated with the + /// substep dt), and the tuning pass that makes the design's targets reachable. + /// + /// Pins BEHAVIOUR RULES, not today's numbers, per the brief: monotonic peak tension against + /// energy, a solid hit breaks / a weak hit does not, repeated weak hits erode temper into an + /// eventual break, breaks cluster near the impact rather than the rim (R7's whole point), and + /// a broken cell's neighbours are more likely to break than distant cells (the dicing cascade). + /// + /// Same reason as Cut 3's probe: this drives ShieldPanel's actual compute dispatches, so it + /// needs a real graphics device -- run WITHOUT -nographics, or every kernel lookup fails with + /// "Kernel 'KInit' not found", which reads like a broken shader and is not one. + /// + /// Run via: Unity -batchmode -projectPath <repo> -executeMethod + /// Aetheria.EditorTools.ShieldPanelCut5Verify.Run -quit -logFile <path> + /// + public static class ShieldPanelCut5Verify + { + const string Tag = "[ShieldPanelCut5Verify]"; + + static readonly MethodInfo PanelUpdate = + typeof(ShieldPanel).GetMethod("Update", BindingFlags.NonPublic | BindingFlags.Instance); + + public static void Run() + { + if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null) + { + Debug.LogError($"{Tag} FAIL: no graphics device in this session. Rerun batchmode " + + "without -nographics (see ShieldPanelCut3Verify's note)."); + EditorApplication.Exit(1); + return; + } + + bool ok = true; + ok &= CheckMonotonicTensionSweep(); + ok &= CheckSolidHitBreaksWeakDoesNot(); + ok &= CheckRepeatedWeakHitsErodeIntoBreak(); + ok &= CheckRepeatedWeakHitsBreakWhereOneDoesNot(); + ok &= CheckBreaksClusterNearImpactNotRim(); + ok &= CheckNeighboursMoreLikelyToBreak(); + + Debug.Log(ok ? $"{Tag} PASS" : $"{Tag} FAIL — see errors above"); + EditorApplication.Exit(ok ? 0 : 1); + } + + // ============================================================== + static (GameObject go, ShieldPanel panel) BuildPanel(float panelRadius = 3f, float cellSize = 0.4f) + { + var sim = AssetDatabase.LoadAssetAtPath( + "Assets/Shaders/Compute/ShieldField/ShieldSim.compute"); + var shader = AssetDatabase.LoadAssetAtPath( + "Assets/Shaders/Compute/ShieldField/ShieldPanel.shader"); + + var go = new GameObject("Cut5Probe_Panel"); + var panel = go.AddComponent(); + panel.tiling = TilingKind.Hex; + panel.panelRadius = panelRadius; + panel.cellSize = cellSize; + panel.sim = sim; + panel.panelShader = shader; + panel.Rebuild(); + return (go, panel); + } + + static void Step(ShieldPanel panel, int n = 1) + { + for (int i = 0; i < n; i++) PanelUpdate.Invoke(panel, null); + } + + static void Strike(ShieldPanel panel, Vector2 localPos, float energy) => + panel.Hit(panel.transform.position + new Vector3(localPos.x, localPos.y, 0), + Vector3.back, energy); + + // Mirrors ShieldField.CellState's layout, kept local exactly as Cut 3's probe does (a + // future field reorder in the production struct is this probe's business to catch). + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + struct CellStateRaw + { + public Vector3 vel; public float spin; public Vector3 pos; public float rot; + public float damage; public float breakTime; public float glow; public float growth; + public float temper; public float released; public float peakTension; public float pad2; + } + + static CellStateRaw[] ReadState(ShieldPanel panel) + { + var raw = new CellStateRaw[panel.DebugCellCount]; + panel.DebugStateBuffer.GetData(raw); + return raw; + } + + static Vector2[] Centers(ShieldPanel panel) + { + var c = new Vector2[panel.DebugCellCount]; + for (int i = 0; i < c.Length; i++) c[i] = panel.DebugCellCenter(i); + return c; + } + + // ============================================================== + // D9: peak tension must rise monotonically (within noise) with energy over the authored + // range, never collapse the way the unclamped-v defect measured past ~energy 60. + // ============================================================== + static bool CheckMonotonicTensionSweep() + { + float[] energies = { 5f, 10f, 20f, 30f, 45f, 60f, 80f, 100f, 140f, 180f, 240f }; + float[] peak = new float[energies.Length]; + + for (int i = 0; i < energies.Length; i++) + { + var (go, panel) = BuildPanel(); + try + { + // Isolate D9 (the wave's velocity clamp) from the fracture cascade: once cells + // start breaking, KFracture's neighbour feedback injects extra tension that has + // nothing to do with the wave integrator, which would confound this specific + // check. breakAt = 2 (above damage's [0,1] range) means nothing can ever cross + // it, so this measures pure elastic peak tension against energy. + panel.breakAt = 2f; + Strike(panel, Vector2.zero, energies[i]); + Step(panel, 150); + var st = ReadState(panel); + peak[i] = st.Max(s => s.peakTension); + int brokenDbg = st.Count(s => s.breakTime >= 0f); + if (brokenDbg > 0) + Debug.LogWarning($"{Tag} DIAG sweep E={energies[i]}: {brokenDbg} broken despite breakAt override!"); + } + finally { UnityEngine.Object.DestroyImmediate(go); } + } + + string trace = string.Join(", ", energies.Zip(peak, (e, p) => $"E={e:F0}->{p:F3}")); + Debug.Log($"{Tag} tension sweep: {trace}"); + + // This is a clamped nonlinear wave PDE sampled after a fixed settling window, not a + // strictly monotone function -- point-to-point wobble of a few tens of percent is + // expected numerical texture, not the defect. The defect this guards against has a + // specific, much larger shape: the map measured tension COLLAPSING above energy ~60, + // i.e. falling back toward the low-energy baseline once v pinned the field in + // compression. So the rule is a running-max floor: no sample may fall below half of + // the best result seen at any lower energy so far. That tolerates ordinary wobble + // (worst observed in tuning: ~55% of the running max) while still catching a real + // collapse (which drops toward the near-zero baseline, not to 50-99% of it). + bool ok = true; + float runningMax = 0f; + for (int i = 0; i < peak.Length; i++) + { + if (i > 0 && peak[i] < runningMax * 0.5f) + { + Debug.LogError($"{Tag} D9 not fixed: tension collapsed at energy {energies[i]:F0} " + + $"(peak {peak[i]:F3} vs running max {runningMax:F3})."); + ok = false; + } + runningMax = Mathf.Max(runningMax, peak[i]); + } + float overallMax = runningMax; + + // Negative signature from the map: no energy in range may leave the field completely + // flat (maxPeakTension == 0), which would mean nothing is reaching the material at all. + if (overallMax <= 1e-4f) + { + Debug.LogError($"{Tag} D9 negative check failed: no energy in the sweep produced any " + + "measurable peak tension (the saturation-to-zero signature)."); + ok = false; + } + + if (ok) Debug.Log($"{Tag} D9 OK: tension never falls below half its running max across the " + + $"sweep (peak {overallMax:F3}) -- no collapse."); + return ok; + } + + // ============================================================== + static bool CheckSolidHitBreaksWeakDoesNot() + { + bool ok = true; + + var (weakGo, weakPanel) = BuildPanel(); + try + { + Strike(weakPanel, Vector2.zero, 8f); + Step(weakPanel, 150); + int broken = ReadState(weakPanel).Count(s => s.breakTime >= 0f); + if (broken != 0) + { + Debug.LogError($"{Tag} weak hit broke {broken} cells; expected 0."); + ok = false; + } + else Debug.Log($"{Tag} weak hit OK: 0 cells broken."); + } + finally { UnityEngine.Object.DestroyImmediate(weakGo); } + + var (strongGo, strongPanel) = BuildPanel(); + try + { + int total = strongPanel.DebugCellCount; + Strike(strongPanel, Vector2.zero, 300f); + Step(strongPanel, 200); + int broken = ReadState(strongPanel).Count(s => s.breakTime >= 0f); + float frac = (float)broken / total; + if (broken < 2) + { + Debug.LogError($"{Tag} solid hit broke {broken} of {total} cells; expected a local " + + "cluster (>=2), not a single cell or nothing."); + ok = false; + } + else if (frac > 0.6f) + { + Debug.LogError($"{Tag} solid hit broke {broken}/{total} ({frac:P0}) — that's the whole panel, " + + "not a local shatter; tuning has overshot."); + ok = false; + } + else Debug.Log($"{Tag} solid hit OK: broke {broken}/{total} ({frac:P0})."); + } + finally { UnityEngine.Object.DestroyImmediate(strongGo); } + + return ok; + } + + // ============================================================== + // Temper erosion: repeated sub-critical hits must measurably wear the temper down toward + // the material's critical margin ("this is the multi-hit budget" -- ShieldSim.compute). + // + // NOTE ON SCOPE: the map's own text frames "a later hit breaks through where the first + // didn't" as an OPERATOR check (Cut 5's own Verification list marks it "operator:", not + // "test:"), and this probe tried to also pin it as an automated rule. It could not: a + // closing hit landed AFTER several prior weak hits on the SAME live panel instance + // measured a materially lower peak tension than the identical energy landed on a FRESH + // panel (e.g. energy 20 broke a fresh panel's control only one increment away at energy + // 21, yet produced no measurable rise over the weak hits' own peak when landed after three + // prior weak hits on one panel) -- a real, reproducible interaction between repeated + // strikes on one live instance that this cut did not chase to ground. Flagged for the + // operator's own repeated-hit play check and for a follow-up investigation; not silently + // asserted as passing. What IS pinned here, reliably: erosion itself measurably progresses. + // ============================================================== + // The operator-facing payoff Cut 5's own report could not pin (see the scope note on + // CheckRepeatedWeakHitsErodeIntoBreak above): N weak hits on one LIVE panel eventually + // break a cell where one weak hit -- even given the same total time to sit and do nothing + // -- does not. energy 15 is calibrated (not energy 8, which erodes temper to 0 but whose + // peak tension of ~0.14 never reaches tensileStrength=0.2, so no amount of repetition would + // ever cross the break threshold -- a genuine dead end at that energy, not a step-budget + // problem): at 15, single-hit peak tension (~0.26) sits below temperInit (0.55) so a cold + // panel absorbs it elastically, but once erosion (Cut 3's reuse mechanism) drops temper far + // enough, that same ~0.26 peak clears net >= tensileStrength and breaks. + static bool CheckRepeatedWeakHitsBreakWhereOneDoesNot() + { + const float weakEnergy = 15f; + const int settleSteps = 120; + const int maxHits = 6; // generous headroom over the 2 hits actually needed at this tuning + + // Control first: one hit, then sit idle for the SAME total step budget the multi-hit + // run below could consume (maxHits * settleSteps) -- rules out "it just needed more + // time to ring out", which would be a timing artifact, not the reuse/erosion payoff. + var (controlGo, controlPanel) = BuildPanel(); + int controlBroken; + float controlPeak; + try + { + Strike(controlPanel, Vector2.zero, weakEnergy); + Step(controlPanel, settleSteps * maxHits); + var raw = ReadState(controlPanel); + controlBroken = raw.Count(s => s.breakTime >= 0f); + controlPeak = raw.Max(s => s.peakTension); + } + finally { UnityEngine.Object.DestroyImmediate(controlGo); } + + if (controlBroken > 0) + { + Debug.LogError($"{Tag} multi-hit control: a single energy-{weakEnergy:F0} hit broke " + + $"{controlBroken} cells even after {settleSteps * maxHits} idle steps -- " + + "not weak enough to be a control."); + return false; + } + + // Repeated hits on ONE live panel, same energy, same strike point. + var (go, panel) = BuildPanel(); + try + { + int hitsToBreak = -1; + int broken = 0; + float lastPeak = 0f; + for (int hit = 1; hit <= maxHits; hit++) + { + Strike(panel, Vector2.zero, weakEnergy); + Step(panel, settleSteps); + var raw = ReadState(panel); + broken = raw.Count(s => s.breakTime >= 0f); + lastPeak = raw.Max(s => s.peakTension); + if (broken > 0) { hitsToBreak = hit; break; } + } + + Debug.Log($"{Tag} multi-hit reuse: control (1 hit + {settleSteps * maxHits} idle steps) " + + $"broke 0 cells (peak tension {controlPeak:F3}); on a live reused panel the " + + $"same energy-{weakEnergy:F0} hit broke through on hit {hitsToBreak} " + + $"(peak tension {lastPeak:F3}, {broken} cell(s) broken)."); + + if (hitsToBreak < 0) + { + Debug.LogError($"{Tag} multi-hit reuse not evident: {maxHits} repeated energy-" + + $"{weakEnergy:F0} hits on one live panel never broke a cell (control " + + "also did not break, so this is not merely 'nothing breaks at this " + + "energy' -- the reuse/erosion payoff did not materialise)."); + return false; + } + if (hitsToBreak < 2) + { + Debug.LogError($"{Tag} multi-hit reuse not evident: the live panel broke on hit 1, " + + "same as a cold panel would -- this doesn't demonstrate reuse eroding " + + "toward a break, it demonstrates the energy alone already breaks fresh."); + return false; + } + + Debug.Log($"{Tag} multi-hit reuse OK: hit 1 alone (and a cold panel given equal idle time) " + + $"do not break; hit {hitsToBreak} on the same live, reused panel does -- the Cut " + + "3 reuse rule and the temper mechanism together produce the operator payoff."); + return true; + } + finally { UnityEngine.Object.DestroyImmediate(go); } + } + + // ============================================================== + static bool CheckRepeatedWeakHitsErodeIntoBreak() + { + const int erosionHits = 6; + const float weakEnergy = 8f; // confirmed elsewhere: never breaks a fresh panel alone + + var (go, panel) = BuildPanel(); + try + { + float temperInit = panel.temperInit; + float temperAfterOne = float.NaN; + + for (int hit = 1; hit <= erosionHits; hit++) + { + Strike(panel, Vector2.zero, weakEnergy); + Step(panel, 120); // let each weak hit's wave settle before the next + if (hit == 1) temperAfterOne = ReadState(panel).Min(s => s.temper); + } + + var raw = ReadState(panel); + int broken = raw.Count(s => s.breakTime >= 0f); + float temperFinal = raw.Min(s => s.temper); + + Debug.Log($"{Tag} temper erosion: min temper {temperInit:F3} -> {temperAfterOne:F3} " + + $"(after 1 hit) -> {temperFinal:F3} (after {erosionHits} hits); {broken} cells " + + "broken along the way (not required by this rule -- see the scope note above)."); + + if (broken > 0) + { + Debug.LogError($"{Tag} temper erosion: {broken} cells broke from weak hits alone -- " + + "energy 8 is not actually sub-critical at the current tuning."); + return false; + } + // The erosion rule itself: repeated sub-critical hits must cut temper by more than + // half of its starting budget -- a real, substantial erosion, not numerical noise. + if (temperFinal > temperInit * 0.5f) + { + Debug.LogError($"{Tag} temper erosion not evident: {erosionHits} weak hits only " + + $"eroded temper from {temperInit:F3} to {temperFinal:F3}."); + return false; + } + // And it must be MONOTONE progress, not a one-shot drop that then does nothing -- + // hit 1 alone must not already have done everything hit 6 achieves. + if (temperFinal >= temperAfterOne - 1e-4f) + { + Debug.LogError($"{Tag} temper erosion not evident: temper after 1 hit ({temperAfterOne:F3}) " + + $"is not measurably above temper after {erosionHits} hits ({temperFinal:F3}) -- " + + "erosion isn't accumulating across repeated hits."); + return false; + } + + Debug.Log($"{Tag} temper erosion OK: {erosionHits} weak hits eroded temper " + + $"{temperInit:F3} -> {temperFinal:F3} without breaking anything."); + return true; + } + finally { UnityEngine.Object.DestroyImmediate(go); } + } + + // ============================================================== + // R7: strike an interior point that is neither the panel centre nor the rim, and confirm + // breaks cluster tightly around THAT point rather than smearing toward the boundary. Under + // the old free-rim term, a strong hit's compressive wave reflects off the whole boundary as + // tension, which (given enough travel time) preferentially loads the rim rather than the + // impact -- exactly the "shatters only near the rim" symptom the operator reported. + // ============================================================== + static bool CheckBreaksClusterNearImpactNotRim() + { + // Same panelRadius/cellSize as the solid-hit check, which is the configuration this + // cut's tuning was actually calibrated against (a bigger panel at the same cellSize + // pushes the growth wavefront's spawnDelay timing out for cells far from centre and + // starves the cascade of frames to work with inside this probe's fixed step budget). + const float panelRadius = 3f; + var impact = new Vector2(0.9f, 0.2f); // interior, well off both centre and rim (|impact| ~= 0.92 vs rim 3) + + var (go, panel) = BuildPanel(panelRadius: panelRadius, cellSize: 0.4f); + try + { + Strike(panel, impact, 300f); + Step(panel, 200); + + var state = ReadState(panel); + var centers = Centers(panel); + int total = state.Length; + + var brokenDist = new System.Collections.Generic.List(); + for (int i = 0; i < total; i++) + if (state[i].breakTime >= 0f) brokenDist.Add(Vector2.Distance(centers[i], impact)); + + if (brokenDist.Count == 0) + { + Debug.LogError($"{Tag} R7 cluster check: the strong off-centre hit broke nothing; " + + "cannot evaluate clustering."); + return false; + } + + float meanAll = 0f; + for (int i = 0; i < total; i++) meanAll += Vector2.Distance(centers[i], impact); + meanAll /= total; + + float meanBroken = brokenDist.Average(); + float maxBroken = brokenDist.Max(); + + Debug.Log($"{Tag} R7 cluster: {brokenDist.Count}/{total} broken, meanDistToImpact(broken)=" + + $"{meanBroken:F3}, meanDistToImpact(all)={meanAll:F3}, maxDistToImpact(broken)=" + + $"{maxBroken:F3}, panelRadius={panelRadius}."); + + bool ok = true; + if (meanBroken > meanAll * 0.35f) + { + Debug.LogError($"{Tag} R7 not held: broken cells' mean distance to impact ({meanBroken:F3}) " + + $"is not tightly clustered against the panel-wide mean ({meanAll:F3}) — " + + "breaks are spreading rather than localising at the hit."); + ok = false; + } + // Distance from impact to the nearest rim point is panelRadius - |impact|; breaks + // reaching anywhere near that distance means the rim, not the impact, is spalling. + float distToRim = panelRadius - impact.magnitude; + if (maxBroken > distToRim * 0.6f) + { + Debug.LogError($"{Tag} R7 not held: a broken cell sits {maxBroken:F3} from the impact, " + + $"{maxBroken / distToRim:P0} of the way to the rim ({distToRim:F3} away) — " + + "the rim is still acting as a stress source."); + ok = false; + } + + if (ok) Debug.Log($"{Tag} R7 OK: breaks cluster at the impact, not the rim."); + return ok; + } + finally { UnityEngine.Object.DestroyImmediate(go); } + } + + // ============================================================== + // Dicing cascade: a broken cell dumps its released energy into its dual-graph neighbours + // (KFracture's feedback loop), so a neighbour of a broken cell should be measurably more + // likely to also be broken than a cell far from any break. Approximate "dual-graph + // neighbour" geometrically (nearest cell centres) since the probe has no access to the + // private edge list -- for a hex tiling that is the same adjacency the sim actually uses. + // ============================================================== + static bool CheckNeighboursMoreLikelyToBreak() + { + var (go, panel) = BuildPanel(panelRadius: 3f, cellSize: 0.4f); + try + { + Strike(panel, Vector2.zero, 150f); + Step(panel, 200); + + var state = ReadState(panel); + var centers = Centers(panel); + int n = state.Length; + bool[] broken = new bool[n]; + for (int i = 0; i < n; i++) broken[i] = state[i].breakTime >= 0f; + + int brokenCount = broken.Count(b => b); + if (brokenCount == 0 || brokenCount == n) + { + Debug.LogError($"{Tag} cascade check: {brokenCount}/{n} broken — need a partial " + + "break to say anything about neighbours vs. distant cells."); + return false; + } + + // Self-calibrate "adjacent" from the tiling's OWN geometry instead of guessing a + // multiple of cellSize (which does not equal true hex centroid spacing): take the + // median nearest-OTHER-cell distance across the whole panel as the typical + // dual-graph edge length, then classify relative to that. + float[] nearestOther = new float[n]; + for (int i = 0; i < n; i++) + { + float best = float.MaxValue; + for (int j = 0; j < n; j++) + { + if (j == i) continue; + best = Mathf.Min(best, Vector2.Distance(centers[i], centers[j])); + } + nearestOther[i] = best; + } + float typicalSpacing = nearestOther.OrderBy(d => d).ElementAt(n / 2); + float nbrRadius = typicalSpacing * 1.2f; + float farRadius = typicalSpacing * 1.7f; + Debug.Log($"{Tag} cascade calibration: typical spacing={typicalSpacing:F3}, " + + $"nbrRadius={nbrRadius:F3}, farRadius={farRadius:F3}, broken={brokenCount}/{n}."); + + // For every cell, find its distance to the nearest OTHER broken cell, then bucket + // it as "near a break" or "distant from every break" and see which bucket breaks + // more often. A cell counts toward its own bucket whether or not it is itself + // broken -- that's the point: does proximity to a break predict being broken. + int nbrTotal = 0, nbrBroken = 0, farTotal = 0, farBroken = 0; + for (int i = 0; i < n; i++) + { + float nearestOtherBrokenDist = float.MaxValue; + for (int j = 0; j < n; j++) + { + if (j == i || !broken[j]) continue; + nearestOtherBrokenDist = Mathf.Min(nearestOtherBrokenDist, Vector2.Distance(centers[i], centers[j])); + } + if (nearestOtherBrokenDist <= nbrRadius) { nbrTotal++; if (broken[i]) nbrBroken++; } + else if (nearestOtherBrokenDist >= farRadius) { farTotal++; if (broken[i]) farBroken++; } + } + + if (nbrTotal == 0 || farTotal == 0) + { + Debug.LogError($"{Tag} cascade check: not enough cells in one bucket to compare " + + $"(neighbours={nbrTotal}, distant={farTotal})."); + return false; + } + + float nbrRate = (float)nbrBroken / nbrTotal; + float farRate = (float)farBroken / farTotal; + Debug.Log($"{Tag} cascade: neighbour-of-a-break rate {nbrBroken}/{nbrTotal}={nbrRate:P1}, " + + $"distant rate {farBroken}/{farTotal}={farRate:P1}."); + + if (nbrRate <= farRate) + { + Debug.LogError($"{Tag} cascade not evident: neighbours of a break ({nbrRate:P1}) are not " + + $"more likely to be broken than distant cells ({farRate:P1})."); + return false; + } + + Debug.Log($"{Tag} cascade OK: neighbours break at {nbrRate:P1} vs {farRate:P1} for distant cells."); + return true; + } + finally { UnityEngine.Object.DestroyImmediate(go); } + } + } +} diff --git a/Assets/Scripts/Editor/ShieldPanelCut5Verify.cs.meta b/Assets/Scripts/Editor/ShieldPanelCut5Verify.cs.meta new file mode 100644 index 000000000..86f19bce9 --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut5Verify.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 767a47fc9125f044b92b5ffdc2390fb4 \ No newline at end of file diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs new file mode 100644 index 000000000..988fe15e7 --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +// New ship models own their presentation anchors. The catalog still owns the schematic and gameplay rules. +public static class ShipPrefabAuthoring +{ + public const string ModelDirectory = "Assets/Models/Ships/"; + public const string PrefabDirectory = "Assets/Content/Prefabs/Ships/"; + + private const string ShieldPath = "Assets/Content/Prefabs/Shield.prefab"; + private const string TractorPath = "Assets/Content/Prefabs/Tractor Beam.prefab"; + private const string PingPath = "Assets/Content/Prefabs/PingEffect.prefab"; + private const string ExplosionPath = "Assets/Prefabs/Fire & Explosion Effects/Prefabs/BigExplosion.prefab"; + private const string InvisiblePath = "Assets/Materials/Invisible.mat"; + private const string MapMaterialPath = "Assets/Materials/UI/Map Icons.mat"; + + [MenuItem("Aetheria/Build Ship Prefab From Selected Model")] + private static void BuildSelected() + { + var path = AssetDatabase.GetAssetPath(Selection.activeObject); + try { Debug.Log($"Ship prefab built: {Build(path)}"); } + catch (Exception ex) { Debug.LogError($"Ship prefab build failed for {path}: {ex.Message}"); } + } + + [MenuItem("Aetheria/Build Ship Prefab From Selected Model", true)] + private static bool CanBuildSelected() => IsShipModel(AssetDatabase.GetAssetPath(Selection.activeObject)); + + public static bool IsShipModel(string path) => + !string.IsNullOrEmpty(path) && path.StartsWith(ModelDirectory, StringComparison.Ordinal) && + path.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase); + + public static string Build(string modelPath) + { + if (!IsShipModel(modelPath)) throw new InvalidOperationException($"Expected an FBX in {ModelDirectory}"); + var model = AssetDatabase.LoadAssetAtPath(modelPath); + if (model == null) throw new InvalidOperationException($"Could not load model {modelPath}"); + + var shipName = System.IO.Path.GetFileNameWithoutExtension(modelPath); + var outputPath = PrefabDirectory + shipName + ".prefab"; + var instance = (GameObject)PrefabUtility.InstantiatePrefab(model); + if (instance == null) throw new InvalidOperationException($"Could not instantiate model {modelPath}"); + try + { + instance.name = shipName; + instance.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + Configure(instance); + PrefabUtility.SaveAsPrefabAsset(instance, outputPath, out var saved); + if (!saved) throw new InvalidOperationException($"Unity did not save {outputPath}"); + return outputPath; + } + finally { Object.DestroyImmediate(instance); } + } + + // Takes an imported model instance. Every ship-specific reference comes from its named hierarchy. + public static void Configure(GameObject root) + { + var nodes = root.GetComponentsInChildren(true); + var named = new Dictionary(StringComparer.Ordinal); + foreach (var node in nodes) + { + if (!Reserved(node.name)) continue; + if (!named.TryAdd(node.name, node)) + throw new InvalidOperationException($"Duplicate reserved name {node.name}"); + } + + Transform Required(string name) => named.TryGetValue(name, out var found) + ? found : throw new InvalidOperationException($"Missing {name}"); + + var mapIcon = Required("SHIP.MapIcon").GetComponent(); + if (mapIcon == null) throw new InvalidOperationException("SHIP.MapIcon needs a mesh renderer"); + var hull = Required("SHIP.HullCollider"); + var hullFilter = hull.GetComponent(); + var hullMesh = hullFilter != null ? hullFilter.sharedMesh : null; + if (hullMesh == null) throw new InvalidOperationException("SHIP.HullCollider needs a mesh"); + var shieldMarker = Required("SHIP.Shield"); + var tractorMarker = Required("SHIP.TractorBeam"); + if (!PositiveScale(shieldMarker.localScale)) + throw new InvalidOperationException("SHIP.Shield scale must have three positive radii"); + + var shieldPrefab = LoadComponent(ShieldPath); + var tractorPrefab = LoadComponent(TractorPath); + var ping = LoadComponent(PingPath); + var explosion = LoadAsset(ExplosionPath); + var invisible = LoadAsset(InvisiblePath); + var mapMaterial = LoadAsset(MapMaterialPath); + var minimapLayer = LayerMask.NameToLayer("Minimap"); + var combatLayer = LayerMask.NameToLayer("Combat"); + if (minimapLayer < 0 || combatLayer < 0) throw new InvalidOperationException("Minimap or Combat layer is missing"); + + var equipment = new List(); + var thrusters = new List(); + var weapons = new List(); + var radiators = new List(); + var pivots = new List(); + foreach (var pair in named.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + var name = pair.Key; + var node = pair.Value; + if (name.StartsWith("HP.", StringComparison.Ordinal)) + { + var parts = name.Split('.'); + if (parts.Length != 3 || string.IsNullOrWhiteSpace(parts[2])) + throw new InvalidOperationException($"Invalid hardpoint name {name}"); + equipment.Add(node); + switch (parts[1]) + { + case "Equipment": break; + case "Thruster": + var emitter = DirectChild(node, "Emitter")?.GetComponent(); + if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child"); + AddOrGet(node.gameObject).Emitter = emitter; + thrusters.Add(node.GetComponent()); + break; + case "Weapon": + var muzzles = node.Cast() + .Where(x => x.name.StartsWith("Muzzle.", StringComparison.Ordinal)) + .Select(x => (node: x, number: MuzzleNumber(x.name))) + .OrderBy(x => x.number).ToArray(); + if (muzzles.Length == 0 || muzzles.Select(x => x.number).Distinct().Count() != muzzles.Length) + throw new InvalidOperationException($"{name} needs uniquely numbered Muzzle. children"); + AddOrGet(node.gameObject).FiringPoint = muzzles.Select(x => x.node).ToArray(); + weapons.Add(node.GetComponent()); + break; + case "Radiator": + var mesh = DirectChild(node, "RadiatorMesh")?.GetComponent(); + if (mesh == null) throw new InvalidOperationException($"{name} needs a RadiatorMesh child"); + AddOrGet(node.gameObject).Mesh = mesh; + radiators.Add(node.GetComponent()); + break; + default: throw new InvalidOperationException($"Unknown hardpoint role in {name}"); + } + } + else if (name.StartsWith("Pivot.", StringComparison.Ordinal)) + { + var values = name.Split('.'); + if (values.Length != 7) throw new InvalidOperationException($"Invalid pivot name {name}"); + var pivot = AddOrGet(node.gameObject); + pivot.Group = ParseInt(values[1], name); + pivot.YawMin = ParseInt(values[2], name); + pivot.YawMax = ParseInt(values[3], name); + pivot.PitchMin = ParseInt(values[4], name); + pivot.PitchMax = ParseInt(values[5], name); + pivot.Speed = ParseInt(values[6], name); + if (pivot.YawMin > pivot.YawMax || pivot.PitchMin > pivot.PitchMax || pivot.Speed <= 0) + throw new InvalidOperationException($"Invalid articulation range or speed in {name}"); + pivots.Add(pivot); + } + else if (name.StartsWith("SHIP.", StringComparison.Ordinal) && + name != "SHIP.MapIcon" && name != "SHIP.HullCollider" && + name != "SHIP.Shield" && name != "SHIP.TractorBeam") + throw new InvalidOperationException($"Unknown ship anchor {name}"); + } + if (equipment.Count == 0) throw new InvalidOperationException("Ship has no HP.* hardpoints"); + + mapIcon.sharedMaterial = mapMaterial; + mapIcon.gameObject.layer = minimapLayer; + hull.gameObject.layer = combatLayer; + var hullRenderer = hull.GetComponent(); + if (hullRenderer != null) hullRenderer.forceRenderingOff = true; + var collider = AddOrGet(hull.gameObject); + collider.sharedMesh = hullMesh; + collider.convex = true; + var hullSurface = AddOrGet(hull.gameObject); + + var shield = (ShieldManager)PrefabUtility.InstantiatePrefab(shieldPrefab, shieldMarker); + shield.transform.localPosition = Vector3.zero; + shield.transform.localRotation = Quaternion.identity; + if (shield.GetComponent() == null) shield.gameObject.AddComponent(); + var tractor = (TractorBeam)PrefabUtility.InstantiatePrefab(tractorPrefab, tractorMarker); + tractor.transform.localPosition = Vector3.zero; + tractor.transform.localRotation = Quaternion.identity; + + var ship = AddOrGet(root); + ship.MapIcon = mapIcon; + ship.InfluencePrefab = null; + ship.PingPrefab = ping; + ship.InvisibleMaterial = invisible; + ship.Shield = shield; + ship.HullColliders = new[] { hullSurface }; + ship.EquipmentHardpoints = equipment.ToArray(); + ship.ThrusterHardpoints = thrusters.ToArray(); + ship.WeaponHardpoints = weapons.ToArray(); + ship.RadiatorHardpoints = radiators.ToArray(); + ship.ArticulationPoints = pivots.ToArray(); + ship.DestroyEffect = explosion; + ship.TractorBeam = tractor; + } + + private static bool Reserved(string name) => name.StartsWith("HP.", StringComparison.Ordinal) || + name.StartsWith("Pivot.", StringComparison.Ordinal) || name.StartsWith("SHIP.", StringComparison.Ordinal); + + private static bool PositiveScale(Vector3 scale) => scale.x > 0 && scale.y > 0 && scale.z > 0; + + private static Transform DirectChild(Transform parent, string name) => + parent.Cast().FirstOrDefault(x => x.name == name); + + private static int MuzzleNumber(string name) + { + if (!int.TryParse(name.Substring("Muzzle.".Length), NumberStyles.None, CultureInfo.InvariantCulture, out var n)) + throw new InvalidOperationException($"Invalid muzzle name {name}"); + return n; + } + + private static int ParseInt(string part, string name) + { + if (!int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + throw new InvalidOperationException($"Invalid numeric field in {name}"); + return value; + } + + private static T AddOrGet(GameObject gameObject) where T : Component + { + var component = gameObject.GetComponent(); + return component != null ? component : gameObject.AddComponent(); + } + + private static T LoadAsset(string path) where T : Object + { + var asset = AssetDatabase.LoadAssetAtPath(path); + return asset != null ? asset : throw new InvalidOperationException($"Missing shared asset {path}"); + } + + private static T LoadComponent(string path) where T : Component + { + var prefab = LoadAsset(path); + var component = prefab.GetComponent(); + return component != null ? component : throw new InvalidOperationException($"{path} needs {typeof(T).Name}"); + } +} + +// Model import finishes before generated prefabs read its hierarchy. Invalid exports leave the last valid prefab. +public sealed class ShipModelImport : AssetPostprocessor +{ + private static void OnPostprocessAllAssets(string[] imported, string[] deleted, string[] moved, string[] movedFrom) + { + var models = imported.Concat(moved).Where(ShipPrefabAuthoring.IsShipModel).Distinct().ToArray(); + if (models.Length == 0) return; + EditorApplication.delayCall += () => + { + foreach (var model in models) + { + try { Debug.Log($"Ship prefab built: {ShipPrefabAuthoring.Build(model)}"); } + catch (Exception ex) { Debug.LogError($"Ship prefab build failed for {model}: {ex.Message}"); } + } + }; + } +} diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta new file mode 100644 index 000000000..8256ec660 --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59713c487d73ab3418750d958e24d777 \ No newline at end of file diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs new file mode 100644 index 000000000..adda9ff7d --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs @@ -0,0 +1,80 @@ +using System; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +// Focused Editor witness for the naming contract. Run with -executeMethod ShipPrefabAuthoringSmoke.Run. +public static class ShipPrefabAuthoringSmoke +{ + public static void Run() + { + GameObject root = null; + try + { + root = new GameObject("Fixture"); + Primitive(root, "SHIP.MapIcon"); + Primitive(root, "SHIP.HullCollider"); + Child(root, "SHIP.Shield").localScale = new Vector3(2, 3, 4); + Child(root, "SHIP.TractorBeam"); + var thruster = Child(root, "HP.Thruster.PortAft"); + Primitive(thruster.gameObject, "Emitter"); + var weapon = Child(root, "HP.Weapon.PortGun"); + Child(weapon.gameObject, "Muzzle.1"); + Child(weapon.gameObject, "Muzzle.0"); + var radiator = Child(root, "HP.Radiator.Port"); + Primitive(radiator.gameObject, "RadiatorMesh"); + Child(root, "HP.Equipment.Reactor"); + Child(root, "Pivot.2.-90.90.-80.80.60"); + + ShipPrefabAuthoring.Configure(root); + var ship = root.GetComponent(); + Require(ship != null && ship.EquipmentHardpoints.Length == 4, "equipment references"); + Require(ship.ThrusterHardpoints.Length == 1 && ship.ThrusterHardpoints[0].Emitter != null, "thruster emitter"); + Require(ship.WeaponHardpoints.Length == 1 && ship.WeaponHardpoints[0].FiringPoint.Length == 2, "weapon muzzles"); + Require(ship.WeaponHardpoints[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order"); + Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh"); + Require(ship.ArticulationPoints.Length == 1 && ship.ArticulationPoints[0].Group == 2, "articulation"); + Require(ship.HullColliders.Length == 1 && ship.HullColliders[0].GetComponent().convex, "hull collision"); + Require(ship.Shield != null && ship.Shield.GetComponent() != null, "shield envelope"); + Require(ship.TractorBeam != null && ship.PingPrefab != null && ship.DestroyEffect != null, "shared assets"); + Require(ship.MapIcon != null && ship.MapIcon.gameObject.layer == LayerMask.NameToLayer("Minimap"), "map icon"); + + var invalid = Child(root, "HP.Weapon.Broken"); + var refused = false; + try { ShipPrefabAuthoring.Configure(root); } + catch (InvalidOperationException ex) { refused = ex.Message.Contains("Muzzle"); } + Require(refused, "invalid hierarchy refusal"); + Object.DestroyImmediate(invalid.gameObject); + + Debug.Log("ShipPrefabAuthoringSmoke: named hierarchy and refusal passed"); + if (Application.isBatchMode) EditorApplication.Exit(0); + } + catch (Exception ex) + { + Debug.LogException(ex); + if (Application.isBatchMode) EditorApplication.Exit(1); + else throw; + } + finally { if (root != null) Object.DestroyImmediate(root); } + } + + private static Transform Child(GameObject parent, string name) + { + var child = new GameObject(name).transform; + child.SetParent(parent.transform, false); + return child; + } + + private static GameObject Primitive(GameObject parent, string name) + { + var mesh = GameObject.CreatePrimitive(PrimitiveType.Cube); + mesh.name = name; + mesh.transform.SetParent(parent.transform, false); + return mesh; + } + + private static void Require(bool condition, string claim) + { + if (!condition) throw new InvalidOperationException($"ShipPrefabAuthoringSmoke failed: {claim}"); + } +} diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta new file mode 100644 index 000000000..751fd4e8e --- /dev/null +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67d84765dc5ea3243b0ab09bc7f1015e \ No newline at end of file diff --git a/Assets/Scripts/EngineAssets.cs b/Assets/Scripts/EngineAssets.cs new file mode 100644 index 000000000..e72020146 --- /dev/null +++ b/Assets/Scripts/EngineAssets.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using GameCult.Caching; +using UnityEngine; +using UnityEngine.AddressableAssets; +using Object = UnityEngine.Object; + +// Turns a CultCache-stored Addressables key (bare GUID, or "guid[subAssetName]" for a sub-asset; +// see CultAssetGuidKey and docs/addressables-cut.md Ruling S) into a loaded asset. Replaces +// UnityHelpers.LoadAsset, which stripped an "Assets/Resources/" path prefix; there is no path to +// strip any more; the catalog names the asset by its stable .meta GUID and Addressables resolves +// it, in editor play mode and in a built player alike. Owns one Addressables handle per (key, +// type) for the process lifetime; nothing here releases a handle, which is the lifetime +// Resources.Load had. All call sites are synchronous construction paths that Instantiate at once, +// so loading stays synchronous (WaitForCompletion) rather than reordering entity construction. +public static class EngineAssets +{ + private static readonly Dictionary<(string Key, Type Type), Object> Cache = new Dictionary<(string, Type), Object>(); + + public static T Load(string key) where T : Object + { + if (string.IsNullOrEmpty(key)) return null; + + var cacheKey = (key, typeof(T)); + if (Cache.TryGetValue(cacheKey, out var cached)) return cached as T; + + T asset; + if (typeof(Component).IsAssignableFrom(typeof(T))) + { + // Addressables cannot load a component directly: load the GameObject and take it from there. + var handle = Addressables.LoadAssetAsync(key); + var gameObject = handle.WaitForCompletion(); + asset = gameObject == null ? null : gameObject.GetComponent(); + } + else + { + var handle = Addressables.LoadAssetAsync(key); + asset = handle.WaitForCompletion(); + } + + if (asset == null) + { + Debug.LogError($"EngineAssets: failed to load {typeof(T).Name} for Addressables key \"{key}\""); + return null; + } + + Cache[cacheKey] = asset; + return asset; + } +} diff --git a/Assets/Scripts/EngineAssets.cs.meta b/Assets/Scripts/EngineAssets.cs.meta new file mode 100644 index 000000000..932a2ac48 --- /dev/null +++ b/Assets/Scripts/EngineAssets.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f3cca818d26268459d28af1cdb469a5 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/ActionBarSlot.cs b/Assets/Scripts/Gameplay/ActionBarSlot.cs index 215c57f8b..42c2e3f1e 100644 --- a/Assets/Scripts/Gameplay/ActionBarSlot.cs +++ b/Assets/Scripts/Gameplay/ActionBarSlot.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using MessagePack; using Newtonsoft.Json; using TMPro; @@ -111,7 +110,7 @@ public class ActionBarConsumableBinding : ActionBarBinding { Slot.Label.gameObject.SetActive(false); Slot.Icon.gameObject.SetActive(true); - Slot.Icon.texture = Resources.Load(data.Icon.Substring("Assets/Resources/".Length).Split('.').First()); + Slot.Icon.texture = EngineAssets.Load(data.Icon); } else Slot.Icon.gameObject.SetActive(false); } @@ -150,7 +149,7 @@ public class ActionBarGearBinding : ActionBarBinding Slot.Icon.gameObject.SetActive(true); Slot.Label.gameObject.SetActive(false); if (!string.IsNullOrEmpty(Item.Data.ActionBarIcon)) - Slot.Icon.texture = Resources.Load(Item.Data.ActionBarIcon.Substring("Assets/Resources/".Length).Split('.').First()); + Slot.Icon.texture = EngineAssets.Load(Item.Data.ActionBarIcon); else if (data is WeaponItemData weaponItemData) Slot.Icon.texture = ActionGameManager.Instance.Settings.GetIcon(weaponItemData.WeaponType).texture; else Slot.Icon.texture = ActionGameManager.Instance.Settings.GetIcon(data.HardpointType).texture; diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 3573dfba3..c62d980e4 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -148,6 +148,7 @@ public class ActionGameManager : MonoBehaviour public float HitMarkerDuration; public SchematicDisplay SchematicDisplay; public SchematicDisplay TargetSchematicDisplay; + public TextMeshProUGUI DebugInfoText; [Header("Target Indicator")] public PlaceUIElementWorldspace TargetIndicator; @@ -183,6 +184,7 @@ public class ActionGameManager : MonoBehaviour private Dictionary _visibleFriendlyIndicators = new Dictionary(); private List _shipSubscriptions = new List(); private List _targetSubscriptions = new List(); + private readonly Dictionary _debugLastShots = new Dictionary(); private float _severeHeatstrokePhase; private bool _uiHidden; private bool _menuShown; @@ -225,11 +227,6 @@ public class ActionGameManager : MonoBehaviour private List _stories = new List(); - public EntitySettings NewEntitySettings - { - get => MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(Settings.GameplaySettings.DefaultEntitySettings)); - } - private void OnApplicationQuit() { SaveRun(); @@ -405,7 +402,24 @@ public class ActionGameManager : MonoBehaviour var currentTargetIndex = Array.IndexOf(targets, CurrentEntity.Target.Value); CurrentEntity.Target.Value = targets[(currentTargetIndex + targets.Length - 1) % targets.Length]; }; - + + // 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. + Input.Player.CycleTargetItem.performed += context => + { + var target = CurrentEntity.Target.Value; + if (target == null) return; + var revealed = target.Equipment.Where(x => FireControl.IsRevealed(CurrentEntity, x)).ToArray(); + if (revealed.Length == 0) + { + CurrentEntity.TrySelectTargetItem(null); + return; + } + var currentItemIndex = Array.IndexOf(revealed, CurrentEntity.ResolvedTargetItem); + CurrentEntity.TrySelectTargetItem(revealed[(currentItemIndex + 1) % revealed.Length]); + }; + #endregion @@ -504,10 +518,29 @@ public class ActionGameManager : MonoBehaviour var itemName = string.Join(" ", args); var item = ItemManager.ItemData.GetAll() .FirstOrDefault(itemData => string.Equals(itemData.Name, itemName, StringComparison.InvariantCultureIgnoreCase)); - if (item != null) + if (item == null) { - _currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f))); + ConsoleController.Instance.AppendLogLine($"No equippable item named \"{itemName}\""); + return; } + if (item is HullData hull) + { + if (hull.HullType != HullType.Ship) + { + ConsoleController.Instance.AppendLogLine($"Refused: {item.Name} is a {hull.HullType} hull, not a ship"); + return; + } + if (DockedEntity == null) + { + ConsoleController.Instance.AppendLogLine($"Refused: dock to take delivery of a {item.Name}"); + return; + } + CommissionShip(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f)) as EquippableItem); + ConsoleController.Instance.AppendLogLine($"{item.Name} moored at {DockedEntity.Name}"); + return; + } + if (!_currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f)))) + ConsoleController.Instance.AppendLogLine($"Refused: no cargo space for {item.Name}"); }); ConsoleController.AddCommand("trackmissile", @@ -518,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; @@ -860,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(); @@ -966,6 +1008,8 @@ public class ActionGameManager : MonoBehaviour foreach(var subscription in _shipSubscriptions) subscription.Dispose(); _shipSubscriptions.Clear(); + _debugLastShots.Clear(); + if (DebugInfoText != null) DebugInfoText.text = ""; } private void BindToEntity(Entity entity) @@ -994,6 +1038,10 @@ public class ActionGameManager : MonoBehaviour GameplayUI.gameObject.SetActive(true); ShipPanel.Display(CurrentEntity, true); SchematicDisplay.ShowShip(CurrentEntity); + + _shipSubscriptions.Add(CurrentEntity.Zone.ShotResolved + .Where(outcome => outcome.Source == CurrentEntity) + .Subscribe(outcome => _debugLastShots[outcome.Weapon] = outcome)); FollowCamera.LookAt = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint; FollowCamera.Follow = ZoneRenderer.EntityInstances[CurrentEntity].transform; @@ -1241,6 +1289,7 @@ public class ActionGameManager : MonoBehaviour } var target = CurrentEntity.Target.Value; + UpdateFireControlDebug(target); if (target != null) { var threshold = Settings.GameplaySettings.TargetDetectionInfoThreshold; @@ -1258,6 +1307,78 @@ 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; + + EquippedItem selectedItem = null; + Weapon selectedWeapon = null; + foreach (var item in CurrentEntity.Equipment) + { + var weapon = item.Behaviors.OfType().FirstOrDefault(x => !(x is LockWeapon)); + if (weapon == null) continue; + selectedItem = item; + selectedWeapon = weapon; + break; + } + + if (selectedWeapon == null) + { + DebugInfoText.text = "FIRE CONTROL\nNo non-lock weapon"; + return; + } + + var d = FireControl.Inspect(selectedWeapon, CurrentEntity, target); + var pendingLine = "pending: none"; + for (var i = CurrentEntity.Zone.PendingShots.Count - 1; i >= 0; i--) + { + var shot = CurrentEntity.Zone.PendingShots[i]; + if (shot.Source != CurrentEntity || shot.Weapon != selectedItem) continue; + if (shot.Committed) + pendingLine = $"shot {shot.ShotId}: committed {ResultLabel(shot.Outcome.Result)}"; + else + { + var pDeviation = FireControl.DeviationProbability(shot, CurrentEntity.Zone.Time, out var deviation); + // Cut 12.2 (docs/fire-control-cut.md): retires the inline estimate (shot.PBase * pDeviation), + // a second, wrong copy of the model now that the target's facing enters at Commit -- this + // calls the same function Commit itself rolls against (TheHudEstimateIsTheCommitPrice). + var estimate = FireControl.CommitProbability(shot, CurrentEntity.Zone.Time, out _); + pendingLine = $"shot {shot.ShotId}: dev {deviation:F1}/{shot.Tracking:F1} x{pDeviation:F3} estimate {estimate:P1}"; + } + break; + } + + var lastLine = _debugLastShots.TryGetValue(selectedItem, out var lastShot) + ? $"last {lastShot.ShotId}: {ResultLabel(lastShot.Result)} cell {lastShot.Cell.x},{lastShot.Cell.y}" + : "last: none"; + var gates = target == null + ? "target: none" + : $"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" + + $"{gates}\n" + + $"range {d.Range:F0} [{d.MinRange:F0}..{d.MaxRange:F0}]\n" + + $"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" + + $"{outcomeLine}\n" + + $"{pendingLine}\n" + + lastLine; + } + private void LateUpdate() { UpdateTargetIndicators(); diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 189438cf6..f3d977c8c 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -192,16 +192,22 @@ public class EntityInstance : MonoBehaviour var data = (InstantWeaponData) instantWeapon.Data; if (!_instantWeaponManagers.ContainsKey(data)) { - var managerPrefab = UnityHelpers.LoadAsset(data.EffectPrefab); + var managerPrefab = EngineAssets.Load(data.EffectPrefab); if(managerPrefab) { _instantWeaponManagers.Add(data, Instantiate(managerPrefab, EffectManagerParent)); } - else Debug.LogError($"No InstantWeaponEffectManager prefab found at path {data.EffectPrefab}"); + else Debug.LogError($"No InstantWeaponEffectManager prefab found for GUID {data.EffectPrefab}"); } - instantWeapon.OnFire += () => - _instantWeaponManagers[data].Fire(instantWeapon, item, this, entity.Target.Value != null && ZoneRenderer.EntityInstances.ContainsKey(entity.Target.Value) ? ZoneRenderer.EntityInstances[entity.Target.Value] : null); + // 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, ShotTarget(shotId), shotId); if (behavior is ChargedWeapon chargedWeapon) { @@ -221,12 +227,12 @@ public class EntityInstance : MonoBehaviour var data = (ConstantWeaponData) constantWeapon.Data; if (!_constantWeaponManagers.ContainsKey(data)) { - var managerPrefab = UnityHelpers.LoadAsset(data.EffectPrefab); + var managerPrefab = EngineAssets.Load(data.EffectPrefab); if(managerPrefab) { _constantWeaponManagers.Add(data, Instantiate(managerPrefab, EffectManagerParent)); } - else Debug.LogError($"No ConstantWeaponEffectManager prefab found at path {data.EffectPrefab}"); + else Debug.LogError($"No ConstantWeaponEffectManager prefab found for GUID {data.EffectPrefab}"); } constantWeapon.OnStartFiring += () => @@ -274,126 +280,11 @@ public class EntityInstance : MonoBehaviour } } - void DamageSchematic(float damage, Shape hitShape) - { - foreach (var v in hitShape.Coordinates) - hitShape[v] = hitShape[v] && hullData.Shape[v]; - - float hullDamage = 0; - var damagePerCell = damage / hitShape.Coordinates.Length; - foreach (var v in hitShape.Coordinates) - { - var d = damagePerCell; - - // Subtract surface damage from armor, passing on the remainder to the item and then to the hull - var prev = entity.Armor[v.x, v.y]; - entity.Armor[v.x, v.y] = max(prev - d, 0); - entity.ArmorDamage.OnNext((v, d)); - d = max(d - prev, 0); - - if (d > 0.1f) - { - var item = entity.GearOccupancy[v.x, v.y]; - if (item != null) - { - prev = item.EquippableItem.Durability; - item.EquippableItem.Durability = max(prev - d, 0); - entity.ItemDamage.OnNext((item, d)); - d = max(d - prev, 0); - } - } - - hullDamage += d; - } - - if(hullDamage > .1f) - { - entity.Hull.Durability -= hullDamage; - entity.HullDamage.OnNext(hullDamage); - } - } - - foreach (var collider in HullColliders) - { - collider.Splash.Subscribe(splash => - { - var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); - foreach (var v in hullData.Shape.Coordinates) - { - var localHitDirection = transform.InverseTransformDirection(splash.Direction.ToUnity()); - var direction = normalize(float2(localHitDirection.x, localHitDirection.z)); - var cellDot = dot(normalize(v - hullData.Shape.CenterOfMass), direction); - if (cellDot < 0) hitShape[v] = true; - } - DamageSchematic(splash.Damage, hitShape); - }); - - collider.Hit.Subscribe(hit => - { - Entity.IncomingHit.OnNext(hit.Source); - var hardpointIndex = (int) hit.TexCoord.x - 1; - - var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); - - // U coordinate between 0-1 indicates a hit that didn't land directly on a hardpoint - // Find the 2D position of the hit scaled to the schematic - float2 hitPos = float2.zero; - if (hardpointIndex < 0 || hardpointIndex >= hullData.Hardpoints.Count) - { - hitPos = float2(hit.TexCoord.x * hullData.Shape.Width, hit.TexCoord.y * hullData.Shape.Height); - // Search all schematic border cells for the cell which is closest to the hit position - var hitCell = int2(-1); - var distance = float.MaxValue; - foreach (var v in hullData.Shape.Coordinates) - { - var cellDist = lengthsq(hitPos - v); - if (cellDist < distance) - { - distance = cellDist; - hitCell = v; - hitPos = v + float2(.5f); - } - } - - hitShape[hitCell] = true; - } - else - { - // Collider UV coordinates starting with 1 correspond to hardpoint index - var hardpoint = hullData.Hardpoints[hardpointIndex]; - - // Obtain the hull coordinates of all cells occupied by the hardpoint - var hardpointCells = hullData.Shape.Inset(hardpoint.Shape, hardpoint.Position); - hitPos = hardpointCells.CenterOfMass; - foreach (var v in hardpointCells.Coordinates) - hitShape[v] = true; - } - - for (int i = 0; i < Mathf.RoundToInt(hit.Spread); i++) - { - hitShape = hitShape.Expand(); - } - - if (hit.Penetration > .5f) - { - // Find the local 2D vector corresponding to the direction of the incoming hit - var localHitDirection = transform.InverseTransformDirection(hit.Direction.ToUnity()); - var penetrationVector = normalize(float2(localHitDirection.x, localHitDirection.z)); - // TODO: Bresenham's line algorithm - // March a ray through the ship from the hit position - var penetrationPoint = hitPos; - var penetrationDistance = 0f; - while (penetrationDistance < hit.Penetration && hullData.Shape[int2(penetrationPoint)]) - { - penetrationDistance += .5f; - hitShape[int2(penetrationPoint)] = true; - penetrationPoint += penetrationVector * .5f; - } - } - - DamageSchematic(hit.Damage, hitShape); - }); - } + // Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (blast, Hit) + // are deleted -- FireControl decides hits now and applies them through Entity.ArmorAbsorb/ItemAbsorb/ + // DamageHull (ServerShared, Cut 12.3/12.4(b); DamageSchematic, the old per-cell single-lane absorb path + // and ApplyHit are all gone), the single owner in place of what used to exist once per Unity effect. + // HullColliders no longer publish Hit or a blast event at all (R8). LookAtPoint = new GameObject($"{entity.Name} Look Point").transform; @@ -434,7 +325,10 @@ public class EntityInstance : MonoBehaviour var t = Instantiate(DestroyEffect).transform; t.position = transform.position; } - entity.Zone.Entities.Remove(entity); + // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7): Zone now removes the entity itself + // (subscribed to Entity.Death when the entity joined) -- this subscription's own business is + // loot and the destroy effect, presentation both, not removal from the simulation's own + // collection. } })); @@ -445,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); @@ -511,11 +409,6 @@ public class EntityInstance : MonoBehaviour x.Value.material.SetFloat("_Emission", Entity.ItemManager.GameplaySettings.TemperatureEmissionCurve.Evaluate(x.Key.RadiatorTemperature)); } - foreach (var x in Barrels) - { - Entity.HardpointTransforms[x.Key] = (x.Value[0].position.ToCultMath(), x.Value[0].forward.ToCultMath()); - } - LookAtPoint.position = transform.position + Entity.LookDirection.ToUnity() * (Entity.Target.Value != null ? max(Entity.TargetRange,Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance) : 10000); LocalSpace.localPosition = transform.position = Entity.Position.ToUnity(); diff --git a/Assets/Scripts/Gameplay/FieldDriver.cs b/Assets/Scripts/Gameplay/FieldDriver.cs index 3ca0927cb..dd6b36cc3 100644 --- a/Assets/Scripts/Gameplay/FieldDriver.cs +++ b/Assets/Scripts/Gameplay/FieldDriver.cs @@ -75,6 +75,8 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel private Material _field; private ComputeBuffer _hitBuffer; private List _hits = new List(); + private ShieldEnvelope _envelope; + private bool _loggedMissingEnvelope; private float _waveOffset; private float _meleeTime; private bool _meleeActive; @@ -115,14 +117,54 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel _collider = GetComponent(); _field = GetComponent().material; _hitBuffer = new ComputeBuffer(MaxHits, 32); + _envelope = GetComponent(); } + // Cut 2 of docs/shield-panel-cut.md: FieldDriver no longer owns the ellipsoid envelope -- + // ShieldEnvelope does, and this transform's localScale is display geometry for the field mesh + // only now. Absent an envelope (compatibility path only; every real carrier gets one in this + // cut), fall back to the pre-Cut-2 formulas and log once rather than silently keeping a second + // opinion about the envelope. + private void WarnMissingEnvelopeOnce() + { + if (_loggedMissingEnvelope) return; + _loggedMissingEnvelope = true; + Debug.LogWarning("FieldDriver has no ShieldEnvelope; falling back to its own transform for the envelope.", this); + } + + private Vector3 EnvelopeRadiiOrFallback() + { + if (_envelope != null) return _envelope.Radii; + WarnMissingEnvelopeOnce(); + return transform.localScale; + } + + // Cut 2 note on the two branches below NOT being the same computation shape: envelope. + // ProjectToSurface returns a WORLD-space point (it transforms back through rotation and + // translation -- Cut 3's panel presenter needs that). The fallback reproduces the pre-Cut-2 + // formula exactly instead, which stayed in LOCAL space with no rotation/translation applied, + // because that is what the shader consumes here (_Hits is uploaded and read in the field + // mesh's own local space, not world space). The two branches are only numerically identical + // when the carrier's transform has zero position and identity rotation -- true of the one + // FieldDriver carrier in the tree today (FieldShieldTest.unity's cubesphere), NOT true in + // general. This is an existing characteristic of Cut 2 as scoped by the map (FieldDriver.cs:125 + // reads envelope.ProjectToSurface(position) directly, per the map's own Cut 2 text), not + // something introduced by this fallback -- flagging it so nobody assumes the two branches are + // interchangeable for a future non-origin, non-identity FieldDriver carrier. public void AddHit(float3 position, float3 direction, float magnitude) { if (_hits.Count >= MaxHits) return; + Vector3 surfacePosition; + if (_envelope != null) + surfacePosition = _envelope.ProjectToSurface(position.ToUnity()); + else + { + WarnMissingEnvelopeOnce(); + surfacePosition = ShieldEnvelope.LegacyLocalSurfacePoint(transform, position.ToUnity()); + } var hit = new FieldHit { - Position = normalize(transform.InverseTransformPoint(position.ToUnity()).ToCultMath()) * transform.localScale.ToCultMath(), + Position = surfacePosition.ToCultMath(), Direction = normalize((transform.rotation * direction.ToUnity()).ToCultMath()), Magnitude = magnitude, Time = 0 @@ -198,7 +240,8 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel _field.SetFloat("_TwistFront", FrontTwist); _field.SetFloat("_TwistRear", RearTwist); - _field.SetVector("_InverseScale", new Vector4(1/transform.localScale.x,1/transform.localScale.y,1/transform.localScale.z)); + var envelopeRadii = EnvelopeRadiiOrFallback(); + _field.SetVector("_InverseScale", new Vector4(1/envelopeRadii.x,1/envelopeRadii.y,1/envelopeRadii.z)); _field.SetMatrix("_ReflRotate", refractionRotation); for (int i = 0; i < _hits.Count; i++) @@ -294,9 +337,10 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel default: throw new ArgumentOutOfRangeException(); } - _field.SetVector("_TendrilBase", Vector3.Scale(transform.InverseTransformPoint(_tendrilBasePos).normalized, transform.localScale)); - _field.SetVector("_TendrilBend", Vector3.Scale(transform.InverseTransformPoint(_tendrilBendTarget), transform.localScale)); - _field.SetVector("_TendrilTarget", Vector3.Scale(transform.InverseTransformPoint(_tendrilTargetPos), transform.localScale)); + var tendrilRadii = EnvelopeRadiiOrFallback(); + _field.SetVector("_TendrilBase", Vector3.Scale(transform.InverseTransformPoint(_tendrilBasePos).normalized, tendrilRadii)); + _field.SetVector("_TendrilBend", Vector3.Scale(transform.InverseTransformPoint(_tendrilBendTarget), tendrilRadii)); + _field.SetVector("_TendrilTarget", Vector3.Scale(transform.InverseTransformPoint(_tendrilTargetPos), tendrilRadii)); } } } diff --git a/Assets/Scripts/Gameplay/GameSettings.cs b/Assets/Scripts/Gameplay/GameSettings.cs index 112c1bf07..2548f1a13 100644 --- a/Assets/Scripts/Gameplay/GameSettings.cs +++ b/Assets/Scripts/Gameplay/GameSettings.cs @@ -10,10 +10,6 @@ using UnityEngine; [CreateAssetMenu(fileName = "Sector Display Properties", menuName = "Aetheria/Sector Display Properties")] public class GameSettings : ScriptableObject { - public string DefaultOverworldSoundbank = "Overworld_Generic"; - public string DefaultCombatSoundbank = "Combat_Generic"; - public string DefaultBossSoundbank = "Boss_Metal"; - public string AmbienceSoundBank = "Ambience"; public string StartingHullName = "Longinus"; public float PickupLifetime = 30; public float LootDropProbability = .25f; @@ -39,7 +35,6 @@ public class GameSettings : ScriptableObject public PlanetSettings PlanetSettings; public ZoneGenerationSettings ZoneSettings; public GameplaySettings GameplaySettings; - public Color HullHitColor; public Color ArmorHitColor; public Color HardpointHitColor; public Color GearHitColor; diff --git a/Assets/Scripts/Gameplay/HullCollider.cs b/Assets/Scripts/Gameplay/HullCollider.cs index 0ff571d8d..e5dc4ad14 100644 --- a/Assets/Scripts/Gameplay/HullCollider.cs +++ b/Assets/Scripts/Gameplay/HullCollider.cs @@ -1,66 +1,18 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using UniRx; -using CultMath; -using CultMath.UnityBridge; -using static CultMath.math; using UnityEngine; -using float2 = CultMath.float2; +using CultMath.UnityBridge; +// Cut 4 (docs/fire-control-cut.md): Hit, Splash, SendHit and SendSplash are deleted -- their last callers +// (ConstantLaser, ConstantLightning, ConstantParticleWeapon, HitscanEffect, Mine) are gone too (see those +// files and FireControl.Detonate, 12.4(b)'s renamed Splash). Nothing decides or applies damage through a Unity +// collider any more (R8): +// this component is now purely a physics surface for ship-on-ship collision impulse and Mine's own proximity +// arming query (Q7), neither of which is hit detection. public class HullCollider : MonoBehaviour { - public Subject Hit = new Subject(); - public Subject Splash = new Subject(); - public Entity Entity { get; set; } - - public void SendHit(float damage, float penetration, float spread, DamageType damageType, Entity source, Vector2 texCoord, Vector3 direction) - { - Hit.OnNext(new HullHitEventArgs - { - Damage = damage, - Penetration = penetration, - Spread = spread, - DamageType = damageType, - Source = source, - TexCoord = texCoord.ToCultMath(), - Direction = direction.ToCultMath() - }); - } - - public void SendSplash(float damage, DamageType damageType, Entity source, Vector3 direction) - { - Splash.OnNext(new HullSplashEventArgs - { - Damage = damage, - DamageType = damageType, - Source = source, - Direction = direction.ToCultMath() - }); - } private void OnCollisionEnter(Collision other) { Entity.Velocity += other.impulse.ToCultMath().xz / Time.deltaTime; } - - public class HullHitEventArgs - { - public float Damage; - public float Penetration; - public float Spread; - public DamageType DamageType; - public Entity Source; - public float2 TexCoord; - public float3 Direction; - } - - public class HullSplashEventArgs - { - public float Damage; - public DamageType DamageType; - public Entity Source; - public float3 Direction; - } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Gameplay/ShieldEnvelope.cs b/Assets/Scripts/Gameplay/ShieldEnvelope.cs new file mode 100644 index 000000000..3057e9775 --- /dev/null +++ b/Assets/Scripts/Gameplay/ShieldEnvelope.cs @@ -0,0 +1,177 @@ +using UnityEngine; + +// Cut 2 of docs/shield-panel-cut.md (R4/R6): the one owner of "where is the shield surface, and +// which way does it face". Before this component existed, that answer was computed twice and +// independently -- FieldDriver.cs derived it from its own transform.localScale, and +// ShieldManager.cs derived it from the Shield prefab instance's transform -- with no shared +// authority and no way for the two to notice if they disagreed. This collapses both into one +// reader of a single transform. It stores nothing; every value below is derived fresh. +public class ShieldEnvelope : MonoBehaviour +{ + // R6: the envelope is derived from the existing transform, not authored as separate centre + + // radii fields, because the transform already drives the physical collider (Shield.prefab's + // MeshCollider / FieldShieldTest's cubesphere MeshCollider) -- authoring a second copy next to + // it would be exactly the kind of duplicate authority this cut exists to remove. + // + // The one number a bare transform can't supply is the conversion from "lossyScale" to + // "ellipsoid semi-axis length in world units", because that depends on how big the attached + // collider mesh already is in local space. The map's draft guessed a flat 0.5 (unit-diameter + // sphere); measuring the two meshes actually in the tree (batchmode probe, both meshes' + // MeshCollider.sharedMesh.bounds) found neither is 0.5: + // - Shield.prefab's Icosphere collider: bounds.extents = (0.0095106, 0.01, 0.01) -- not a + // perfect cube, because a base icosahedron's vertices are not all equidistant from its own + // axis-aligned bounding box on every axis even though the shape is "spherical". Shield.prefab's + // base localScale of 100 is there to cancel the round number (100 * 0.01 = radius 1 on y/z), + // which is why the ship overrides (e.g. Longinus ~395,235,990) read directly as ~100x the + // documented "effective radii" in docs/shield-panel-cut.md's table without this factor, and + // why that factor must be read per-axis from the mesh rather than assumed uniform. + // - The FieldShieldTest cubesphere's collider: bounds.extents = (1, 1, 1) exactly, so its + // lossyScale (4, 3, 12) already *is* the radii with no correction -- which is why + // FieldDriver's pre-Cut-2 code could get away with using transform.localScale directly. + // Reading the mesh instead of hardcoding either number is what makes one component correct for + // both authorities without either one special-casing the other. + Vector3 _localExtents = Vector3.one; + bool _readExtents; + + void Awake() => ReadLocalExtents(); + + void ReadLocalExtents() + { + if (_readExtents) return; + _readExtents = true; + + Mesh mesh = null; + var meshCollider = GetComponent(); + if (meshCollider != null) mesh = meshCollider.sharedMesh; + if (mesh == null) + { + var meshFilter = GetComponent(); + if (meshFilter != null) mesh = meshFilter.sharedMesh; + } + + // No mesh at all: nothing to correct for, so the transform's own scale is already the + // radii (extents of 1 is a no-op multiplier). This is the compatibility case, not the + // expected one -- every carrier in the tree today (Shield.prefab, the cubesphere) has one. + _localExtents = mesh != null ? mesh.bounds.extents : Vector3.one; + } + + /// World-space centre of the ellipsoid. + public Vector3 Center => transform.position; + + /// World-space semi-axis lengths of the ellipsoid, accounting for the attached + /// collider mesh's own local-space size (see the field comment above). + public Vector3 Radii + { + get + { + if (!_readExtents) ReadLocalExtents(); // covers edit-mode / pre-Awake callers + var s = transform.lossyScale; + return new Vector3(s.x * _localExtents.x, s.y * _localExtents.y, s.z * _localExtents.z); + } + } + + // Local-space unit direction from the envelope's centre toward worldPoint, accounting for the + // transform's rotation and (non-uniform) scale. This is the one primitive ProjectToSurface, + // SurfaceDirection, and both static fallback entry points below are built from, and the only + // place InverseTransformPoint may appear for shield-surface purposes (see the negative check + // in docs/shield-panel-cut.md Cut 2). Static and keyed on an explicit Transform so a caller + // with no ShieldEnvelope component can still go through this one owner instead of re-deriving + // the maths itself. + static Vector3 LocalUnitDirection(Transform t, Vector3 worldPoint) + { + var local = t.InverseTransformPoint(worldPoint); + return local.sqrMagnitude > 1e-12f ? local.normalized : Vector3.forward; + } + + Vector3 LocalUnitDirection(Vector3 worldPoint) => LocalUnitDirection(transform, worldPoint); + + /// + /// Projects a world-space point outward onto the ellipsoid surface, returning a world-space + /// point. This is the true surface projection: local direction, scale by the local-space + /// extents, transform back through the object's rotation and scale. + /// + public Vector3 ProjectToSurface(Vector3 worldPoint) + { + if (!_readExtents) ReadLocalExtents(); + var dir = LocalUnitDirection(worldPoint); + var localSurface = Vector3.Scale(dir, _localExtents); + return transform.TransformPoint(localSurface); + } + + /// + /// The true outward normal of the ellipsoid at a world-space point already on (or near) its + /// surface: normalize(p.x/a^2, p.y/b^2, p.z/c^2) in local space, then rotated back to world. + /// This is deliberately NOT normalize(p) -- normalize(p) is only the correct normal for a + /// sphere. On a non-uniform ellipsoid (Longinus is ~4:1) it is visibly wrong: it points along + /// the radius vector rather than perpendicular to the surface, which reads as the panel tilting + /// toward the ship's centre instead of lying flat against the hull (docs/shield-panel-cut.md + /// Cut 2 and the Cut 4 flank-click check). FieldDriver and ShieldManager both get away with + /// normalize(p) today only because they use it to pick a *direction*, not a *normal* -- see + /// SurfaceDirection below, which is what they keep using. + /// + public Vector3 SurfaceNormal(Vector3 worldSurfacePoint) + { + if (!_readExtents) ReadLocalExtents(); + var local = transform.InverseTransformPoint(worldSurfacePoint); + var a = Mathf.Max(_localExtents.x, 1e-6f); + var b = Mathf.Max(_localExtents.y, 1e-6f); + var c = Mathf.Max(_localExtents.z, 1e-6f); + var gradient = new Vector3(local.x / (a * a), local.y / (b * b), local.z / (c * c)); + // A normal is a covector: under a non-uniform scale it transforms by the INVERSE of the + // scale, then the rotation -- the opposite of how an ordinary vector (or TransformDirection, + // which multiplies by scale) transforms. Dividing by lossyScale here and rotating with the + // bare quaternion (no TransformDirection) is what makes this differ from normalize(p) on a + // non-uniform ellipsoid; using TransformDirection directly was tried and measurably wrong + // (see the batchmode probe in docs/shield-panel-cut.md Cut 2 -- it disagreed with a + // finite-difference gradient by 5-20%, not floating-point noise). + var s = transform.lossyScale; + var gradientOverScale = new Vector3( + s.x != 0 ? gradient.x / s.x : gradient.x, + s.y != 0 ? gradient.y / s.y : gradient.y, + s.z != 0 ? gradient.z / s.z : gradient.z); + var worldGradient = transform.rotation * gradientOverScale; + return worldGradient.sqrMagnitude > 1e-12f ? worldGradient.normalized : transform.forward; + } + + /// + /// The local-frame unit direction from the envelope's centre toward worldPoint -- exactly what + /// FieldDriver.AddHit and ShieldManager.ShowHit computed by hand before this cut, kept as its + /// own named method (rather than inlining InverseTransformPoint at each call site) so the + /// negative check ("no shield-surface projection outside ShieldEnvelope.cs") stays true. This + /// is a *direction*, not the true surface normal -- see the SurfaceNormal doc comment. + /// + public Vector3 SurfaceDirection(Vector3 worldPoint) => LocalUnitDirection(transform, worldPoint); + + // --- Fallback entry points for a carrier with no ShieldEnvelope component yet. --- + // + // Not every carrier gets the component in the same commit as this file (Q5: the operator rigs + // prefabs and scenes by hand, on their own schedule), so both FieldDriver and ShieldManager + // must keep working, unchanged, until their own object is rigged. "Unchanged" means byte-for- + // byte the pre-Cut-2 formula, not a plausible-looking replacement -- a silently different + // fallback is worse than either the old code or a hard failure. Rather than let each consumer + // re-derive that formula by hand (recreating the exact duplicate-authority problem this cut + // exists to remove), both fallback formulas are named, static, one-owner methods here, and + // ShieldPanelCut2Verify.CheckLegacyFallbackFormulas pins each one against an independent + // re-derivation of the original code. + + /// + /// Compatibility path for a Transform with no ShieldEnvelope component: the exact direction + /// ShieldManager.ShowHit computed by hand before this cut (normalize(InverseTransformPoint), + /// no scale or mesh-extents factor at all -- a direction never needed one). + /// + public static Vector3 SurfaceDirection(Transform transform, Vector3 worldPoint) + => LocalUnitDirection(transform, worldPoint); + + /// + /// Compatibility path for a Transform with no ShieldEnvelope component: the exact local-frame + /// value FieldDriver.AddHit computed by hand before this cut -- normalize(local) scaled by the + /// transform's own localScale, deliberately NOT transformed back through rotation/translation + /// (FieldDriver's shader consumes this in local mesh space, not world space; see the + /// ProjectToSurface-vs-this note in FieldDriver.cs). This is a legacy, local-space formula, + /// not a general "surface point in the world" API -- ProjectToSurface is that, and requires a + /// real ShieldEnvelope (it needs the mesh's local extents, which no mesh means no envelope to + /// read them from). + /// + public static Vector3 LegacyLocalSurfacePoint(Transform transform, Vector3 worldPoint) + => Vector3.Scale(LocalUnitDirection(transform, worldPoint), transform.localScale); +} diff --git a/Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta b/Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta new file mode 100644 index 000000000..d247e6050 --- /dev/null +++ b/Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e25c67ee83c01364fb121caeda952606 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/ShieldInterceptor.cs b/Assets/Scripts/Gameplay/ShieldInterceptor.cs new file mode 100644 index 000000000..29d9b4558 --- /dev/null +++ b/Assets/Scripts/Gameplay/ShieldInterceptor.cs @@ -0,0 +1,198 @@ +using System.Collections.Generic; +using UnityEngine; +using CultMath.UnityBridge; +using ShieldField; + +// Cut 3 of docs/shield-panel-cut.md (R2): the shield is an interceptor. It converts each absorbed +// hit into a hard-light panel posed on the ship's ShieldEnvelope -- it decides nothing about the +// rule (R3/R6: the hits it shows are cosmetic, fire control decides what actually happened). +// +// Adds no pool: Assets/Scripts/Prototype.cs already is one (Instantiate/ReturnToPool), and +// ShieldManager.ShowHit already uses it for exactly this short-lived per-hit shape. +public class ShieldInterceptor : MonoBehaviour, IAbsorbPresenter +{ + public ShieldEnvelope Envelope; + public Prototype PanelPrototype; // a prefab carrying one ShieldPanel (Cut 4 rigs it) + public int MaxLivePanels = 12; // §2.3: ~0.12 ms CPU issuance per live panel + public float PanelRadius = 2f; // the SHIELD ITEM's size, not the hull's (R2) + public float EnergyScale = 1f; + // Cut 3 fix: capped at 1, not 2 -- ShieldPanel.ContainsWorldPoint (the one owner of "is this + // point within my own radius", shared by Hit's own strike gate) clamps the scale to at most 1, + // so a value above 1 was already silently inert. The old [0,2] range let you dial in a number + // that did nothing past 1; this range now tells the truth about what the knob controls. + [Range(0f, 1f)] public float ReuseFraction = 1f; + [Tooltip("Margin added on top of growDuration + shardLife before a panel with no further " + + "hits is released back to the pool (§3: a fixed lifetime, not a GPU readback).")] + public float DeathMargin = 0.25f; + public FracturePattern[] PatternByDamageType; + + struct LivePanel + { + public ShieldPanel Panel; + public Prototype Proto; + public Vector3 Position; + public float SpawnedAt; + public float DieAt; + } + + readonly List _live = new(16); + + void Awake() + { + // Cut 4 rigs the scene; this is the one push Cut 3 owns so the prototype's tiling is built + // at the item's size (R2: size is the item's, not the hull's) rather than whatever radius + // happens to be authored on the prefab. It only lands correctly if the prototype's own + // ShieldPanel has not already run its first OnEnable/Rebuild -- see the Cut 4 recipe note + // on starting PanelPrototype's GameObject inactive in the scene. + if (PanelPrototype != null) + { + var proto = PanelPrototype.GetComponent(); + if (proto != null) proto.panelRadius = PanelRadius; + } + _live.Capacity = Mathf.Max(_live.Capacity, MaxLivePanels); + } + + void Update() + { + // Release timed-out panels. A GPU readback of "is anything still visible" would stall the + // pipeline every frame for every live panel (§3's cost note); a fixed lifetime, started at + // arm time, is the honest and cheap alternative -- same tradeoff the map makes for Death in + // ShieldPanel itself. + for (int i = _live.Count - 1; i >= 0; i--) + { + if (Time.time < _live[i].DieAt) continue; + _live[i].Proto.ReturnToPool(); + _live.RemoveAt(i); + } + } + + public int LiveCount => _live.Count; + + /// Cut 3 verification surface (Q7: no NUnit path for these files) -- lets the + /// batchmode probe step and read back a specific live panel's simulation without duplicating + /// the interceptor's own spawn/reuse/pose logic. + public ShieldPanel DebugPanelAt(int i) => _live[i].Panel; + + // IAbsorbPresenter. The event is enough (map §"The event is enough"): project the event's + // world-space position onto the envelope, pose a panel there with the envelope's true surface + // normal, strike whichever panel owns that point. + public void Absorb(AbsorbEvent e) + { + if (Envelope == null || PanelPrototype == null) return; + + Vector3 worldPos = e.Position.ToUnity(); + Vector3 intercept = Envelope.ProjectToSurface(worldPos); + Vector3 dir = e.Direction.ToUnity(); + float energy = e.Magnitude * EnergyScale; + var pattern = PatternFor(e.DamageType); + + int reuseIndex = FindReusable(intercept); + if (reuseIndex >= 0) + { + // Reuse: a second hit landing inside a living panel strikes THAT panel rather than + // spawning a new one -- this is the rule that makes temper erosion (ShieldSim.compute's + // multi-hit budget) visible at all. No ResetSim here: that would wipe the very state + // this rule exists to preserve. + var lp = _live[reuseIndex]; + lp.Panel.Hit(intercept, dir, energy, pattern); + // Cut 3 fix: DieAt was set once at spawn time and never refreshed on reuse, so a panel + // that keeps getting struck could still expire and get pooled mid-fracture -- the + // operator's "pooling seems very rough". A struck-again panel is exactly as alive as one + // freshly spawned right now; give it the same margin from THIS hit. + lp.DieAt = Time.time + lp.Panel.growDuration + lp.Panel.shardLife + DeathMargin; + _live[reuseIndex] = lp; + return; + } + + ShieldPanel panel; + Prototype panelProto; + if (_live.Count >= MaxLivePanels) + { + // Pool exhausted: recycle the oldest live panel in place rather than drop the strike + // (one event, one strike) or grow past the §2.3 frame budget. The oldest is the one + // furthest through its own fade. Reused directly (not routed through + // Prototype.ReturnToPool + Instantiate) because the interceptor already holds the + // exact instance to reassign; a round trip through the pool's free list would only add + // SetParent/SetActive churn for the same outcome. + int oldest = OldestIndex(); + panel = _live[oldest].Panel; + panelProto = _live[oldest].Proto; + _live.RemoveAt(oldest); + } + else + { + panelProto = PanelPrototype.Instantiate(); + panel = panelProto.GetComponent(); + } + + var normal = Envelope.SurfaceNormal(intercept); + var t = panel.transform; + t.position = intercept; + t.rotation = Quaternion.LookRotation(normal); + // Scale is never touched here: R2/Cut 3 require it stay 1, which is a property of the + // prototype's own authored transform and its parent chain (Cut 4's rig), not something + // this component enforces at pose time -- parenting under the envelope's non-uniform + // Shield-prefab scale would skew every shard (§ Cut 3 "Scale must be 1"). + + panel.ResetSim(); + panel.Hit(intercept, dir, energy, pattern); + + _live.Add(new LivePanel + { + Panel = panel, + Proto = panelProto, + Position = intercept, + SpawnedAt = Time.time, + DieAt = Time.time + panel.growDuration + panel.shardLife + DeathMargin + }); + } + + // Cut 3 fix: this used to compare a raw world-space chord distance from the panel's stale + // spawn-time Position against PanelRadius*ReuseFraction. On the envelope's own documented + // non-uniform ellipsoid (ShieldEnvelope.cs's Longinus/FieldShieldTest numbers), a chord across + // curvature is not the same length as the panel's own in-plane extent -- a click clearly outside + // the rendered tiling could still chord-measure inside the threshold and get routed to Hit(), + // which had no bounds check of its own and dutifully processed it (the operator's "clicking + // outside the hex still makes it tremble and shatter"). ShieldPanel.ContainsWorldPoint is the + // one owner of "is this point within my own radius" now (Hit() gates on it too), so reuse + // selection and the strike gate can never disagree. + int FindReusable(Vector3 point) + { + int best = -1; + float bestDist2 = float.MaxValue; + for (int i = 0; i < _live.Count; i++) + { + if (!_live[i].Panel.ContainsWorldPoint(point, ReuseFraction)) continue; + float d2 = (_live[i].Position - point).sqrMagnitude; + if (d2 < bestDist2) + { + bestDist2 = d2; + best = i; + } + } + return best; + } + + int OldestIndex() + { + int oldest = 0; + float oldestTime = float.MaxValue; + for (int i = 0; i < _live.Count; i++) + { + if (_live[i].SpawnedAt < oldestTime) + { + oldestTime = _live[i].SpawnedAt; + oldest = i; + } + } + return oldest; + } + + FracturePattern PatternFor(DamageType t) + { + int i = (int)t; + if (PatternByDamageType != null && i >= 0 && i < PatternByDamageType.Length) + return PatternByDamageType[i]; + return FracturePattern.Radial; + } +} diff --git a/Assets/Scripts/Gameplay/ShieldInterceptor.cs.meta b/Assets/Scripts/Gameplay/ShieldInterceptor.cs.meta new file mode 100644 index 000000000..661fc6598 --- /dev/null +++ b/Assets/Scripts/Gameplay/ShieldInterceptor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fdca6a832d764b6e89fc83fdcf7b42d7 diff --git a/Assets/Scripts/Gameplay/ShieldManager.cs b/Assets/Scripts/Gameplay/ShieldManager.cs index 853e17c01..cf12797d7 100644 --- a/Assets/Scripts/Gameplay/ShieldManager.cs +++ b/Assets/Scripts/Gameplay/ShieldManager.cs @@ -11,9 +11,24 @@ public class ShieldManager : MonoBehaviour { public Prototype ShieldPrototype; public float CollisionHitDuration = 3; - + public Entity Entity { get; set; } + private ShieldEnvelope _envelope; + private bool _loggedMissingEnvelope; + + void Awake() + { + _envelope = GetComponent(); + } + + private void WarnMissingEnvelopeOnce() + { + if (_loggedMissingEnvelope) return; + _loggedMissingEnvelope = true; + Debug.LogWarning("ShieldManager has no ShieldEnvelope; falling back to its own transform for the hit direction.", this); + } + private void OnCollisionEnter(Collision other) { var otherShield = other.collider.GetComponent(); @@ -73,7 +88,32 @@ public class ShieldManager : MonoBehaviour public void ShowHit(Vector3 point, float duration) { var shield = ShieldPrototype.Instantiate(); - shield.Direction = normalize(shield.transform.InverseTransformPoint(point).ToCultMath()).ToUnity(); + // Cut 2 of docs/shield-panel-cut.md: this used to compute + // normalize(shield.transform.InverseTransformPoint(point)) itself, duplicating + // FieldDriver's projection against a different transform (Authority B vs A in the map's + // §1). shield.transform sits at the same place as this ShieldManager's own transform (the + // Shield prefab instance's pooled visual is parented under it with an identity local + // offset), so this ShieldManager's ShieldEnvelope is the same envelope. Direction is a pick + // of direction, not the true surface normal -- ShieldAnimation.Direction keeps its existing + // meaning (R6's note that both existing consumers get away with normalize(p) because they + // want a direction, not a normal). + // + // Every "Shield" object gets a ShieldEnvelope in this cut, but the operator rigs prefabs by + // hand on their own schedule (Q5), so until Longinus.prefab/Djinni.prefab are actually + // edited, every ship in the game has none yet. A missing envelope must therefore behave + // exactly like the deleted code, not degrade -- a fallback that silently changes behaviour + // (this used to hard-fail to a fixed direction) is worse than either the old code or a + // loud failure. So this goes through ShieldEnvelope's own static fallback entry point + // (one owner, no second copy of the maths) rather than recomputing the projection here. + if (_envelope == null) + { + WarnMissingEnvelopeOnce(); + shield.Direction = ShieldEnvelope.SurfaceDirection(shield.transform, point); + } + else + { + shield.Direction = _envelope.SurfaceDirection(point); + } shield.Duration = duration; } } diff --git a/Assets/Scripts/Gameplay/ShipInstance.cs b/Assets/Scripts/Gameplay/ShipInstance.cs index 56ff14e3f..8a0b22b6e 100644 --- a/Assets/Scripts/Gameplay/ShipInstance.cs +++ b/Assets/Scripts/Gameplay/ShipInstance.cs @@ -50,7 +50,7 @@ public class ShipInstance : EntityInstance var drive = ship.GetBehavior(); if (drive != null) { - var particles = Instantiate(UnityHelpers.LoadAsset(drive.DriveData.Particles), transform, false); + var particles = Instantiate(EngineAssets.Load(drive.DriveData.Particles), transform, false); var main = particles.main; main.customSimulationSpace = LocalSpace; _aetherDrive = new AetherDriveInstance @@ -64,7 +64,7 @@ public class ShipInstance : EntityInstance _thrusters = ship.GetBehaviors().Select(thruster => { var effectData = (ThrusterData) thruster.Data; - var particles = Instantiate(UnityHelpers.LoadAsset(effectData.ParticlesPrefab), transform, false); + var particles = Instantiate(EngineAssets.Load(effectData.ParticlesPrefab), transform, false); var particlesShape = particles.shape; var thrusterHardpoint = ThrusterHardpoints .FirstOrDefault(t => t.name == ship.Hardpoints[thruster.Item.Position.x, thruster.Item.Position.y].Transform); @@ -123,16 +123,20 @@ public class ShipInstance : EntityInstance forceOverLifetime.xMultiplier = _aetherDrive.Drive.ThrustDirection.x * _aetherDrive.BaseForce; forceOverLifetime.zMultiplier = _aetherDrive.Drive.ThrustDirection.y * _aetherDrive.BaseForce; var emissionModule = _aetherDrive.Particles.emission; - emissionModule.rateOverTimeMultiplier = _aetherDrive.BaseEmission * thrust; + // Cut 8 (operator ask 2026-09-19): intent (thrust) times condition -- presentation reads the + // simulation's own ratio rather than recomputing any part of it. AetherDrive.Condition is the + // equivalent of Thruster.Condition below (Torque in place of Thrust). + emissionModule.rateOverTimeMultiplier = _aetherDrive.BaseEmission * thrust * _aetherDrive.Drive.Condition; } - + foreach (var thrusterInstance in _thrusters) { var emissionModule = thrusterInstance.System.emission; - var item = thrusterInstance.Thruster.Item.EquippableItem; - var data = Entity.ItemManager.GetData(item); thrusterInstance.MaxParticleCount = thrusterInstance.System.particleCount; - emissionModule.rateOverTimeMultiplier = thrusterInstance.BaseEmission * thrusterInstance.Thruster.Axis * (item.Durability / data.Durability); + // Cut 8 (operator ask 2026-09-19): intent (Axis) times condition (Thruster.Condition, the item's + // resolved Thrust against its own perfect-conditions Thrust) -- no durability/heat/power arithmetic + // recomputed here, only the same emission math that already existed. + emissionModule.rateOverTimeMultiplier = thrusterInstance.BaseEmission * thrusterInstance.Thruster.Axis * thrusterInstance.Thruster.Condition; } transform.rotation = Ship.Rotation.ToUnity(); diff --git a/Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs b/Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs index aded241fb..23fb7216e 100644 --- a/Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs +++ b/Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs @@ -1,8 +1,11 @@ using System.Collections; using System.Collections.Generic; using UnityEngine; -using static CultMath.math; +// Cut 4 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already +// rolls this weapon's damage once per GameplaySettings.BeamResolveInterval (ConstantWeapon.Execute) before +// this beam is ever drawn (R8). What is left is pure endpoint presentation, the same convention Laser.cs +// (Cut 3) uses for a discrete shot: draw to the target's current position, or out to Range with no target. public class ConstantLaser : MonoBehaviour { public AnimationCurve StartCurve; @@ -13,13 +16,10 @@ public class ConstantLaser : MonoBehaviour public float FadeDuration; public float CycleDuration; public LineRenderer LineRenderer; - - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } + public Entity SourceEntity { get; set; } public float Range { get; set; } + public Transform TargetTransform { get; set; } private float _intensity; private float _stopIntensity; @@ -70,36 +70,9 @@ public class ConstantLaser : MonoBehaviour } LineRenderer.SetPosition(0, transform.position); - bool hitFound = false; - foreach (var hit in Physics.RaycastAll(new Ray(transform.position, transform.forward), Range, 1 | (1 << 17))) - { - var shield = hit.collider.GetComponent(); - if (shield) - { - if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue; - if (shield.Entity != SourceEntity) - { - shield.Entity.Shield.TakeHit(DamageType, Damage); - shield.ShowHit(hit.point, sqrt(Damage)); - LineRenderer.SetPosition(1, hit.point); - hitFound = true; - break; - } - } - var hull = hit.collider.GetComponent(); - if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (hull.Entity != SourceEntity) - { - hull.SendHit(Damage * Time.deltaTime, Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, transform.forward); - LineRenderer.SetPosition(1, hit.point); - hitFound = true; - break; - } - } - } - if(!hitFound) - LineRenderer.SetPosition(1, transform.position + transform.forward * Range); + LineRenderer.SetPosition(1, TargetTransform != null + ? TargetTransform.position + : transform.position + transform.forward * Range); } public void Stop() diff --git a/Assets/Scripts/Gameplay/Weapons/ConstantLaserManager.cs b/Assets/Scripts/Gameplay/Weapons/ConstantLaserManager.cs index 351314e61..039e9344b 100644 --- a/Assets/Scripts/Gameplay/Weapons/ConstantLaserManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ConstantLaserManager.cs @@ -19,10 +19,9 @@ public class ConstantLaserManager : ConstantWeaponEffectManager t.SetParent(barrel); t.forward = barrel.forward; t.position = barrel.position; - p.Damage = item.Evaluate(data.Damage); - p.Penetration = item.Evaluate(data.Penetration); - p.Spread = item.Evaluate(data.DamageSpread); - p.DamageType = data.DamageType; + // Cut 4 (docs/fire-control-cut.md): FireControl already rolls this weapon's damage; the presentation + // only needs to know where to draw the beam. + p.TargetTransform = target != null ? target.transform : null; _lasers.Add(item, p); } diff --git a/Assets/Scripts/Gameplay/Weapons/ConstantLightning.cs b/Assets/Scripts/Gameplay/Weapons/ConstantLightning.cs index 3088c32b7..cbf07abd7 100644 --- a/Assets/Scripts/Gameplay/Weapons/ConstantLightning.cs +++ b/Assets/Scripts/Gameplay/Weapons/ConstantLightning.cs @@ -1,8 +1,12 @@ using System.Collections; using System.Collections.Generic; using UnityEngine; -using static CultMath.math; +// Cut 4 (docs/fire-control-cut.md): the SphereCast, shield branch and SendHit are deleted -- FireControl +// already rolls this weapon's damage once per GameplaySettings.BeamResolveInterval (ConstantWeapon.Execute) +// before this bolt is ever drawn (R8). What is left is pure endpoint presentation: fixed to the target's +// current position when there is one, out to Range otherwise -- the same convention Lightning.cs (Cut 3) +// uses for a discrete shot. public class ConstantLightning : MonoBehaviour { public LightningCompute Lightning; @@ -11,14 +15,11 @@ public class ConstantLightning : MonoBehaviour public float StartWidth = 1; public float EndWidth = 1; public float FadeDuration; - + public Transform Barrel { get; set; } - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } public EntityInstance Source { get; set; } public float Range { get; set; } + public Transform TargetTransform { get; set; } private bool _stopping; private float _startTime; @@ -50,31 +51,10 @@ public class ConstantLightning : MonoBehaviour Lightning.EndWidth = EndWidth; } - Lightning.FixedEndpoint = false; - var hits = Physics.SphereCastAll(Barrel.position, HitRadius, Barrel.forward, Range, 1 | (1 << 17)); - foreach (var hit in hits) - { - var shield = hit.collider.GetComponent(); - if (shield && (shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (shield.Entity == Source.Entity) continue; - shield.Entity.Shield.TakeHit(DamageType, Damage * Time.deltaTime); - shield.ShowHit(hit.point, sqrt(Damage)); - } - var hull = hit.collider.GetComponent(); - if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (hull.Entity == Source.Entity) continue; - hull.SendHit(Damage * Time.deltaTime, Penetration, Spread, DamageType, Source.Entity, hit.textureCoord, Barrel.forward); - } - - Lightning.FixedEndpoint = true; - Lightning.EndPosition = hit.point; - - break; - } - if(!Lightning.FixedEndpoint) - Lightning.EndPosition = Barrel.position + Barrel.forward * Range; + Lightning.FixedEndpoint = TargetTransform != null; + Lightning.EndPosition = TargetTransform != null + ? TargetTransform.position + : Barrel.position + Barrel.forward * Range; Lightning.StartPosition = Barrel.position; } diff --git a/Assets/Scripts/Gameplay/Weapons/ConstantLightningManager.cs b/Assets/Scripts/Gameplay/Weapons/ConstantLightningManager.cs index 77a1be884..111df4433 100644 --- a/Assets/Scripts/Gameplay/Weapons/ConstantLightningManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ConstantLightningManager.cs @@ -16,11 +16,10 @@ public class ConstantLightningManager : ConstantWeaponEffectManager var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); p.Barrel = barrel; - p.Damage = item.Evaluate(data.Damage); p.Range = item.Evaluate(data.Range); - p.Penetration = item.Evaluate(data.Penetration); - p.Spread = item.Evaluate(data.DamageSpread); - p.DamageType = data.DamageType; + // Cut 4 (docs/fire-control-cut.md): FireControl already rolls this weapon's damage; the presentation + // only needs to know where to draw the bolt. + p.TargetTransform = target != null ? target.transform : null; _bolts.Add(item, p); } diff --git a/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeapon.cs b/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeapon.cs index f09d10ed4..e1d7e11d5 100644 --- a/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeapon.cs +++ b/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeapon.cs @@ -4,19 +4,19 @@ using System.Collections.Generic; using System.Linq; using UnityEngine; +// Cut 4 (docs/fire-control-cut.md): the trigger-collider plumbing and SendSplash are deleted -- FireControl +// already rolls this weapon's damage once per GameplaySettings.BeamResolveInterval (ConstantWeapon.Execute) +// before these particles ever collide with anything (R8). What is left is pure visual emission; nothing here +// decides or applies damage any more. public class ConstantParticleWeapon : MonoBehaviour { public ParticleSystem[] Particles; - - public float Damage { get; set; } - public DamageType DamageType { get; set; } + public EntityInstance Source { get; set; } public EntityInstance Target { get; set; } private bool _stopping; private float _emission; - private List _collisionParticles = new List(); - private HullCollider _hull; private void Start() { @@ -38,18 +38,10 @@ public class ConstantParticleWeapon : MonoBehaviour main.simulationSpace = ParticleSystemSimulationSpace.Custom; main.customSimulationSpace = Source.LocalSpace; } - + var trigger = Particles[0].trigger; while(trigger.colliderCount > 0) trigger.RemoveCollider(0); - - if (Target == null) return; - - foreach (var collider in Target.HullColliders) - { - _hull = collider; - trigger.AddCollider(collider.GetComponent()); - } } private void Update() @@ -62,21 +54,6 @@ public class ConstantParticleWeapon : MonoBehaviour } } - private void OnParticleTrigger() - { - if (Target == null || !_hull) return; - _collisionParticles.Clear(); - Particles[0].GetTriggerParticles(ParticleSystemTriggerEventType.Enter, _collisionParticles); - if(_collisionParticles.Count > 0) - { - _hull.SendSplash( - Damage * Time.deltaTime, - DamageType, - Source.Entity, - (transform.position - Target.transform.position).normalized); - } - } - public void Stop() { foreach(var p in Particles) diff --git a/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeaponManager.cs b/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeaponManager.cs index 6326330e3..8b0095975 100644 --- a/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeaponManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ConstantParticleWeaponManager.cs @@ -19,8 +19,7 @@ public class ConstantParticleWeaponManager : ConstantWeaponEffectManager t.SetParent(barrel); t.forward = barrel.forward; t.position = barrel.position; - p.Damage = item.Evaluate(data.Damage); - p.DamageType = data.DamageType; + // Cut 4 (docs/fire-control-cut.md): FireControl already rolls this weapon's damage. _weapons.Add(item, p); p.Initialize(); } diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index 16005ca3d..26a6db650 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -1,6 +1,5 @@ using System; using System.Collections; -using System.Collections.Generic; using UnityEngine; using CultMath; using CultMath.UnityBridge; @@ -9,6 +8,10 @@ using Random = UnityEngine.Random; using static Noise1D; using float3 = CultMath.float3; +// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already +// decided this shot's fate before this object was ever spawned (R8). The homing/guidance flight (the whole +// point of this presentation) is untouched; children spawned on split carry no damage of their own to apply +// either, since they never did anything but inherit their parent's now-deleted fields. public class GuidedProjectile : MonoBehaviour { public Prototype HitEffect; @@ -27,29 +30,91 @@ public class GuidedProjectile : MonoBehaviour public float TopSpeed; public Transform Source; public Func TargetPosition; - + private float _phase; private float _prevDist; private bool _active; private bool _alive; + private float _spawnTime; private Vector3 _targetVelocity; private Vector3 _previousTargetPosition; - + + // Cut 3: FireControl.Fire's ShotId -- this missile's handle onto Zone.ShotCommitted/ShotResolved. + public int ShotId { get; set; } public Transform Target { get; set; } public float3 StartPosition { get; set; } public float Range { get; set; } public Vector3 Velocity { get; set; } - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } public Entity SourceEntity { get; set; } + // How long after spawn the round keeps its binding to the simulation's verdict: the shot's own flight time (the + // manager sets it from the committed shot's ArrivalTime), so a round that faded out early still lives to show + // the detonation the simulation resolves at arrival. 0 (the default) kills the round when its fade ends. + public float BindingLifetime { get; set; } + public event Action OnKill; + // The simulation's verdict on this round, bound by GuidedProjectileManager to Zone.ShotCommitted and + // ShotResolved for its ShotId: this object flies and looks, and neither decides where the round bursts nor when. + // The one owner of the subscription's lifetime: a new binding disposes the one it replaces, Kill disposes it, + // and so does destruction, so a projectile torn down with its scene never leaves an observer on the zone's + // subjects to touch a destroyed transform. + public IDisposable Binding + { + get => _binding; + set + { + _binding?.Dispose(); + _binding = value; + } + } + + private IDisposable _binding; + + void OnDestroy() => Binding = null; + + // The commit named a burst point: fly to it, whatever this round was homing on (a target it would have + // passed, or a Range clamp it would have overshot). The point is a static target, so the old target's motion + // is dropped with it: the jump to the new point must not feed the guidance a target velocity. + public void BurstAt(Vector3 point) + { + Target = null; + TargetPosition = () => point; + _previousTargetPosition = point; + _targetVelocity = Vector3.zero; + } + + // The resolution. A round that detonated (a hit, or a burst) bursts now, at the simulation's burst point when + // it has one; a round the simulation resolved without a detonation (a contact or delayed round that missed, + // one whose target is gone) just falls away, with no explosion to show. + public void Resolve(bool detonated, Vector3? point) + { + // A round that already faded out (overshot, out of flight) still shows the simulation's detonation: only a + // round that has been killed is done. The fade is stopped so it cannot Kill the round a second time. + if (!_alive) return; + if (!detonated) + { + if (_active) StartCoroutine(FadeOut()); + return; + } + StopAllCoroutines(); + // A hit carries no burst point: the effect plays where the round was homing (a round that faded out has stopped + // short of it), and is skipped when the round never knew where its target was. + Vector3? effectAt = point ?? (TargetPosition != null ? TargetPosition() : Target ? Target.position : (Vector3?) null); + if (point.HasValue) transform.position = point.Value; + if (HitEffect != null && effectAt.HasValue) + { + var ht = HitEffect.Instantiate(); + ht.position = effectAt.Value; + } + StartCoroutine(Kill()); + } + void OnEnable() { _active = _alive = true; + _spawnTime = Time.time; + BindingLifetime = 0f; // a pooled round starts unbound; the manager binds it after enabling _phase = Random.value * 100; _prevDist = Single.MaxValue; Particles.startColor = Color.white; @@ -83,14 +148,11 @@ public class GuidedProjectile : MonoBehaviour var targetDist = diff.magnitude; var sourceDist = length(StartPosition.xz - position.xz); + // Out of range, or flown past its target: the round falls away. Only the simulation's detonation + // (Resolve) shows an explosion; a round that merely ran out of flight has none. if (sourceDist > Range || dot(diff.ToCultMath(), Velocity.ToCultMath()) < 0) { StartCoroutine(FadeOut()); - if (HitEffect != null) - { - var ht = HitEffect.Instantiate(); - ht.position = t.position; - } return; } _prevDist = targetDist; @@ -112,10 +174,6 @@ public class GuidedProjectile : MonoBehaviour var perpendicularRandom = randomDirection.x * right + randomDirection.y * up; child.Velocity = (normalize(lerp(perpendicularRandom, dir.ToCultMath(), SplitSeparationForwardness)) * length(Velocity.ToCultMath()) * SplitSeparationVelocity).ToUnity(); child.Range = Range; - child.Damage = Damage / Children; - child.Penetration = Penetration; - child.Spread = Spread; - child.DamageType = DamageType; child.Source = Source; child.Target = Target; child.SourceEntity = SourceEntity; @@ -149,44 +207,6 @@ public class GuidedProjectile : MonoBehaviour if(_alive) { - var ray = new Ray(t.position, Velocity); - foreach (var hit in Physics.RaycastAll(ray, Velocity.magnitude * Time.deltaTime, 1 | (1 << 17))) - { - var shield = hit.collider.GetComponent(); - if (shield) - { - if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue; - if (shield.Entity != SourceEntity) - { - shield.Entity.Shield.TakeHit(DamageType, Damage); - shield.ShowHit(hit.point, sqrt(Damage)); - } - } - var hull = hit.collider.GetComponent(); - if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (hull.Entity != SourceEntity) - { - hull.SendHit(Damage, Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, transform.forward); - transform.position = hit.point; - StartCoroutine(Kill()); - } - } - else// if (hit.transform.gameObject.layer == 1) - { - StartCoroutine(Kill()); - return; - } - - if (HitEffect != null) - { - var ht = HitEffect.Instantiate(); - ht.SetParent(hit.collider.transform); - ht.position = hit.point; - return; - } - } - t.position += Velocity * Time.deltaTime; } } @@ -206,11 +226,16 @@ public class GuidedProjectile : MonoBehaviour yield return null; } + // The wait holds still: a round waiting on the verdict must not drift off its last position. + Velocity = Vector3.zero; + // Faded, but the simulation's verdict has not necessarily arrived: wait for it until the shot's flight is over. + while (Time.time - _spawnTime < BindingLifetime) yield return null; StartCoroutine(Kill()); } IEnumerator Kill() { + Binding = null; _active = false; _alive = false; Particles.Stop(true, ParticleSystemStopBehavior.StopEmitting); diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index cf63f7bce..e5eb74b79 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -9,50 +9,67 @@ 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)>(); - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { if(weapon.Data is LauncherData launcher) { - if (target == null) return; + // A round the simulation fired at no target (FireControl.Fire: a fused round with no valid targeting + // data, or out of arc) flies to the point it bursts at; one with no target and no burst is not a shot. + PendingShot shot = default; + if (target == null && !(source.Entity.Zone.TryGetShot(shotId, out shot) && shot.Fuse != null)) return; + var burst = shot.BurstPosition.ToUnity(); var p = ProjectilePrototype.Instantiate(); + p.ShotId = shotId; p.Source = source.transform; p.SourceEntity = source.Entity; - p.Target = target.transform; + if (target != null) p.Target = target.transform; + else p.TargetPosition = () => burst; p.Frequency = launcher.DodgeFrequency; var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); p.StartPosition = (p.transform.position = barrel.position).ToCultMath(); - p.Damage = weapon.Damage; p.Range = weapon.Range; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; p.GuidanceCurve = launcher.GuidanceCurve.ToCurve(); p.LiftCurve = launcher.LiftCurve.ToCurve(); p.ThrustCurve = launcher.ThrustCurve.ToCurve(); 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) { var p = ProjectilePrototype.Instantiate(); + p.ShotId = shotId; p.Source = source.transform; p.SourceEntity = source.Entity; p.Frequency = guidance.DodgeFrequency; var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); p.StartPosition = (p.transform.position = barrel.position).ToCultMath(); - p.Damage = weapon.Damage; p.Range = weapon.Range; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; p.GuidanceCurve = guidance.GuidanceCurve.ToCurve(); p.LiftCurve = guidance.LiftCurve.ToCurve(); p.ThrustCurve = guidance.ThrustCurve.ToCurve(); @@ -60,6 +77,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.Thrust = item.Evaluate(guidance.Thrust); p.TopSpeed = item.Evaluate(guidance.MissileVelocity); p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.LookDirection).ToUnity(); + Bind(p, source.Entity.Zone, shotId); } else Debug.LogError($"Weapon {item.Data.Name} linked to {name} effect, but is not a Launcher!"); } diff --git a/Assets/Scripts/Gameplay/Weapons/HitscanEffect.cs b/Assets/Scripts/Gameplay/Weapons/HitscanEffect.cs index 49201cfe0..673e38be6 100644 --- a/Assets/Scripts/Gameplay/Weapons/HitscanEffect.cs +++ b/Assets/Scripts/Gameplay/Weapons/HitscanEffect.cs @@ -1,8 +1,11 @@ using System.Collections; using System.Collections.Generic; using UnityEngine; -using static CultMath.math; +// Cut 4 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already +// decided this shot's fate before Fire() was ever called (R8), the same as Laser/Lightning (Cut 3). What is +// left is pure endpoint presentation: draw to the target's current position when there is one, out to Range +// otherwise. public class HitscanEffect : MonoBehaviour { public float Duration; @@ -10,71 +13,31 @@ public class HitscanEffect : MonoBehaviour public LineRenderer Line; public ParticleSystem LineEffect; public Prototype HitEffect; - - public Zone Zone { get; set; } - - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } + + // Cut 4: FireControl.Fire's ShotId -- unused by this endpoint-only presentation today, kept so a future + // pass can draw to the exact rolled cell instead of the target's overall position (Laser.ShotId). + public int ShotId { get; set; } public Entity SourceEntity { get; set; } public float Range { get; set; } - + public Transform TargetTransform { get; set; } + private float _startTime; private bool _active = false; - + public void Fire() { _startTime = Time.time; - var hitFound = false; - var ray = new Ray(transform.position, transform.forward); - foreach (var hit in Physics.RaycastAll(ray, Range, 1 | (1 << 17))) - { - var shield = hit.collider.GetComponent(); - if (shield) - { - if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue; - if (shield.Entity != SourceEntity) - { - shield.Entity.Shield.TakeHit(DamageType, Damage); - shield.ShowHit(hit.point, sqrt(Damage)); - hitFound = true; - } - } - var hull = hit.collider.GetComponent(); - if (hull) - { - if (hull.Entity != SourceEntity) - { - hull.SendHit(Damage, Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, transform.forward); - hitFound = true; - } - } - - if (hitFound && HitEffect != null) - { - var ht = HitEffect.Instantiate(); - ht.SetParent(hit.collider.transform); - ht.position = hit.point; - } - - var length = (hit.point - transform.position).magnitude; - Line.SetPosition(1, Vector3.forward * length); - var emission = LineEffect.emission; - emission.rateOverTimeMultiplier = length; - var shape = LineEffect.shape; - shape.position = Vector3.forward * (length / 2); - shape.scale = Vector3.one * (length / 2); - } - if(!hitFound) - { - Line.SetPosition(1, Vector3.forward * Range); - var emission = LineEffect.emission; - emission.rateOverTimeMultiplier = Range; - var shape = LineEffect.shape; - shape.position = Vector3.forward * (Range / 2); - shape.scale = Vector3.one * (Range / 2); - } + var length = TargetTransform != null + ? Vector3.Distance(transform.position, TargetTransform.position) + : Range; + + Line.SetPosition(1, Vector3.forward * length); + var emission = LineEffect.emission; + emission.rateOverTimeMultiplier = length; + var shape = LineEffect.shape; + shape.position = Vector3.forward * (length / 2); + shape.scale = Vector3.one * (length / 2); + LineEffect.Play(true); _active = true; } diff --git a/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs b/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs index 046b99434..5075c3344 100644 --- a/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs @@ -3,7 +3,7 @@ using UnityEngine; public class HitscanManager : InstantWeaponEffectManager { public Prototype Prototype; - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = Prototype.Instantiate(); p.SourceEntity = source.Entity; @@ -15,12 +15,9 @@ public class HitscanManager : InstantWeaponEffectManager t.localPosition = Vector3.zero; // t.position = barrel.position; // t.forward = barrel.forward; + p.ShotId = shotId; p.Range = weapon.Range; - p.Damage = weapon.Damage; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; - p.Zone = source.Entity.Zone; + p.TargetTransform = target != null ? target.transform : null; p.Fire(); } } \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs b/Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs index 13683cc08..cc2dd6f99 100644 --- a/Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs @@ -4,5 +4,8 @@ using UnityEngine; public abstract class InstantWeaponEffectManager : MonoBehaviour { - public abstract void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target); + // Cut 3 (docs/fire-control-cut.md): shotId is FireControl.Fire's ShotId (docs/fire-control-cut.md, 0b + // table) -- the presentation's handle onto Zone.ShotCommitted / ShotResolved for this specific shot. + // Implementations apply nothing; they perform whatever the shot's outcome says once it resolves. + public abstract void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId); } \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/Weapons/Laser.cs b/Assets/Scripts/Gameplay/Weapons/Laser.cs index a111de91d..196a3019e 100644 --- a/Assets/Scripts/Gameplay/Weapons/Laser.cs +++ b/Assets/Scripts/Gameplay/Weapons/Laser.cs @@ -1,22 +1,24 @@ -using System; -using System.Collections; -using System.Collections.Generic; using UnityEngine; -using static CultMath.math; +// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already +// rolled and, for a velocity-0 weapon like this one, already resolved this shot before the beam was ever +// drawn (a short flight commits and resolves in the same Zone.Update tick as Fire). The beam endpoint is +// presentation, rebuilt from the target's current position rather than a physics hit -- it no longer decides +// anything (R8). public class Laser : MonoBehaviour { public AnimationCurve IntensityCurve; public float Duration; public LineRenderer LineRenderer; - - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } + public Entity SourceEntity { get; set; } public float Range { get; set; } + // Cut 3: FireControl.Fire's ShotId. Unused by this simple endpoint-only presentation today, kept so a + // future pass can draw the beam to the exact rolled cell instead of the target's overall position. + public int ShotId { get; set; } + public Transform TargetTransform { get; set; } + private float _startTime; private readonly Vector3[] _zeros = {Vector3.zero, Vector3.zero}; @@ -34,38 +36,12 @@ public class Laser : MonoBehaviour GetComponent().ReturnToPool(); return; } - + LineRenderer.SetPosition(0, transform.position); - bool hitFound = false; - foreach (var hit in Physics.RaycastAll(new Ray(transform.position, transform.forward), Range, 1 | (1 << 17))) - { - var shield = hit.collider.GetComponent(); - if (shield) - { - if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue; - if (shield.Entity != SourceEntity) - { - shield.Entity.Shield.TakeHit(DamageType, Damage); - shield.ShowHit(hit.point, sqrt(Damage)); - LineRenderer.SetPosition(1, hit.point); - hitFound = true; - break; - } - } - var hull = hit.collider.GetComponent(); - if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (hull.Entity != SourceEntity) - { - hull.SendHit(Damage * (Time.deltaTime / Duration), Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, transform.forward); - LineRenderer.SetPosition(1, hit.point); - hitFound = true; - break; - } - } - } - if(!hitFound) - LineRenderer.SetPosition(1, transform.position + transform.forward * Range); + var endpoint = TargetTransform != null + ? TargetTransform.position + : transform.position + transform.forward * Range; + LineRenderer.SetPosition(1, endpoint); LineRenderer.widthMultiplier = IntensityCurve.Evaluate(lerp); } diff --git a/Assets/Scripts/Gameplay/Weapons/LaserManager.cs b/Assets/Scripts/Gameplay/Weapons/LaserManager.cs index 04177bf48..de70054ac 100644 --- a/Assets/Scripts/Gameplay/Weapons/LaserManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/LaserManager.cs @@ -6,7 +6,7 @@ public class LaserManager : InstantWeaponEffectManager { public Prototype ProjectilePrototype; - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = ProjectilePrototype.Instantiate(); var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; @@ -15,11 +15,9 @@ public class LaserManager : InstantWeaponEffectManager t.SetParent(barrel); t.forward = barrel.forward; t.position = barrel.position; + p.ShotId = shotId; p.SourceEntity = source.Entity; - p.Damage = weapon.Damage; p.Range = weapon.Range; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; + p.TargetTransform = target != null ? target.transform : null; } } diff --git a/Assets/Scripts/Gameplay/Weapons/Lightning.cs b/Assets/Scripts/Gameplay/Weapons/Lightning.cs index da2a4cb7e..ddb5a28f3 100644 --- a/Assets/Scripts/Gameplay/Weapons/Lightning.cs +++ b/Assets/Scripts/Gameplay/Weapons/Lightning.cs @@ -1,64 +1,30 @@ -using System; -using System.Collections; -using System.Collections.Generic; using UnityEngine; -using CultMath; -using static CultMath.math; -using Random = UnityEngine.Random; -using static CultMath.math; +// Cut 3 (docs/fire-control-cut.md): the SphereCast, shield branch and SendHit are deleted -- FireControl +// already rolled and resolved this shot before Fire() below ever ran (a velocity-0 weapon like this one +// commits and resolves in the same tick it fires). The endpoint capture stays as a visual: it now just tracks +// the target's transform instead of a physics hit (R8). public class Lightning : MonoBehaviour { public LightningCompute LightningCompute; public float HitRadius; - - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } + public EntityInstance Source { get; set; } public float Range { get; set; } public EntityInstance Target { get; set; } public Transform Barrel { get; set; } - private bool _colliderHit; - private Vector3 _colliderLocalPosition; - private Transform _colliderTransform; + // Cut 3: FireControl.Fire's ShotId. Unused by this simple endpoint-only presentation today, kept so a + // future pass can draw to the exact rolled cell instead of the target's overall position. + public int ShotId { get; set; } + private Vector3 _endpoint; public void Fire() { - _colliderHit = false; LightningCompute.OnLeaderComplete = null; - LightningCompute.FixedEndpoint = false; - var hits = Physics.SphereCastAll(Barrel.position, HitRadius, Barrel.forward, Range, 1 | (1 << 17)); - foreach (var hit in hits) - { - var shield = hit.collider.GetComponent(); - if (shield && (shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (shield.Entity == Source.Entity) continue; - LightningCompute.OnLeaderComplete = () => - { - shield.Entity.Shield.TakeHit(DamageType, Damage); - shield.ShowHit(hit.point, sqrt(Damage)); - }; - } - var hull = hit.collider.GetComponent(); - if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (hull.Entity == Source.Entity) continue; - LightningCompute.OnLeaderComplete = () => - hull.SendHit(Damage, Penetration, Spread, DamageType, Source.Entity, hit.textureCoord, Barrel.forward); - } - - _colliderHit = true; - _colliderLocalPosition = hit.collider.transform.InverseTransformPoint(hit.point); - _colliderTransform = hit.collider.transform; - LightningCompute.FixedEndpoint = true; - break; - } - LightningCompute.OnPulseComplete = () => + LightningCompute.FixedEndpoint = Target != null; + LightningCompute.OnPulseComplete = () => GetComponent().ReturnToPool(); LightningCompute.StartAnimation(); _endpoint = Barrel.position + Barrel.forward * Range; @@ -68,10 +34,7 @@ public class Lightning : MonoBehaviour { if (Barrel == null) return; - if (_colliderHit) - { - if (_colliderTransform) _endpoint = _colliderTransform.TransformPoint(_colliderLocalPosition); - } + if (Target != null) _endpoint = Target.transform.position; LightningCompute.EndPosition = _endpoint; LightningCompute.StartPosition = Barrel.position; diff --git a/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs b/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs index 93621217a..24689ee8f 100644 --- a/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs @@ -6,18 +6,15 @@ public class LightningGunManager : InstantWeaponEffectManager { public Prototype Prototype; - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = Prototype.Instantiate(); var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); + p.ShotId = shotId; p.Barrel = barrel; p.Source = source; - p.Damage = weapon.Damage; p.Range = weapon.Range; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; p.Target = target; p.Fire(); } diff --git a/Assets/Scripts/Gameplay/Weapons/Mine.cs b/Assets/Scripts/Gameplay/Weapons/Mine.cs index 7c0699d75..57d56f68e 100644 --- a/Assets/Scripts/Gameplay/Weapons/Mine.cs +++ b/Assets/Scripts/Gameplay/Weapons/Mine.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using CultMath; +using CultMath.UnityBridge; using static CultMath.math; using UnityEngine; using Random = UnityEngine.Random; @@ -86,23 +87,17 @@ public class Mine : MonoBehaviour _material.SetFloat("_Emission", _emission * EmissionCurve.Evaluate(_pulseLerp)); } + // Cut 4 (docs/fire-control-cut.md, Q7): the arming OverlapSphere above stays -- that is contact detection, + // not hit detection. Blast damage is not: it goes through the same FireControl.Detonate every blast-shaped + // weapon uses now (12.4(b) renamed Splash), over the zone's own planar entities, not a Unity collider + // query. Presentation (ShowHit) goes with the query it depended on -- nothing here decides who was hit any + // more, so nothing here can point a hit effect at them; a future pass can rebuild that off + // FireControl-published state if the mine ever ships (R9 keeps it regardless). F12-6: BlastRange stays a + // presentation-side input -- Mine has no catalog weapon record carrying Fuse/BlastRadius. public void Explode() { var position = transform.position; - foreach (var collider in Physics.OverlapSphere(position, BlastRange, 1 | (1 << 17))) - { - var shield = collider.GetComponent(); - if (shield && (shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - shield.Entity.Shield.TakeHit(DamageType, Damage); - shield.ShowHit(position, sqrt(Damage)); - } - var hull = collider.GetComponent(); - if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - hull.SendSplash(Damage, DamageType, Source.Entity, (collider.transform.position - position).normalized); - } - } + FireControl.Detonate(Source.Entity.Zone, position.ToCultMath().xz, BlastRange, Damage, DamageType); var ht = HitEffect.Instantiate(); ht.position = position; diff --git a/Assets/Scripts/Gameplay/Weapons/MineManager.cs b/Assets/Scripts/Gameplay/Weapons/MineManager.cs index b316b6cff..303fab395 100644 --- a/Assets/Scripts/Gameplay/Weapons/MineManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/MineManager.cs @@ -10,7 +10,7 @@ public class MineManager : InstantWeaponEffectManager public Prototype ProjectilePrototype; public bool InheritVelocity; - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = ProjectilePrototype.Instantiate(); var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; diff --git a/Assets/Scripts/Gameplay/Weapons/Projectile.cs b/Assets/Scripts/Gameplay/Weapons/Projectile.cs index f93e2eb82..70b591649 100644 --- a/Assets/Scripts/Gameplay/Weapons/Projectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/Projectile.cs @@ -1,41 +1,32 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using CultMath; -using static CultMath.math; +using System.Collections; using UnityEngine; -using Random = UnityEngine.Random; +using static CultMath.math; +// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already +// decided this shot's fate before this object was ever spawned (R8). What is left is pure flight-path +// presentation: fly, and disappear at Range or ShotId's resolution, whichever comes first. +// Cut 6b, 6.2 (12.4(b): Apply's own switch on the frozen Fuse decides this now, not Step): the flak-round +// blast resolves in the simulation, through FireControl.Detonate -- a MonoBehaviour calling it would be +// exactly the authority Cut 3 spent itself deleting, so the blast-distance/blast-radius fields this object +// used to carry for that are gone. This object's job stays fly-and-disappear. public class Projectile : MonoBehaviour { public TrailRenderer Trail; public float Gravity; public float Drag = .1f; public Prototype HitEffect; - - public float AirburstDistance; - public float AirburstRange; - public float DirectHitDamageMultiplier = 1; - + private bool _alive; - + + // Cut 3: FireControl.Fire's ShotId -- this projectile's handle onto Zone.ShotCommitted/ShotResolved. + public int ShotId { get; set; } public Zone Zone { get; set; } - + public Vector3 StartPosition { get; set; } public Vector3 Velocity { get; set; } - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } public Entity SourceEntity { get; set; } public float Range { get; set; } - // Start is called before the first frame update - void Start() - { - - } - private void OnEnable() { _alive = true; @@ -45,71 +36,18 @@ public class Projectile : MonoBehaviour void Update() { if (SourceEntity == null) return; - + if(_alive) { var t = transform; - var position = t.position; Velocity -= Vector3.up * (Gravity * Time.deltaTime); Velocity *= max(0, 1 - Drag * Time.deltaTime); - var forward = Velocity.normalized; - t.forward = forward; - var ray = new Ray(position, Velocity); - foreach (var hit in Physics.RaycastAll(ray, Velocity.magnitude * Time.deltaTime, 1 | (1 << 17))) - { - var shield = hit.collider.GetComponent(); - var hull = hit.collider.GetComponent(); - if (shield) - { - if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue; - if (shield.Entity != SourceEntity) - { - shield.Entity.Shield.TakeHit(DamageType, Damage*DirectHitDamageMultiplier); - shield.ShowHit(hit.point, sqrt(Damage * DirectHitDamageMultiplier)); - } - } - else if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (hull.Entity != SourceEntity) - { - hull.SendHit(Damage*DirectHitDamageMultiplier, Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, forward); - transform.position = hit.point; - StartCoroutine(Kill()); - } - } - else - { - StartCoroutine(Kill()); - return; - } - - if (HitEffect != null) - { - var ht = HitEffect.Instantiate(); - ht.SetParent(hit.collider.transform); - ht.position = hit.point; - return; - } - } - + t.forward = Velocity.normalized; + transform.position += Velocity * Time.deltaTime; var distanceTraveled = (transform.position - StartPosition).magnitude; if(distanceTraveled > Range) StartCoroutine(Kill()); - if (AirburstRange > 1 && distanceTraveled > AirburstDistance) - { - StartCoroutine(Kill()); - var ht = HitEffect.Instantiate(); - ht.position = t.position; - foreach (var collider in Physics.OverlapSphere(t.position, AirburstRange, 1)) - { - var hull = collider.GetComponent(); - if (hull) - { - hull.SendSplash(Damage, DamageType, SourceEntity, (collider.transform.position - t.position).normalized); - } - } - } } } diff --git a/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs index a081f8c23..c9fc2e13f 100644 --- a/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs @@ -1,8 +1,6 @@ using System.Collections; using System.Collections.Generic; using UnityEngine; -using CultMath; -using static CultMath.math; using Random = UnityEngine.Random; public class ProjectileManager : InstantWeaponEffectManager @@ -10,29 +8,25 @@ public class ProjectileManager : InstantWeaponEffectManager public Prototype ProjectilePrototype; public bool InheritVelocity; - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = ProjectilePrototype.Instantiate(); var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); var angle = weapon.Spread / 2; + p.ShotId = shotId; p.SourceEntity = source.Entity; p.Velocity = Quaternion.Euler( - Random.Range(-angle, angle), - Random.Range(-angle, angle), - Random.Range(-angle, angle)) * - barrel.forward * + Random.Range(-angle, angle), + Random.Range(-angle, angle), + Random.Range(-angle, angle)) * + barrel.forward * weapon.Velocity; p.StartPosition = p.transform.position = barrel.position + p.Velocity * (Random.value * Time.deltaTime); if(InheritVelocity) p.Velocity += new Vector3(source.Entity.Velocity.x, 0, source.Entity.Velocity.y); - p.Damage = weapon.Damage; p.Range = weapon.Range; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; p.Zone = source.Entity.Zone; - p.AirburstDistance = target != null ? length(source.Entity.Position - target.Entity.Position) : (weapon.Range * .75f); p.Trail.Clear(); } } diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 0a702a7b8..bee8a4c9f 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -29,6 +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."); + // Every catalog document is held to the one check Upsert applies on write: R-heat (docs/stats-and-power- + // cut.md), stat modifiers, role usage (Cut 7). Fails loudly, naming the item. + foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); + foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); return cache; } catch @@ -53,7 +57,39 @@ public static class CultRecordRefs new CultRecordRef(cache.TryGetHandle(document)?.Key ?? throw new InvalidOperationException($"This cache holds no such {document.GetType().Name}.")); - // Writes a document to its home store; the cache mints its key the first time. - public static CultRecordRef Upsert(this CultCache cache, T document) where T : class => - new CultRecordRef(cache.UpsertAsync(document).Result.Key); + // F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the validation body itself, pulled out of Upsert + // below so it has exactly one owner. Every catalog write in this repository was supposed to go through + // Upsert, but three of tools/AetherDb/Program.cs's own migration commands (Dangling, ShieldMigrate, + // BrownoutMigrate) cannot: they repair an EXISTING record in place and must preserve its key, which Upsert's + // own cache.UpsertAsync(document) does not support (Upsert mints or resolves a key from the document itself, + // not from a caller-supplied one). Rather than leave those three writing raw and unvalidated -- as they did + // before this fix -- they call this method directly, immediately before their own identity-preserving + // batch.Upsert(document.GetType(), document, key) commit, so the same rules run either way. Takes `object` + // (not a generic T) because that is what those three tools' own (Document, Key) change lists are typed as. + public static void Validate(object document) + { + if (document is EquippableItemData data) + { + StatValidation.ValidateHeatResponse(data); + StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); + StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); + } + if (document is ConsumableItemData consumable) + { + StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors); + StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors); + } + } + + // Writes a document to its home store; the cache mints its key the first time. Every catalog write in this + // repository (tools, tests, migration scripts) goes through this one function, or through Validate above + // directly when the caller must preserve an existing key (see Validate's own comment) -- so this is where + // R-heat's validation closes the gap AetheriaStores.Open leaves: Open only checks what is already on disk + // when a session starts, not what a session then writes. Validating here means an invalid heat response is + // refused before the write ever reaches disk, not just the next time someone reopens the file. + public static CultRecordRef Upsert(this CultCache cache, T document) where T : class + { + Validate(document); + return new CultRecordRef(cache.UpsertAsync(document).Result.Key); + } } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index f63d49553..d605ff8a5 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -33,15 +33,36 @@ public class CombatState : BaseState var toTarget = target.Position - _agent.Ship.Position; var targetDistance = length(toTarget); + + // Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at + // this range, or clear the aim point when nothing qualifies -- the same predicate + // (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses. + EquippedItem bestTargetItem = null; + var bestTargetDps = float.MinValue; + foreach (var targetItem in target.Equipment) + { + if (!(targetItem.Behaviors.FirstOrDefault(b => b is Weapon) is Weapon targetWeapon)) continue; + if (!FireControl.IsRevealed(_agent.Ship, targetItem)) continue; + var dps = targetWeapon.RangeDamagePerSecond(targetDistance); + if (dps > bestTargetDps) + { + bestTargetDps = dps; + bestTargetItem = targetItem; + } + } + _agent.Ship.TrySelectTargetItem(bestTargetItem); + for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++) { 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) @@ -83,26 +104,21 @@ public class CombatState : BaseState var testWeapon = _agent.Ship.WeaponGroups[selectedGroup].weapons.First(); if(testWeapon.Velocity > 1) { + // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's + // own snapshot and the roll's deviation measurement -- two separate predictions here and in the + // roll would let an AI aim at one point and get judged for missing a different one. var targetHullData = _agent.ItemManager.GetData(target.Hull) as HullData; - var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); - var shipVelocity = float3(_agent.Ship.Velocity.x, 0, _agent.Ship.Velocity.y); - var predictedPosition = first_order_intercept( - _agent.Ship.Position, - float3.zero, - testWeapon.Velocity, - target.Position, - targetVelocity - ); + var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target); predictedPosition.y = _agent.Ship.Zone.GetHeight(predictedPosition.xz) + targetHullData.GridOffset; toTarget = normalize(predictedPosition - _agent.Ship.Position); } - - var shouldFire = dot( - _agent.Ship.HardpointTransforms[_agent.Ship.Hardpoints[testWeapon.Item.Position.x, testWeapon.Item.Position.y]].direction, - toTarget) > .99f; + + // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a + // verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it + // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (shouldFire) + if (FireControl.AgentFires(weapon, _agent.Ship, target)) weapon.Activate(); else if (weapon.Firing) weapon.Deactivate(); diff --git a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs index 3e43ebc92..e8498ca7f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs @@ -47,7 +47,7 @@ public class AetherDriveData : BehaviorData [Inspectable, JsonProperty("torqueAudio"), Key(12), RuntimeInspectable] public uint TorqueRatioAudioParameter; - [Inspectable, CultInspectorAssetPath, JsonProperty("particles"), Key(13)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("particles"), Key(13)] public string Particles; public override Behavior CreateInstance(EquippedItem item) @@ -61,7 +61,7 @@ public class AetherDriveData : BehaviorData } } -public class AetherDrive : Behavior +public class AetherDrive : Behavior, IPowerConsumer { private AetherDriveData _data; private float3 _axis; @@ -73,6 +73,11 @@ public class AetherDrive : Behavior public AetherDriveData DriveData => _data; + // Cut 8 (operator ask 2026-09-19): the equivalent quantity to Thruster.Condition -- Torque is the stat that + // actually governs this behaviour's rotor spin-up (PowerRequest/Execute above both read it), so it is what + // "broken" means for a drive. 1f for the item-less ConsumableItemEffect case, same reasoning as Thruster. + public float Condition => Item?.ConditionRatio(_data.Torque) ?? 1f; + public float3 Axis { get => _axis; @@ -89,6 +94,38 @@ public class AetherDrive : Behavior _data = data; } + // Cut 3 (docs/stats-and-power-cut.md): the request PowerBus needs before Execute runs. Only the rotor + // spin-up (torque accelerating Rpm toward MaximumRpm) costs power -- spending existing spin into thrust + // (Execute's decay-to-thrust arithmetic below) is free, same as before. Pure and side-effect-free: it reads + // Rpm but does not write it, so Execute's own identical arithmetic a few lines later -- which does perform + // the real decay -- produces the same numbers Rpm actually moves by. Accepts the recompute (Q3's ruling). + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): Torque, LambdaMultiplier, + // MaximumRpm, PassiveCoupling and EnergyDraw are all registered request fields (StatValidation. + // PowerRequestFields) -- read nominally (what full power would produce) so Torque carrying its own + // PowerSupply term (Cut 7's brownout curve) no longer makes this tick's request depend on this tick's own + // grant. Execute below is unchanged: it calls the real, curved Evaluate, so the actual spin-up still + // degrades with whatever the bus actually grants. + public float PowerRequest(float dt) + { + var couplingLambda = _data.CouplingLambda * Item.EvaluateNominalPower(_data.LambdaMultiplier) * max(abs(_axis), EvaluateNominalPower(_data.PassiveCoupling)); + var rpmAfterDecay = decay(Rpm, couplingLambda, dt); + var maximumRpm = EvaluateNominalPower(_data.MaximumRpm); + var torqueProfile = float3( + _data.TorqueProfile.Evaluate(rpmAfterDecay.x / maximumRpm), + _data.TorqueProfile.Evaluate(rpmAfterDecay.y / maximumRpm), + _data.TorqueProfile.Evaluate(rpmAfterDecay.z / maximumRpm)); + var potentialTorque = EvaluateNominalPower(_data.Torque) * torqueProfile; + var potentialRpmDelta = potentialTorque / length(_data.RotorMass) * dt; + var actualRpmDelta = min(maximumRpm - rpmAfterDecay, potentialRpmDelta); + var torqueRatio = actualRpmDelta / potentialRpmDelta; + var draw = torqueRatio * EvaluateNominalPower(_data.EnergyDraw) / 3; + return (draw.x + draw.y + draw.z) * dt; + } + + // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Medium -- mobility, same as Thruster. + public int DefaultPowerTier => PowerTiers.Medium; + public override bool Execute(float dt) { var rotorSpeed = Rpm * _data.RotorDiameter / 100; @@ -129,19 +166,20 @@ public class AetherDrive : Behavior var potentialRpmDelta = potentialTorque / length(_data.RotorMass) * dt; var actualRpmDelta = min(MaximumRpm - Rpm, potentialRpmDelta); var torqueRatio = actualRpmDelta / potentialRpmDelta; - var draw = torqueRatio * Evaluate(_data.EnergyDraw) / 3; - + Item.SetAudioParameter(SpecialAudioParameter.Intensity, max(max(abs(_axis.x), abs(_axis.y)), abs(_axis.z))); Item.SetAudioParameter(_data.RpmAudioParameter, (Rpm.x + Rpm.y + Rpm.z) / 3 / MaximumRpm); Item.SetAudioParameter(_data.TorqueRatioAudioParameter, max(max(torqueRatio.x, torqueRatio.y), torqueRatio.z)); - if (Entity.TryConsumeEnergy((draw.x + draw.y + draw.z)*dt)) + // Cut 7 (docs/stats-and-power-target.md): actualRpmDelta already derives from Evaluate(_data.Torque) + // above, so a Torque stat carrying a PowerSupply term already shrinks the rotor's spin-up under a + // partial grant -- this no longer demands a full one, only that supply has not been cut to true zero. + if (Item.PowerSupply > 1e-4f) { Rpm += actualRpmDelta; return true; } - - + return false; } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 4013d5c93..b9c4c152e 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -27,16 +27,8 @@ public abstract class Behavior { if(Item != null) { - var hardpoint = Entity.Hardpoints[Item.Position.x, Item.Position.y]; - if (hardpoint != null && Entity.HardpointTransforms.ContainsKey(hardpoint)) - { - return normalize(Entity.HardpointTransforms[hardpoint].direction); - } - else - { - var itemDirection = Entity.Direction.Rotate(Item.EquippableItem.Rotation); - return float3(itemDirection.x, 0, itemDirection.y); - } + var itemDirection = Entity.Direction.Rotate(Item.EquippableItem.Rotation); + return float3(itemDirection.x, 0, itemDirection.y); } return float3(Entity.Direction.x, 0, Entity.Direction.y); @@ -58,6 +50,13 @@ public abstract class Behavior } public float Evaluate(PerformanceStat stat) => Item?.Evaluate(stat) ?? Consumable.Evaluate(stat); + + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): the read a PowerRequest + // implementation uses for a stat named in StatValidation.PowerRequestFields, in place of Evaluate above. A + // consumable-hosted instance (Item == null) never reaches PowerBus.Step at all (see e.g. EnergyDraw.Execute's + // own comment), and ConsumableItemEffect.PowerSupplyFactor is already the identity (1) unconditionally, so + // Consumable.Evaluate is already nominal with respect to power and needs no separate context. + public float EvaluateNominalPower(PerformanceStat stat) => Item?.EvaluateNominalPower(stat) ?? Consumable.Evaluate(stat); protected void AddHeat(float heat) => Item?.AddHeat(heat); // TODO: Heat for Consumables protected void CauseDamage(float damage) @@ -124,16 +123,27 @@ public interface IProgressBehavior float Progress { get; } } -public interface IOrderedBehavior -{ - int Order { get; } -} - public interface IPopulationAssignment { int AssignedPopulation { get; set; } } +// Cut 3 (docs/stats-and-power-cut.md §1.2): a behaviour that wants to draw from its entity's PowerBus declares a +// request instead of spending energy itself. PowerBus.Step calls this on every consumer once per tick, before +// any behaviour's Execute runs, so PowerRequest must be computable from state that is already current at that +// point -- a resolved stat and, where the map calls for one, a behaviour-supplied scalar such as Thruster's +// throttle axis (set externally before the tick) -- never from work Execute itself would otherwise do. The +// behaviour reads back how much of its own request was granted from Item.PowerSupply during its own Execute. +// +// Cut 5 (docs/stats-and-power-cut.md §1.3): every kind of consumer also names the priority tier (PowerTiers.cs) +// it defaults to. EquippedItem reads this exactly once, to seed EquippableItem.PowerTier the first time an item +// without a player-chosen tier gets equipped -- after that the stored field is what PowerBus reads, never this. +public interface IPowerConsumer +{ + float PowerRequest(float dt); + int DefaultPowerTier { get; } +} + [Inspectable, Union(0, typeof(GuidedWeaponData)), Union(1, typeof(LauncherData)), @@ -173,6 +183,9 @@ public interface IPopulationAssignment Union(36, typeof(ConstantWeaponData)), Union(37, typeof(ChargedWeaponData)), Union(38, typeof(AutoWeaponData)), + // Cut 2 (docs/fire-control-cut.md): 39 is the next free index against this list, not the 39 the + // superseded draft quoted -- checked directly here rather than trusted from that document. + Union(39, typeof(TargetingSystemData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData { diff --git a/Assets/Scripts/ServerShared/Behaviors/Capacitor.cs b/Assets/Scripts/ServerShared/Behaviors/Capacitor.cs index d05b7725b..4139c5a93 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Capacitor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Capacitor.cs @@ -42,6 +42,11 @@ public class Capacitor : Behavior AddHeat(abs(charge) * (1-Efficiency)); } + // Cut 3 (docs/stats-and-power-cut.md): PowerBus needs Capacity before this behaviour's own Execute runs this + // tick (the bus steps first, §1.2), so it resolves the stat itself rather than reading the cached property, + // which still holds last tick's value at that point. + public float ResolveCapacity() => Evaluate(_data.Capacity); + public Capacitor(CapacitorData data, EquippedItem item) : base(data, item) { _data = data; diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index 7f80ab9d0..bc80bc1e5 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -67,6 +67,13 @@ public class ChargedWeapon : InstantWeapon public float ChargeTime { get; protected set; } public float ChargeEnergy { get; protected set; } public float ChargeHeat { get; protected set; } + + // Cut 4 (docs/stats-and-power-cut.md, Cut 4): "its charge cycle already is a buffer, and ChargeEnergy + // should become the fill rate rather than a separate concept." ChargeEnergy was authored but never spent + // by anything (Trigger() always paid the base Weapon.Energy per shot); it becomes this weapon's input + // capacitor rate instead of the InstantWeapon default (Capacity / Cooldown), so a charged weapon's own + // authored charge-up speed IS its sustained rate of fire, not a second, disconnected number. + protected override float RateOverride => Evaluate(_data.ChargeEnergy); public override float DamagePerSecond => Damage * _data.ChargeFiringDamageMultiplier / (Cooldown + ChargeTime); public override float RangeDamagePerSecond(float range) @@ -153,7 +160,10 @@ public class ChargedWeapon : InstantWeapon public override void Activate() { - if(!_charging && !_coolingDown) + // CanFire also holds a weapon whose released charge has a burst pending: its cooldown starts at its first round, + // so a charge started before then would overlap it. + // A refused weapon never starts charging: it would pay charge heat and audio for a shot Trigger drops. + if(!_charging && CanFire && !FireControl.Refuses(this, Entity)) { OnStartCharging?.Invoke(); Item.FireAudioEvent(ChargedWeaponAudioEvent.Start); diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 26027ca4b..e9dab4ce6 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -25,13 +25,16 @@ public class ConstantWeaponData : WeaponData } } -public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior +public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerConsumer { private ConstantWeaponData _data; private int _ammo = 1; private float _ammoInterval; private float _reload; private bool _reloading; + // Cut 4 (docs/fire-control-cut.md): accumulates while firing; rolled off every time it reaches + // GameplaySettings.BeamResolveInterval, through FireControl.Fire/Step exactly like a discrete shot. + private float _beamTimer; public override int Ammo { @@ -53,13 +56,18 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior public event Action OnReloadComplete; public event Action OnStartFiring; public event Action OnStopFiring; - + // Cut 4 (docs/fire-control-cut.md): carries the ShotId each beam roll's FireControl.Fire assigned, the + // same handle InstantWeapon.OnFire gives discrete shots. No presentation binds to it yet -- kept for a + // future pass, same as Laser.ShotId. + public event Action OnBeamShot; + public void ResetEvents() { OnReloadBegin = null; OnReloadComplete = null; OnStartFiring = null; OnStopFiring = null; + OnBeamShot = null; } public ConstantWeapon(ConstantWeaponData data, EquippedItem item) : base(data, item) @@ -72,19 +80,47 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior _data = data; } + // Cut 3 (docs/stats-and-power-cut.md): the request the bus needs before Execute runs -- what continuing to + // fire this tick would cost, or nothing when not firing or safed. _firing is set externally (Activate/ + // Deactivate) before Entity.Update calls PowerBus.Step, so it is already current when this runs. + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): Energy is a registered + // request field (StatValidation.PowerRequestFields) -- read nominally, same reasoning as every other + // IPowerConsumer in this cut. Damage (the field Cut 7 curves) is a separate stat base.Execute reads with the + // real Evaluate. + // 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). + // 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; + public override bool Execute(float dt) { base.Execute(dt); - if (_firing && !StanceAllowsFire) + if (_firing && !MayFire) { - // Safed: shooter has a target and hasn't declared hostility toward it. + // 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), or a fused round is + // refused. _firing = false; OnStopFiring?.Invoke(); return false; } if (_firing) { - if (!Entity.TryConsumeEnergy(Evaluate(_data.Energy) * dt)) + // Cut 7 (docs/stats-and-power-target.md): base.Execute(dt) above already re-read Damage through + // Evaluate(_data.Damage), so a Damage stat carrying a PowerSupply term already fires this tick for + // less under a partial grant -- the weapon no longer safes itself off (the "flicking off gear" bug + // the ruling names) just because the grant fell short of 1. Only true zero supply still stops it. + if (Item != null && Item.PowerSupply <= 1e-4f) { _firing = false; OnStopFiring?.Invoke(); @@ -135,13 +171,32 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior CauseWearDamage(dt); AddHeat(Evaluate(_data.Heat) * dt); Entity.VisibilitySources[this] = Evaluate(_data.Visibility); + + // Cut 4 (docs/fire-control-cut.md): a beam is a sequence of rolls, not a continuous truth -- one + // FireControl.Fire per BeamResolveInterval, for that interval's worth of Damage, through the same + // freeze-and-queue path a discrete shot uses (a beam's authored Velocity is 0, so the flight + // commits and resolves in this same tick, R4's short-flight degradation). + var interval = Entity.ItemManager.GameplaySettings.BeamResolveInterval; + _beamTimer += dt; + while (_beamTimer >= interval) + { + _beamTimer -= interval; + // FireControl.Fire must run whether or not anything is listening: `OnBeamShot?.Invoke(FireControl.Fire(...))` + // looks equivalent but is not -- C#'s null-conditional short-circuits the whole expression, + // argument included, when OnBeamShot has no subscriber (true today, R9's "no design uses it + // yet"), so the roll would silently never happen. Evaluate it into a local first. + var shotId = FireControl.Fire(this, Item, Entity, Damage * interval); + OnBeamShot?.Invoke(shotId); + } } return true; } public override void Activate() { - if(!_firing && !_reloading) + // A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and + // audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing. + if(!_firing && !_reloading && !FireControl.Refuses(this, Entity)) { _firing = true; OnStartFiring?.Invoke(); diff --git a/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs b/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs index 5fd4b9277..5a1d8a424 100644 --- a/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs +++ b/Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs @@ -27,7 +27,7 @@ public class EnergyDrawData : BehaviorData } } -public class EnergyDraw : Behavior +public class EnergyDraw : Behavior, IPowerConsumer { private EnergyDrawData _data; @@ -41,8 +41,30 @@ public class EnergyDraw : Behavior _data = data; } + // Cut 3 (docs/stats-and-power-cut.md): the request PowerBus needs before Execute runs, in place of the + // direct Entity.TryConsumeEnergy spend that used to happen inside Execute. + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): EnergyDraw is a + // registered request field (StatValidation.PowerRequestFields) -- read nominally, same reasoning as every + // other IPowerConsumer in this cut. + public float PowerRequest(float dt) => EvaluateNominalPower(_data.EnergyDraw) * (_data.PerSecond ? dt : 1); + + // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Utility -- the generic/unclassified draw. An item + // that declares power use without asking for a specific tier should not accidentally outrank a named one. + public int DefaultPowerTier => PowerTiers.Utility; + public override bool Execute(float dt) { - return Entity.TryConsumeEnergy(Evaluate(_data.EnergyDraw) * (_data.PerSecond ? dt : 1)); + // A consumable-hosted instance (Item null) has no PowerBus entry (§1.2's grants are keyed by + // EquippedItem); named rather than silently assumed away, it always succeeds here, the same way every + // other context-dependent factor a ConsumableItemEffect answers with the identity elsewhere in this cut. + // + // Cut 7 (docs/stats-and-power-target.md): unlike the other four continuous consumers, EnergyDraw has no + // performance stat of its own to curve -- its Execute return value IS its whole effect, the gate that + // decides whether the rest of its BehaviorGroup runs at all (Entity.cs's per-group Execute chain). There + // is nothing here for a PowerSupply term to degrade continuously, so brownout is expressed the only way + // this behaviour can express it: a partial grant still passes (no more flicking off at 80%), and only a + // true zero grant closes the gate. + return Item == null || Item.PowerSupply > 1e-4f; } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs new file mode 100644 index 000000000..c20b2b8b5 --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs @@ -0,0 +1,59 @@ +/* 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 static CultMath.math; + +// Cut 4 (docs/stats-and-power-cut.md §1.2, §0b "An input capacitor"): the input-only buffer that lets an +// instant activation (a shot, a ping, a shield's damage reserve) draw from the PowerBus continuously instead +// of spending stored charge directly -- closing Cut 3's named, temporary exception +// (Entity.TrySpendCapacitorCharge/CanSpendCapacitorCharge, both deleted with their last caller this cut). +// +// Not a Behavior of its own: per §0b, an input capacitor is identified by the Behavior instance that owns it +// (InstantWeapon, Sensor, Shield), which holds one of these as a plain field. Two writers, disjoint signs, +// named: the bus fills it, through AddCharge, called only from the owning behaviour's own Execute after it +// reads back its granted fraction from Item.PowerSupply; the owning behaviour drains it, through TrySpend, +// called only when it actually activates. Nothing else may touch Charge. +public class InputCapacitor +{ + public float Capacity { get; private set; } + public float Rate { get; private set; } + public float Charge { get; private set; } + + // Cut 4 (§7 Q4, operator ruling): "derived from the item's Energy and Cooldown ... with an authored + // override" so burst capacity and sustained rate stay separate levers. capacityOverride/rateOverride of 0 + // mean "not authored, use the derived default" -- the same identity-when-absent convention PerformanceStat + // itself already uses (a term-less stat resolves to its identity factor). Cutting Charge down here (rather + // than only clamping in AddCharge) is what lets a Capacity that shrinks under a stat change immediately + // stop promising a shot it can no longer pay for. + public void UpdateStats(float energy, float cooldown, float capacityOverride = 0f, float rateOverride = 0f) + { + Capacity = capacityOverride > 0f ? capacityOverride : max(0f, energy); + Rate = rateOverride > 0f ? rateOverride : (cooldown > 0f ? Capacity / cooldown : Capacity); + Charge = clamp(Charge, 0f, Capacity); + } + + // What PowerBus should count as this consumer's demand this tick (via the owning behaviour's + // IPowerConsumer.PowerRequest), and what that behaviour should scale by its own Item.PowerSupply and pass + // to AddCharge once its own Execute runs. The two calls must agree on dt and must not have let Charge move + // in between, because PowerBus.Step calls this before any behaviour's Execute runs this tick (§1.2: + // "computable from state that is already current at that point"). + public float RequestedFill(float dt) => min(Rate * dt, max(0f, Capacity - Charge)); + + public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity); + + public bool CanSpend(float cost) => Charge >= cost; + + // Atomic and whole-or-nothing, mirroring Entity.TrySpendCapacitorCharge's old contract exactly (down to the + // near-zero shortcut) but scoped to this one behaviour's own buffer instead of the entity's shared bus + // capacitors: either the cost is covered in full, or nothing moves. This is the mechanism behind the + // operator's ruling that no item ever receives a fraction of a shot -- callers that want that guarantee + // spend exactly Capacity, which only ever succeeds at full charge. + public bool TrySpend(float cost) + { + if (cost < .01f) return true; + if (!CanSpend(cost)) return false; + Charge -= cost; + return true; + } +} diff --git a/Assets/Scripts/UnityHelpers.cs.meta b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs.meta similarity index 60% rename from Assets/Scripts/UnityHelpers.cs.meta rename to Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs.meta index 548a62250..c667a0681 100644 --- a/Assets/Scripts/UnityHelpers.cs.meta +++ b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs.meta @@ -1,11 +1,11 @@ fileFormatVersion: 2 -guid: 7b4543cc6e0fec6498202ce0387b911d +guid: 2323335634942be0e17abf63f5c6d256 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index b5f5d2dec..2801110c9 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -32,11 +32,17 @@ public class InstantWeaponData : WeaponData } } -public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior +public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerConsumer { private InstantWeaponData _data; + // Cut 4 (docs/stats-and-power-cut.md, Cut 4): replaces the direct Entity.TrySpendCapacitorCharge spend at + // both Trigger() and Execute() below -- the named, temporary exception Cut 3 left standing. + 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 @@ -48,7 +54,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior 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; @@ -66,7 +73,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior public event Action OnReloadBegin; public event Action OnReloadComplete; public event Action OnCooldownComplete; - public event Action OnFire; + // Cut 3 (docs/fire-control-cut.md): carries the ShotId FireControl.Fire assigned, so a Unity effect + // manager can bind its presentation to the one shot it belongs to instead of applying damage itself. + public event Action OnFire; public virtual void ResetEvents() { @@ -92,16 +101,25 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior { // Safed: shooter has a target and hasn't declared hostility toward it. if (!StanceAllowsFire) return; + // Cut 3 (docs/fire-control-cut.md, Q2): arc-gated the same way AI and turret fire are (Weapon. + // ArcAllowsFire), and applied here because this is the one place every shooter's trigger passes + // 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 && (!Entity.TryConsumeEnergy(Energy) || !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() @@ -116,6 +134,45 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior Energy /= (int) BurstCount; } + // Cut 4 (docs/stats-and-power-cut.md §7 Q4): capacity and rate, resolved fresh rather than off the cached + // Energy/Cooldown properties above -- Capacitor.ResolveCapacity's precedent (PowerBus.Step, which calls + // PowerRequest below, runs before this behaviour's own Execute this tick, so those cached properties would + // still hold last tick's value). Capacity is one shot's own energy cost, exactly what the two deleted + // direct-spend call sites measured. Default rate keeps sustained fire exactly at Cooldown's pace when the + // bus grants the full request; RateOverride lets an owner replace that (ChargedWeapon does). + protected virtual float RateOverride => 0f; + + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): Count, Energy and + // Cooldown are all registered request fields (StatValidation.PowerRequestFields) -- read nominally here so + // the capacitor's own Capacity/Rate this feeds into PowerRequest below never depends on this tick's own + // grant. Nothing here needs the real, curved value: PowerRequest only ever asks "how much would a full shot + // cost," and the actual charge added back in Execute is separately scaled by Item.PowerSupply. + protected void RefreshInputCapacitor() + { + var burstCount = max(1, (int) EvaluateNominalPower(_data.Count)); + var perShotEnergy = EvaluateNominalPower(_data.Energy) / burstCount; + var cooldown = EvaluateNominalPower(_data.Cooldown); + _capacitor.UpdateStats(perShotEnergy, cooldown, rateOverride: RateOverride); + } + + // Cut 4: the request PowerBus needs before Execute runs, mirroring EnergyDraw's IPowerConsumer pattern. + public float PowerRequest(float dt) + { + RefreshInputCapacitor(); + return _capacitor.RequestedFill(dt); + } + + // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense. The ruling's own example of what + // a reactor throttle should sacrifice first. + public int DefaultPowerTier => PowerTiers.Low; + + // Cut 4: whole-or-nothing against this behaviour's own input capacitor -- the operator's ruling ("no item + // ever receives a fraction of a shot") means a fire attempt only ever succeeds at full charge, so the cost + // spent is always exactly Capacity, never the possibly-stale cached Energy. A consumable-hosted instance + // (Item == null) has no PowerBus entry (Behaviors.cs: "no PowerBus entry ... always succeeds"), so nothing + // would ever fill this capacitor -- bypass it entirely rather than starving such an instance forever. + protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity); + private bool UseAmmo() { if (_data.MagazineSize <= 1) return true; @@ -153,6 +210,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior public override bool Execute(float dt) { base.Execute(dt); + // Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item + // instance never reaches PowerBus.Step (see TrySpendActivationEnergy) and so never accrues charge. + if (Item != null) + _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); if (_coolingDown) { _cooldown -= dt / (_data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown); @@ -173,17 +234,42 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior _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 && (!Entity.TryConsumeEnergy(Energy) || !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; - OnFire?.Invoke(); + 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. + // Cut 4 fix: FireControl.Fire must run whether or not anything is listening -- inlined into + // `OnFire?.Invoke(...)` it looked equivalent but was not. C#'s null-conditional short-circuits the + // whole expression, argument included, whenever OnFire has no subscriber (a headless run with no + // Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired. + // Evaluate it into a local first. + var shotId = FireControl.Fire(this, Item, Entity); + OnFire?.Invoke(shotId); if(!firedThisFrame) { Item.FireAudioEvent(WeaponAudioEvent.Fire); diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index 665471184..7499ab6f8 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -59,7 +59,13 @@ public class LockWeapon : InstantWeapon { get => saturate(_lock); } - + + // Cut 3 (docs/fire-control-cut.md): the same threshold CanFire above already gates firing on, exposed so + // FireControl.HitProbability can zero an unlocked LockWeapon's probability for AI/HUD decisions made + // before a shot is ever attempted. + public bool IsLocked => _lock > .99f; + + public LockWeapon(LockWeaponData data, EquippedItem item) : base(data, item) { _data = data; diff --git a/Assets/Scripts/ServerShared/Behaviors/Radiator.cs b/Assets/Scripts/ServerShared/Behaviors/Radiator.cs index 3f31cdbb0..a43a1a5c7 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Radiator.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Radiator.cs @@ -39,15 +39,15 @@ public class RadiatorData : BehaviorData } } -public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior +public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior, IPowerConsumer { public float RadiatorTemperature { get; private set; } - + public float Emissivity { get; private set; } public float PumpedHeat { get; private set; } public float WasteHeat { get; private set; } public float EnergyUsage { get; private set; } - + private RadiatorData _data; public Radiator(RadiatorData data, EquippedItem item) : base(data, item) @@ -59,6 +59,27 @@ public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior _data = data; } + // Cut 3 (docs/stats-and-power-cut.md): mirrors Execute's own early-out below -- a radiator that would not + // even try to pump this tick (waste would outrun pump capacity) requests nothing, exactly like before. + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PumpedHeat, WasteHeat and + // EnergyUsage are all registered request fields (StatValidation.PowerRequestFields) -- read nominally (what + // full power would pump) so PumpedHeat carrying its own PowerSupply term (Cut 7's brownout curve) no longer + // makes this tick's request depend on this tick's own grant. Execute below is unchanged: it calls the real, + // curved Evaluate, so the actual pumping still degrades with whatever the bus actually grants. + public float PowerRequest(float dt) + { + var pumpedHeat = EvaluateNominalPower(_data.PumpedHeat); + var wasteHeat = EvaluateNominalPower(_data.WasteHeat); + var tempRatio = max(RadiatorTemperature / Temperature, 1); + if (tempRatio > pumpedHeat / wasteHeat) return 0f; + return EvaluateNominalPower(_data.EnergyUsage) * tempRatio * dt; + } + + // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Critical -- the closest thing this game has to + // life support. A starved radiator cascades into wear and shutdown across every other item on the hull. + public int DefaultPowerTier => PowerTiers.Critical; + public override bool Execute(float dt) { PumpedHeat = Evaluate(_data.PumpedHeat); @@ -67,12 +88,18 @@ public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior var itemTemperature = Temperature; var tempRatio = max(RadiatorTemperature / itemTemperature, 1); - + // Temperature ratio would cause more waste heat than pump capacity, stop executing if (tempRatio > PumpedHeat / WasteHeat) return true; - if (!Entity.TryConsumeEnergy(EnergyUsage * tempRatio * dt)) return false; - + // Cut 7 (docs/stats-and-power-target.md): no separate power gate at all. PumpedHeat above is already a + // plain Evaluate() read, so a PumpedHeat stat carrying a PowerSupply term already pumps less under a + // partial grant -- waste heat below is unaffected by the curve, so a starved radiator falls behind and + // the ship heats up, which is the reduced-performance failure the ruling asks for instead of the pump + // simply refusing to run. F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PowerSupply is a + // multiplier PerformanceStat.Evaluate applies to the whole resolved value, not a term blended into the + // Min/Max interpolation, so at true zero supply PumpedHeat resolves to exactly 0 regardless of Min -- so + // "produces nothing" already falls out of Evaluate() without a special case here. var pumpedHeat = PumpedHeat * max(itemTemperature - _data.TemperatureFloor, 0); // Radiator temperature is below temperature floor, stop executing diff --git a/Assets/Scripts/ServerShared/Behaviors/Reactor.cs b/Assets/Scripts/ServerShared/Behaviors/Reactor.cs index 4a65a337f..e5321ac17 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Reactor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Reactor.cs @@ -2,12 +2,9 @@ * 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.Linq; using MessagePack; using Newtonsoft.Json; -using UniRx; using CultMath; using static CultMath.math; @@ -37,118 +34,69 @@ public class ReactorData : BehaviorData } } -public class Reactor : Behavior, IOrderedBehavior, IDisposable +public class Reactor : Behavior { private ReactorData _data; + // Cut 3 (docs/stats-and-power-cut.md §1.2): reported total, not a sink. Nothing writes into this from + // outside any more -- PowerBus.NetDraw is the only input Execute reads to decide overload/throttle. public float Draw { get; private set; } - + public float CurrentLoadRatio { get; private set; } - public int Order => 100; - - private List _capacitors; - - private List _subscriptions = new List(); - + // Cut 5 (docs/stats-and-power-cut.md §1.3): IOrderedBehavior/Order (was 100, the highest, so Reactor always + // ran last) is deleted -- the bus now steps before any equipment's Execute regardless of order, and nothing + // else claimed IOrderedBehavior, so the whole interface goes with its only implementer (§7 O3). public Reactor(ReactorData data, EquippedItem item) : base(data, item) { _data = data; - FindCapacitors(); } public Reactor(ReactorData data, ConsumableItemEffect item) : base(data, item) { _data = data; - FindCapacitors(); } - private void FindCapacitors() - { - _capacitors = Entity.GetBehaviors().ToList(); - _subscriptions.Add(Entity.Equipment.ObserveAdd().Subscribe(onAdd => - { - var capacitor = onAdd.Value.GetBehavior(); - if (capacitor != null) _capacitors.Add(capacitor); - })); - _subscriptions.Add(Entity.Equipment.ObserveRemove().Subscribe(onRemove => - { - var capacitor = onRemove.Value.GetBehavior(); - if (capacitor != null) _capacitors.Remove(capacitor); - })); - } - - public void ConsumeEnergy(float energy) - { - Draw += energy; - } + // Cut 3: the resolved generation PowerBus.Step needs before this behaviour's own Execute runs this tick. + public float Generation(float dt) => Evaluate(_data.Charge) * dt; public override bool Execute(float dt) { - var charge = Evaluate(_data.Charge) * dt; + var charge = Generation(dt); var efficiency = Evaluate(_data.Efficiency); - - // This behavior executes last, so any components drawing power have already done so - - // Subtract the baseline charge from draw - Draw -= charge; - - // Generate heat using baseline efficiency var heat = charge / efficiency; - // We have an energy deficit, have to overload the reactor - if (Draw > .01f) + // Cut 3: PowerBus already decided, for the whole entity, how much demand generation and stored charge + // could not cover (positive) or how much generation was left over after demand and after topping up + // every capacitor (negative). Split evenly across every online reactor, the same way + // Entity.TryConsumeEnergy used to divide unmet demand among reactors before this behaviour ever saw it. + var onlineReactors = Entity.GetBehaviors().Count(r => r.Item.Online.Value); + var share = onlineReactors > 0 ? Entity.PowerBus.NetDraw / onlineReactors : 0f; + + if (share > .01f) { - CurrentLoadRatio = (Draw + charge) / max(charge, .01f); + // Deficit: the bus already drained every capacitor it could; this is what is left. Overload power + // always neutralizes it, at overload efficiency. + CurrentLoadRatio = (share + charge) / max(charge, .01f); var overloadEfficiency = Evaluate(_data.OverloadEfficiency); - - // Generate heat using overload efficiency, usually much less efficient! - heat += Draw / overloadEfficiency; - - // Overload power will always neutralize the energy deficit - Draw = 0; + heat += share / overloadEfficiency; + Draw = share; } - - // We have an energy surplus, try to store energy in our capacitors - if (Draw < -.01f) + else if (share < -.01f) { - int nonFullCapacitorCount; - do - { - var chargeToAdd = -Draw; - nonFullCapacitorCount = _capacitors.Count(c => c.Charge < c.Capacity - .01f); - foreach (var capacitor in _capacitors) - { - if (capacitor.Charge < capacitor.Capacity - .01f) - { - var chargeAdded = min(chargeToAdd / nonFullCapacitorCount, capacitor.Capacity - capacitor.Charge); - capacitor.AddCharge(chargeAdded); - Draw += chargeAdded; - } - } - } while (nonFullCapacitorCount > 0 && Draw < -.01f); - } - - // We still have an energy surplus, try to throttle the reactor to reduce heat generation - if (Draw < -.01f) - { - CurrentLoadRatio = (Draw + charge) / max(charge, .01f); - heat -= Draw / efficiency * (1 - 1 / Evaluate(_data.ThrottlingFactor)); + // Surplus the bus could not absorb into any capacitor (all full): throttle to reduce heat generation. + CurrentLoadRatio = (share + charge) / max(charge, .01f); + heat -= share / efficiency * (1 - 1 / Evaluate(_data.ThrottlingFactor)); Draw = 0; } else { CurrentLoadRatio = 1; + Draw = 0; } - + Item.SetAudioParameter(SpecialAudioParameter.Intensity, max(.25f, 1 - 1 / CurrentLoadRatio)); - + AddHeat(heat); return true; } - - public void Dispose() - { - foreach(var sub in _subscriptions) - sub.Dispose(); - } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index 417bb453b..228657b5d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -50,9 +50,13 @@ public class SensorData : BehaviorData } } -public class Sensor : Behavior, IEventBehavior +public class Sensor : Behavior, IEventBehavior, IPowerConsumer { private SensorData _data; + + // Cut 4 (docs/stats-and-power-cut.md, Cut 4): replaces the direct Entity.TrySpendCapacitorCharge spend in + // Ping() below -- the named, temporary exception Cut 3 left standing. + private readonly InputCapacitor _capacitor = new InputCapacitor(); private float _pingCooldown; private float _pingLerp; private bool _pinging; @@ -80,9 +84,35 @@ public class Sensor : Behavior, IEventBehavior OnPingEnd = null; } + // Cut 4: capacity/rate, resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls + // PowerRequest below before this behaviour's own Execute runs this tick). + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PingEnergy and + // PingCooldown are registered request fields (StatValidation.PowerRequestFields) -- read nominally, same + // reasoning as InstantWeapon's own RefreshInputCapacitor. + private void RefreshInputCapacitor() + { + var energy = EvaluateNominalPower(_data.PingEnergy); + var cooldown = EvaluateNominalPower(_data.PingCooldown); + _capacitor.UpdateStats(energy, cooldown); + } + + // Cut 4: the request PowerBus needs before Execute runs. + public float PowerRequest(float dt) + { + RefreshInputCapacitor(); + return _capacitor.RequestedFill(dt); + } + + // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Utility -- everything else. + public int DefaultPowerTier => PowerTiers.Utility; + public void Ping() { - if(_pingCooldown < 0 && Entity.TryConsumeEnergy(Evaluate(_data.PingEnergy))) + // Whole-or-nothing against the ping's own input capacitor -- fires only at full charge (operator + // ruling: no fraction of a shot). A consumable-hosted instance (Item == null) has no PowerBus entry, so + // nothing would ever fill this capacitor; bypass it rather than starving such an instance forever. + if(_pingCooldown < 0 && (Item == null || _capacitor.TrySpend(_capacitor.Capacity))) { Entity.VisibilitySources[this] = Evaluate(_data.PingVisibility); _pinging = true; @@ -106,6 +136,10 @@ public class Sensor : Behavior, IEventBehavior public override bool Execute(float dt) { + // Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item + // instance never reaches PowerBus.Step (see Ping()) and so never accrues charge. + if (Item != null) + _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); if (_pinging) { _pingLerp += dt / _data.PingDuration; diff --git a/Assets/Scripts/ServerShared/Behaviors/Shield.cs b/Assets/Scripts/ServerShared/Behaviors/Shield.cs index 474a3f37e..cef8e8941 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Shield.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Shield.cs @@ -10,12 +10,27 @@ using static CultMath.math; [Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] public class ShieldData : BehaviorData { - [Inspectable, JsonProperty("efficiency"), Key(1), RuntimeInspectable] + [Inspectable, JsonProperty("efficiency"), Key(1), RuntimeInspectable] public PerformanceStat Efficiency = new PerformanceStat(); - [Inspectable, JsonProperty("energy"), Key(2), RuntimeInspectable] + [Inspectable, JsonProperty("energy"), Key(2), RuntimeInspectable] public PerformanceStat EnergyUsage = new PerformanceStat(); - + + // Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): supersedes Cut + // 4's derived sizing (reserve = EnergyUsage, refilling every second). The reserve is now an authored energy + // pool, sized independently of EnergyUsage (which stays the per-damage-point cost multiplier). + [Inspectable, JsonProperty("capacity"), Key(3), RuntimeInspectable] + public PerformanceStat Capacity = new PerformanceStat(); + + // Seconds to go from empty to full while the shield is up (holding, not broken). + [Inspectable, JsonProperty("refillDuration"), Key(4), RuntimeInspectable] + public PerformanceStat RefillDuration = new PerformanceStat(); + + // Seconds to go from empty to full while the shield is broken. This is the punish window: how long a hit + // that overwhelms the reserve leaves the hull exposed. + [Inspectable, JsonProperty("restoreDuration"), Key(5), RuntimeInspectable] + public PerformanceStat RestoreDuration = new PerformanceStat(); + public override Behavior CreateInstance(EquippedItem item) { return new Shield(this, item); @@ -26,13 +41,47 @@ public class ShieldData : BehaviorData } } -public class Shield : Behavior, IProgressBehavior +public class Shield : Behavior, IProgressBehavior, IPowerConsumer, IAlwaysUpdatedBehavior { public float Efficiency { get; private set; } public float EnergyUsage { get; private set; } + public float Capacity { get; private set; } + public float RefillDuration { get; private set; } + public float RestoreDuration { get; private set; } + + // Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): the state Cut 4's + // reserve never had. A hit the reserve cannot fully cover breaks the shield instead of being silently + // refused with its charge intact. Persists tick to tick (unlike Efficiency/EnergyUsage/Capacity above, + // which are re-evaluated fresh every Execute): it is the shield's own history, not a derived stat. + // + // Operator's second ruling, same date, on the fork the first ruling left open: the breaking hit passes + // through in full -- no partial absorption. The reserve does not spend the portion it could have covered; + // TakeHit is never called for this hit at all -- CanTakeHit's false return is the caller's signal to route + // the whole hit to the hull instead, and Break() below (F5, Soul pass 2026-09-19) is what the caller invokes + // at that same point to actually break the shield, since CanTakeHit itself is now a pure query (see every + // caller under Assets/Scripts/Gameplay/Weapons). The break is a distinct event the player feels, not a + // discount on an overkill hit. + // + // A second question the operator asked me to settle and state: what happens to whatever charge the reserve + // held at the moment it broke. Chosen: emptied by the break, not left at its pre-hit charge. A reserve that + // broke at 9/10 and kept that charge would refill to full in a heartbeat under RestoreDuration (which is + // sized for a full recharge, not a top-up) -- the punish window would only ever bite on a shield broken + // from near-empty. Zeroing it makes RestoreDuration mean the same thing every time a shield breaks, + // regardless of how close to full it was when the hit that broke it landed. + public bool Broken { get; private set; } private ShieldData _data; + // Operator ruling, 2026-09-19: reserve capacity is now an authored number (Capacity), independent of + // EnergyUsage (which stays the per-damage-point cost multiplier applied in CanTakeHit/TakeHit below). Rate + // is derived the same way InputCapacitor already derives every other consumer's rate (Capacity/duration, + // UpdateStats' own cooldown-to-rate formula) -- just fed RefillDuration while up and RestoreDuration while + // Broken, so refilling from partial and restoring from broken are two different speeds through the one + // mechanism every other IPowerConsumer already uses. A partial bus grant (Item.PowerSupply < 1) stretches + // either duration proportionally for free: AddCharge below is scaled by PowerSupply same as every other + // consumer, so a half grant simply takes twice as long to fill regardless of which duration is active. + private readonly InputCapacitor _reserve = new InputCapacitor(); + public Shield(ShieldData data, EquippedItem item) : base(data, item) { _data = data; @@ -42,21 +91,101 @@ public class Shield : Behavior, IProgressBehavior _data = data; } + // Resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls PowerRequest below before + // this behaviour's own Execute runs this tick), reading Broken as it stood at the end of the previous tick. + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): Capacity, RefillDuration + // and RestoreDuration are registered request fields (StatValidation.PowerRequestFields) -- read nominally. + // This also feeds Update's own real accumulation math below (RefreshReserve is called from both PowerRequest + // and Update), which is fine: the actual charge added is separately scaled by Item.PowerSupply there, so + // nominalizing the reserve's physical size/rate does not change how much of a partial grant it actually + // receives, only removes any dependency the size/rate calculation itself could have had on this tick's grant. + private void RefreshReserve() + { + var capacity = EvaluateNominalPower(_data.Capacity); + var duration = Broken ? EvaluateNominalPower(_data.RestoreDuration) : EvaluateNominalPower(_data.RefillDuration); + _reserve.UpdateStats(capacity, cooldown: duration, capacityOverride: capacity); + } + + // The request PowerBus needs before Execute runs. + public float PowerRequest(float dt) + { + RefreshReserve(); + return _reserve.RequestedFill(dt); + } + + // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): High -- hull survival the player is actively + // trading power for, but not the life-support constant Radiator is, so it sits one tier below. + public int DefaultPowerTier => PowerTiers.High; + public override bool Execute(float dt) { Efficiency = Evaluate(_data.Efficiency); EnergyUsage = Evaluate(_data.EnergyUsage); + Capacity = Evaluate(_data.Capacity); + RefillDuration = Evaluate(_data.RefillDuration); + RestoreDuration = Evaluate(_data.RestoreDuration); return true; } + // F4 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the reserve's own recharge, and clearing Broken + // once it is full, used to live in Execute above -- which Entity.Update only calls while Item.Active is + // true. A hit that breaks the shield usually also cooks it (AddHeat in TakeHit below), which can knock the + // item thermally offline in the same moment it breaks -- Execute then stops running for as long as the item + // stays offline, so the shield could never come back no matter how long RestoreDuration allows for. Restoring + // must not depend on whether the item is currently Active, so it moved to IAlwaysUpdatedBehavior.Update, + // which Entity.Update calls every tick unconditionally (Cooldown.cs already relies on the same interface for + // exactly this reason -- see Entity.cs's own alwaysUpdatedBehavior loop, outside the `if (Active.Value)` + // block Execute lives in). PowerBus (F2) only bills and refreshes Item.PowerSupply while the item is Active, + // so while offline this keeps recharging at the last grant it actually received rather than freezing dead or + // fabricating a fresh one -- the punish window still runs down on its own clock instead of being held + // hostage by an unrelated shutdown. + public void Update(float dt) + { + // Item is non-null-checked the same way Execute's old version was: a null-Item (consumable-hosted) + // instance never reaches PowerBus.Step and so never accrues charge -- see CanTakeHit's own comment. + if (Item == null) return; + RefreshReserve(); + _reserve.AddCharge(_reserve.RequestedFill(dt) * Item.PowerSupply); + // Restored to full on the restore duration: back up. + if (Broken && _reserve.Charge >= _reserve.Capacity - .01f) Broken = false; + } + + // Cut 4 (docs/stats-and-power-cut.md, Cut 4): a hit taken, not a chosen activation, drawing from the + // shield's own continuous reserve instead of the entity's shared bus capacitors -- closing Cut 3's named, + // temporary exception. A consumable-hosted instance (Item == null) has no PowerBus entry, so nothing would + // ever fill this reserve; bypass it rather than starving such an instance forever. + // + // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this used to mutate -- breaking the shield and + // draining the reserve -- as a side effect of the query itself, on the premise that "every caller queries + // CanTakeHit exactly once per hit." That premise was false: RaycastAll returns both the shield collider and + // the hull collider for one shot, and every caller under Assets/Scripts/Gameplay/Weapons queries this twice + // per hit (once deciding whether the shield collider absorbs it, once deciding whether the hull collider is + // exposed). A caller must be able to ask without consequence, so this is now a pure read: Broken and the + // reserve's own CanSpend, nothing more. See Break() below for where the mutation moved. public bool CanTakeHit(DamageType type, float damage) { - return Entity.CanConsumeEnergy(damage * EnergyUsage); + if (Item == null) return true; + if (Broken) return false; + return _reserve.CanSpend(damage * EnergyUsage); + } + + // F5: the mutation CanTakeHit used to perform when it returned false, now a caller invokes explicitly at the + // one point damage is actually applied -- specifically, the point a caller decides to route a hit past this + // shield (to the hull) instead of absorbing it, which is also the only point that decision is made at all, + // so this is naturally called at most once per hit despite the double CanTakeHit query above. Idempotent + // (a no-op once Broken is already true) as a defense-in-depth against a caller structure that cannot + // guarantee single-call discipline, not as licence to call it more than once on purpose. + public void Break() + { + if (Item == null || Broken) return; + Broken = true; + _reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge } public void TakeHit(DamageType type, float damage) { - Entity.TryConsumeEnergy(damage * EnergyUsage); + if (Item != null) _reserve.TrySpend(damage * EnergyUsage); AddHeat(damage / Efficiency); } diff --git a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs index 6f1f506c5..5e56e4c42 100644 --- a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs +++ b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs @@ -3,6 +3,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ using System; +using System.Collections.Generic; using System.Linq; using MessagePack; using Newtonsoft.Json; @@ -36,18 +37,24 @@ public class StatModifierData : BehaviorData public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwaysUpdatedBehavior { + // A resolved value is per (item, stat) (docs/stats-and-power-cut.md §1.1), so a modifier that reaches a stat + // must attach under the owner that specific stat resolves through -- the EquippedItem the field came from -- + // not under the modifying behaviour's own owner. Two items of the same design targeted by one modifier get + // two separate resolver entries, exactly as two separate Evaluate calls on them already would. + private readonly struct Target + { + public readonly object Owner; + public readonly PerformanceStat Stat; + public Target(object owner, PerformanceStat stat) { Owner = owner; Stat = stat; } + } + private StatModifierData _data; - private PerformanceStat[] _stats; - - private static Type[] _statObjects; + private Target[] _targets; private bool _applied; private bool _executed; - private static Type[] StatObjects => _statObjects = _statObjects ?? typeof(BehaviorData).GetAllChildClasses() - .Concat(typeof(EquippableItemData).GetAllChildClasses()).ToArray(); - public StatModifier(StatModifierData data, EquippedItem item) : base(data, item) { _data = data; @@ -58,48 +65,175 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa _data = data; } + // Cut 2 (docs/stats-and-power-cut.md): the (type name, field name) resolution that used to live here, done + // fresh and silently at every equip, now goes through StatValidation.ResolveStatField -- resolved once, + // memoized, and refused loudly (at catalog load, or here if a caller built one without going through the + // catalog) instead of leaving `_stats` null for ApplyModifier to throw an opaque NullReferenceException on. + // + // Gate 2 fix (Soul pass over Cut 2): Initialize is the entity's own re-activation hook, called exactly once + // per (re)activation for every StatModifier on it -- it is the one place that already knows "this behaviour's + // targets are about to change." Detaching an old attachment, if any, belongs here, not in Update's _executed/ + // _applied latch: that latch only tracks per-tick execution and was never told a re-Initialize had happened, + // so after deactivate -> unequip the target -> equip a replacement -> activate, _applied stayed true from the + // stale attachment and Update's `_executed && !_applied` guard never fired again -- the modifier silently + // stopped reaching anything. Making Initialize own the detach/reattach transition removes the split + // authority instead of just resetting the flag: whatever this modifier was attached to before is explicitly + // let go before new targets are computed, so the next Execute+Update reliably reapplies against them. public void Initialize() { - var targetType = StatObjects.FirstOrDefault(so => so.Name == _data.Stat.Target); - if (targetType != null) + if (_applied) + RemoveModifier(); + _targets = TargetsOf(Entity, _data); + ValidateNoCycle(Entity, _data, _targets); + ValidateNoPowerSupplyChain(Entity, _data, _targets, Item); + } + + private static Target[] TargetsOf(Entity entity, StatModifierData data) + { + var (targetType, statField) = StatValidation.ResolveStatField(data.Stat); + var gear = entity.Equipment + .Where(g => string.IsNullOrEmpty(data.RequireBehavior) || + entity.ItemManager.GetData(g.EquippableItem).Behaviors.Any(b => b.GetType().Name == data.RequireBehavior)); + return typeof(EquippableItemData).IsAssignableFrom(targetType) + ? gear.Where(g => entity.ItemManager.GetData(g.EquippableItem).GetType() == targetType) + .Select(g => new Target(g, statField.GetValue(entity.ItemManager.GetData(g.EquippableItem)) as PerformanceStat)) + .Where(t => t.Stat != null) + .ToArray() + : gear.Where(g => entity.ItemManager.GetData(g.EquippableItem).Behaviors.Any(bd => bd.GetType() == targetType)) + .SelectMany(g => entity.ItemManager.GetData(g.EquippableItem).Behaviors + .Where(bd => bd.GetType() == targetType) + .Select(bd => new Target(g, statField.GetValue(bd) as PerformanceStat))) + .Where(t => t.Stat != null) + .ToArray(); + } + + // Shared by ValidateNoCycle and ValidateNoPowerSupplyChain below: every StatModifier currently on this + // entity, as an edge from the magnitude stat it reads to the stat(s) it writes. Built fresh from + // Entity.Equipment every time (never a static or catalog-side map), so it costs nothing to discard and cannot + // leak: by the time Activate() calls any StatModifier's Initialize, every EquippedItem (and so every + // StatModifier) on the entity already exists (Entity.cs Activate). Both checks see exactly what is actually + // equipped, not what the catalog merely allows. + private static Dictionary> BuildModifierEdges(Entity entity, StatModifierData data, Target[] targets) + { + var edges = new Dictionary>(); + foreach (var behavior in entity.Equipment.SelectMany(e => e.Behaviors).OfType()) { - var statField = targetType.GetFields() - .Where(f => f.FieldType == typeof(PerformanceStat)).FirstOrDefault(f => f.Name == _data.Stat.Stat); - if (statField != null) + if (behavior._data.Modifier == null) continue; + Target[] behaviorTargets; + try { - if (typeof(EquippableItemData).IsAssignableFrom(targetType)) - _stats = Entity.Equipment - .Select(hp => hp.EquippableItem) - .Where(gear => string.IsNullOrEmpty(_data.RequireBehavior) || ItemManager.GetData(gear).Behaviors.Any(behavior => behavior.GetType().Name == _data.RequireBehavior)) - .Where(gear => ItemManager.GetData(gear).GetType() == targetType) - .Select(gear => statField.GetValue(ItemManager.GetData(gear)) as PerformanceStat) - .ToArray(); - else - _stats = Entity.Equipment - .Select(hp => hp.EquippableItem) - .Where(gear => string.IsNullOrEmpty(_data.RequireBehavior) || ItemManager.GetData(gear).Behaviors.Any(behavior => behavior.GetType().Name == _data.RequireBehavior)) - .SelectMany(gear => ItemManager.GetData(gear).Behaviors) - .Where(behaviorData => behaviorData.GetType() == targetType) - .Select(behaviorData => statField.GetValue(behaviorData) as PerformanceStat) - .ToArray(); + behaviorTargets = ReferenceEquals(behavior._data, data) ? targets : TargetsOf(entity, behavior._data); } + catch (InvalidOperationException) + { + continue; // another modifier's own unresolved reference is that modifier's Initialize to refuse + } + if (!edges.TryGetValue(behavior._data.Modifier, out var list)) + edges[behavior._data.Modifier] = list = new List(); + foreach (var target in behaviorTargets) + if (target.Stat != null) list.Add(target.Stat); + } + return edges; + } + + // A modifier's magnitude (_data.Modifier) is itself a PerformanceStat, evaluated through the same resolver as + // everything else it modifies. If that magnitude stat is, directly or through another modifier on this + // entity, one of the stats this modifier writes, resolving it would depend on a value computed from itself + // one tick late -- an authoring error, not a feature. Refused at equip, naming the entity and the reference. + private static void ValidateNoCycle(Entity entity, StatModifierData data, Target[] targets) + { + if (data.Modifier == null) return; + + var edges = BuildModifierEdges(entity, data, targets); + + var visited = new HashSet(); + bool ReachesSelf(PerformanceStat node) + { + if (!edges.TryGetValue(node, out var next)) return false; + foreach (var target in next) + { + if (ReferenceEquals(target, data.Modifier)) return true; + if (visited.Add(target) && ReachesSelf(target)) return true; + } + return false; + } + if (ReachesSelf(data.Modifier)) + throw new InvalidOperationException( + $"{entity.Name}: stat modifier \"{data.Stat.Target}.{data.Stat.Stat}\" reads a magnitude stat whose " + + "modifier chain reaches back to itself -- a stat cannot (even transitively) modify its own magnitude"); + } + + // Cut 6 (docs/stats-and-power-cut.md), narrowed by the nominal-request ruling (operator ruling 2026-09-19, + // ItemData.cs's own PowerRequestFields comment): this is now the ONLY surviving half of "a stat that decides + // a power request may not depend on power supply, directly or through a modifier chain." The direct half -- + // a request stat's own declared Terms naming PowerSupply -- used to be refused statically + // (StatValidation.ValidateNoPowerSupplyOnRequest, at catalog load, Upsert and equip), but every + // PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now reads a PowerRequestFields stat + // through EquippedItem.EvaluateNominalPower (Entity.cs), which pins that stat's own PowerSupplyFactor to 1 + // regardless of its Terms -- so a direct term can no longer make the request depend on its own answer, and + // that static check was deleted. What EvaluateNominalPower does NOT pin is ScaleModifier/ConstantModifier -- + // it forwards those to the item's real, non-nominal resolver entries (same as ConditionRatio's own + // NominalContext), so a modifier chain that reaches a power-tainted magnitude stat still corrupts a nominal + // read too. That is exactly what this check still catches, and it needs an entity to do it: whether a given + // modifier's target is "a power request stat" is static (the (BehaviorData type, field) registry in + // StatValidation), but which items are actually feeding a modifier chain into it is a fact about this + // concrete ship's loadout. Walks the same magnitude->target edges ValidateNoCycle builds, in the other + // direction: is this modifier's own magnitude stat "power-tainted" -- does it, or something feeding it + // through another modifier on this entity, carry a PowerSupply term -- and does it write onto a known + // request stat. Refused at equip, naming both the modifying item and the item whose request it would + // corrupt. + private static void ValidateNoPowerSupplyChain(Entity entity, StatModifierData data, Target[] targets, EquippedItem modifyingItem) + { + if (data.Modifier == null) return; + var edges = BuildModifierEdges(entity, data, targets); + if (!IsPowerTainted(data.Modifier, edges, new HashSet())) + return; + + foreach (var target in targets) + { + if (!(target.Owner is EquippedItem targetItem)) continue; + if (!StatValidation.TryGetPowerRequestBehaviorName(targetItem.Data, target.Stat, out var behaviorName)) + continue; + throw new InvalidOperationException( + $"{entity.Name}: stat modifier on \"{modifyingItem?.Data.Name ?? "?"}\" would let " + + $"\"{targetItem.Data.Name}\".{behaviorName}'s power request depend on power supply through a " + + "modifier chain -- a stat that decides how much power a behaviour asks for may not depend, even " + + "transitively, on how much it receives"); } } + // A magnitude stat is power-tainted if its own Terms name PowerSupply directly, or if it is itself the + // target of some other modifier on this entity whose magnitude is (transitively) power-tainted. `visited` + // guards the search against a real cycle -- ValidateNoCycle already refuses those outright, but this walk + // must not stack-overflow while that refusal is still in flight for a different modifier on the same entity. + private static bool IsPowerTainted(PerformanceStat stat, Dictionary> edges, HashSet visited) + { + if (stat == null || !visited.Add(stat)) return false; + foreach (var term in stat.Terms) + if (term.Source == StatSource.PowerSupply) + return true; + foreach (var pair in edges) + if (pair.Value.Contains(stat) && IsPowerTainted(pair.Key, edges, visited)) + return true; + return false; + } + + // Cut 2: the modifier value used to be written straight into the catalog stat's own per-entity dictionary + // (the leak, §0.3). It now attaches to the resolver entry it targets, keyed by (that item, that stat); it is + // discarded with the entity, never with the catalog. private void ApplyModifier() { _applied = true; - foreach (var stat in _stats) - (_data.Type == StatModifierType.Constant - ? stat.GetConstantModifiers(Entity) - : stat.GetScaleModifiers(Entity))[this] = Evaluate(_data.Modifier); + var value = Evaluate(_data.Modifier); + foreach (var target in _targets) + Entity.Resolver.AttachModifier(target.Owner, target.Stat, this, _data.Type, value); } private void RemoveModifier() { _applied = false; - foreach (var stat in _stats) - (_data.Type == StatModifierType.Constant ? stat.GetConstantModifiers(Entity) : stat.GetScaleModifiers(Entity)).Remove(this); + foreach (var target in _targets) + Entity.Resolver.DetachModifier(target.Owner, target.Stat, this); } public override bool Execute(float dt) diff --git a/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs b/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs new file mode 100644 index 000000000..96d9f3703 --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs @@ -0,0 +1,83 @@ +/* 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 MessagePack; +using Newtonsoft.Json; + +// Cut 2 (docs/fire-control-cut.md): the required interior Tool gear that makes aimed fire possible at all +// (R2, Q4). An entity with no working targeting system is not merely worse -- FireControl falls back to +// GameplaySettings.UnaidedAccuracy and a Precision of 0, which is authored to make unaided fire a last +// resort (Q4: "really, really bad"). This behaviour carries no power machinery of its own: EnergyDraw and +// Heat, authored alongside it on the same item, already cover that, and it adds nothing new. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] +public class TargetingSystemData : BehaviorData +{ + // The ceiling on hit probability (Cut 3's roll multiplies against this; nothing here computes a + // probability). A stat, not a constant, so lot quality, wear, heat and PowerSupply all reach it through + // EquippedItem.Evaluate -- a starved targeting system rolls worse rather than switching off (the + // brownout ruling, docs/stats-and-power-cut.md, applied here). + [Inspectable, JsonProperty("accuracy"), Key(1), RuntimeInspectable] + public PerformanceStat Accuracy = new PerformanceStat(); + + // The info level (see Entity.EntityInfoGathered) at which sensor state stops limiting hits. Below the + // system's own resolution, an otherwise-earned hit still degrades on account of thin sensor data; + // consumed by Cut 3's roll. Higher is better, same as every other stat here: Cut 6c.1 (operator ruling + // 2026-09-19) has FireControl.HitProbability take Resolution's reciprocal to derive the actual info + // ceiling (ceiling = detection + (1 - detection) / Resolution), so authoring a bigger number buys a lower + // ceiling -- less info needed -- rather than the field's own value being read as the ceiling directly. + [Inspectable, JsonProperty("resolution"), Key(2), RuntimeInspectable] + public PerformanceStat Resolution = new PerformanceStat(); + + // Cut 6d (docs/fire-control-cut.md): grouping tightness, not a probability. FireControl.Sigma derives a + // Gaussian kernel's sigma as 1/Precision, in hull-schematic cell units -- a higher number groups tighter + // around whatever is aimed at (Entity.TargetItem's cells, or the hull's own centre of mass unaimed). + // Consumed by Cut 3's roll (folded into HitProbability as pOnHull) and by Commit's cell draw, both through + // the one kernel function -- never re-derived separately. + [Inspectable, JsonProperty("precision"), Key(3), RuntimeInspectable] + public PerformanceStat Precision = new PerformanceStat(); + + // How much target-side deviation from the predicted intercept this system forgives before a shot goes + // wide. Consumed by Cut 3's roll; unused until then. + [Inspectable, JsonProperty("tracking"), Key(4), RuntimeInspectable] + public PerformanceStat Tracking = new PerformanceStat(); + + public override Behavior CreateInstance(EquippedItem item) + { + return new TargetingSystem(this, item); + } + + public override Behavior CreateInstance(ConsumableItemEffect item) + { + return new TargetingSystem(this, item); + } +} + +public class TargetingSystem : Behavior +{ + private readonly TargetingSystemData _data; + + public float Accuracy { get; private set; } + public float Resolution { get; private set; } + public float Precision { get; private set; } + public float Tracking { get; private set; } + + public TargetingSystem(TargetingSystemData data, EquippedItem item) : base(data, item) + { + _data = data; + } + + public TargetingSystem(TargetingSystemData data, ConsumableItemEffect item) : base(data, item) + { + _data = data; + } + + public override bool Execute(float dt) + { + Accuracy = Evaluate(_data.Accuracy); + Resolution = Evaluate(_data.Resolution); + Precision = Evaluate(_data.Precision); + Tracking = Evaluate(_data.Tracking); + return true; + } +} diff --git a/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs.meta b/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs.meta new file mode 100644 index 000000000..63ea1ca6c --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e72dd6e15fead417f201fdc92c1fd259 +timeCreated: 1758283200 diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index 54aa39c34..29cf8f181 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -23,7 +23,7 @@ public class ThrusterData : BehaviorData [Inspectable, JsonProperty("energy"), Key(4), RuntimeInspectable] public PerformanceStat EnergyUsage = new PerformanceStat(); - [Inspectable, CultInspectorAssetPath, JsonProperty("Particles"), Key(5)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("Particles"), Key(5)] public string ParticlesPrefab; public override Behavior CreateInstance(EquippedItem item) @@ -37,7 +37,7 @@ public class ThrusterData : BehaviorData } } -public class Thruster : Behavior, IAnalogBehavior +public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer { public float Thrust { get; private set; } public float Torque { get; } @@ -49,9 +49,15 @@ public class Thruster : Behavior, IAnalogBehavior } private ThrusterData _data; - + private float _input; + // Cut 8 (operator ask 2026-09-19): presentation's read of "how healthy does this thruster actually look" -- + // Thrust is the stat that governs this behaviour's real output, so it is the equivalent quantity to condition + // against. 1f for the ConsumableItemEffect constructor's item-less case; that path has no durability, heat or + // power-supply state to be broken by, so it always reads perfect. + public float Condition => Item?.ConditionRatio(_data.Thrust) ?? 1f; + public Thruster(ThrusterData data, EquippedItem item) : base(data, item) { _data = data; @@ -71,10 +77,29 @@ public class Thruster : Behavior, IAnalogBehavior Thrust = Evaluate(_data.Thrust); } + // Cut 3 (docs/stats-and-power-cut.md): the resolved stat times the behaviour-supplied throttle scalar (§1.2), + // exactly the shape the map names. _input is set externally (the ship's controls) before Entity.Update calls + // PowerBus.Step, so it is already current when this runs. + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): EnergyUsage is a + // registered request field (StatValidation.PowerRequestFields) -- read nominally, same reasoning as every + // other IPowerConsumer in this cut, though EnergyUsage carries no PowerSupply term in Thruster's own shipped + // catalog today; Thrust (the field Cut 7 curves) is a separate stat Execute reads with the real Evaluate. + public float PowerRequest(float dt) => _input > .01f ? _input * EvaluateNominalPower(_data.EnergyUsage) : 0f; + + // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Medium -- mobility. Losing thrust for a tick + // under brownout is an inconvenience, not the cascading failure a starved radiator or shield causes. + public int DefaultPowerTier => PowerTiers.Medium; + public override bool Execute(float dt) { Item.SetAudioParameter(SpecialAudioParameter.Intensity, _input); - if(_input > .01f && Entity.TryConsumeEnergy(_input * Evaluate(_data.EnergyUsage))) + // Cut 7 (docs/stats-and-power-target.md): "continuous consumers brown out ... through a power supply + // curve on their performance stats." Thrust below is a plain Evaluate() read, so once the catalog's own + // Thrust stat carries a PowerSupply term, a partial grant already comes back reduced -- this gate no + // longer demands a full grant, only that the thruster is being asked to do anything (_input) and that it + // has not been cut to true zero supply (the epsilon PowerBus itself already treats as "nothing granted"). + if(_input > .01f && Item.PowerSupply > 1e-4f) { Thrust = Evaluate(_data.Thrust); Entity.Velocity -= Direction.xz * _input * Thrust / Entity.Mass * dt; diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 6e39f94e0..48b4c6c4e 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -73,15 +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; - var fire = dot( - x.Direction, - Entity.LookDirection) > .99f; - if (x.Evaluate(data.Range) > dist && fire) + // The same per-weapon decision Combat.cs makes; the range test is part of it (a shot out of range + // is not designated and prices at zero). + if (FireControl.AgentFires(x, Entity, Entity.Target.Value)) { x.Activate(); } diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 5e5636f00..47f2acf11 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -40,7 +40,7 @@ public abstract class WeaponData : BehaviorData [InspectableAnimationCurve, JsonProperty("damageCurve"), Key(7)] public BezierCurve DamageCurve; - [Inspectable, CultInspectorAssetPath, JsonProperty("effect"), Key(8)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("effect"), Key(8)] public string EffectPrefab; [Inspectable, JsonProperty("energy"), Key(9), RuntimeInspectable] @@ -100,6 +100,16 @@ public abstract class Weapon : Behavior, IActivatedBehavior // With no target set, behaviour is unchanged (nothing to be safe about). public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value); + // Cut 3 (docs/fire-control-cut.md, Q2): player fire is arc-gated like everyone else's. This is the same + // predicate (FireControl.InArc) Combat.cs and TurretController.cs gate AI and turret fire with, read at + // the one point every shooter's trigger passes through (InstantWeapon.Trigger) -- not a parallel check, and + // not the AI's own AgentMinHitProbability heuristic, which is a "worth it" decision layered above this + // 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. + // 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) { _data = data; diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index 23f9ba337..45edba408 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -22,7 +22,7 @@ public class Faction [Inspectable, CultInspectorTextArea, JsonProperty("description"), Key(3)] public string Description; - [Inspectable, CultInspectorAssetPath, JsonProperty("logo"), Key(4)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("logo"), Key(4)] public string Logo; [Inspectable, CultReference(typeof(PersonalityAttribute), many: true), JsonProperty("personality"), Key(5)] diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 5870c11a5..6ad751e01 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -6,6 +6,7 @@ using GameCult.Caching; using System; using System.Collections.Generic; using System.Linq; +using MessagePack; using UniRx; using CultMath; using static CultMath.math; @@ -44,6 +45,13 @@ public abstract class Entity public List Children = new List(); public ReactiveProperty Target = new ReactiveProperty((Entity)null); + // Cut 2 (docs/fire-control-cut.md): the stored aim-point slot. Written only by TrySelectTargetItem + // below, and nulled whenever Target changes (subscribed in Activate). Do not read this field directly + // outside Entity -- read ResolvedTargetItem, which re-validates on every call, so decay (or the item + // leaving the target) drops the aim point the moment it is no longer earned, with no loop that has to + // notice and clear it. + public ReactiveProperty TargetItem = new ReactiveProperty((EquippedItem)null); + public float3 LookDirection; public string Name; @@ -53,14 +61,21 @@ public abstract class Entity public readonly Dictionary Messages = new Dictionary(); public readonly Dictionary VisibilitySources = new Dictionary(); - public readonly ReactiveDictionary EntityInfoGathered = new ReactiveDictionary(); - public readonly Dictionary HardpointTransforms = - new Dictionary(); - + public readonly ReactiveDictionary EntityInfoGathered = new ReactiveDictionary(); + public (List weapons, List items)[] WeaponGroups; public List PopulationAssignments = new List(); + // Cut 2 (docs/stats-and-power-cut.md): the sole owner of every resolved (item, stat) value for this entity. + // Constructed with the entity and reachable only from it -- nothing on the catalog side ever points back -- + // so it is collected with the entity instead of surviving it for the life of the process (§0.3, §1.1). + public readonly StatResolver Resolver = new StatResolver(); + + // Cut 3 (docs/stats-and-power-cut.md): the sole owner of every power grant for this entity, same lifecycle + // reasoning as Resolver above (§1.2). + public readonly PowerBus PowerBus; + public EquippedItem[,] GearOccupancy; public HardpointData[,] Hardpoints; public float[,] Armor; @@ -68,8 +83,6 @@ public abstract class Entity private EquippedItem[] _orderedEquipment; private List _weapons = new List(); - private List _capacitors = new List(); - private List _reactors = new List(); private List _heatsinks = new List(); private List _activeConsumables = new List(); @@ -267,11 +280,159 @@ public abstract class Entity foreach(var subscription in _watchedEntitySubscriptions[disappearingEntity.Value]) subscription.Dispose(); _watchedEntitySubscriptions.Remove(disappearingEntity.Value); })); - + + // Cut 2 (docs/fire-control-cut.md): a Target change nulls the aim point. Fires immediately on + // subscribe with whatever Target already holds, which is harmless -- TargetItem starts null anyway. + _subscriptions.Add(Target.Subscribe(_ => TargetItem.Value = null)); + + // 9.3 (docs/fire-control-cut.md, Soul C3): a Target surviving Deactivate can point at an entity + // this zone removed while inactive -- Deactivate disposes the Zone.Entities.ObserveRemove + // subscription that is the only thing that nulls a stale Target, and EntityInfoGathered was just + // rebuilt above from the zone's *current* membership, not from whatever Target still remembers. + // Reconcile here, now that both the fresh EntityInfoGathered and the Target-change subscription + // above are live (so this write also clears TargetItem through it): an active entity's Target + // must always be an entity it has info on. Activate owns this reconciliation rather than + // Deactivate nulling Target outright, because nulling on every dock would throw away a target + // that is still alive and simply never changed while docked -- the common case, and not the one + // that crashed. + if (Target.Value != null && !EntityInfoGathered.ContainsKey(Target.Value)) + Target.Value = null; + if(WeaponGroups.All(wg=>!wg.items.Any())) GenerateWeaponGroups(); } + // Cut 2 (docs/fire-control-cut.md): the only writer of TargetItem. Accepts null (clearing the aim + // point) or an item belonging to the current Target that FireControl.IsRevealed resolves true for this + // entity as observer. Returns whether the write took effect; a rejected non-null selection leaves + // TargetItem unchanged. + public bool TrySelectTargetItem(EquippedItem item) + { + if (item == null) + { + TargetItem.Value = null; + return true; + } + + if (Target.Value == null || item.Entity != Target.Value || !FireControl.IsRevealed(this, item)) + return false; + + TargetItem.Value = item; + return true; + } + + // The read path for TargetItem (see the field's own comment): null once the aimed item is no longer + // revealed to this entity, or no longer belongs to the current Target, even though nothing wrote + // TargetItem.Value at the moment that became true. Re-checked on every call; never cached. + public EquippedItem ResolvedTargetItem => + TargetItem.Value != null && Target.Value == TargetItem.Value.Entity && FireControl.IsRevealed(this, TargetItem.Value) + ? TargetItem.Value + : null; + + // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of + // what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so + // Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per + // cell -- always local to one lane, since armour never spans cells -- while deferring the item phase to + // ItemAbsorb's own pool for EVERY occupied cell, not only ones shared with another lane: a lane that turns + // out to be an item's only contributor still pools through a list of one (ItemAbsorb's own degenerate + // case), never a separate solo path. ArmorDamage fires only for incoming > 0. + public float ArmorAbsorb(int2 cell, float damage) + { + var d = damage; + + if (d > 0f) + { + var prevArmor = Armor[cell.x, cell.y]; + Armor[cell.x, cell.y] = max(prevArmor - d, 0); + ArmorDamage.OnNext((cell, d)); + d = max(d - prevArmor, 0); + } + + return d; + } + + // Cut 12.3 fix batch (one apply path, operator ruling 2026-09-25 "do not expect items taking up multiple + // cells to be an exception, this should be one code path"): the ONE function that decides an item's own + // absorption, whatever the item's shape or however many lanes of one shot reach it. `incoming` holds every + // contributing lane's own post-armour remainder for this resolve, one lane's worth included -- a + // single-lane item is this rule's own degenerate case, not a separate function or a separate threshold. + // The existing .1f threshold is decided once, on the POOLED total, never per contribution (a pooled path + // that gated each contribution separately would let two shares under .1f each slip past an item that a + // single .08f solo hit would already have stopped at). Absorbed durability is split back across `incoming` + // in place, pro-rata to what each lane brought -- the divide below never sees total <= 0.1f, so it never + // sees zero (the guard above already returned). ItemDamage fires once per contributing lane, reporting its + // own INCOMING share -- not the post-clamp amount, the same convention ArmorAbsorb already uses -- and only + // when that lane's own share is itself > 0, so a zero-deposit contributor (a lane whose armour ate its + // whole share before reaching this item) never fires a phantom event. `item` is never null here: both + // callers (`FireControl.ApplyPooled`'s `Resolve`, one lane's worth of contributions included, and + // `FireControl.Detonate`'s own per-entity item-pool resolution, one covered cell's worth included) already + // guarantee it -- a pool is only ever opened under a non-null `GearOccupancy` cell. + public void ItemAbsorb(EquippedItem item, Span incoming) + { + var total = 0f; + for (var i = 0; i < incoming.Length; i++) total += incoming[i]; + if (total <= 0.1f) return; + + var before = item.EquippableItem.Durability; + var absorbed = min(total, before); + item.EquippableItem.Durability = max(before - absorbed, 0f); + var fraction = absorbed / total; + for (var i = 0; i < incoming.Length; i++) + { + if (incoming[i] > 0f) ItemDamage.OnNext((item, incoming[i])); + incoming[i] -= incoming[i] * fraction; + } + } + + // Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one + // HullDamage event are unchanged. FireControl.Apply calls this once per resolved hit, with the summed + // remainder every lane's own walk left over (Q12-3 = A: a lane's remainder goes to the hull wherever the + // lane ends -- penetration exhausted, a gap, or the far side). + public void DamageHull(float damage) + { + if (damage > .1f) + { + Hull.Durability -= damage; + HullDamage.OnNext(damage); + } + } + + // Cut 12.1 (docs/fire-control-cut.md): the one schematic-frame owner. Maps a world-planar vector into this + // entity's own schematic frame -- x = starboard, y = bow -- so FireControl.Apply's lane walk and + // FireControl.Detonate's per-entity point conversion both read the same transform instead of each carrying + // their own copy of forward/right. + public float2 ToSchematic(float2 worldPlanar) + { + var forward = normalize(Direction); + var right = float2(forward.y, -forward.x); + return float2(dot(worldPlanar, right), dot(worldPlanar, forward)); + } + + // Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the world<->schematic POINT owners, built on + // ToSchematic's frame -- the entity's own position is the schematic's centre of mass. FireControl.Detonate + // uses ToSchematicPoint to find where a blast sits in each candidate's own schematic (the host of a contact + // or delayed blast included: it is treated like any other entity in the radius, its cells found again + // here, not carried over from the commit). ToWorldPoint is the inverse, used once, to convert a contact or + // delayed fuse point (found on the committed lane, in the host's schematic frame at commit) into the one + // world point Detonate's single input shape takes -- the round trip through the host's own pose at arrival + // is what fixes the host's damage to the commit (R4) while every bystander is judged live. + public float2 ToSchematicPoint(float2 worldPlanar) + { + var hullData = ItemManager.GetData(Hull) as HullData; + var cellSize = ItemManager.GameplaySettings.SchematicCellSize; + return ToSchematic(worldPlanar - Position.xz) / cellSize + hullData.Shape.CenterOfMass; + } + + public float2 ToWorldPoint(float2 schematicPoint) + { + var hullData = ItemManager.GetData(Hull) as HullData; + var cellSize = ItemManager.GameplaySettings.SchematicCellSize; + var local = (schematicPoint - hullData.Shape.CenterOfMass) * cellSize; + var forward = normalize(Direction); + var right = float2(forward.y, -forward.x); + return Position.xz + local.x * right + local.y * forward; + } + // Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null) // until this entity detects them, mirroring the existing detection model (VisibleEntities, driven // by EntityInfoGathered crossing TargetDetectionInfoThreshold). Detected but not yet hostility-rated @@ -337,12 +498,13 @@ public abstract class Entity public Entity(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings) { - Settings = settings; + Settings = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings)); ItemManager = itemManager; Zone = zone; Hull = hull; HullData = itemManager.GetData(hull) as HullData; Name = HullData.Name; + PowerBus = new PowerBus(this); MapEntity(); WeaponGroups = new (List weapons, List items)[itemManager.GameplaySettings.WeaponGroupCount]; for(int i=0; i Hull.Durability < .01f).Select(_ => CauseOfDeath.HullDestroyed) .Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke)) .Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia)) - .Merge(ItemDestroyed.Where(i=>i.GetBehavior()!=null).Select(_ => CauseOfDeath.CockpitDestroyed)); + .Merge(ItemDestroyed.Where(i=>i.GetBehavior()!=null).Select(_ => CauseOfDeath.CockpitDestroyed)) + // Death is terminal: the first cause ends the stream, so no damage path (a blast whose cockpit kill is + // followed by its own hull damage, a second item, a heat tick) can raise it twice to any subscriber. + .Take(1); //CurrentSecurityLevel.Value = SecurityLevel.Open; } @@ -582,7 +747,7 @@ public abstract class Entity } Equipment.Remove(item); - _orderedEquipment = Equipment.OrderBy(x => x.SortPosition).ToArray(); + _orderedEquipment = Equipment.ToArray(); var hullData = ItemManager.GetData(Hull) as HullData; var itemData = ItemManager.GetData(item.EquippableItem); @@ -600,10 +765,6 @@ public abstract class Entity _weapons.Remove(weapon); foreach (var group in WeaponGroups) { group.items.Remove(item); group.weapons.Remove(weapon); } } - if (b is Capacitor capacitor) - _capacitors.Remove(capacitor); - if (b is Reactor reactor) - _reactors.Remove(reactor); if (b is Radiator heatsink) _heatsinks.Remove(heatsink); if (b is Shield) @@ -614,6 +775,11 @@ public abstract class Entity Sensor = null; } + // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): the resolver's §0b lifecycle promise -- a resolver + // entry is "destroyed at unequip" -- was never wired up. Without this, every unequipped item stayed + // reachable from the resolver's dictionaries for the entity's whole remaining life. + Resolver.Forget(item); + return item.EquippableItem; } @@ -780,10 +946,6 @@ public abstract class Entity { if (b is Weapon weapon) _weapons.Add(weapon); - if(b is Capacitor capacitor) - _capacitors.Add(capacitor); - if(b is Reactor reactor) - _reactors.Add(reactor); if(b is Radiator heatsink) _heatsinks.Add(heatsink); if (b is Shield shield) @@ -806,7 +968,7 @@ public abstract class Entity } Mass += itemData.Mass; - _orderedEquipment = Equipment.OrderBy(x => x.SortPosition).ToArray(); + _orderedEquipment = Equipment.ToArray(); return true; } @@ -847,45 +1009,11 @@ public abstract class Entity return true; } - public bool CanConsumeEnergy(float energy) - { - var capEnergy = _capacitors.Sum(cap => cap.Charge); - int onlineReactors = _reactors.Count(reactor=>reactor.Item.Online.Value); - return capEnergy > energy || onlineReactors > 0; - } - - public bool TryConsumeEnergy(float energy) - { - if (energy < .01f) return true; - int chargedCapacitors; - do - { - chargedCapacitors = _capacitors.Count(capacitor => capacitor.Charge > .01f); - var chargeToRemove = energy; - foreach (var cap in _capacitors) - { - if(cap.Charge > 0.01f) - { - var chargeRemoved = min(chargeToRemove / chargedCapacitors, cap.Charge); - cap.AddCharge(-chargeRemoved); - energy -= chargeRemoved; - } - } - } while (chargedCapacitors > 0 && energy > .01f); - - if (energy < .01f) return true; - - int onlineReactors = _reactors.Count(reactor=>reactor.Item.Online.Value); - foreach (var reactor in _reactors) - { - if (reactor.Item.Online.Value) - { - reactor.ConsumeEnergy(energy / onlineReactors); - } - } - - return onlineReactors > 0; - } + // Cut 4 (docs/stats-and-power-cut.md, Cut 4): CanSpendCapacitorCharge/TrySpendCapacitorCharge died here with + // their last caller. Cut 3 named them a temporary exception for the four instant draws (a burst, a shot, a + // ping, a hit taken); all four now spend from their own InputCapacitor instead (InstantWeapon, Sensor, + // Shield), fed continuously by PowerBus like every other consumer. The entity's shared bus capacitors are + // now touched only by PowerBus itself (§0b: "the bus fills; the owning behaviour spends"). private void AddChild(Entity entity) { @@ -943,6 +1071,8 @@ public abstract class Entity public virtual void Update(float delta) { + if (!_active) return; + var hullData = ItemManager.GetData(Hull) as HullData; TargetRange = Target.Value == null ? -1 : length(Position - Target.Value.Position); @@ -1018,9 +1148,20 @@ public abstract class Entity for (var i = 0; i < _activeConsumables.Count; i++) { _activeConsumables[i].Update(delta); - if(_activeConsumables[i].RemainingDuration < 0) _activeConsumables.RemoveAt(i--); + if (_activeConsumables[i].RemainingDuration < 0) + { + // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this + // list without ever telling the resolver, leaving its generation/cache/modifier entries + // reachable (keyed by this ConsumableItemEffect instance) for the rest of the process. + Resolver.Forget(_activeConsumables[i]); + _activeConsumables.RemoveAt(i--); + } } + // Cut 3 (docs/stats-and-power-cut.md §1.2): stepped once per tick, before any equipped item's + // Behaviors execute, so every IPowerConsumer's grant is decided before it acts on it. + PowerBus.Step(delta); + foreach (var equippedItem in _orderedEquipment) { equippedItem.Update(delta); @@ -1137,7 +1278,10 @@ public abstract class Entity } } -public class ConsumableItemEffect +// The consumable case: no durability, no modifiers (there is no entity's worth of equipment to modify against), +// and progress-through-duration substitutes for heat rather than being exponentiated by the stat's heat field. +// That substitution is the disagreement the map names, kept exactly as it behaved before the collapse. +public class ConsumableItemEffect : IStatContext { public float RemainingDuration { get; private set; } public Entity Entity { get; } @@ -1171,24 +1315,36 @@ public class ConsumableItemEffect } RemainingDuration -= delta; + // Progress moves every tick; a stat with a ConsumableProgress term must recompute against it, the same + // way EquippedItem signals Heat and Durability from UpdatePerformance below. + Entity.Resolver.InvalidateSource(this, StatSource.ConsumableProgress); } - public float Evaluate(PerformanceStat stat) - { - var effectiveness = Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration); - var quality = pow(Lot.QualityForRole(stat.FromRole), stat.QualityExponent); + // Cut 2 (docs/stats-and-power-cut.md): the resolver owns the value; this is the caller's read of it, keyed by + // this effect instance (never by Entity, so two active consumables never share an entry). + public float Evaluate(PerformanceStat stat) => Entity.Resolver.Resolve(this, stat, this); - var result = lerp(stat.Min, stat.Max, effectiveness * quality); - - if (float.IsNaN(result)) - return stat.Min; - return result; - } + // Cut 1 (docs/stats-and-power-cut.md): progress-through-duration is now its own StatSource + // (ConsumableProgress) rather than a hard-coded override of "heat" that applied to every stat regardless of + // its declared terms. A consumable stat that wants this must declare a ConsumableProgress term; one that + // does not gets the identity (1), same as any other context reading a term it has no source for. + public float HeatFactor(float exponent) => 1f; + public float DurabilityFactor(float exponent) => 1f; + public float ConsumableProgressFactor(float exponent) => + pow(Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration), exponent); + public float PowerSupplyFactor(float exponent) => 1f; + // No modifiers: there is no entity's worth of equipment to modify against (comment above, unchanged by Cut 2). + public float ScaleModifier(PerformanceStat stat) => 1f; + public float ConstantModifier(PerformanceStat stat) => 0f; } -public class EquippedItem +// The equipped case: the only one with heat, live durability, and modifiers, because it is the only one with an +// entity and a running simulation behind it. +public class EquippedItem : IStatContext { - public int SortPosition; + // SortPosition (Cut 3-era, only ever set by Reactor.Order) is deleted by Cut 5 (docs/stats-and-power-cut.md + // §1.3): "EquippedItem.SortPosition must no longer influence who is fed" -- the tiered allocation pass + // replaces it, and IOrderedBehavior/Reactor.Order go with it (§7 O3). public EquippableItem EquippableItem; public int2 Position; @@ -1343,30 +1499,134 @@ public class EquippedItem foreach (var behavior in Behaviors) { - if (behavior is IOrderedBehavior orderedBehavior) - SortPosition = orderedBehavior.Order; if(behavior is IPopulationAssignment populationAssignment) entity.PopulationAssignments.Add(populationAssignment); } + + // Cut 5 (docs/stats-and-power-cut.md §1.3, Q5 "defaulted per behaviour kind"): seed the stored tier + // exactly once, the first time this unit is ever equipped. A unit the player has already assigned a + // tier to (or one seeded on an earlier equip) keeps it -- this never runs again for that unit. + if (EquippableItem.PowerTier == PowerTiers.Unassigned) + { + var consumerTiers = Behaviors.OfType().Select(c => c.DefaultPowerTier).ToArray(); + if (consumerTiers.Length > 0) + EquippableItem.PowerTier = consumerTiers.Min(); + } + + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, superseding F6): Cut + // 6's static half of this check -- refusing any PowerRequestFields stat whose own Terms named + // PowerSupply -- assumed PowerRequest read those stats the same way Execute does. It does not any more: + // EvaluateNominalPower (below, and Behavior.EvaluateNominalPower) is what every PowerRequest/RefreshReserve/ + // RefreshInputCapacitor implementation now calls for a request-field stat, and it pins that stat's own + // PowerSupplyFactor to 1 regardless of what Terms the stat declares -- so a direct PowerSupply term on a + // request field can no longer make the request depend on its own answer, and the six shipped records this + // ruling exists to make legal again (RadiatorData.PumpedHeat, AetherDriveData.Torque) are not an authoring + // error to refuse. What is still refused: a request field fed a PowerSupply-tainted value through a + // *modifier chain* (StatModifier.ValidateNoPowerSupplyChain, StatModifier.cs) -- EvaluateNominalPower + // forwards ScaleModifier/ConstantModifier to the item's real, non-nominal resolver entries (same as + // ConditionRatio's NominalContext already did), so a modifier chain that reaches a power-tainted magnitude + // stat still corrupts the nominal read too. That dynamic check is unchanged and still runs from + // Initialize below via StatModifier -- it is the only surviving half of Cut 6's rule. } - public float Evaluate(PerformanceStat stat) + // Cut 2 (docs/stats-and-power-cut.md): the resolver owns the value; this is the caller's read of it, keyed by + // this EquippedItem instance, so two ships equipping the same design never share a resolved value. + public float Evaluate(PerformanceStat stat) => Entity.Resolver.Resolve(this, stat, this); + + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): what this item wants at + // full power supply, not what it is currently managing -- the read every PowerRequest/RefreshReserve/ + // RefreshInputCapacitor implementation uses for a stat named in StatValidation.PowerRequestFields, so a stat + // that is both what a request asks for and what it also produces (RadiatorData.PumpedHeat, AetherDriveData. + // Torque) stops being circular at the root instead of being forbidden outright. Only PowerSupply is pinned: + // Heat and Durability are NOT, because they are not the term a request would be circular through -- this + // tick's PowerBus.Step (which calls PowerRequest) always runs before this tick's grant exists, so nothing + // here could depend on an answer that has not been computed yet, and a hot or worn item honestly asking for + // less power is real degradation the ruling never objected to, only "the request depends on its own answer." + // Bypasses the resolver's (owner, stat) cache the same way ConditionRatio's NominalContext below does and for + // the same reason: the cache key is (this, stat) alone, so resolving a second, different context under that + // key would clobber whatever Execute's own real Evaluate(stat) call cached for this same tick. + public float EvaluateNominalPower(PerformanceStat stat) => stat.Evaluate(new PowerRequestContext(this)); + + // Real-condition view of this same item for EvaluateNominalPower above: everything but PowerSupply is + // forwarded to the real item, including ScaleModifier/ConstantModifier -- deliberately NOT pinned, so a + // modifier chain that reaches a power-tainted magnitude stat still taints a nominal read through them too. + // That is exactly the residual case StatModifier.ValidateNoPowerSupplyChain still refuses: nominalizing this + // item's own direct Terms cannot undo a value baked in by another item's modifier. + private readonly struct PowerRequestContext : IStatContext { - var heat = pow(ThermalPerformance, ThermalExponent * stat.HeatExponentMultiplier); - var durability = pow(DurabilityPerformance, DurabilityExponent * stat.DurabilityExponentMultiplier); - var quality = pow(Lot.QualityForRole(stat.FromRole), stat.QualityExponent); + private readonly EquippedItem _item; + public PowerRequestContext(EquippedItem item) => _item = item; + public Lot Lot => _item.Lot; + public float HeatFactor(float exponent) => _item.HeatFactor(exponent); + public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent); + public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent); + public float PowerSupplyFactor(float exponent) => 1f; + public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat); + public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat); + } - var scaleModifier = 1.0f; - var scaleModifiers = stat.GetScaleModifiers(Entity).Values; - foreach (var value in scaleModifiers) scaleModifier *= value; + public float HeatFactor(float exponent) => pow(ThermalPerformance, ThermalExponent * exponent); + public float DurabilityFactor(float exponent) => pow(DurabilityPerformance, DurabilityExponent * exponent); + public float ConsumableProgressFactor(float exponent) => 1f; + // Cut 6 (docs/stats-and-power-cut.md), applied per F1 (operator ruling 2026-09-19): PerformanceStat.Evaluate + // multiplies this straight onto the resolved value rather than blending it into the Min/Max interpolation, so + // a stat with a PowerSupply term is exactly unchanged at full supply (pow(1, exponent) == 1 regardless of + // exponent) and exactly zero at zero supply (pow(0, exponent) == 0 for any exponent > 0), no matter what Min + // is -- "a continuous consumer that degrades instead of stopping" down to genuinely nothing. PowerBus writes + // PowerSupply below; the resolver invalidates this source once per tick from the same write (PowerBus.cs + // AllocateTiers), so this read is always this tick's own grant, never a stale one. + public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent); - float constantModifier = 0; - foreach (var value in stat.GetConstantModifiers(Entity).Values) constantModifier += value; + // Cut 3 (docs/stats-and-power-cut.md §1.2): PowerBus's grant ratio for this item, in [0,1], from whichever + // tick last ran its Step. Display-only and, as of Cut 6, stat-source-only: the only other reader is the + // resolver, through PowerSupplyFactor above, on behalf of whichever IPowerConsumer behaviour declared a + // PowerSupply term. Forbidden writers: nothing but PowerBus sets this. Defaults to 1 (fully supplied) for an + // item that draws no power, or before the bus has run once. + public float PowerSupply { get; internal set; } = 1f; - var result = lerp(stat.Min, stat.Max, durability * quality * heat) * scaleModifier + constantModifier; - if (float.IsNaN(result)) - return stat.Min; - return result; + // Cut 2: these used to read the catalog stat's own per-entity dictionary (the leak, §0.3). A modifier now + // attaches to this entity's resolver, keyed by (this item, stat); reading it here is unchanged. + public float ScaleModifier(PerformanceStat stat) => Entity.Resolver.ScaleModifier(this, stat); + public float ConstantModifier(PerformanceStat stat) => Entity.Resolver.ConstantModifier(this, stat); + + // Cut 8 (operator ask 2026-09-19): "multiply [emission] by actual performance so a broken thruster emits a + // puny flame." One ratio per (item, stat): this item's actually-resolved value against what the same item, + // same lot, same quality, same modifier stack would produce with every degradable term at its identity -- + // full durability, optimal temperature, full power supply. Quality and lot are deliberately NOT part of + // "perfect": NominalContext forwards Lot/ScaleModifier/ConstantModifier unchanged and only pins Heat, + // Durability, PowerSupply and ConsumableProgress to 1, so a cheap item's honestly-lower ceiling still reads + // 1 at full health -- it is not reported as damaged for being cheap. + // + // Cost: Evaluate(stat) is the same resolver-cached read every other caller already pays for (§ above), so it + // is a dictionary hit here unless something already invalidated it this tick. The nominal side cannot reuse + // that cache -- the resolver's cache key is (owner, stat) alone, and owner is `this`, so resolving a second, + // different context under the same key would clobber the real cached value -- so it calls stat.Evaluate + // directly against NominalContext, bypassing the cache entirely. That is one pass over the stat's own Terms + // list (Thrust/Torque declare at most three: Quality, Heat or Durability, PowerSupply), each a pow() and a + // multiply, i.e. a handful of flops with no allocation (NominalContext is a readonly struct). Cheap enough to + // call once per thruster per frame, which is all presentation (ShipInstance.Update) does with it. + // No epsilon guard on a zero/near-zero nominal: math.min/max (CultMath's own DXIL-lowering semantics, + // math.cs) resolve a NaN operand to the OTHER operand, not to NaN, so saturate(0f/0f) -- the one way nominal + // degenerates to exactly 0 (a stat authored with Max <= 0 and no ScaleModifier/ConstantModifier moving it off + // that) -- is a real, tested 0f (ConditionIsZeroNotNaNWhenTheNominalValueItselfDegeneratesToZero), not NaN. A + // manual guard here would duplicate that behaviour rather than add any. + public float ConditionRatio(PerformanceStat stat) => saturate(Evaluate(stat) / stat.Evaluate(new NominalContext(this))); + + // Perfect-conditions view of this same item for ConditionRatio above. Everything but the three degradable + // factors is forwarded to the real item so a modifier stack or a cheap lot's lower Quality term still shapes + // "perfect" the same way it shapes "actual" -- only Heat/Durability/PowerSupply/ConsumableProgress are + // pinned to their identity (1), since those are exactly the terms condition is supposed to measure. + private readonly struct NominalContext : IStatContext + { + private readonly EquippedItem _item; + public NominalContext(EquippedItem item) => _item = item; + public Lot Lot => _item.Lot; + public float HeatFactor(float exponent) => 1f; + public float DurabilityFactor(float exponent) => 1f; + public float ConsumableProgressFactor(float exponent) => 1f; + public float PowerSupplyFactor(float exponent) => 1f; + public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat); + public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat); } public void AddHeat(float heat, bool ignoreThermalMass = false) @@ -1376,16 +1636,18 @@ public class EquippedItem } public void UpdatePerformance() - { + { var temp = Temperature; ThermalPerformance = Data.Performance(temp); + Entity.Resolver.InvalidateSource(this, StatSource.Heat); var deltaTemp = math.abs(temp - oldTemperature); DurabilityPerformance = EquippableItem.Durability / Data.Durability; + Entity.Resolver.InvalidateSource(this, StatSource.Durability); var performanceThreshold = Entity.Settings.ShutdownPerformance; Wear = (1 - pow(ThermalPerformance, (1 - pow(Lot.Quality, ItemManager.GameplaySettings.QualityWearExponent)) * ItemManager.GameplaySettings.ThermalWearExponent) + - deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent + deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent ) * Data.Durability / Data.ThermalResilience; ThermalOnline.Value = ThermalPerformance > performanceThreshold || Entity.OverrideShutdown && EquippableItem.OverrideShutdown; DurabilityOnline.Value = EquippableItem.Durability > .01f; diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index fc375dd8d..4b727eee4 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -48,8 +48,6 @@ public static class EntitySerializer public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack) { - pack.Settings ??= MessagePackSerializer.Deserialize( - MessagePackSerializer.Serialize(itemManager.GameplaySettings.DefaultEntitySettings)); return pack switch { ShipPack shipPack => Unpack(itemManager, zone, shipPack), diff --git a/Assets/Scripts/ServerShared/Enums.cs b/Assets/Scripts/ServerShared/Enums.cs index e556ba656..2b5a9637b 100644 --- a/Assets/Scripts/ServerShared/Enums.cs +++ b/Assets/Scripts/ServerShared/Enums.cs @@ -107,6 +107,15 @@ public enum WeaponModifiers Cluster = 1 << 6 } +// Cut 12.4(a) (docs/fire-control-cut.md): what a weapon's blast radius (WeaponItemData.BlastRadius) detonates +// as. Null means no blast at all -- WeaponItemData.Fuse is frozen null unless BlastRadius > 0 (FireControl.Fire). +public enum WeaponFuse +{ + Contact, + Proximity, + Delayed +} + public enum DamageType { Kinetic, diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs new file mode 100644 index 000000000..06c9d3ca5 --- /dev/null +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -0,0 +1,1940 @@ +/* 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.Buffers; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 1 (docs/fire-control-cut.md): geometry -- whether a weapon bears on a point is decided here and +// nowhere else -- no prefab, transform, ArticulationPoint or renderer may influence it (R6, R7). Mount +// direction and arc are both derived per read from catalog data and the item's authored rotation; neither +// is ever stored on the entity. +// Cut 2 adds the reveal rule (R5) and the resolved targeting-system stats an entity fires with, both +// likewise derived on every read and never cached. +public static class FireControl +{ + // The planar unit vector a hardpoint's weapon points along, derived solely from the item's authored + // rotation (R6, the same convention reaction thrusters already use) -- never from a live Unity + // transform. This is the item-rotation path Behavior.Direction now uses unconditionally, after the + // HardpointTransforms readback branch is deleted. + public static float3 MountDirection(EquippedItem item) + { + var itemDirection = item.Entity.Direction.Rotate(item.EquippableItem.Rotation); + return normalize(float3(itemDirection.x, 0, itemDirection.y)); + } + + // Full arc width in degrees: the per-hardpoint override when authored (nonzero), else + // GameplaySettings.FiringArc. Zero on the hardpoint means "use the default," not "no arc." + public static float ArcFor(EquippedItem item) + { + var hardpoint = item.Entity.Hardpoints[item.Position.x, item.Position.y]; + var arc = hardpoint?.FiringArc ?? 0f; + return arc > 0 ? arc : item.ItemManager.GameplaySettings.FiringArc; + } + + // Planar bearing test (R7: the simulation is 2D, the 3D is set dressing -- target height never enters + // this). An arc of 360 degrees or more passes unconditionally, which is how a turret hardpoint + // (authored FiringArc: 360, Q1) tracks all the way around without a second type. + // Cut 5, 5.4 (Soul finding 11): a planar bearing shorter than 1e-6 -- point-blank range, target and firer + // co-located -- returns true rather than falling through to normalize's NaN. This used to live as a + // separate special case in Weapon.ArcAllowsFire; one bearing test now owns it, so a target at a weapon's + // own exact position can never fail to bear regardless of caller. + public static bool InArc(EquippedItem weapon, float3 toTarget) + { + var arc = ArcFor(weapon); + if (arc >= 360f) return true; + var planarToTarget = float3(toTarget.x, 0, toTarget.z); + if (lengthsq(planarToTarget) < 1e-6f) return true; + var planarTarget = normalize(planarToTarget); + 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. + // + // Ranking: the target's own non-hull equipment, hardpoint-mounted gear first (the big obvious things), + // then by size descending, ties broken by equipment order (OrderByDescending is a stable sort, so this + // needs no explicit tie-break key). Item i of N reveals once info crosses + // lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1)) -- the first item to reveal + // does so at the armor tier, the last at the gear tier. + public static bool IsRevealed(Entity observer, EquippedItem item) + { + var target = item.Entity; + // Cut 5, 5.5 (Soul finding 10): a destroyed item drops out of the ranking entirely rather than merely + // failing its own tier -- ranked.IndexOf(item) then returns -1 for it (the same "no longer on this + // target" path below), and the survivors' tiers close up over the gap instead of leaving one. + var ranked = target.Equipment + .Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f) + .OrderByDescending(e => e.Data.HardpointType != HardpointType.Tool) + .ThenByDescending(e => e.Data.Shape.Coordinates.Length) + .ToList(); + var index = ranked.IndexOf(item); + if (index < 0) return false; // the hull itself, or an item that is no longer on this target + + var settings = observer.ItemManager.GameplaySettings; + var info = observer.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; + var tier = lerp(settings.TargetArmorInfoThreshold, settings.TargetGearInfoThreshold, + (float) index / max(1, ranked.Count - 1)); + return info >= tier; + } + + // Cut 2, Q4: the stats an entity actually fires with. A working targeting system (equipped, online -- + // not destroyed, not shut down) supplies its own resolved stats; anything else falls back to the + // authored unaided figures, which make aiming without one a last resort rather than an alternative. + // Cut 3's roll is the only planned reader. + public static float Accuracy(Entity entity) + { + var system = entity.GetBehavior(); + return system != null && system.Item.Active.Value + ? system.Accuracy + : entity.ItemManager.GameplaySettings.UnaidedAccuracy; + } + + public static float Resolution(Entity entity) + { + var system = entity.GetBehavior(); + return system != null && system.Item.Active.Value ? system.Resolution : 1f; + } + + // Cut 6d (docs/fire-control-cut.md): falls back to GameplaySettings.UnaidedPrecision, the same shape + // Accuracy/Tracking already fall back to their own Unaided* floors. The old coin-flip rule could get away + // with a bare 0 here (0 meant "never draw the aimed-item branch," a legitimate value under that rule); the + // dart-throw kernel reads Precision as a grouping tightness, and 0 would ask Sigma for an infinite, division- + // guarded group -- not the authored "spraying at the silhouette" floor the spec asks for. + public static float Precision(Entity entity) + { + var system = entity.GetBehavior(); + return system != null && system.Item.Active.Value ? system.Precision : entity.ItemManager.GameplaySettings.UnaidedPrecision; + } + + // Cut 5, 5.1 (Soul finding 3): falls back to GameplaySettings.UnaidedTracking, exactly the shape Accuracy + // falls back to UnaidedAccuracy. Tracking is now always positive -- Commit no longer carries a zero case. + public static float Tracking(Entity entity) + { + var system = entity.GetBehavior(); + return system != null && system.Item.Active.Value ? system.Tracking : entity.ItemManager.GameplaySettings.UnaidedTracking; + } + + // Cut 3, the risk this map names explicitly: Combat.cs used to run its own first_order_intercept call to + // aim, while the roll measured deviation against a separately-computed prediction -- two functions + // claiming to answer the same question will eventually disagree, and an AI would then aim at one point and + // be judged against another. This is the one function; CombatState (and FireControl.Fire below) both call + // it instead of touching CultMath.first_order_intercept directly. Planar: source velocity is never fed in + // (no shooter here leads its own motion into the shot), matching every existing call site this replaces. + public static float3 PredictedIntercept(Weapon weapon, Entity source, Entity target) + { + var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); + return weapon.Velocity > .01f + ? first_order_intercept(source.Position, float3.zero, weapon.Velocity, target.Position, targetVelocity) + : target.Position; + } + + // Cut 3, R1/R3: the targeting solution (0b table) -- pure, no draw, recomputed fresh on every call. AI + // reads this to decide whether a shot is worth taking; the HUD reads it to draw a number; Fire below reads + // it once, at the instant the trigger is pulled, and freezes the result into the shot (R10, Q6). Two reads + // a tick apart may differ; nothing here or anywhere else caches one. + // Cut 10 (docs/fire-control-cut.md): the hot path. Every AI and turret calls this per weapon per tick, + // and the common case is a target out of range or out of arc -- so the cheap gates run first and bail, + // exactly as they did before the diagnostics refactor. The factors are computed only for a shot that is + // actually possible, and they come from the same functions Inspect uses: one fire-control model, shared + // at the level of its factors rather than by routing the hot path through a diagnostic struct. + // HitProbabilityMatchesInspect pins that the two can never disagree. + // Cut 12.2 (docs/fire-control-cut.md): the shooter-side share of the probability -- everything Fire + // 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 _, 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) => + 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; + + 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; + return Accuracy(source) * PSensor(settings, Resolution(source), info); + } + + // Cut 12.2 fix batch (F6, Soul, 2026-09-24): the one place a bearing, a silhouette and PSpread are + // assembled into a live forecast -- HitProbability and Inspect both call this instead of each carrying + // its own copy. They used to each build their own bearing from TravelDirection and the target's facing; + // a mutant that read the SHOOTER's facing instead of the target's survived in HitProbability's own copy + // (M11) while the identical mistake in Inspect's copy was caught (M22), because two functions computing + // the same thing are two places for the same bug to hide and only be found once. + // Cost fix batch (Self, 2026-09-24): Forecast reads only sil.POnHull and sil.Span, never sil.Intervals, so + // it rents the Silhouette's own scratch array from ArrayPool.Shared and returns it before + // returning -- Silhouette itself (given this pooled buffer) allocates nothing, measured directly. + // Commit's own path (CommitProbability -> Silhouette, uncalled from here) still needs sil.Intervals alive + // for the lateral draw that follows it, so it keeps its own owned array; only the forecast pools. + // S3 fix batch (Hands, 2026-09-25) correction: "the forecast path no longer allocates on a gated-in call" + // overclaimed -- a gated-in HitProbability call still allocates roughly 120 bytes (measured, Release; ~280 + // for Inspect, which reads one more gated stat). 12.3 fix batch correction: that cost is exactly three + // GetBehavior calls (Accuracy, Resolution via PSensor, Precision), at ~40 B each -- each + // one walks Entity.Equipment, a ReactiveCollection whose enumerator is heap-allocated per foreach. It is + // NOT `Behaviors` (the earlier wording here was wrong): EquippedItem.Behaviors is a plain array, allocates + // nothing to enumerate. With an item aimed at, ResolveAimPoint -> CellsOf also allocates (a List, then + // ToArray) -- about 944 B per HitProbability, on top of the three GetBehavior calls. None of this is + // Silhouette's or this pooling's cost; it predates Cut 12.2 and belongs to Entity's own collection choice + // and to CellsOf, out of this cut's scope. Cut 10's own guard is what actually matters + // operationally: HitProbability and CommitProbability both bail before Forecast is ever called for the + // common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0). + private static (Silhouette Silhouette, float PSpread) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range) + { + var bearing = Bearing(target, TravelDirection(weapon, source, target)); + var buffer = ArrayPool.Shared.Rent(targetHull.Shape.Coordinates.Length); + float pSpread; + Silhouette sil; + try + { + sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, precision, buffer); + var settings = source.ItemManager.GameplaySettings; + pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + // sil.Intervals is the returned buffer -- cleared here so nothing outside this function can read it + // after it goes back to the pool and another caller starts overwriting it. + sil.Intervals = null; + return (sil, pSpread); + } + + public static float HitProbability(Weapon weapon, Entity source, Entity target) + { + var pFire = PFire(weapon, source, target, out var range); + if (pFire <= 0f) return 0f; + + var targetHull = source.ItemManager.GetData(target.Hull) as HullData; + var (sil, pSpread) = Forecast(weapon, source, target, targetHull, Precision(source), range); + return pFire * pSpread * sil.POnHull; + } + + // Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes + // every factor unconditionally, gates included, because a HUD wants to see why a shot is impossible -- + // which is precisely why the hot path must not be routed through it. Both read the same factor + // functions below, and PBase is HitProbability's own answer, so the gates and the product have one + // owner and the HUD cannot grow a second, subtly different model. + public static FireControlDiagnostic Inspect(Weapon weapon, Entity source, Entity target) + { + var settings = source.ItemManager.GameplaySettings; + var diagnostic = new FireControlDiagnostic + { + HasTarget = target != null, + Accuracy = Accuracy(source), + Resolution = Resolution(source), + Precision = Precision(source), + Tracking = Tracking(source), + 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.xz); + diagnostic.Designated = solution.Designated; + diagnostic.InArc = InArc(weapon.Item, toTarget); + + var targetHull = source.ItemManager.GetData(target.Hull) as HullData; + diagnostic.Info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; + diagnostic.InfoDemandCeiling = InfoDemandCeiling(settings, diagnostic.Resolution); + diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info); + diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor; + + var (sil, pSpread) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range); + diagnostic.PSpread = pSpread; + diagnostic.POnHull = sil.POnHull; + + diagnostic.PBase = HitProbability(weapon, source, target); + return diagnostic; + } + + // Cut 6c, 6c.1 (operator ruling 2026-09-19): Resolution stays a benefit -- higher is better -- so the + // formula takes its reciprocal to derive the actual info ceiling instead of reading Resolution as that + // ceiling directly. Resolution 1 (the unaided fallback) yields a ceiling of 1: needs complete + // information, the floor by construction. The max guards a zero or negative authored Resolution; no + // authored value should ever reach it. + private static float InfoDemandCeiling(GameplaySettings settings, float resolution) => + settings.TargetDetectionInfoThreshold + (1f - settings.TargetDetectionInfoThreshold) / max(resolution, 1e-3f); + + private static float PSensor(GameplaySettings settings, float resolution, float info) => + saturate(unlerp(settings.TargetDetectionInfoThreshold, InfoDemandCeiling(settings, resolution), info)); + + // Whether the weapon's own barrel-dispersion cone reaches the target's actual silhouette at this range: + // weapon hardware, range-dependent, aim-point-blind. 1 for any zero-spread weapon. Cut 12.2: span comes + // from the same Silhouette pOnHull reads, so the two factors can never disagree about the target's size + // (the old bounding half-extent, max(Width, Height), is gone). + private static float PSpread(float spread, float span, float range, float cellSize) + { + if (spread <= 0) return 1f; + var halfExtent = .5f * span * cellSize; + var angularRadius = degrees(atan(halfExtent / range)); + return saturate(angularRadius / (spread / 2f)); + } + + // Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): the shot's world-planar travel + // direction -- PredictedIntercept minus the source's own position, planar (R7). Frozen into PendingShot + // at Fire (R10); HitProbability and Inspect recompute it fresh, at the current source/target positions, + // for their forecast. Below a length of 1e-6 (point-blank, source and intercept coincide) it falls back + // to world +z, the same fallback Apply used before this cut (`:463`). + public static float2 TravelDirection(Weapon weapon, Entity source, Entity target) + { + var toIntercept = (PredictedIntercept(weapon, source, target) - source.Position).xz; + return lengthsq(toIntercept) < 1e-6f ? float2(0, 1) : normalize(toIntercept); + } + + // Cut 12.2: the only place a world direction meets a target's facing. One function, so nothing else may + // compute a bearing (the authority map's forbidden-writer rule) -- Commit and the live forecast both read + // this, never a second copy of the formula. + private static float2 Bearing(Entity target, float2 travelDirection) => normalize(target.ToSchematic(travelDirection)); + + // Cut 12.2: the lateral axis a bearing implies, fixed once here. Silhouette and Lane both read it instead + // of each carrying their own copy of the sign. + private static float2 Lateral(float2 bearing) => float2(-bearing.y, bearing.x); + + // Cut 12.2 fix batch (S1, Soul's second pass): the one function that projects a unit cell onto an axis -- + // the exact support of the axis-aligned unit square centred on c along `axis`, half-width + // h = (|axis.x| + |axis.y|) / 2. Silhouette's shadow (axis = ell) and Lane's own admission test (axis = + // ell) now read this SAME arithmetic instead of Lane re-deriving its own copy of centre/h -- the two used + // to differ only in that Lane's copy was a plain re-derivation, not a second formula, but "not a second + // formula" is exactly why they never drifted apart in exact arithmetic and exactly why the real defect was + // never here: it was the second, independent SlabAlongB gate below, deleted with this fix. + private static Interval Extent(float2 c, float2 axis) + { + var h = (abs(axis.x) + abs(axis.y)) / 2f; + var centre = dot(c, axis); + return new Interval { Lo = centre - h, Hi = centre + h }; + } + + public static float DeviationProbability(PendingShot shot, float now, out float deviation) + { + var elapsed = now - shot.FireTime; + var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed; + deviation = shot.Target == null ? 0f : length((shot.Target.Position - predicted).xz); + 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 + // presentation. Cut 5, 5.7 (Soul finding 12): does not store a predicted intercept -- Commit judges + // deviation against a straight-line projection from FireTargetPosition/FireTargetVelocity, never the + // intercept, so a stored copy decided nothing and PendingShot no longer carries one. + // Cut 4 (docs/fire-control-cut.md): damageOverride lets a continuous weapon fire a shot for less than its + // full Damage -- ConstantWeapon.Execute rolls one of these per GameplaySettings.BeamResolveInterval, for + // Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's + // authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation, + // unchanged). Every other caller keeps reading the frozen weapon.Damage it always did. + public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null) + { + var zone = source.Zone; + var target = source.Target.Value; + var now = zone.Time; + + // 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). + // 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 = origin; + if (engaged != null) + { + 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; + + var shot = new PendingShot + { + ShotId = zone.NextShotId(), + Source = source, + Target = engaged, + Weapon = item, + Aimed = source.ResolvedTargetItem, + Damage = damageOverride ?? weapon.Damage, + Penetration = weapon.Penetration, + DamageSpread = weapon.DamageSpread, + DamageType = weapon.WeaponData.DamageType, + PFire = pFire, + Tracking = Tracking(source), + Precision = Precision(source), + TravelDirection = travelDirection, + Spread = weapon.Spread, + FireRange = fireRange, + FireTime = now, + FireOrigin = origin, + FireTargetPosition = targetPosition, + FireTargetVelocity = targetVelocity, + ArrivalTime = now + flightTime, + // 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, + Committed = false + }; + + zone.PendingShots.Add(shot); + return shot.ShotId; + } + + // 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 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; + if (shots.Count == 0) return; + + var now = zone.Time; + for (var i = shots.Count - 1; i >= 0; i--) + { + var shot = shots[i]; + + var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target); + if (targetGone) + { + // 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, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); + shots.RemoveAt(i); + if (!shot.Committed) zone.ShotCommitted.OnNext(miss); + zone.ShotResolved.OnNext(miss); + continue; + } + + if (!shot.Committed && now >= shot.CommitTime) + { + shot.Outcome = Commit(zone, shot, now); + shot.Committed = true; + // A contact burst short of max range arrives when the round has flown that far, not at the frozen + // max-range time (CommitBurst). Never later than the frozen time. + if (shot.Outcome.Result == ShotResult.Burst) shot.ArrivalTime = min(shot.ArrivalTime, now + shot.Outcome.ArrivalIn); + shots[i] = shot; + zone.ShotCommitted.OnNext(shot.Outcome); + } + + if (shot.Committed && now >= shot.ArrivalTime) + { + // Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of + // which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage + // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here + // the same way; only Apply decides what that means. + // Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one + // that throws would resolve, and hurt, again on every later tick. + shots.RemoveAt(i); + Apply(zone, shot); + zone.ShotResolved.OnNext(shot.Outcome); + } + } + } + + // R3/R4: the one roll. p_base was frozen at fire; the only thing measured live is how far the target has + // actually strayed, by now, from where a straight-line projection of its fire-time velocity said it would + // be -- forgiven by the targeting system's Tracking, likewise frozen at fire. + // + // Cut 7: the short-circuit below (`p > 0f &&`) is a guard, NOT an invariant. It used to be one -- a shot + // gated to zero at fire had to draw nothing so the shared stream advanced exactly as if it had never + // queued -- but Cut 6b gave every shot its own generator, seeded from (zone, shot id) and discarded here, + // so whether a gated shot draws is unobservable from anywhere. The claim this comment used to make is + // gone with the stream it was about, and the test that asserted it became vacuous the same moment; what + // replaced it pins the live rule instead (combat touches no shared stream, on any path). + // 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply, + // xor-shift, multiply, xor-shift. Diffuses a structured seed (here, a zone seed XORed with a small, + // low-bits-only ShotId) across every bit before the first xorshift draw reads it, so two seeds that + // differ only in ShotId's low bits still land on uncorrelated first outputs. Caller-side mixing only + // -- CultMath.Random itself is unchanged (see Commit's own note on why that follow-up is a separate + // CultLib cut). + private static uint MixSeed(uint seed) + { + seed ^= seed >> 16; + seed *= 0x85ebca6bu; + seed ^= seed >> 13; + seed *= 0xc2b2ae35u; + seed ^= seed >> 16; + return seed; + } + + // Cut 12.2 (docs/fire-control-cut.md, "Bearing timing"): steps 1-5. Everything the shooter decided is + // frozen in `shot` (R10); everything the target is doing -- position (deviation) and now also facing + // (bearing) -- is read live, here, at the commit tick. The Cut 7 guard extends to cover the new factors: + // when PFire x pDeviation is already 0, no Silhouette is built. Shared by Commit's own roll and by the + // debug HUD's forecast (TheHudEstimateIsTheCommitPrice) -- one function, one commit-time price. + public static float CommitProbability(PendingShot shot, float now, out Silhouette sil) + { + sil = default; + if (shot.Target == null) return 0f; + + var pDeviation = DeviationProbability(shot, now, out _); + var p = shot.PFire * pDeviation; + if (p <= 0f) return 0f; + + var bearing = Bearing(shot.Target, shot.TravelDirection); + var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; + sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision); + 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 + // the degenerate zero seed -- so two runs of the same fight from the same galaxy seed roll identically + // no matter what the UI drew from the shared stream in between. This generator is local and dies with + // the call: nothing outside Commit may seed or advance a combat draw, and Commit may not read or write + // a shared stream. + // 9.1 (docs/fire-control-cut.md): the per-shot seed is unchanged -- a pure function of + // (zone identity, shot id), Cut 6b's own rule -- but CultMath.Random draws only one xorshift + // round for NextFloat's first call, and one round does not diffuse a seed that differs from its + // neighbours only in ShotId's low bits. Unmixed, every zone's first draw was a near-constant + // function of ShotId (measured span ~0.125), making a shot a step function of p fixed at zone + // creation, not a roll (Soul C1). MixSeed is a standard fmix32 finalizer (MurmurHash3): this is + // a caller's obligation, not a CultMath defect -- seeding xorshift with a structured value and + // drawing once is the documented way to get a correlated first output, and a follow-up to give + // CultMath.Random a mixed-seed entry point of its own is recorded for a separate CultLib cut. + var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)); + var p = CommitProbability(shot, now, out var sil); + + var hit = p > 0f && random.NextFloat() < p; + var cell = int2.zero; + var bearing = float2.zero; + var lateral = 0f; + var shielded = false; + var shieldBroken = false; + + if (hit) + { + // Cut 12.2: the same silhouette pOnHull drew its mass from -- same bearing (live, at this commit + // tick), same sigma (the frozen Precision). A shot that passed the roll always lands on metal (R3: + // one roll decides damage; the off-hull share was already priced into that roll, not resolved here + // as a second stage). The lateral draw picks where along the shadow the shot lands; Lane converts + // that (bearing, lateral) pair into the actual cell -- only the first element (the impact cell, + // nearest the shooter's side) is used here; the rest of the lane is 12.3's armour march. + bearing = Bearing(shot.Target, shot.TravelDirection); + lateral = LateralDraw(sil, random.NextFloat()); + + var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; + var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + try + { + var count = Lane(hullData, bearing, lateral, buffer); + // R3: a hit that passed its roll always lands on metal. With LateralDraw's half-open clamp, + // the drawn `lateral` always falls strictly inside the interval sil.POnHull priced, so Lane + // must find at least one occupied cell -- an empty lane here is not a miss to fall back on + // silently (that would put a hit marker on (0,0), which may not even be occupied); it is proof + // the draw and the lane have drifted apart. + if (count == 0) + throw new InvalidOperationException( + $"shot {shot.ShotId}: Lane found no occupied cell at bearing {bearing} lateral {lateral} " + + "after a hit passed its roll -- LateralDraw and Lane have drifted apart (R3)."); + cell = buffer[0].Cell; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + // Q12-9 = A (docs/fire-control-cut.md): Commit decides no shield for a shot that carries a fuse -- + // Detonate decides every blast's shields live, at detonation, the host included, charged the + // entity's own covered share. A direct hit (Fuse == null) is unchanged: Commit still decides it here, + // on the whole frozen Damage. + if (shot.Fuse == null) + { + var shield = shot.Target.Shield; + var shieldActive = shield != null && shield.Item.Active.Value; + if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true; + else if (shieldActive) shieldBroken = true; + } + } + + // A proximity round detonates at its burst point whether or not the roll hit, so a roll that missed is a + // Burst, not a Miss: the outcome types what happened, and the HUD must not call a real explosion a miss. A + // contact or delayed round detonates only on a hit, so its miss stays a Miss. + var proximity = shot.Fuse == WeaponFuse.Proximity; + var outcome = MakeOutcome(shot, hit ? ShotResult.Hit : proximity ? ShotResult.Burst : ShotResult.Miss, + shielded, shieldBroken, cell, bearing, lateral, now); + if (proximity) + { + outcome.BurstPoint = shot.BurstPosition.xz; + outcome.HasBurstPoint = true; + } + return outcome; + } + + // Operator rulings 2026-09-30. A fused round with no target bursts at the first hull its aim line crosses + // before the max-range point Fire froze, whatever its fuse (contact, proximity and delayed alike: a round with + // nothing locked stops on what is in its way), and at the max-range point when the line is clear. The point is + // read from this tick's poses and frozen into the outcome (R4, the same timing Commit reads facing at). + // Nothing is rolled or drawn, so determinism needs nothing beyond the frozen inputs. + private static ShotOutcome CommitBurst(Zone zone, PendingShot shot, float now) + { + var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now); + outcome.HasBurstPoint = true; + var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); + var travelled = min(reach, max(HullDistance(zone, shot, reach), shot.BlastRadius)); + if (travelled < reach) + { + outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; + // The round arrives once it has flown that far, which is sooner than the frozen max-range arrival. + outcome.ArrivalIn = max(0f, shot.FireTime + (shot.ArrivalTime - shot.FireTime) * travelled / reach - now); + } + else outcome.BurstPoint = shot.BurstPosition.xz; + return outcome; + } + + // The distance along a no-lock round's aim line to the first hull it crosses, or `reach` (the + // max-range point) when it meets nothing first. The shooter's own hull is never a candidate. + // SEAM FOR THE MINING CAMPAIGN: mining chunks are not Entities and are not on this branch. When they land, + // whatever they are contributes its own entry distance to this minimum, and nothing else in the file changes. + private static float HullDistance(Zone zone, PendingShot shot, float reach) + { + var nearest = reach; + foreach (var entity in zone.Entities) + { + if (entity == shot.Source) continue; + nearest = min(nearest, HullEntry(entity, shot.FireOrigin.xz, shot.TravelDirection)); + } + return nearest; + } + + // Where the line from `origin` along the unit planar `direction` meets `entity`'s hull: the world distance from + // `origin` to the first metal cell ahead, +infinity when the line meets none, and negative when the origin is + // already inside the hull (the blast radius floors every caller's use of it). Read through Lane's own cell + // admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset is its own, and cells + // wholly behind the origin are skipped. Only a no-lock round asks. + private static float HullEntry(Entity entity, float2 origin, float2 direction) + { + var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; + var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; + var start = entity.ToSchematicPoint(origin); + var bearing = Bearing(entity, direction); + var lateral = dot(start, Lateral(bearing)); + var along = dot(start, bearing); + + var entry = float.PositiveInfinity; + var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + try + { + var count = CellsOnLine(hullData, bearing, lateral, buffer); + for (var i = 0; i < count; i++) + { + if (!(buffer[i].Exit > along)) continue; + entry = (buffer[i].Entry - along) * cellSize; + break; + } + return entry; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + // R4: the commit is authoritative and immutable from here on -- this only performs what Commit already + // decided. The one owner of the shield-absorbs-or-hull-takes-it branch (it used to exist seven times, + // once per Unity effect). + // Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step + // march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A): + // 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the + // centre lane, at the committed Lateral, always does (Commit already proved it, R3). Each lane is Lane's + // own contiguous cell run from its own facing cell, clipped to the penetration depth (a cell is reached + // when entry - impact.entry < penetration; the impact cell is always reached, replacing the deleted + // `> .5f` threshold at zero cost). Whatever a lane does not spend -- penetration exhausted, a gap, or the + // far side -- goes to the hull where the lane ends (Q12-3 = A), summed across every lane into one + // DamageHull call. + // F2 fix batch (Soul, 2026-09-25; operator: "I would prefer if the overlapping damage cells were diffused + // sideways to thicken the line rather than doubling up"): lanes are spaced by the shadow width a cell + // casts at this bearing -- Extent's own h, doubled -- not by one cell. At axis-aligned bearings that width + // is exactly 1 (today's spacing, unchanged); at 45 degrees it is sqrt(2). Reusing Extent (the one function + // that owns h, Silhouette's shadow and Lane's own admission test) instead of re-deriving |bx|+|by| a + // second way is the same discipline Cut 12.2's own fix batch already applied to Lane -- two derivations of + // one geometric quantity is exactly how that cut's crash happened. Because each cell's admitted lateral + // interval is exactly this width wide and half-open, lanes this far apart can never both admit the same + // cell: no cell is struck twice, and the footprint widens at angles instead of doubling up. + // F1 fix batch (Soul's second pass, 2026-09-25): the F2 spacing above is correct in exact arithmetic, but + // the loop that used to sit here still asked the wrong question in float: it called Lane() once per lane + // index, each call re-deriving that lane's own lateral s(li) = Lateral + (li - n) * laneSpacing by forward + // float addition, then testing s(li) against a cell's Extent(c, ell) interval independently of every other + // lane's test. Two lane lines are only ever exactly laneSpacing apart in real arithmetic; accumulating that + // offset by repeated addition can shift a side lane by an ulp or two, and a shifted s(li) can land inside a + // NEIGHBOURING cell's half-open interval that its own, unshifted s(li) was never meant to reach -- the same + // "two derivations of one quantity" defect Cut 12.2's own fix batch found and removed from Lane itself + // (S1 above), now recurring one layer up. + // Self's review (2026-09-25): the first fix for this replaced the per-lane Lane() loop with a ceil-based + // index formula that computed lane membership from a cell's own Extent directly -- but that formula and + // Lane's own half-open test (Lo <= s < Hi) were still two separate computations, agreeing only when a + // cell's own (Hi - Lo) equalled laneSpacing exactly in float. That is the identical trap one level removed, + // not removed. `Lanes` below is the actual single owner: it calls Lane() itself once per lane index, so + // Commit's placement (Lane, the n = 0 case) and Apply's damage split (Lanes, every case) read one function, + // and resolves the resulting rare cross-lane collision by ownership rather than by a competing formula -- + // see `Lanes`. + // Cut 12.3 fix batch (2026-09-25, operator ruling "one code path" -- docs/fire-control-cut.md, Cut 12.3 + // status): the earlier fix batch's own survey-then-branch (ApplySequential vs. ApplyWithSharedItems) was a + // split authority -- a second reachability derivation for "is this item shared" alongside Lanes' own + // membership rule, the identical shape of failure as 12.2's empty lane and 12.3's own double strike. There + // is now one walk, `ApplyPooled`, in which every item pools the deposits of whatever lanes reach it, a + // pool of one lane included -- a single-cell item is this rule's own degenerate case, not a fork. + // + // Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of which model + // a shot uses, through the switch on the frozen shot.Fuse below. Step has no damage branch left -- every + // arrived shot, direct hit or blast, comes through here. A null fuse keeps 12.3's lane path unchanged; a + // proximity fuse detonates at arrival with no roll, whether the shot hit or missed + // (ProximityDetonatesEvenOnAGuaranteedMiss keeps today's AirburstSplashesEvenOnAGuaranteedMiss rule); a + // contact or delayed fuse detonates only on a committed hit, at a point 12.3's own Lane/Reach machinery + // locates. This is a default, not a ruling: a blast shot's DamageSpread is not read, and P sits on the + // centre lane (see the fuse-point comment on the Contact/Delayed branch below). + private static void Apply(Zone zone, PendingShot shot) + { + // A Burst outcome detonates at the point Commit decided (its BurstPoint): a fused round with no target, + // which has nothing to hit or miss, and a proximity round, which detonates whether its roll hit or not. + // Detonate is still the one blast path. + if (shot.Outcome.Result == ShotResult.Burst || shot.Fuse == WeaponFuse.Proximity) + { + Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType); + return; + } + + switch (shot.Fuse) + { + case null: + ApplyDirectHit(shot); + return; + case WeaponFuse.Contact: + case WeaponFuse.Delayed: + ApplyBlastHit(shot); + return; + } + } + + // Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step + // march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A): + // 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the + // centre lane, at the committed Lateral, always does (Commit already proved it, R3). Each lane is Lane's + // own contiguous cell run from its own facing cell, clipped to the penetration depth by Reach (the impact + // cell is always reached, replacing the deleted `> .5f` threshold at zero cost). Whatever a lane does not + // spend -- penetration exhausted, a gap, or the far side -- goes to the hull where the lane ends (Q12-3 = + // A), summed across every lane into one DamageHull call. + // Cut 12.4(b): unchanged from 12.3 except that the inline penetration clip is now Reach, the shared owner + // the delayed fuse point also reads (see "The penetrator"). + private static void ApplyDirectHit(PendingShot shot) + { + if (!shot.Outcome.Hit) return; + + // Cut 5, 5.2 (Soul finding 4): the frozen decision, not a re-check -- a shield that recharges during + // the flight does not retroactively survive a hit that broke it at commit time. + if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break(); + + if (shot.Outcome.Shielded) + { + shot.Target.Shield.TakeHit(shot.DamageType, shot.Damage); + return; + } + + var target = shot.Target; + // Cut 12.3: moved here from Entity.ApplyHit's own first line -- the shield branches above still return + // before this runs, unchanged from 12.2. Cut 12.4(b): fires only for a direct hit; a proximity burst + // and every bystander in any blast never fire it (AContactBlastReportsTheHit covers the blast half). + target.IncomingHit.OnNext(shot.Source); + + // Cut 12.2 (R4): the committed geometry, frozen. Apply reads no position or facing of its own -- + // shot.Outcome.Bearing is already the schematic-frame bearing Commit computed at the commit tick, so + // turning the target after commit changes nothing here (TurningAfterCommitChangesNothing). + var hullData = target.ItemManager.GetData(target.Hull) as HullData; + var bearing = shot.Outcome.Bearing; + var n = (int) floor(shot.DamageSpread + .5f); // today's rounding, Entity.cs:389 before this cut + var laneCount = 2 * n + 1; + // F2: the shadow width at this bearing, from Extent's own h (the cell argument does not affect h). + var shadowExtent = Extent(float2.zero, Lateral(bearing)); + var laneSpacing = shadowExtent.Hi - shadowExtent.Lo; + + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) + buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers); + + // Cut 12.4(b) ("The penetrator", Q12-8): Reach is now the one penetration-reach owner, shared with the + // delayed fuse point below -- no second reach derivation exists anywhere in this file. + for (var li = 0; li < laneCount; li++) + walked[li] = Reach(buffers[li], walked[li], shot.Penetration); + + var metalLanes = 0; + for (var li = 0; li < laneCount; li++) + if (walked[li] > 0) metalLanes++; + + // The centre lane (li == n) always meets metal: it walks from the committed Lateral, and Commit's own + // guard already proved Lane finds an occupied cell there (R3) -- metalLanes is never 0. + var damagePerLane = shot.Damage / metalLanes; + + var hullDamage = ApplyPooled(target, buffers, walked, laneCount, damagePerLane); + + for (var li = 0; li < laneCount; li++) ArrayPool.Shared.Return(buffers[li]); + + target.DamageHull(hullDamage); + } + + // Cut 12.4(b) (docs/fire-control-cut.md, "Where P is"): a contact or delayed blast needs a committed hit -- + // on a miss nothing happens, exactly like a direct hit's own gate. IncomingHit fires once, for the host, + // before the blast is handed to Detonate (which then treats the host like any other entity in the radius: + // Detonate never reads a host parameter, and finds the host's own cells again by ToSchematicPoint). + private static void ApplyBlastHit(PendingShot shot) + { + if (!shot.Outcome.Hit) return; + + var target = shot.Target; + target.IncomingHit.OnNext(shot.Source); + + // "Where P is": the lane's own parametrisation gives the point directly, point(t) = t*b + s*ell, on the + // committed lane (the committed Bearing and Lateral, which Commit's own guard already proved non-empty, + // R3). Contact: t is the impact cell's own entry, lane[0].Entry. Delayed: t_fuse is where the frozen + // Penetration runs out or the metal does, whichever comes first -- Reach (the same owner the direct-hit + // walk reads) decides how deep the lane reaches, and t_fuse clamps to the last reached cell's exit so + // the disc is always centred on metal. + var hullData = target.ItemManager.GetData(target.Hull) as HullData; + var bearing = shot.Outcome.Bearing; + var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + float2 schematicPoint; + try + { + var walked = Lane(hullData, bearing, shot.Outcome.Lateral, buffer); + var t = buffer[0].Entry; + if (shot.Fuse == WeaponFuse.Delayed) + { + var reach = Reach(buffer, walked, shot.Penetration); + t = min(buffer[0].Entry + shot.Penetration, buffer[reach - 1].Exit); + } + schematicPoint = t * bearing + shot.Outcome.Lateral * Lateral(bearing); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + // "Conversion to the world": the round trip through the host's own ToWorldPoint/ToSchematicPoint + // cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while + // every bystander is judged against where it actually is at arrival -- the same live rule a proximity + // burst already applies to everyone. + // The arming rule applies to this point as it does to every other burst (Solve's Armed): a hull met nearer + // the muzzle than the blast radius is detonated on at the radius, along the round's own flight line. + var hullPoint = target.ToWorldPoint(schematicPoint); + var worldPoint = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(hullPoint.x, shot.FireOrigin.y, hullPoint.y)).xz; + Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType); + } + + // Cut 12.4(b) (docs/fire-control-cut.md, "The penetrator", Q12-8): the one penetration-reach owner, moved + // verbatim from Apply's own inline clip (Cut 12.3 fix batch) so the delayed fuse point above can read the + // identical rule the direct-hit walk already used. The rule is unchanged: the impact cell is always + // reached, and a later cell is reached while entry - impact.entry < penetration. Two callers, and no third: + // a null-fuse shot spends damage along the walk (ApplyDirectHit), a contact-or-delayed shot only locates P + // (ApplyBlastHit) -- no shot ever takes both branches of Apply's switch. + private static int Reach(LaneCell[] cells, int walked, float penetration) + { + if (walked == 0) return 0; + var impactEntry = cells[0].Entry; + var reach = 1; + while (reach < walked && cells[reach].Entry - impactEntry < penetration) reach++; + return reach; + } + + // Cut 12.3 fix batch (2026-09-25, operator rulings "proportional absorption; go" and "one code path" -- + // docs/fire-control-cut.md, Cut 12.3 status): every lane walks its own already-clipped cells near to far. + // Armour is never shared between lanes (Lanes' own disjointness rule), so Entity.ArmorAbsorb runs the + // instant a lane reaches a cell, exactly as before. A lane that reaches an ITEM cell instead deposits its + // own post-armour remainder into that item's pool and blocks there -- a pool of one lane included, so a + // single-cell item is this rule's own degenerate case, not a separate fast path. A pool resolves the + // moment no other unfinished lane can still reach that item down its own remaining (already-clipped) walk + // -- for an item only one lane can ever touch, that is immediately, the same instant it is deposited. Once + // resolved the item's pool is gone, so a non-convex item a lane crosses a second time (or a second lane + // arriving after a first pool already resolved) opens a brand new pool under the identical rule -- there is + // no separate "solo absorb after a forced resolve" behaviour anywhere in this function. + // + // Cycle rule (Self's default, recorded as such in the map): two lanes can cross two different shared + // (multi-cell, non-convex) items in reversed relative order -- lane A reaches item1 before item2, lane B + // reaches item2 before item1 -- so neither pool can ever become ready in each lane's own causal order and + // every remaining lane ends up blocked. When that happens (no lane can advance and no pool is + // independently ready), every CURRENTLY OPEN pool resolves at once, together, from whatever deposits it + // already holds. Each blocked lane sits in exactly one open pool, so this batch always clears every blocked + // lane in one pass, needs no ordering key, and is mirror-symmetric by construction (nothing about which + // lane is "left" or "right" ever enters the tie-break). If somehow no pool is open when every lane is + // blocked, that is a real defect -- rem would otherwise be silently dropped -- so this throws instead of + // breaking out of the loop. + private static float ApplyPooled(Entity target, LaneCell[][] buffers, int[] walked, int laneCount, float damagePerLane) + { + // Pooled bookkeeping (this file's own established convention -- ArrayPool is already how Apply rents + // its own per-lane cell buffers): a shot that never crosses an item is the overwhelmingly common case, + // and must not pay a per-shot heap allocation for lane state it never needs to keep past this call. + var pointer = ArrayPool.Shared.Rent(laneCount); + var rem = ArrayPool.Shared.Rent(laneCount); + var finished = ArrayPool.Shared.Rent(laneCount); + var blocked = ArrayPool.Shared.Rent(laneCount); + try + { + for (var li = 0; li < laneCount; li++) + { + pointer[li] = 0; + blocked[li] = false; + finished[li] = walked[li] == 0; + rem[li] = finished[li] ? 0f : damagePerLane; + } + + // Lazily allocated: a shot that never crosses an item (the overwhelmingly common case) never allocates + // a pool at all. + Dictionary> pools = null; + var hullDamage = 0f; + + void FinishLane(int li) + { + finished[li] = true; + hullDamage += rem[li]; + } + + // "No other unfinished lane still has that item among its remaining clipped cells" -- read directly off + // each other lane's own remaining walk (from just past wherever it currently sits), never from a + // precomputed reachability table. A lane already IN this item's own pool has already arrived and does + // not need to arrive again. + bool StillReachableByAnotherLane(EquippedItem item, List<(int lane, float amount)> contributors) + { + for (var li2 = 0; li2 < laneCount; li2++) + { + if (finished[li2]) continue; + var alreadyContributed = false; + for (var k = 0; k < contributors.Count; k++) + if (contributors[k].lane == li2) { alreadyContributed = true; break; } + if (alreadyContributed) continue; + + // No +1 needed when li2 is blocked at this same item's cell: `alreadyContributed` above already + // caught that case (a blocked lane is always a contributor to the item it is blocked on), so + // `pointer[li2]` itself is never this item's own cell by the time this line runs. + for (var i = pointer[li2]; i < walked[li2]; i++) + if (target.GearOccupancy[buffers[li2][i].Cell.x, buffers[li2][i].Cell.y] == item) return true; + } + return false; + } + + void Resolve(EquippedItem item) + { + var contributors = pools[item]; + pools.Remove(item); // a later visit to this same item opens a fresh pool under the same rule + var amounts = ArrayPool.Shared.Rent(contributors.Count); + try + { + for (var k = 0; k < contributors.Count; k++) amounts[k] = contributors[k].amount; + target.ItemAbsorb(item, new Span(amounts, 0, contributors.Count)); + for (var k = 0; k < contributors.Count; k++) + { + var lane = contributors[k].lane; + rem[lane] = amounts[k]; + blocked[lane] = false; + pointer[lane]++; + // One writer for lane completion: AdvanceLane, not here. Unblocking and advancing the + // pointer is enough -- the next call to AdvanceLane(lane), later this pass or the next while + // iteration, is the one place that checks `pointer >= walked` and calls FinishLane. + } + } + finally + { + ArrayPool.Shared.Return(amounts); + } + } + + void Deposit(int li, EquippedItem item) + { + pools ??= new Dictionary>(); + if (!pools.TryGetValue(item, out var list)) pools[item] = list = new List<(int, float)>(); + list.Add((li, rem[li])); + blocked[li] = true; + if (!StillReachableByAnotherLane(item, list)) Resolve(item); + } + + bool AdvanceLane(int li) + { + var moved = false; + while (!finished[li] && !blocked[li]) + { + if (pointer[li] >= walked[li]) + { + FinishLane(li); + moved = true; + break; + } + var cell = buffers[li][pointer[li]].Cell; + rem[li] = target.ArmorAbsorb(cell, rem[li]); + moved = true; + var item = target.GearOccupancy[cell.x, cell.y]; + if (item != null) Deposit(li, item); + else pointer[li]++; + } + return moved; + } + + bool AllFinished() + { + for (var li = 0; li < laneCount; li++) + if (!finished[li]) return false; + return true; + } + + while (!AllFinished()) + { + var progressed = false; + for (var li = 0; li < laneCount; li++) + if (!finished[li] && !blocked[li] && AdvanceLane(li)) progressed = true; + + if (!progressed) + { + if (pools == null || pools.Count == 0) + throw new InvalidOperationException( + "FireControl.ApplyPooled: every remaining lane is blocked but no item pool is open -- " + + "a lane's own damage would be lost rather than reaching an item or the hull."); + // Cycle rule: resolve every currently open pool at once, from whatever it already holds -- a + // snapshot of the keys, since Resolve mutates `pools` as it goes. + foreach (var item in new List(pools.Keys)) Resolve(item); + } + } + + return hullDamage; + } + finally + { + ArrayPool.Shared.Return(pointer); + ArrayPool.Shared.Return(rem); + ArrayPool.Shared.Return(finished); + ArrayPool.Shared.Return(blocked); + } + } + + // Self's fix (2026-09-25): the ONE public membership query behind both Commit's placement and Apply's + // damage split. Lane(s) is exactly this function's n = 0 case -- Lanes calls Lane(hull, b, centreLateral + + // k * laneSpacing, ...) once per lane index k, so a lane's own cell set can never disagree with what + // Lane(s) alone returns for that s: there is no second formula for "does this cell belong to this lane", + // only Lane's own half-open Extent test, called at 2n+1 different lines. This is what removes the trap a + // ceil-based index formula reintroduced one level up from Cut 12.2's own crash (see Apply's comment above): + // that formula and Lane's own test were still two computations, agreeing only when a cell's own (Hi - Lo) + // equalled the externally supplied laneSpacing exactly in float. + // What is left, once every lane's own result is individually exact, is a DIFFERENT and much narrower + // question: can two separate calls to Lane(), at two different (and individually correct) lines, still walk + // the SAME cell? Yes, rarely -- laneSpacing (F2's shadow width, one value shared by every lane) can differ + // by a few ulps from one specific cell's own computed (Hi - Lo), the same float drift Soul's second pass + // found. Lanes resolves that by ownership, not by a competing membership rule: a cell walked by more than + // one lane keeps the lane closest to the centre (ties are impossible -- equal |k| means the same lane), and + // the centre lane (k = 0) never loses a cell to a side lane. Its own final cell set is therefore always + // exactly Lane(centreLateral)'s own output, unmodified -- R3 (the centre lane always meets metal, Commit's + // own guard) and the 12.2 placement contract (HitsLandOnTheFacingEdge) hold structurally, not by chance. A + // side lane that cedes a cell simply omits it from its own walk; the walk's existing contiguity rule (a + // cell more than 1e-4 past the previous SURVIVING cell's exit ends the lane) then decides on its own whether + // the lane continues past the gap that leaves -- no new rule, the same one `Lane` already applies at a + // genuine gap. laneSpacing itself is untouched: it is still the one F2 value that places every lane's own + // query line. + public static int[] Lanes(HullData hull, float2 b, float centreLateral, int n, float laneSpacing, LaneCell[][] buffers) + { + var laneCount = 2 * n + 1; + var raw = new LaneCell[laneCount][]; + var rawCount = new int[laneCount]; + for (var li = 0; li < laneCount; li++) + { + var k = li - n; + var s = centreLateral + k * laneSpacing; + raw[li] = ArrayPool.Shared.Rent(hull.Shape.Coordinates.Length); + rawCount[li] = Lane(hull, b, s, raw[li]); + } + + // Ownership: whichever lane is closest to the centre keeps a cell two lanes both walked; the centre + // lane (k = 0, |k| = 0) can never be outranked. + var ownerLane = new Dictionary(); + for (var li = 0; li < laneCount; li++) + { + var k = Math.Abs(li - n); + for (var i = 0; i < rawCount[li]; i++) + { + var cell = raw[li][i].Cell; + if (!ownerLane.TryGetValue(cell, out var curLi) || Math.Abs(curLi - n) > k) + ownerLane[cell] = li; + } + } + + var walked = new int[laneCount]; + for (var li = 0; li < laneCount; li++) + { + var w = 0; + for (var i = 0; i < rawCount[li]; i++) + { + if (ownerLane[raw[li][i].Cell] != li) continue; // ceded to a closer lane -- this lane's own gap + if (w > 0 && raw[li][i].Entry > buffers[li][w - 1].Exit + 1e-4f) break; + buffers[li][w++] = raw[li][i]; + } + walked[li] = w; + ArrayPool.Shared.Return(raw[li]); + } + return walked; + } + + private static ShotOutcome MakeOutcome(PendingShot shot, ShotResult result, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) + { + return new ShotOutcome + { + ShotId = shot.ShotId, + Source = shot.Source, + Target = shot.Target, + Weapon = shot.Weapon, + Result = result, + Shielded = shielded, + ShieldBroken = shieldBroken, + Cell = cell, + Bearing = bearing, + Lateral = lateral, + ArrivalIn = max(0f, shot.ArrivalTime - now), + DamageType = shot.DamageType + }; + } + + // Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the one owner of blast damage -- a blast is + // an area, not a bundle of rays (Q12-7 dissolved the ray model's remainder question along with the rays + // themselves). Splash's signature with the point made planar (R7). Callers: Apply's three fuse branches and + // Mine.Explode. Nothing draws: every candidate entity in radius takes its share, unconditionally. + // + // For each candidate (an entity whose centre lies within radius plus half its own hull diagonal -- + // CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius, which Splash's centre-distance cull could not pass): + // take Ps = ToSchematicPoint(P) and rCells = radius / SchematicCellSize once, then for every occupied hull + // cell whose unit square meets the disc, share_c = damage * overlap(c) / (pi * rCells^2) -- the exact area + // of the disc inside that cell's unit square, over the disc's own area. Every share that falls on an + // unoccupied cell, or outside the schematic, is lost (TheShareOffTheHullIsLost); nothing renormalises over + // the covered cells. + // + // Every shield decision for a blast is made here, live, charged the entity's summed shares under today's + // CanTakeHit/Break/TakeHit rules (Q12-9 = A: the host of a contact or delayed blast decides its shield here + // too, never at Commit). If the shield absorbs, no cell of that entity takes anything; if it cannot, it + // breaks and the cells take their shares. + // + // Armour protects only its own cell's share (ArmorProtectsOnlyItsOwnCellsShare) -- "armour absorbs first" + // runs per cell, in hull Coordinates order. Only once every covered cell has deposited does an item resolve + // its own pool, once (AMultiCellItemAbsorbsItsCoveredCellsAsOnePool): no per-cell item threshold survives + // anywhere, because a disc's schedule has no walk -- no cell's share depends on another + // cell's leftover, so every deposit is known before any item resolves and there is nothing for a + // reachability test or a cycle rule to decide (unlike ApplyPooled's lane walk, which this function does not + // call: doing so would buy no behaviour, per ProbeC in the cut doc). Leftovers -- unoccupied-cell shares, + // and whatever an item's pool does not absorb -- join one entity-wide hull total, applied through one + // DamageHull call. + public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType) + { + // 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; + + var centre = entity.ToSchematicPoint(worldPlanar); + var rCells = radius / cellSize; + var normaliser = PI * rCells * rCells; + + // Shares, computed once, in hull Coordinates order -- the same order armour absorbs in below and + // an item's pool deposits in. + var coords = shape.Coordinates; + List<(int2 Cell, float Share)> covered = null; + var totalShare = 0f; + for (var i = 0; i < coords.Length; i++) + { + var overlap = CircleSquareOverlap(centre, rCells, coords[i]); + if (overlap <= 0f) continue; + var share = damage * overlap / normaliser; + (covered ??= new List<(int2 Cell, float Share)>()).Add((coords[i], share)); + totalShare += share; + } + // An entity the disc does not touch has nothing to decide: no shield is consulted, nothing is dealt. + if (covered == null) continue; + + var shield = entity.Shield; + var shieldActive = shield != null && shield.Item.Active.Value; + if (shieldActive) + { + if (shield.CanTakeHit(damageType, totalShare)) + { + shield.TakeHit(damageType, totalShare); + continue; + } + shield.Break(); + } + + // Armour first, per cell; an item's covered cells pool their post-armour deposits and resolve once, + // from the pooled sum -- ItemAbsorb's own degenerate case for a pool of one covers a single-cell + // item, exactly as it does in ApplyPooled. + Dictionary> itemPools = null; + var hullTotal = 0f; + foreach (var (cell, share) in covered) + { + var postArmor = entity.ArmorAbsorb(cell, share); + var item = entity.GearOccupancy[cell.x, cell.y]; + if (item == null) { hullTotal += postArmor; continue; } + itemPools ??= new Dictionary>(); + if (!itemPools.TryGetValue(item, out var pool)) itemPools[item] = pool = new List(); + pool.Add(postArmor); + } + + if (itemPools != null) + foreach (var (item, pool) in itemPools) + { + var amounts = pool.ToArray(); + entity.ItemAbsorb(item, amounts); + for (var i = 0; i < amounts.Length; i++) hullTotal += amounts[i]; + } + + entity.DamageHull(hullTotal); + } + } + + // Cut 12.4(b) (docs/fire-control-cut.md, "Exact overlap, in float"): the exact area of a unit square, + // centred on `cell` (the unit square centred on its integer coordinate, the same convention every other + // frame rule uses), that lies inside a disc of radius `r` centred at `diskCentre` -- both in the same + // (schematic-cell) units. Allocates nothing. + private static float CircleSquareOverlap(float2 diskCentre, float r, int2 cell) + { + var xlo = cell.x - .5f - diskCentre.x; + var xhi = cell.x + .5f - diskCentre.x; + var ylo = cell.y - .5f - diskCentre.y; + var yhi = cell.y + .5f - diskCentre.y; + return RectDiskOverlap(xlo, xhi, ylo, yhi, r); + } + + // The area of intersection of a disc of radius r centred at the origin with the axis-aligned rectangle + // [xlo,xhi] x [ylo,yhi]. Integrates the disc's chord height over the rectangle's x-span: for a fixed x, the + // disc spans y in [-h,h] with h = sqrt(r^2 - x^2), and the covered length there is + // min(yhi,h) - max(ylo,-h), clamped to zero. That covered length changes formula only where h(x) crosses + // ylo or yhi (or at the rectangle's own edges), so the span is cut at those breakpoints and each piece is + // integrated in closed form with AntiderivSqrt, the antiderivative of sqrt(r^2 - x^2). + private static float RectDiskOverlap(float xlo, float xhi, float ylo, float yhi, float r) + { + if (r <= 0f) return 0f; + xlo = max(xlo, -r); + xhi = min(xhi, r); + if (xlo >= xhi) return 0f; + + Span bp = stackalloc float[6]; + var n = 0; + bp[n++] = xlo; + bp[n++] = xhi; + + // The breakpoints where h(x) = sqrt(r^2 - x^2) crosses ylo or yhi -- the only places the covered + // length's formula (below) can change. No local function: a stackalloc Span cannot be captured by one. + Span ys = stackalloc float[2] { ylo, yhi }; + for (var k = 0; k < 2; k++) + { + var y = ys[k]; + if (abs(y) >= r) continue; + var xc = sqrt(r * r - y * y); + if (-xc > xlo && -xc < xhi) bp[n++] = -xc; + if (xc > xlo && xc < xhi) bp[n++] = xc; + } + + // Insertion sort: n is at most 6. + for (var i = 1; i < n; i++) + { + var key = bp[i]; + var j = i - 1; + while (j >= 0 && bp[j] > key) { bp[j + 1] = bp[j]; j--; } + bp[j + 1] = key; + } + + var area = 0f; + for (var i = 0; i < n - 1; i++) + { + var a = bp[i]; + var b = bp[i + 1]; + if (b - a < 1e-9f) continue; + + var mid = .5f * (a + b); + var h = sqrt(max(0f, r * r - mid * mid)); + // Inclusive: a rectangle edge exactly tangent to the disc (h == yhi at a piece whose midpoint is 0) + // is the disc's own boundary, so the chord is the true bound there. A strict test sent that piece to + // the whole-rectangle branch and counted the square in full -- a boundary flip that creates damage. + var upperIsDisc = h <= yhi; + var lowerIsDisc = -h >= ylo; + var upper = upperIsDisc ? h : yhi; + var lower = lowerIsDisc ? -h : ylo; + if (upper <= lower) continue; + + float piece; + if (upperIsDisc && lowerIsDisc) + piece = 2f * (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)); + else if (upperIsDisc) + piece = (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)) - ylo * (b - a); + else if (lowerIsDisc) + piece = yhi * (b - a) + (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)); + else + piece = (yhi - ylo) * (b - a); + + area += piece; + } + return max(0f, area); + } + + // The antiderivative of sqrt(r^2 - x^2): x/2 * sqrt(r^2 - x^2) + r^2/2 * asin(x/r). Clamped into [-r,r] + // first -- every caller here already clips its integration bounds into that range, so this is a guard + // against float rounding at the boundary, not a second clipping rule. + private static float AntiderivSqrt(float x, float r) + { + var xc = clamp(x, -r, r); + return .5f * (xc * sqrt(max(0f, r * r - xc * xc)) + r * r * asin(xc / r)); + } + + // Cut 12.2 (docs/fire-control-cut.md): Sigma keeps its Cut 6d/9.2 rule unchanged -- the frozen Precision's + // reciprocal, in hull-schematic cell units, floored at half a cell. The 9.2 floor's old rationale ("the + // discrete sum undershoots the continuous integral") is retired: Silhouette below integrates the Gaussian + // exactly over the shadow's intervals, so an exact 1D integral does not undershoot. The floor survives as + // a design minimum on group tightness (F12-3, TheSigmaFloorHoldsAtHalfACell) -- a targeting system is never + // authored tighter than half a schematic cell's width of spread, regardless of what the exact integral + // would otherwise allow. + private const float SigmaFloor = 0.5f; + private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor); + + // The aim point a silhouette is centred on (R10/Q6: the same frozen Aimed both HitProbability and Fire + // read) -- the aimed item's own cell centroid when it currently occupies cells on the target, else the + // hull's own centre of mass. One path; the uniform-random branch Cut 6d replaced stays deleted. + private static float2 ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed) + { + var aimedCells = aimed != null ? CellsOf(target, aimed) : null; + if (aimedCells != null && aimedCells.Length > 0) + return aimedCells.Aggregate(float2.zero, (total, c) => total + (float2) c) / aimedCells.Length; + return hullData.Shape.CenterOfMass; + } + + // Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): the merged lateral shadow the + // hull casts across the bearing, and the exact 1D Gaussian mass it carries -- the one function HitProbability's + // forecast, Inspect's HUD factors and Commit's roll and placement all read. Nowhere else may compute a + // sigma, a projection or a bearing. Allocates one interval array of length N (occupied-cell count, <=128 on + // shipped hulls) plus a sort -- only on this, the gated-in, path (Cut 10's own gated-out guard is + // unaffected: HitProbability and CommitProbability both bail on a zero shooter/deviation factor before + // this is ever called). + // Cost fix batch (Self, 2026-09-24): `buffer`, when supplied, replaces the `new Interval[coords.Length]` + // allocation -- the caller owns renting and returning it (ArrayPool.Shared, sized to at least + // coords.Length). Only HitProbability's and Inspect's forecast reads POnHull/Span and is done with the + // array before returning, so only they pool it; Commit keeps its own array alive across the LateralDraw + // call that follows, so it still passes null and gets an owned allocation. A caller-supplied buffer may be + // larger than coords.Length (ArrayPool rents "at least"), so every loop below is bounded by coords.Length, + // never buffer.Length. + public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision, Interval[] buffer = null) + { + var ell = Lateral(bearing); + var coords = hull.Shape.Coordinates; + + var intervals = buffer ?? new Interval[coords.Length]; + for (var i = 0; i < coords.Length; i++) + intervals[i] = Extent((float2) coords[i], ell); + Array.Sort(intervals, 0, coords.Length); + + // Merge in place: sorted ascending by Lo, so an interval overlaps (or exactly abuts) the last kept + // interval whenever its own Lo does not exceed that interval's Hi. + var count = 0; + for (var i = 0; i < coords.Length; i++) + { + if (count > 0 && intervals[i].Lo <= intervals[count - 1].Hi) + intervals[count - 1].Hi = max(intervals[count - 1].Hi, intervals[i].Hi); + else + intervals[count++] = intervals[i]; + } + + var sigma = Sigma(precision); + var aimPoint = ResolveAimPoint(target, hull, aimed); + var a = dot(aimPoint, ell); + + var pOnHull = 0f; + for (var i = 0; i < count; i++) + pOnHull += Phi((intervals[i].Hi - a) / sigma) - Phi((intervals[i].Lo - a) / sigma); + + var span = count > 0 ? intervals[count - 1].Hi - intervals[0].Lo : 0f; + + return new Silhouette + { + Intervals = intervals, + Count = count, + AimPoint = aimPoint, + A = a, + Sigma = sigma, + Span = span, + POnHull = saturate(pOnHull) + }; + } + + // Phi(z) = 1/2 (1 + erf(z / sqrt(2))): the standard normal CDF, CultMath's erf the one function underneath + // every Gaussian-mass sum this cut computes. + private static float Phi(float z) => .5f * (1f + erf(z * (1f / SQRT2))); + + private const float SQRT2 = 1.4142135f; + + // Cut 12.2: the lateral draw. One NextFloat u, taken after the hit roll -- walks the cumulative interval + // mass to u * POnHull to select interval k, then inverts the Gaussian CDF inside it. `p` here is the + // absolute CDF value at the drawn point (Phi(lo_k) plus the leftover target mass within interval k), not + // a value renormalised to [0,1] -- inverting that gives s directly in the same units A and Sigma are in. + // Clamped into [lo_k, hi_k) at the end to absorb rounding (a guard, not an invariant); erfinv's own + // handling of the domain edges (+-infinity at p = 0 or 1) means the clamp is what actually keeps s finite + // for an interval with negligible mass. + private static float LateralDraw(Silhouette sil, float u) + { + var target = u * sil.POnHull; + var cumulative = 0f; + for (var k = 0; k < sil.Count; k++) + { + var interval = sil.Intervals[k]; + var loValue = Phi((interval.Lo - sil.A) / sil.Sigma); + var hiValue = Phi((interval.Hi - sil.A) / sil.Sigma); + var mass = hiValue - loValue; + + if (target <= cumulative + mass || k == sil.Count - 1) + { + var p = clamp(loValue + (target - cumulative), loValue, hiValue); + var s = sil.A + sil.Sigma * SQRT2 * erfinv(2f * p - 1f); + // Half-open [lo_k, hi_k), matching the shadow interval itself ("Each occupied cell c projects + // to [dot(c,ell)-h, dot(c,ell)+h)"). A draw that rounds to exactly Hi is a real, reachable + // float value (Soul found 3 of them enumerating all 2^24 NextFloat outputs on the + // TurningArmour geometry) -- clamping to the closed [Lo, Hi] let s == Hi through, which Lane's + // own half-open filter (`s >= centre + h` continues) then rejects for every cell, returning an + // empty lane for a shot that already passed its roll (R3 violation). The epsilon matches + // Lane's own contiguity tolerance; every merged interval is at least one cell's shadow width + // wide (>= 2h), far above it. + var hiExclusive = max(interval.Lo, interval.Hi - 1e-4f); + return clamp(s, interval.Lo, hiExclusive); + } + + cumulative += mass; + } + + return sil.A; // sil.Count == 0: no metal in the shadow at all -- unreachable while POnHull > 0 gated Commit's roll + } + + // Every occupied cell the line meets, sorted by entry, in the pooled buffer; returns how many. Lane below + // walks the contiguous run from the first; a caller that needs the whole line (a contact fuse looking for + // the first cell ahead of its origin) reads all of them. One admission test either way. + private static int CellsOnLine(HullData hull, float2 b, float s, LaneCell[] buffer) + { + var ell = Lateral(b); + var coords = hull.Shape.Coordinates; + + var n = 0; + for (var i = 0; i < coords.Length; i++) + { + var c = (float2) coords[i]; + var lateral = Extent(c, ell); + if (s < lateral.Lo || s >= lateral.Hi) continue; // half-open, the same admission Silhouette sums over + + // F9 (Soul's fix batch, 2026-09-24): Projection (dot(cell, b)) is precomputed here, into the + // struct, so the sort below needs no closure over `b`. S3 fix batch: the sort itself now uses + // LaneCell's own IComparable, not a per-call Comparer.Create allocation the "never + // allocates" comment used to contradict. Projection is also the "along-bearing projection" the cut + // doc orders entry by. + AlongBearing(c, b, ell, s, out var entry, out var exit); + buffer[n++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit, Projection = dot(c, b) }; + } + + Array.Sort(buffer, 0, n); + return n; + } + + // Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): exact slab traversal along the + // bearing at a fixed lateral offset s, replacing the old 0.5-step sampling march. Collects the occupied + // cells whose shadow interval contains s, each with its own entry/exit parameter along b, ordered by + // 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++) + { + if (buffer[i].Entry > buffer[i - 1].Exit + 1e-4f) break; + walked++; + } + return walked; + } + + // Cut 12.2 fix batch (S1): the along-bearing entry/exit parameters for a cell the shadow test above has + // ALREADY admitted -- unconditional, never a gate. The doc's own proof ("s lies in the shadow is exactly + // the same event as this lane has metal") makes the intersection this computes a certainty, not a + // hypothesis to re-test: for a convex shape, a value inside its projection onto ell is achieved by a + // nonempty segment of the line ell=s, so the box-slab solve below cannot come back empty in exact + // arithmetic. It only ever disagreed with the shadow test by float rounding at the interval's own edge, + // which is exactly where SlabAlongB used to throw the metal away; here that same rounding is absorbed by + // clamping entry <= exit instead of rejecting. + private static void AlongBearing(float2 c, float2 b, float2 ell, float s, out float entry, out float exit) + { + entry = float.NegativeInfinity; + exit = float.PositiveInfinity; + SlabAxis(b.x, c.x - .5f, c.x + .5f, s * ell.x, ref entry, ref exit); + SlabAxis(b.y, c.y - .5f, c.y + .5f, s * ell.y, ref entry, ref exit); + if (entry > exit) exit = entry; // rounding only -- the shadow test already proved a real intersection + } + + // The slab (ray-box) bound of point(t) = t*b + s*ell against the unit square centred on cell c, one axis at + // a time: solving bAxis*t + val in [lo, hi], then folding the result into the running (entry, exit) bound. + // A near-zero bAxis component means the ray does not move along that axis at all, so it adds no constraint + // on t (the shadow test above already established this axis's bound holds). + private static void SlabAxis(float bAxis, float lo, float hi, float val, ref float entry, ref float exit) + { + if (abs(bAxis) < 1e-9f) return; // no motion along this axis: already covered by the shadow admission test + + var t1 = (lo - val) / bAxis; + var t2 = (hi - val) / bAxis; + entry = max(entry, min(t1, t2)); + exit = min(exit, max(t1, t2)); + } + + // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source + // of truth for where an equipped item's footprint lands, the same table Detonate and ApplyPooled read. + private static int2[] CellsOf(Entity target, EquippedItem item) + { + var hullData = target.ItemManager.GetData(target.Hull) as HullData; + var cells = new List(); + foreach (var v in hullData.Shape.Coordinates) + if (target.GearOccupancy[v.x, v.y] == item) + cells.Add(v); + return cells.ToArray(); + } +} + +// Cut 3 (docs/fire-control-cut.md, 0b table): a shot in flight. Born in FireControl.Fire, aged by +// FireControl.Step (called from Zone.Update after the entity loop), never serialised -- a zone saved mid- +// flight loses its shots, which is correct, a save is a scene boundary. Zone owns the collection and the +// ShotId; FireControl owns every transition. Nothing else may add, remove or mutate one. +public struct PendingShot +{ + public int ShotId; + public Entity Source; + public Entity Target; + public EquippedItem Weapon; + public EquippedItem Aimed; + + // The frozen payload (R10, Q6): the gun's own stats at the instant the trigger was pulled. Nothing + // downstream re-evaluates any of these. + public float Damage; + public float Penetration; + public float DamageSpread; + public DamageType DamageType; + // Cut 12.2 (docs/fire-control-cut.md): renamed from PBase -- its meaning changes from "the whole shot's + // frozen roll price" to "the shooter's own share of it," behind the same gates (Accuracy x PSensor). The + // spread and hull factors are no longer frozen here; they are priced live at Commit, against the target's + // facing then (Bearing timing). + public float PFire; + public float Tracking; + public float Precision; + + // Cut 12.2: frozen at Fire alongside the rest of the shooter's own decision (R10) -- the shot's + // world-planar travel direction (FireControl.TravelDirection), and the weapon stats PSpread reads at + // Commit against the target's live silhouette. + public float2 TravelDirection; + public float Spread; + public float FireRange; + + // Cut 12.4(a): the blast payload, frozen at Fire alongside everything else above. BlastRadius is zero for + // a weapon whose data does not carry one -- Step reads that zero as "resolve with Apply," not a separate + // bool. Fuse is frozen only when BlastRadius > 0 (Solve decides "detonates" once); it is unread until + // Apply's fuse switch (12.4(b)). + public float3 BurstPosition; + public 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; + public float CommitTime; + public float ArrivalTime; + + public bool Committed; + 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 Designated; + public bool InArc; + public float Range; + public float MinRange; + public float MaxRange; + public float Info; + public float InfoDemandCeiling; + public float Accuracy; + public float Resolution; + public float Precision; + public float Tracking; + public float PSensor; + // Cut 12.2 (optional, presentation): the shooter-side factor alone, Accuracy x PSensor -- the same value + // Fire freezes into PendingShot.PFire. + public float PFire; + public float PSpread; + public float POnHull; + public float PBase; + // What a round from this weapon does (Solve): the forecast for a fused weapon, whose percentages above do + // not describe it. BurstReach is read only when Outcome is Burst. + public FireOutcome Outcome; + public float BurstReach; +} + +// Cut 12.2 (docs/fire-control-cut.md): a merged interval of a hull's lateral shadow, in the schematic frame's +// lateral (ell) coordinate. +// S3 fix batch (Hands, 2026-09-25): IComparable (sorted by Lo) instead of a separate IComparer -- +// Array.Sort(T[], int, int, IComparer) boxes/wraps the comparer into a fresh delegate on CoreCLR every call +// even when the comparer instance itself is a cached singleton; Array.Sort(T[], int, int) resolves T's own +// IComparable at JIT time and allocates nothing per call. Same order as the deleted IntervalByLoComparer. +public struct Interval : IComparable +{ + public float Lo; + public float Hi; + public int CompareTo(Interval other) => Lo.CompareTo(other.Lo); +} + +// Cut 12.2: the hull's lateral shadow at one bearing, and the exact Gaussian mass it carries. Intervals holds +// the merged, sorted shadow (only the first Count entries are valid -- the array is sized to the hull's own +// occupied-cell count, not trimmed, to avoid a second allocation). A, Sigma and POnHull are read together by +// the lateral draw; Span is read by PSpread so the two factors can never disagree about the target's size. +public struct Silhouette +{ + public Interval[] Intervals; + public int Count; + public float2 AimPoint; + public float A; + public float Sigma; + public float Span; + public float POnHull; +} + +// Cut 12.2: one occupied cell FireControl.Lane crossed at a fixed lateral offset, with its own slab entry/exit +// parameter along the bearing. Entry ascending is nearest-to-farthest along the shot's own travel direction. +// S3 fix batch (Hands, 2026-09-25): IComparable replaces the separate LaneCellComparer, for the same +// reason Interval's own comparer was deleted -- the (T[], int, int, IComparer) sort overload allocates a +// wrapper delegate per call even for a cached singleton comparer; (T[], int, int) does not. Same order as the +// deleted comparer: entry ascending, ties by projection along b, then cell index for full determinism. +public struct LaneCell : IComparable +{ + public int2 Cell; + public float Entry; + public float Exit; + // F9 (Soul's fix batch, 2026-09-24): dot(Cell, b), precomputed once per candidate so Lane's sort tie-break + // needs no closure over the bearing. + public float Projection; + + public int CompareTo(LaneCell other) + { + var byEntry = Entry.CompareTo(other.Entry); + if (byEntry != 0) return byEntry; + var byProjection = Projection.CompareTo(other.Projection); + if (byProjection != 0) return byProjection; + var byX = Cell.x.CompareTo(other.Cell.x); + return byX != 0 ? byX : Cell.y.CompareTo(other.Cell.y); + } +} + +// Cut 3 (docs/fire-control-cut.md, 0b table): a commit. Created once, at ArrivalTime - CommitHorizon (or at +// fire time when the flight is shorter than the horizon), immutable from then on, published once on +// Zone.ShotCommitted and again (unchanged) on Zone.ShotResolved at arrival. Presentations are the only +// readers, and nothing outside presentation may read one to change state. +public sealed class ShotOutcome +{ + public int ShotId; + public Entity Source; + public Entity Target; + public EquippedItem Weapon; + // 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 + // deciding it, and a shield that recharges mid-flight cannot retroactively dodge it. + public bool ShieldBroken; + public int2 Cell; + // Cut 12.2 (R4): the committed geometry, frozen alongside the rest of the outcome -- the schematic-frame + // bearing and the lateral offset the roll's placement drew. Apply reads these, not a live position or + // facing (TurningAfterCommitChangesNothing). + public float2 Bearing; + public float Lateral; + public float ArrivalIn; + public DamageType DamageType; +} + +// What a committed shot came to. A Burst is a detonation that is not a hit on a target: a fused round that had no +// valid target and rolled nothing, or a proximity round whose roll missed but which bursts anyway. It is neither +// a hit nor a miss, and the presentation must not call it one. +public enum ShotResult +{ + Miss, + Hit, + Burst +} diff --git a/Assets/Scripts/ServerShared/FireControl.cs.meta b/Assets/Scripts/ServerShared/FireControl.cs.meta new file mode 100644 index 000000000..0c35816a2 --- /dev/null +++ b/Assets/Scripts/ServerShared/FireControl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e84f481f2a2e9fdc572e40e239eacc92 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index b416cfef0..59d93c1ba 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -6,6 +6,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; using GameCult.Caching; using MessagePack; using Newtonsoft.Json; @@ -349,7 +350,7 @@ public class ConsumableItemData : CraftedItemData [Inspectable, JsonProperty("duration"), Key(12)] public float Duration; - [Inspectable, CultInspectorAssetPath, JsonProperty("icon"), Key(13)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("icon"), Key(13)] public string Icon; [Inspectable, JsonProperty("effectiveness"), Key(14)] @@ -359,7 +360,7 @@ public class ConsumableItemData : CraftedItemData [JsonObject(MemberSerialization.OptIn)] public abstract class EquippableItemData : CraftedItemData { - [Inspectable, CultInspectorAssetPath, JsonProperty("schematic"), Key(10)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("schematic"), Key(10)] public string Schematic; [Inspectable, JsonProperty("behaviors"), Key(11)] @@ -368,11 +369,17 @@ public abstract class EquippableItemData : CraftedItemData [Inspectable, JsonProperty("durability"), Key(12)] public float Durability; + // R-heat requires every equippable to carry a coherent heat response (StatValidation.ValidateHeatResponse + // refuses a zero-span range), so these four fields default to a legal, non-degenerate placeholder rather + // than to a zero-span trap -- the same shape HeatResponseTests' own fixture authors by hand. A design that + // cares about its thermal behaviour overrides all four; one that does not (most test fixtures, most gear + // that predates R-heat and has not been reviewed) still opens and evaluates sanely instead of refusing to + // load or resolving to a permanently-dead Performance(). [InspectableTemperature, JsonProperty("minTemp"), Key(13)] - public float MinimumTemperature; + public float MinimumTemperature = 0; [InspectableTemperature, JsonProperty("maxTemp"), Key(14)] - public float MaximumTemperature; + public float MaximumTemperature = 100; // [InspectableField, JsonProperty("durabilityExponent"), Key(15), SimplePerformanceStat] // public PerformanceStat DurabilityExponent = new PerformanceStat(); @@ -380,8 +387,8 @@ public abstract class EquippableItemData : CraftedItemData // [InspectableField, JsonProperty("heatExponent"), Key(16), SimplePerformanceStat] // public PerformanceStat HeatExponent = new PerformanceStat(); - [InspectableAnimationCurve, JsonProperty("heatCurve"), Key(17)] - public BezierCurve HeatPerformanceCurve; + // MessagePack key 17 belonged to HeatPerformanceCurve, a BezierCurve; R-heat (docs/stats-and-power-cut.md) + // replaced the authored shape with minimum, maximum, optimum and plateau width. Do not reuse. [Inspectable, JsonProperty("resilience"), Key(18)] public float ThermalResilience = 1; @@ -389,7 +396,7 @@ public abstract class EquippableItemData : CraftedItemData // [Inspectable, JsonProperty("sfx"), Key(19)] // public string SoundEffectTrigger; - [Inspectable, CultInspectorAssetPath, JsonProperty("actionIcon"), Key(20)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("actionIcon"), Key(20)] public string ActionBarIcon; [Inspectable, JsonProperty("soundBank"), Key(21)] @@ -400,39 +407,33 @@ public abstract class EquippableItemData : CraftedItemData [IgnoreMember] public abstract HardpointType HardpointType { get; } - - [IgnoreMember] private const int STEPS = 64; - [IgnoreMember] private float? _optimum; - - [IgnoreMember] - public float OptimalTemperature - { - get - { - if (_optimum==null) - { - var samples = Enumerable.Range(0, STEPS).Select(i => (float) i / STEPS).ToArray(); - float max = 0; - _optimum = 0; - foreach (var f in samples) - { - var p = HeatPerformanceCurve?.Evaluate(f)??0; - if (p > max) - { - max = p; - _optimum = f; - } - } - } + // R-heat (docs/stats-and-power-cut.md): the authored shape is minimum, maximum (both above), optimum and + // plateau width. Where the part likes to be, and how forgiving it is. Authored, not derived -- the 100-sample + // bezier scan that used to infer this from HeatPerformanceCurve is gone, and so is its cache. + [InspectableTemperature, JsonProperty("optimalTemperature"), Key(30)] + public float OptimalTemperature = 50; - return (float) (MinimumTemperature + _optimum * (MaximumTemperature - MinimumTemperature)); - } - } + // The band, centered on OptimalTemperature, across which performance is 1. Plateau width is the + // operational-lifespan lever: inside it, Wear's thermal term is zero (Performance() returns exactly 1), so a + // well-managed item takes wear only from deltaTemp (Entity.cs UpdatePerformance). + [Inspectable, JsonProperty("plateauWidth"), Key(31)] + public float PlateauWidth = 20; + // Performance is 1 across the plateau and falls linearly to 0 at each bound -- asymmetric whenever the + // optimum is off-centre, which is how most gear behaves and how negent gear inverts. The plateau clamps to + // the bounds rather than poking past them, defensively, though StatValidation already refuses an authored + // overshoot at load -- this is redundant, not load-bearing. public float Performance(float temperature) { - return saturate(HeatPerformanceCurve?.Evaluate(unlerp(MinimumTemperature, MaximumTemperature, temperature))??1); + if (temperature <= MinimumTemperature || temperature >= MaximumTemperature) return 0f; + var halfPlateau = PlateauWidth * 0.5f; + var plateauLow = max(MinimumTemperature, OptimalTemperature - halfPlateau); + var plateauHigh = min(MaximumTemperature, OptimalTemperature + halfPlateau); + if (temperature >= plateauLow && temperature <= plateauHigh) return 1f; + if (temperature < plateauLow) + return saturate(unlerp(MinimumTemperature, plateauLow, temperature)); + return saturate(unlerp(MaximumTemperature, plateauHigh, temperature)); } } @@ -488,6 +489,21 @@ public class WeaponItemData : GearData [Inspectable, JsonProperty("modifiers"), Key(28)] public WeaponModifiers WeaponModifiers; + + // Cut 12.4(a) (docs/fire-control-cut.md): the authored blast radius a weapon's shot detonates with, in + // world units. Renamed from slot 29's old airburst-range field -- the slot and type are unchanged, so + // CultCache opens the catalog as compatible drift. Null (every shipped record) means no blast; WeaponModifiers + // no longer decides this (Q12-6, "WeaponModifiers are labels, not behaviour"). + [Inspectable, JsonProperty("blastRadius"), Key(29)] + public float? BlastRadius; + + // Cut 12.4(a): what kind of blast BlastRadius describes. Nullable under the 7.4 rule -- null means no + // blast, and every shipped record reads null. FireControl.Fire freezes this from the weapon only when + // BlastRadius > 0; a fuse without a radius, or a radius without a fuse, is inert and resolves as a direct + // hit (nothing polices that, per Q12-6). Key 30 and 31 belong to EquippableItemData's OptimalTemperature + // and PlateauWidth, inherited here -- the next free key across WeaponItemData's own ancestry is 32. + [Inspectable, JsonProperty("fuse"), Key(32)] + public WeaponFuse? Fuse; } [CultDocument("aetheria.hulldata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] @@ -496,7 +512,7 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("hardpoints"), Key(23)] public List Hardpoints = new List(); - [Inspectable, CultInspectorAssetPath, JsonProperty("prefab"), Key(24)] + [Inspectable, CultInspectorAssetGuid, JsonProperty("prefab"), Key(24)] public string Prefab; [Inspectable, JsonProperty("hullType"), Key(25)] @@ -542,6 +558,9 @@ public class HardpointData [Inspectable, JsonProperty("transform"), Key(3)] public string Transform; [Inspectable, JsonProperty("rotation"), Key(4)] public ItemRotation Rotation; [Inspectable, JsonProperty("armor"), Key(5)] public float Armor; + // Full arc width in degrees a weapon on this hardpoint may bear across; 0 means "use + // GameplaySettings.FiringArc" (docs/fire-control-cut.md R6). Turrets author 360 (Q1). + [Inspectable, JsonProperty("firingArc"), Key(6)] public float FiringArc; public override string ToString() { @@ -572,6 +591,51 @@ public class HardpointData private static Dictionary _tintColors; } +// What a PerformanceStat reads to resolve one number: the lot it prices quality against, and the per-context +// factors that used to be three separate Evaluate bodies. EquippedItem, ConsumableItemEffect and the +// unequipped case (ItemManager's own private context) are the three implementations; each supplies only the +// sources it has, and says so through what it returns rather than through a second, quietly different formula. +// Cut 1 (docs/stats-and-power-cut.md): each factor now takes the declaring term's own exponent rather than +// reading a fixed field off the stat, because the stat no longer carries one fixed field per source. +public interface IStatContext +{ + Lot Lot { get; } + float HeatFactor(float exponent); + float DurabilityFactor(float exponent); + // Progress through a consumable effect's duration, standing in for "condition" on a consumable the way heat + // does for equipped gear. Only ConsumableItemEffect has one; every other context is the identity (1). + float ConsumableProgressFactor(float exponent); + // Cut 6 (the power bus) wires this to the bus's grant ratio. F1 (docs/stats-and-power-cut.md, operator + // ruling 2026-09-19): this is not one of the terms blended into the Min/Max interpolation -- PerformanceStat + // applies it as a separate multiplier on the fully-resolved value, so zero supply always resolves to zero. + float PowerSupplyFactor(float exponent); + float ScaleModifier(PerformanceStat stat); + float ConstantModifier(PerformanceStat stat); +} + +// The declared sources a stat term can read. Closed: adding a member is a schema change with a census +// (docs/stats-and-power-cut.md §2). +public enum StatSource +{ + Heat, + Durability, + Quality, + PowerSupply, + ConsumableProgress +} + +// One declared dependency of a stat: a source and the exponent it is raised to inside the stat's Min/Max +// interpolation. Role is meaningful only for Quality; PerformanceStat.Evaluate refuses it elsewhere. +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class StatTerm +{ + [JsonProperty("source"), Key(0)] public StatSource Source; + [JsonProperty("exponent"), Key(1)] public float Exponent; + // The design role whose part quality this term reads; unset reads the item's own workmanship quality. + // Njordr states the same rule as a derivation over dimensions; this is that rule with one dimension. + [Inspectable, JsonProperty("role"), Key(2)] public string Role; +} + [MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class PerformanceStat { @@ -579,45 +643,259 @@ public class PerformanceStat [JsonProperty("max"), Key(1)] public float Max; - [JsonProperty("heatExponentMultiplier"), Key(2)] - public float HeatExponentMultiplier; + // MessagePack keys 2, 3, 4 and 5 belonged to fixed exponent fields (HeatExponentMultiplier, + // DurabilityExponentMultiplier, QualityExponent, FromRole) now expressed as declared Terms below; do not + // reuse them. - [JsonProperty("durabilityExponentMultiplier"), Key(3)] - public float DurabilityExponentMultiplier; + [Inspectable, JsonProperty("terms"), Key(6)] + public List Terms = new List(); - [JsonProperty("qualityExponent"), Key(4)] - public float QualityExponent; - - // The design role whose part quality this stat reads; unset reads the item's own workmanship quality. - // Njordr states the same rule as a derivation over dimensions; this is that rule with one dimension. - [Inspectable, JsonProperty("fromRole"), Key(5)] - public string FromRole; - - [IgnoreMember] private Dictionary> _scaleModifiers; - [IgnoreMember] private Dictionary> _constantModifiers; + // Cut 2 (docs/stats-and-power-cut.md): this catalog object used to hold two Dictionary fields + // here, keyed by every entity that ever evaluated it, with no removal -- the leak (§0.3). A stat is shared; + // a resolved value, and the modifiers that shape it, are not. Both now live in the entity's own StatResolver + // (StatResolver.cs), reachable only from the entity, so they die when the entity does instead of when the + // process does. Do not reintroduce per-entity state here under any name. - [IgnoreMember] - private Dictionary> ScaleModifiers => - _scaleModifiers = _scaleModifiers ?? new Dictionary>(); - - [IgnoreMember] - private Dictionary> ConstantModifiers => - _constantModifiers = _constantModifiers ?? new Dictionary>(); - - public Dictionary GetScaleModifiers(Entity entity) + // The one evaluation path. A stat with no terms resolves to Max (the identity factor, 1, times Min/Max + // interpolation lands on the top) -- that is the same number a stat with all-zero exponents produced before + // this cut, because pow(x, 0) == 1 regardless of x. Callers keep their own NaN handling: an unequipped read + // throws with diagnostic detail, an equipped or consumable read falls back to Min. That disagreement is not + // named as a defect, so it is not touched here. + // + // F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PowerSupply is not folded into the + // interpolation factor with every other source. Heat, Durability and Quality all describe "how worn/hot/well + // made is this part" -- a part that is still there, just less good at its job, so blending toward Min is + // right for them. Power is not a degradation of the part; it is whether the part is receiving anything to + // work with at all, so it multiplies the fully-interpolated, fully-modified value instead: a PowerSupply term + // still lives in this stat's own Terms list (it is still authored per stat, still censused in + // PowerRequestFields below), it is just applied as a separate multiplier rather than contributing to + // `factor`. Consequences named in the ruling: PowerSupply == 0 makes the whole + // expression 0 regardless of Min (pow(0, exponent) == 0 for any exponent > 0), and a Min == Max stat still + // responds to a PowerSupply term because the multiplier no longer has to move a degenerate interpolation. + public float Evaluate(IStatContext context) { - if(!ScaleModifiers.ContainsKey(entity)) - ScaleModifiers[entity] = new Dictionary(); + var factor = 1f; + var powerMultiplier = 1f; + foreach (var term in Terms) + { + if (term.Source == StatSource.PowerSupply) + { + powerMultiplier *= context.PowerSupplyFactor(term.Exponent); + continue; + } + factor *= term.Source switch + { + // F6 (docs/stats-and-power-cut.md Cut 2 Soul pass): nothing ever calls Resolver.InvalidateSource + // for StatSource.Quality. Harmless today because EquippedItem.Lot is set once in the constructor + // and never reassigned, so the value this reads never actually moves under a live resolver entry. + // §0b designs an upgrade as a lot swap the resolver must see ("an upgrade mints a new lot and + // repoints the item, which the resolver sees as a quality-source change") -- that repoint does not + // exist yet. Whichever cut adds it must also call InvalidateSource(item, StatSource.Quality) at + // the point EquippedItem.Lot is reassigned, or every Quality-termed stat keeps the pre-upgrade + // value forever. + StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent), + StatSource.Heat => context.HeatFactor(term.Exponent), + StatSource.Durability => context.DurabilityFactor(term.Exponent), + StatSource.ConsumableProgress => context.ConsumableProgressFactor(term.Exponent), + _ => throw new ArgumentOutOfRangeException(nameof(term.Source), term.Source, $"Unknown StatSource on {Min}-{Max} stat") + }; + } + return (lerp(Min, Max, factor) * context.ScaleModifier(this) + context.ConstantModifier(this)) * powerMultiplier; + } +} - return ScaleModifiers[entity]; +// Cut 1's loud refusal: the heat-response shape (min, max, optimum, plateau width) must describe a coherent +// range. Runs at catalog load (AetheriaStores.Open) and at every catalog write that goes through +// CultRecordRefs.Upsert (AetheriaStores.cs). That is not, in fact, every catalog write in this repository. F7 +// (docs/stats-and-power-cut.md, Soul pass 2026-09-19) found four holes where the two named here left off, not +// one: +// - CultCache Studio's generic document editor writes straight through CultCache, bypassing Upsert, and Studio +// exposes no per-document validation hook to attach to yet. Still open -- Studio is not this repository's to +// patch from here. +// - tools/AetherDb/Program.cs's Dangling, ShieldMigrate and BrownoutMigrate commands each repair existing +// records in place and so cannot go through Upsert (its cache.UpsertAsync(document) does not accept a +// caller-supplied key -- see AetheriaStores.cs's own Validate comment); each lands its changes through +// `db.Cache.Commit(batch => batch.Upsert(document.GetType(), document, key))`, CultCache's own batch API, +// keyed explicitly to preserve the record's existing identity. `brownout-migrate apply` is how the shipped +// catalog's PowerSupply terms were actually written (Cut 7), and all three now call +// `AetheriaStores.Validate(document)` immediately before that commit -- the same checks Upsert runs, just +// invoked directly since the identity-preserving write path cannot call Upsert itself. Closed: a dangling-ref +// fixup, a shield migration or a brownout migration can no longer land an EquippableItemData or +// ConsumableItemData with an invalid heat response, an unresolvable stat modifier reference, or a power +// request that depends on its own supply. +public static class StatValidation +{ + public static void ValidateHeatResponse(EquippableItemData data) + { + if (float.IsNaN(data.MinimumTemperature) || float.IsNaN(data.MaximumTemperature) || + float.IsNaN(data.OptimalTemperature) || float.IsNaN(data.PlateauWidth)) + throw new InvalidOperationException( + $"{data.Name}: heat response carries NaN (min {data.MinimumTemperature}, max {data.MaximumTemperature}, " + + $"optimum {data.OptimalTemperature}, plateau {data.PlateauWidth})"); + if (data.MinimumTemperature == data.MaximumTemperature) + throw new InvalidOperationException( + $"{data.Name}: MinimumTemperature and MaximumTemperature are both {data.MinimumTemperature} -- a " + + "zero-span range is dead at every temperature, which is an authoring error, not an authored immunity"); + if (data.OptimalTemperature < data.MinimumTemperature || data.OptimalTemperature > data.MaximumTemperature) + throw new InvalidOperationException( + $"{data.Name}: OptimalTemperature {data.OptimalTemperature} lies outside its bounds " + + $"[{data.MinimumTemperature}, {data.MaximumTemperature}]"); + if (data.PlateauWidth < 0) + throw new InvalidOperationException($"{data.Name}: PlateauWidth {data.PlateauWidth} is negative"); + var halfPlateau = data.PlateauWidth * 0.5f; + var plateauLow = data.OptimalTemperature - halfPlateau; + var plateauHigh = data.OptimalTemperature + halfPlateau; + if (plateauLow < data.MinimumTemperature || plateauHigh > data.MaximumTemperature) + throw new InvalidOperationException( + $"{data.Name}: plateau [{plateauLow}, {plateauHigh}] pokes past its bounds " + + $"[{data.MinimumTemperature}, {data.MaximumTemperature}] -- the plateau must clamp to the bounds, " + + "not exceed them"); } - public Dictionary GetConstantModifiers(Entity entity) - { - if(!ConstantModifiers.ContainsKey(entity)) - ConstantModifiers[entity] = new Dictionary(); + // Cut 2 (docs/stats-and-power-cut.md): a StatReference names its target by (type name, field name) so a + // modifier can point at any design or behaviour without a hard type reference -- but before this cut an + // unresolvable reference failed silently: StatModifier.Initialize left `_stats` null and the first + // ApplyModifier threw an unhelpful NullReferenceException at random equip time. Resolving it here, once, + // memoized, and failing loudly at catalog load or Upsert (through ValidateStatModifiers below) names the + // record instead of leaving the bug for whoever equips the item first. + private static readonly Dictionary<(string Target, string Stat), (Type Type, FieldInfo Field)> _resolvedStatFields = + new Dictionary<(string, string), (Type, FieldInfo)>(); - return ConstantModifiers[entity]; + private static Type[] _statReferenceTypes; + private static Type[] StatReferenceTypes => _statReferenceTypes ??= typeof(BehaviorData).GetAllChildClasses() + .Concat(typeof(EquippableItemData).GetAllChildClasses()).ToArray(); + + public static (Type Type, FieldInfo Field) ResolveStatField(StatReference reference) + { + var key = (reference.Target, reference.Stat); + if (_resolvedStatFields.TryGetValue(key, out var cached)) return cached; + var targetType = StatReferenceTypes.FirstOrDefault(t => t.Name == reference.Target); + var field = targetType?.GetFields().FirstOrDefault(f => f.FieldType == typeof(PerformanceStat) && f.Name == reference.Stat); + if (targetType == null || field == null) + throw new InvalidOperationException( + $"stat modifier targets \"{reference.Target}.{reference.Stat}\", which does not resolve to a PerformanceStat field"); + var resolved = (targetType, field); + _resolvedStatFields[key] = resolved; + return resolved; + } + + // Runs wherever ValidateHeatResponse runs (AetheriaStores.Open, CultRecordRefs.Upsert): every StatModifierData + // authored on this design must resolve, named here rather than at whichever equip happens to hit it first. + public static void ValidateStatModifiers(string ownerName, IEnumerable behaviors) + { + foreach (var behavior in behaviors) + if (behavior is StatModifierData modifier) + try + { + ResolveStatField(modifier.Stat); + } + catch (InvalidOperationException ex) + { + throw new InvalidOperationException($"{ownerName}: {ex.Message}"); + } + } + + // Cut 7 (docs/stats-and-power-cut.md): "a stat naming a role its design lacks" must be refused, not silently + // fall back to the lot's own workmanship (Lot.QualityForRole already falls back for a role a lot never rolled + // -- that is the intended forward-compat path for an old lot meeting a newly-authored role. This is the other + // direction: a StatTerm naming a role the design itself never declared is an authoring mistake, not a lot + // predating the role, and must fail loudly rather than silently reading generic quality). Every PerformanceStat + // field on every behaviour is walked; only StatSource.Quality terms carry a Role at all (Evaluate ignores Role + // for every other source). + public static void ValidateRoleUsage(string ownerName, List roles, IEnumerable behaviors) + { + var declared = new HashSet((roles ?? new List()).Select(r => r.Name)); + foreach (var behavior in behaviors ?? Enumerable.Empty()) + { + if (behavior == null) continue; + foreach (var field in behavior.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat))) + { + if (!(field.GetValue(behavior) is PerformanceStat stat) || stat.Terms == null) continue; + foreach (var term in stat.Terms) + { + if (term.Source != StatSource.Quality || string.IsNullOrEmpty(term.Role)) continue; + if (!declared.Contains(term.Role)) + throw new InvalidOperationException( + $"{ownerName}: {behavior.GetType().Name}.{field.Name} names role \"{term.Role}\", " + + $"which this design does not declare (declared: {(declared.Count == 0 ? "none" : string.Join(", ", declared))})"); + } + } + } + } + + // Cut 6 (docs/stats-and-power-cut.md): "a stat that decides a power request may not depend on power supply, + // directly or through a modifier chain, and no stat may depend on itself." IPowerConsumer.PowerRequest(dt) + // never hands the bus a number it invented on the spot -- each implementation evaluates a fixed set of + // PerformanceStat fields on its own BehaviorData. Those fields are a compile-time fact about the C# type, not + // something authored per catalog instance, so they are named here once -- the same (type, field) address + // ResolveStatField already uses for StatReference -- rather than adding a second reflection idiom or a + // per-instance flag. Naming it here (not a new interface member on IPowerConsumer) means the check needs no + // Behavior instance, no EquippedItem and no Entity: TryGetPowerRequestBehaviorName below can run over the + // catalog's own Data objects with nothing else in hand. + // + // F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) named every field a PowerRequest implementation + // actually reads, not just each consumer's top-level request field: Radiator.PowerRequest also reads + // PumpedHeat and WasteHeat, AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and + // PassiveCoupling, Shield.PowerRequest (via RefreshReserve) also reads RefillDuration and RestoreDuration, + // InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown and Count, and Sensor. + // PowerRequest (via RefreshInputCapacitor) also reads PingCooldown. Audited directly against each + // PowerRequest method's own body (and RefreshReserve/RefreshInputCapacitor, which PowerRequest calls into) + // rather than guessed from the behaviour's public surface; still the complete census a modifier chain must + // be checked against (see TryGetPowerRequestBehaviorName and StatModifier.ValidateNoPowerSupplyChain below). + // + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, superseding F6): F6 also + // added a static check here -- ValidateNoPowerSupplyOnRequest, refusing any of these fields whose own Terms + // named PowerSupply outright -- on the premise that PowerRequest reads these stats the same way Execute + // does. It does not any more: every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now + // calls EquippedItem.EvaluateNominalPower (Entity.cs) for a field named here, which pins that stat's own + // PowerSupplyFactor to 1 regardless of what Terms it declares -- so a direct PowerSupply term on one of + // these fields can no longer make the request depend on its own answer, and that static check was deleted + // rather than left enforcing an invariant nominal evaluation had already made false (it would otherwise + // still refuse the six shipped records this ruling exists to make legal again). The census below still has + // exactly one live reader: TryGetPowerRequestBehaviorName, for the modifier-chain half of the rule + // (StatModifier.ValidateNoPowerSupplyChain, StatModifier.cs) -- EvaluateNominalPower forwards + // ScaleModifier/ConstantModifier unchanged (real, not nominal), so a modifier chain that reaches a + // power-tainted magnitude stat still corrupts a nominal read too, and that is what remains forbidden. + public static readonly (Type Type, string Field)[] PowerRequestFields = + { + (typeof(EnergyDrawData), nameof(EnergyDrawData.EnergyDraw)), + (typeof(ThrusterData), nameof(ThrusterData.EnergyUsage)), + (typeof(ConstantWeaponData), nameof(WeaponData.Energy)), + (typeof(InstantWeaponData), nameof(WeaponData.Energy)), + (typeof(InstantWeaponData), nameof(InstantWeaponData.Cooldown)), + (typeof(InstantWeaponData), nameof(InstantWeaponData.Count)), + (typeof(SensorData), nameof(SensorData.PingEnergy)), + (typeof(SensorData), nameof(SensorData.PingCooldown)), + (typeof(ShieldData), nameof(ShieldData.Capacity)), + (typeof(ShieldData), nameof(ShieldData.RefillDuration)), + (typeof(ShieldData), nameof(ShieldData.RestoreDuration)), + (typeof(RadiatorData), nameof(RadiatorData.EnergyUsage)), + (typeof(RadiatorData), nameof(RadiatorData.PumpedHeat)), + (typeof(RadiatorData), nameof(RadiatorData.WasteHeat)), + (typeof(AetherDriveData), nameof(AetherDriveData.EnergyDraw)), + (typeof(AetherDriveData), nameof(AetherDriveData.Torque)), + (typeof(AetherDriveData), nameof(AetherDriveData.LambdaMultiplier)), + (typeof(AetherDriveData), nameof(AetherDriveData.MaximumRpm)), + (typeof(AetherDriveData), nameof(AetherDriveData.PassiveCoupling)), + }; + + // Whether `stat` is the request stat of some IPowerConsumer behaviour on `data` -- used by StatModifier's + // equip-time chain check to tell "an ordinary modifier target" from "a modifier target that would corrupt a + // power request." Returns the owning behaviour's type name for the error message. + public static bool TryGetPowerRequestBehaviorName(EquippableItemData data, PerformanceStat stat, out string behaviorName) + { + foreach (var behavior in data.Behaviors) + foreach (var (type, field) in PowerRequestFields) + { + if (!type.IsInstanceOfType(behavior)) continue; + if (ReferenceEquals(type.GetField(field)?.GetValue(behavior), stat)) + { + behaviorName = type.Name; + return true; + } + } + behaviorName = null; + return false; } } diff --git a/Assets/Scripts/ServerShared/ItemInstance.cs b/Assets/Scripts/ServerShared/ItemInstance.cs index 739b7835b..d4e7d9d89 100644 --- a/Assets/Scripts/ServerShared/ItemInstance.cs +++ b/Assets/Scripts/ServerShared/ItemInstance.cs @@ -52,6 +52,13 @@ public class EquippableItem : CraftedItemInstance { [JsonProperty("durability"), Key(7)] public float Durability; [JsonProperty("override"), Key(8)] public bool OverrideShutdown; + + // Cut 5 (docs/stats-and-power-cut.md §1.3, §0b "A power tier"): a player choice, stored on the unit rather + // than the design, so two units of the same design can be prioritized differently. PowerTiers.Unassigned + // until the first equip seeds a behaviour-kind default (EquippedItem's constructor) or the player overrides + // it from the schematic UI; from then on PowerBus only ever reads it, never derives it. An old run store + // missing key 12 deserializes to this same field initializer, so no migration is needed (§ Cut 5). + [JsonProperty("powerTier"), Key(12)] public int PowerTier = PowerTiers.Unassigned; } [MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index 3d7037950..281228165 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -71,22 +71,44 @@ public class ItemManager // The one resolution path from a crafted instance to the lot it was minted from. public Lot GetLot(CraftedItemInstance item) => Lots[item.Lot]; - // Returns stat when not equipped + // Returns stat when not equipped: a null-entity context with no heat, no power and no modifiers. It says so + // in its own name (UnequippedStatContext) rather than being a third quiet dialect of the arithmetic. public float Evaluate(PerformanceStat stat, EquippableItem item) { var data = GetData(item); - var lot = GetLot(item); - var quality = pow(lot.QualityForRole(stat.FromRole), stat.QualityExponent); - var durabilityExponent = lerp( - GameplaySettings.DurabilityQualityMin, - GameplaySettings.DurabilityQualityMax, - pow(lot.Quality, GameplaySettings.DurabilityQualityExponent)); - var durability = pow(item.Durability / data.Durability, durabilityExponent * stat.DurabilityExponentMultiplier); - var result = lerp(stat.Min, stat.Max, quality * durability); + var result = stat.Evaluate(new UnequippedStatContext(this, item, data)); if (float.IsNaN(result)) throw new InvalidOperationException($"Performance Stat on {data.Name} evaluating as NaN: input data is invalid! Durability: {item.Durability} / {data.Durability}"); return result; + } + // The unequipped case: quality and durability read live off the item and its lot; heat and modifiers do not + // exist without an entity, so they resolve as the identity for their term rather than a bespoke formula. + private sealed class UnequippedStatContext : IStatContext + { + private readonly EquippableItem _item; + private readonly EquippableItemData _data; + private readonly float _durabilityExponent; + public Lot Lot { get; } + + public UnequippedStatContext(ItemManager items, EquippableItem item, EquippableItemData data) + { + _item = item; + _data = data; + Lot = items.GetLot(item); + _durabilityExponent = lerp( + items.GameplaySettings.DurabilityQualityMin, + items.GameplaySettings.DurabilityQualityMax, + pow(Lot.Quality, items.GameplaySettings.DurabilityQualityExponent)); + } + + public float HeatFactor(float exponent) => 1f; + public float DurabilityFactor(float exponent) => + pow(_item.Durability / _data.Durability, _durabilityExponent * exponent); + public float ConsumableProgressFactor(float exponent) => 1f; + public float PowerSupplyFactor(float exponent) => 1f; + public float ScaleModifier(PerformanceStat stat) => 1f; + public float ConstantModifier(PerformanceStat stat) => 0f; } public int GetPrice(CraftedItemInstance item) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index da432b7c2..11e277f7f 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -258,6 +258,41 @@ public class LoadoutGenerator capacitor.Rotation = capacitorRotation; if (!entity.TryEquip(capacitor, capacitorPosition)) throw new InvalidLoadoutException("Failed to equip selected capacitor!"); + + // Cut 2 (docs/fire-control-cut.md, Q4): required equipment for anything that can fire -- unaided + // fire is a last resort, not an alternative loadout choice. EquipHardpoints runs before FillInterior + // (OutfitEntity), so entity.Weapons is already populated here. Zenith and anything else with no + // weapon hardpoints gets none. + // + // Cut 6c, 6c.2: this used to be `required: true` and throw InvalidLoadoutException when no seller was + // available -- a galaxy that happened not to draw a targeting design's manufacturer failed entity + // generation outright, a single point of failure the ten-seed smoke never sampled. It degrades instead: + // required: false, so RandomProducts' own manufacturer-widening fallback never fires and an unmet + // requirement returns null quietly rather than searching outside the galaxy for one. An entity that + // ends up with no targeting system fires through FireControl's unaided fallback + // (GameplaySettings.UnaidedTracking, Cut 5.1) rather than not firing at all -- that floor is what makes + // this degrade safe. The gap is logged so it surfaces as content debt (AetherDb loadout reports it per + // seed) instead of vanishing silently. + if (entity.Weapons.Any()) + { + emptyShape = entity.UnoccupiedSpace; + + var (targetingProduct, targetingData) = RandomProduct(2, item => + item.Behaviors.Any(b => b is TargetingSystemData) && + item.Shape.FitsWithin(emptyShape, out _, out _)); + if (targetingData == null) + { + ItemManager.Log("No targeting system available for armed entity; it will fire unaided."); + } + else + { + targetingData.Shape.FitsWithin(emptyShape, out var targetingRotation, out var targetingPosition); + var targetingSystem = ItemManager.CreateInstance(targetingProduct) as EquippableItem; + targetingSystem.Rotation = targetingRotation; + if (!entity.TryEquip(targetingSystem, targetingPosition)) + throw new InvalidLoadoutException("Failed to equip selected targeting system!"); + } + } } } diff --git a/Assets/Scripts/ServerShared/PowerBus.cs b/Assets/Scripts/ServerShared/PowerBus.cs new file mode 100644 index 000000000..488ea07d2 --- /dev/null +++ b/Assets/Scripts/ServerShared/PowerBus.cs @@ -0,0 +1,253 @@ +/* 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 System.Linq; +using static CultMath.math; + +// Cut 3 (docs/stats-and-power-cut.md §1.2): the sole owner of every power grant for one Entity, stepped once per +// tick from Entity.Update, before any equipped item's Behaviors execute. It replaces +// Entity.TryConsumeEnergy/CanConsumeEnergy: at HEAD every draw succeeded as long as one reactor was online, and +// any shortfall was silently taxed onto the reactor as heat instead of ever being refused (§0.5). The bus makes +// the ceiling real: demand beyond what this tick's generation and stored capacitor charge can cover goes unmet. +// +// Cut 5 (docs/stats-and-power-cut.md §1.3) replaces the single shared fraction with priority tiers +// (PowerTiers.cs): the total this tick's supply can cover (TotalGrant, unchanged by tiers -- see below) is +// walked tier by tier, lowest number first. A tier is fed in full before the next tier sees anything left; a +// tier with no demand simply passes its whole share down; within a tier, every consumer's own request is +// rationed by the same fraction, so two requests of unequal size still divide proportionally. This is the death +// of the old "whoever equipped first drains the capacitors first" hidden priority (§0.5), replaced with an +// authored one instead of no priority at all. +// +// Bus capacitors are charged and drained only here (§0b): Reactor no longer touches them, and neither does any +// IPowerConsumer. The four instant draws the cut map names (a burst, a shot, a ping, a hit taken) are +// IPowerConsumers too as of Cut 4: each spends from its own InputCapacitor (InstantWeapon, Sensor, Shield), +// never from these bus capacitors directly. Entity.TrySpendCapacitorCharge/CanSpendCapacitorCharge, Cut 3's +// named, temporary exception, died with their last caller. +public class PowerBus +{ + private readonly Entity _entity; + + public PowerBus(Entity entity) => _entity = entity; + + // Every figure below is this tick's, overwritten wholesale by the next Step -- nothing here is persisted + // (§0.6); a fresh Entity starts with the defaults below and they read as "fully supplied, nothing drawn" + // until the first Step runs. + public float TotalGeneration { get; private set; } + public float TotalDemand { get; private set; } + + // Energy actually delivered this tick, summed across every tier: generation first, then whatever stored + // capacitor charge covers the rest, capped at demand. Equal to TotalDemand whenever supply covers it; equal + // to (generation + available charge) otherwise -- never more, which TryConsumeEnergy could never promise (a + // reactor always paid the remainder as heat, so overdraw was invisible at this level). Which consumers that + // total actually reaches is what tiering (below) decides; this figure is unaffected by tiers -- the ceiling + // is a property of supply, not of priority. + public float TotalGrant { get; private set; } + + // Aggregate fraction of all demand granted this tick (TotalGrant / TotalDemand) -- a fleet-wide summary, not + // what any one consumer necessarily received. Still written onto every consuming EquippedItem.PowerSupply + // when every tier in play happens to land on the same ratio (single-tier loadouts, the common case in the + // Cut 3/4 test fixtures), but a multi-tier shortfall gives different tiers different ratios -- read + // TierGrantRatio or the item's own PowerSupply for that. + public float GrantRatio { get; private set; } = 1f; + + // F3 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the sentinel AllocateTiers writes for a tier with no + // consumers equipped in it at all. Distinct from 1f -- an empty tier has not been "fully supplied," there was + // nothing to supply, and reporting it as 1f is exactly the fully-satisfied signal a UI is meant to read as + // "this tier is fine," on a ship that may be fully blacked out. A reader must treat NaN as "nothing to report + // here," not as satisfied or starved. + public const float NoDemand = float.NaN; + + // Cut 5: this tick's within-tier grant ratio, indexed by PowerTiers.Critical..Utility. 1f means that tier's + // demand was fully met; NoDemand (NaN) means the tier has no consumers at all (see NoDemand above); anything + // else below 1f is exactly the "starved" state a future UI (Cut 8) reads instead of reconstructing it from + // per-item PowerSupply. A tier below a starved one is always 0f -- §1.3's "a tier boundary does not leak". + public float[] TierGrantRatio { get; } = InitFullTiers(); + + // Before the bus has ever run a Step, there is no grant information at all yet -- not "fully supplied," + // "nothing to report" (NoDemand), same as an empty tier reads once Step does run. + private static float[] InitFullTiers() + { + var ratios = new float[PowerTiers.Count]; + for (var i = 0; i < ratios.Length; i++) ratios[i] = NoDemand; + return ratios; + } + + // What Reactor.Execute reads to run its own heat/throttle arithmetic (Cut 3: "the arithmetic survives, moved + // under the bus's numbers"). Positive: demand left unmet after generation AND stored charge, split evenly + // across online reactors for overload heat. Negative: generation left over after demand and after topping up + // every capacitor, split evenly for the throttling arithmetic. Reactor.Draw no longer feeds this -- it is now + // a reported total, not a sink (§1.2). + public float NetDraw { get; private set; } + + // One equipped item's power request for this tick, captured once so PowerRequest -- which some behaviours + // (Shield, InstantWeapon, Sensor) implement by refreshing their own cached stats -- is never called twice in + // one Step. + private readonly struct Draw + { + public readonly EquippedItem Item; + public readonly int Tier; + public readonly float Request; + public Draw(EquippedItem item, int tier, float request) { Item = item; Tier = tier; Request = request; } + } + + public void Step(float dt) + { + var reactors = new List(); + var capacitors = new List(); + var draws = new List(); + foreach (var item in _entity.Equipment) + foreach (var behavior in item.Behaviors) + { + switch (behavior) + { + // F2 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this must agree with the one predicate + // Entity.Update actually gates Execute on (Active.Value, Entity.cs -- Enabled && Online), in both + // directions. Gating on Online alone let a reactor the player switched off (Enabled = false, so + // Active is false but Online stays true) keep generating here even though Reactor.Execute -- the + // behaviour that produces the heat receipt -- never runs for it: free, heat-free power. Capacitor + // has no IPowerConsumer/generation role and is unconditionally a store regardless of its own + // item's state (mirrors pre-existing behaviour; nothing in the ruling asks for that to change). + case Reactor reactor when item.Active.Value: + reactors.Add(reactor); + break; + case Capacitor capacitor: + capacitors.Add(capacitor); + break; + } + // The other direction of the same rule: a consumer switched off or shut down (Active.Value false) + // never runs its Execute, so it must never be billed either -- otherwise a disabled item's demand + // starves every live consumer and drains the capacitors for energy nobody spends. + if (behavior is IPowerConsumer consumer && item.Active.Value) + { + // Cut 5 (docs/stats-and-power-cut.md §1.3): the item's own stored choice wins once it has one; + // PowerTiers.Unassigned only survives past EquippedItem's constructor for a consumer added to + // the game after this unit was minted, which the constructor never saw -- fall back to the + // behaviour's own default rather than stranding it at an invalid tier. + var tier = item.EquippableItem.PowerTier; + if (tier == PowerTiers.Unassigned) tier = consumer.DefaultPowerTier; + tier = clamp(tier, 0, PowerTiers.Count - 1); + draws.Add(new Draw(item, tier, max(0f, consumer.PowerRequest(dt)))); + } + } + + TotalGeneration = reactors.Sum(r => r.Generation(dt)); + TotalDemand = draws.Sum(d => d.Request); + + var availableCharge = capacitors.Sum(c => c.Charge); + var preCapacitorNet = TotalDemand - TotalGeneration; + + float overload = 0f, unabsorbedSurplus = 0f; + if (preCapacitorNet > 0f) + { + var chargeDrawn = min(preCapacitorNet, availableCharge); + if (chargeDrawn > 0f) DrainEvenly(capacitors, chargeDrawn); + overload = preCapacitorNet - chargeDrawn; + } + else if (preCapacitorNet < 0f) + { + unabsorbedSurplus = FillEvenly(capacitors, -preCapacitorNet); + } + + NetDraw = overload - unabsorbedSurplus; // exactly one of the two is ever nonzero + TotalGrant = TotalDemand - overload; + GrantRatio = TotalDemand <= 1e-4f ? 1f : saturate(TotalGrant / TotalDemand); + + AllocateTiers(draws); + } + + // Cut 5 (docs/stats-and-power-cut.md §1.3): walks tiers lowest-number-first, feeding each in full before the + // next sees anything. remaining only ever shrinks, and once it hits 0 every later tier's ratio is 0 by the + // same division -- a lower tier can never take from a higher one (§1.3 "a tier boundary does not leak"), + // because a higher tier's share is committed (remaining -= grant) before a lower tier is even considered. + // Within a tier every consumer shares that tier's one ratio against its own request, so two unequal requests + // still divide proportionally -- the same property Cut 3 proved for the whole ship, now proved per tier. + private void AllocateTiers(List draws) + { + var tierDemand = new float[PowerTiers.Count]; + // F3 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): demand alone cannot tell "no consumer is + // equipped in this tier" from "a real consumer is equipped here and its own request happens to be 0 (or + // effectively 0) this tick" -- both sum to the same tierDemand. Only the first is actually empty; the + // second still has an item reading this tier's ratio back as its own PowerSupply, and that item is owed + // a real answer (trivially 1: it asked for nothing, so nothing it could be short of), not NoDemand. + var tierHasConsumer = new bool[PowerTiers.Count]; + foreach (var draw in draws) { tierDemand[draw.Tier] += draw.Request; tierHasConsumer[draw.Tier] = true; } + + var remaining = TotalGrant; + var ratios = TierGrantRatio; + for (var tier = 0; tier < PowerTiers.Count; tier++) + { + // A tier with genuinely no consumers is not satisfied, it is empty: report NoDemand, not the "fully + // supplied" signal 1f. Nothing to subtract from `remaining` either way. + if (!tierHasConsumer[tier]) { ratios[tier] = NoDemand; continue; } + var demand = tierDemand[tier]; + // This used to short-circuit ANY demand at or below 1e-4 the same way as a genuinely empty tier, + // which both reported a grant nobody made (an item reading ratio 1 while the ship generates nothing, + // down to Soul's own probe at 5e-5) and leaked that tier's unsubtracted share to every lower one. A + // consumer requesting truly nothing (demand <= 0f exactly) is trivially fully met; anything above + // that, however tiny, is rationed like any other tier. + if (demand <= 0f) { ratios[tier] = 1f; continue; } + var grant = min(demand, remaining); + ratios[tier] = saturate(grant / demand); + remaining -= grant; + } + + foreach (var draw in draws) + { + draw.Item.PowerSupply = ratios[draw.Tier]; + // Cut 6 (docs/stats-and-power-cut.md): the missing half of the wiring -- a stat with a PowerSupply + // term must recompute when the grant actually moves. Called unconditionally, once per draw per tick, + // the same shape as EquippedItem.UpdatePerformance's own Heat/Durability invalidation: "recomputes at + // most once per tick per (item, stat)," not "only when the value moved." An item with no PowerSupply + // term pays nothing extra -- the resolver's per-source generation bookkeeping (StatResolver.Resolve) + // only ever looks at sources a stat's own Terms declared. + _entity.Resolver.InvalidateSource(draw.Item, StatSource.PowerSupply); + } + } + + // Mirrors Entity.TryConsumeEnergy's old do/while exactly (draw evenly across every capacitor that still has + // charge, repeating as capacitors empty out), just relocated: bus capacitors are drained only by the bus now. + private static void DrainEvenly(List capacitors, float amount) + { + int chargedCount; + do + { + chargedCount = capacitors.Count(c => c.Charge > .01f); + if (chargedCount == 0) break; + var share = amount / chargedCount; + foreach (var cap in capacitors) + { + if (cap.Charge <= .01f) continue; + var drawn = min(share, cap.Charge); + cap.AddCharge(-drawn); + amount -= drawn; + } + } while (chargedCount > 0 && amount > .01f); + } + + // Fills every capacitor evenly up to its own capacity and returns whatever would not fit, for Reactor to + // throttle away -- the same job Reactor.cs used to do to itself (surplus branch), moved here because bus + // capacitors are charged only by the bus (§0b). Resolves Capacity itself rather than reading each + // Capacitor's cached property: the bus steps before any behaviour's Execute runs this tick, so that property + // still holds last tick's value. + private static float FillEvenly(List capacitors, float amount) + { + int nonFullCount; + do + { + nonFullCount = capacitors.Count(c => c.Charge < c.ResolveCapacity() - .01f); + if (nonFullCount == 0) break; + var share = amount / nonFullCount; + foreach (var cap in capacitors) + { + var capacity = cap.ResolveCapacity(); + if (cap.Charge >= capacity - .01f) continue; + var added = min(share, capacity - cap.Charge); + cap.AddCharge(added); + amount -= added; + } + } while (nonFullCount > 0 && amount > .01f); + return amount; + } +} diff --git a/Assets/Scripts/ServerShared/PowerBus.cs.meta b/Assets/Scripts/ServerShared/PowerBus.cs.meta new file mode 100644 index 000000000..8b02dfa52 --- /dev/null +++ b/Assets/Scripts/ServerShared/PowerBus.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8f9e089f203a4ea4f8af7310b452851d \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/PowerTiers.cs b/Assets/Scripts/ServerShared/PowerTiers.cs new file mode 100644 index 000000000..e6bf90d89 --- /dev/null +++ b/Assets/Scripts/ServerShared/PowerTiers.cs @@ -0,0 +1,39 @@ +/* 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/. */ + +// Cut 5 (docs/stats-and-power-cut.md §1.3, Q5: "four or five tiers, defaulted per behaviour kind"). Five tiers, +// numbered so the lowest number is fed first -- PowerBus's allocation pass walks 0..Count-1 in order. Naming +// them here, once, means IPowerConsumer.DefaultPowerTier implementations read as a sentence ("Radiator is +// Critical") instead of a bare integer repeated eight times. +// +// Assignment, per behaviour kind, and why: +// - Critical: Radiator. This game has no dedicated life-support behaviour, and heat is the closest thing to +// one -- a starved radiator does not just fail itself, it cascades into wear and eventual shutdown across +// every other item on the hull (EquippedItem.UpdatePerformance). Feed it first or lose the rest anyway. +// - High: Shield. Hull survival the player is actively trading power for; not a life-support constant, so it +// sits below Radiator rather than beside it. +// - Medium: Thruster, AetherDrive. Mobility -- needed to hold position, evade, or disengage, but a ship that +// loses thrust for a tick under brownout is inconvenienced, not dead the way an overheating hull is. +// - Low: ConstantWeapon, InstantWeapon. Offense. The ruling's own framing ("low-priority subsystems starve +// first") names weapons as the default example of what a reactor throttle should sacrifice first. +// - Utility: Sensor, EnergyDraw. Everything else, including the generic/unclassified draw -- an item that +// declares power use without asking for a specific tier should not accidentally outrank a named priority. +// +// A tier is a stored player choice once assigned (EquippableItem.PowerTier), not a derivation the bus repeats +// every tick (§1.3 "Derived state: nothing"). These defaults are read exactly once, in EquippedItem's +// constructor, to seed that stored field the first time an item without a chosen tier gets equipped. +public static class PowerTiers +{ + public const int Critical = 0; + public const int High = 1; + public const int Medium = 2; + public const int Low = 3; + public const int Utility = 4; + + public const int Count = 5; + + // EquippableItem.PowerTier's sentinel for "the player has not chosen one and this item has never been + // equipped yet" -- distinct from 0 (Critical), which is a real, meaningful choice. + public const int Unassigned = -1; +} diff --git a/Assets/Scripts/ServerShared/PowerTiers.cs.meta b/Assets/Scripts/ServerShared/PowerTiers.cs.meta new file mode 100644 index 000000000..b3d61cc80 --- /dev/null +++ b/Assets/Scripts/ServerShared/PowerTiers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bd20d57dee4a44fa84660631a9c4f5c1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index ff5980109..ffd369b01 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -212,10 +212,58 @@ public class GameplaySettings public float TargetArmorInfoThreshold; public float TargetGearInfoThreshold; public float ConvergenceMinimumDistance; + public float FiringArc = 120; + // Cut 2 (docs/fire-control-cut.md, Q4): what an entity with no working targeting system fires with -- + // the ceiling FireControl.Accuracy falls back to (Resolution 1, Precision 0 alongside it). Authored low + // on purpose ("really, really bad," operator 2026-09-19): .05 against the two catalog designs' own + // Accuracy ranges (the authoring spec this cut produces proposes .45-.6 for the 1-cell design and + // .65-.85 for the 2-cell one) leaves unaided fire capable of hitting something close, slow and + // unaware, and little else. One authored setting, easy to find and turn -- the operator rules the real + // figure in play. + public float UnaidedAccuracy = .05f; + // Cut 5 (docs/fire-control-cut.md, 5.1, Soul finding 3): the unaided fallback for Tracking, alongside + // UnaidedAccuracy above -- an entity with no working targeting system used to fall back to Tracking 0, + // which Commit turned into a hard wall (any nonzero deviation was an automatic miss). Ten world units of + // fire-time-projection deviation forgiveness is the first guess and the operator's knob, the same shape + // as UnaidedAccuracy: authored deliberately bad, not authored broken. + public float UnaidedTracking = 10f; + // Cut 6d (docs/fire-control-cut.md): the unaided fallback for Precision, alongside UnaidedAccuracy and + // UnaidedTracking above -- FireControl.Precision falls back to this the same way it already falls back to + // UnaidedAccuracy/UnaidedTracking. Precision is now a grouping tightness (FireControl.Sigma = 1/Precision, + // in hull-schematic cell units), not the old coin-flip probability, so the unaided floor has to be + // authored on that scale. .3 -> sigma 3.33 cells: against the live catalog's own hulls (LonginusX 6x17, + // Zenith 12x12, Turret 8x8) that puts pOnHull at the hull's own centre of mass around .5-.84 -- broadly + // sprayed across the whole silhouette (sigma is a large fraction of the hull's own width) while still + // landing on the ship more often than not. A tighter floor (e.g. .1, sigma 10) was tried first and + // rejected: it drove pOnHull for the same hulls down to .08-.18, reading as "can't hit the broad side of a + // barn" rather than "sprays the silhouette." First guess, the same deliberately-bad shape as its two + // siblings; the operator rules the real figure in play. + public float UnaidedPrecision = .3f; public float AgentRangeExponent = .25f; public float AgentForwardLerp = .5f; public float AgentMaxForwardDistance = 50; - public float AgentFiringMinDot = .99f; + + // Cut 3 (docs/fire-control-cut.md): first guesses: the headless fixture is the tuning harness, the + // operator smoke is the arbiter (§ Risks). + // + // How long before impact a shot's outcome commits (R4). Deviation counts up to this horizon; after it + // the result is frozen and the rest of the flight is pure choreography. + public float CommitHorizon = .5f; + + // World units per hull-schematic cell, used only by FireControl.HitProbability's angular-size term + // (pSpread) to turn a hull's cell footprint into a real-world silhouette size at range. + public float SchematicCellSize = 2f; + + // The floor an AI's own predicted hit probability (FireControl.HitProbability) must clear before it counts + // a firing solution worth taking (Combat.cs, TurretController.cs). Player fire is gated on arc alone + // (Weapon.ArcAllowsFire, Q2) -- this threshold is an AI fire-discipline heuristic, not part of the roll. + public float AgentMinHitProbability = .2f; + + // Cut 4 (docs/fire-control-cut.md): how often a continuous weapon (ConstantWeapon) rolls a discrete + // outcome for Damage * this interval, through the same FireControl.Fire/Step pair a discrete shot uses -- + // a beam is a sequence of rolls, not a continuous truth. First guess; the headless fixture is the tuning + // harness, the operator smoke is the arbiter, same as Cut 3's other first guesses. + public float BeamResolveInterval = .25f; } [Serializable, MessagePackObject(keyAsPropertyName: true), JsonObject] diff --git a/Assets/Scripts/ServerShared/StatResolver.cs b/Assets/Scripts/ServerShared/StatResolver.cs new file mode 100644 index 000000000..bbd3ea542 --- /dev/null +++ b/Assets/Scripts/ServerShared/StatResolver.cs @@ -0,0 +1,177 @@ +/* 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.Runtime.CompilerServices; + +// Cut 2 (docs/stats-and-power-cut.md): one resolver per Entity, owning every resolved (item, stat) value for +// that entity. PerformanceStat -- the catalog object -- used to hold two Dictionary fields and grew +// one entry per entity that ever evaluated it, for the life of the process (§0.3). A stat is shared; a resolved +// value is not (§0b) -- that sentence is this type's whole reason to exist. It is reachable only from the Entity +// that owns it, so it dies when the entity does, not when the catalog does. +// +// "Owner" below is whatever called Resolve/AttachModifier for a stat -- an EquippedItem or a ConsumableItemEffect, +// compared by reference, never by value. Two ships equipping the same design share the PerformanceStat instance +// but never share an owner, so they never share a cached value or a modifier. +public class StatResolver +{ + private readonly struct Entry : IEquatable + { + public readonly object Owner; + public readonly PerformanceStat Stat; + + public Entry(object owner, PerformanceStat stat) + { + Owner = owner; + Stat = stat; + } + + public bool Equals(Entry other) => ReferenceEquals(Owner, other.Owner) && ReferenceEquals(Stat, other.Stat); + public override bool Equals(object obj) => obj is Entry other && Equals(other); + + public override int GetHashCode() => unchecked( + RuntimeHelpers.GetHashCode(Owner) * 397 ^ RuntimeHelpers.GetHashCode(Stat)); + } + + // A resolved value is valid only as long as every source it read is still at the generation it read. + // Generations are per (owner, source): a term's *source kind*, not the specific stat, so invalidating "this + // owner's heat changed" invalidates every stat of that owner's that declared a Heat term, and no others. + private class CachedValue + { + public float Value; + public Dictionary Generations; + } + + private class ModifierSet + { + public readonly Dictionary Scale = new Dictionary(); + public readonly Dictionary Constant = new Dictionary(); + } + + private readonly Dictionary _cache = new Dictionary(); + private readonly Dictionary _modifiers = new Dictionary(); + private readonly Dictionary> _generations = + new Dictionary>(); + + private int GenerationOf(object owner, StatSource source) + { + if (_generations.TryGetValue(owner, out var perSource) && perSource.TryGetValue(source, out var generation)) + return generation; + return 0; + } + + // Called unconditionally, every tick, by EquippedItem.UpdatePerformance for Heat and Durability and by + // ConsumableItemEffect.Update for ConsumableProgress -- not only when the source's value actually moved. So + // the true invariant is not "recomputes only when a term's source changes": a Heat- or Durability-termed stat + // recomputes at most once per tick per (item, stat), every tick, regardless of whether the value moved. + // Quality is the one source this scheme still defends against a real no-op tick (F6: nothing invalidates it + // today, since EquippedItem.Lot is set once in the constructor -- harmless until an upgrade needs a lot swap + // to invalidate it, which §0b designs for but this cut does not yet wire up). Invalidates every cached value + // of this owner's that declared a term reading this source -- and only those. + public void InvalidateSource(object owner, StatSource source) + { + if (!_generations.TryGetValue(owner, out var perSource)) + _generations[owner] = perSource = new Dictionary(); + perSource[source] = GenerationOf(owner, source) + 1; + } + + // The only place a resolved value is computed. Returns the cached value when every source the stat's Terms + // declared is still at the generation it was computed against; otherwise evaluates once, against the live + // context, and caches the new value against the current generations. + public float Resolve(object owner, PerformanceStat stat, IStatContext context) + { + var entry = new Entry(owner, stat); + if (_cache.TryGetValue(entry, out var cached) && IsCurrent(owner, cached)) + return cached.Value; + + var value = stat.Evaluate(context); + if (float.IsNaN(value)) value = stat.Min; + + var generations = new Dictionary(); + foreach (var term in stat.Terms) + if (!generations.ContainsKey(term.Source)) + generations[term.Source] = GenerationOf(owner, term.Source); + _cache[entry] = new CachedValue { Value = value, Generations = generations }; + return value; + } + + private bool IsCurrent(object owner, CachedValue cached) + { + foreach (var pair in cached.Generations) + if (GenerationOf(owner, pair.Key) != pair.Value) + return false; + return true; + } + + public float ScaleModifier(object owner, PerformanceStat stat) + { + if (!_modifiers.TryGetValue(new Entry(owner, stat), out var set)) return 1f; + var result = 1f; + foreach (var value in set.Scale.Values) result *= value; + return result; + } + + public float ConstantModifier(object owner, PerformanceStat stat) + { + if (!_modifiers.TryGetValue(new Entry(owner, stat), out var set)) return 0f; + var result = 0f; + foreach (var value in set.Constant.Values) result += value; + return result; + } + + // Attached by StatModifier when its behaviour executes and detached when it stops (StatModifier.cs); the + // modifier's own instance is the key both ways, exactly as it was on the catalog object before this cut -- + // only the dictionary this now lives in has changed owner. Invalidates the entry directly: a modifier is not + // one of the stat's declared Terms, so the source-generation scheme above does not see it move. + public void AttachModifier(object owner, PerformanceStat stat, object modifierKey, StatModifierType type, float value) + { + var entry = new Entry(owner, stat); + if (!_modifiers.TryGetValue(entry, out var set)) + _modifiers[entry] = set = new ModifierSet(); + (type == StatModifierType.Constant ? set.Constant : set.Scale)[modifierKey] = value; + _cache.Remove(entry); + } + + public void DetachModifier(object owner, PerformanceStat stat, object modifierKey) + { + var entry = new Entry(owner, stat); + if (_modifiers.TryGetValue(entry, out var set)) + { + set.Scale.Remove(modifierKey); + set.Constant.Remove(modifierKey); + } + _cache.Remove(entry); + } + + // Cut 2 gap (docs/stats-and-power-cut.md §0b: a resolver entry is "destroyed at unequip / entity teardown"): + // nothing ever dropped an owner from _cache, _modifiers or _generations, so every EquippedItem that was ever + // unequipped and every ConsumableItemEffect that ever expired stayed reachable from these three dictionaries + // for the life of the process -- the same shape of leak §0.3 named, one level down. Called from the unequip + // path (Entity.cs TryUnequip) and from consumable expiry (Entity.cs Update); an owner with nothing recorded is + // a harmless no-op. + public void Forget(object owner) + { + _generations.Remove(owner); + RemoveEntriesForOwner(_cache, owner); + RemoveEntriesForOwner(_modifiers, owner); + } + + private static void RemoveEntriesForOwner(Dictionary dict, object owner) + { + List stale = null; + foreach (var entry in dict.Keys) + if (ReferenceEquals(entry.Owner, owner)) + (stale ??= new List()).Add(entry); + if (stale == null) return; + foreach (var entry in stale) + dict.Remove(entry); + } + + // Test/diagnostic surface only, never a hot path: the retained-set sizes a mutation test pins against a + // baseline after equip/evaluate/unequip and activate/evaluate/expire cycles (Cut 2 Gate 1). + public int GenerationOwnerCount => _generations.Count; + public int CacheEntryCount => _cache.Count; + public int ModifierEntryCount => _modifiers.Count; +} diff --git a/Assets/Scripts/ServerShared/StatResolver.cs.meta b/Assets/Scripts/ServerShared/StatResolver.cs.meta new file mode 100644 index 000000000..96a739ca7 --- /dev/null +++ b/Assets/Scripts/ServerShared/StatResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b9e2b7d6335e48709359a81ae2ea98ad +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index edfd043bf..9b34e6687 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -33,6 +33,31 @@ public class Zone private Random _random; public List Agents = new List(); + // Cut 3 (docs/fire-control-cut.md, 0b table): Zone owns the pending-shot collection and the ShotId + // namespace; FireControl owns every transition a shot goes through. ShotCommitted publishes once a shot's + // outcome is decided (R4's commit horizon); ShotResolved republishes the same, unchanged outcome at + // arrival, right before the shot is removed. Presentations are the only readers of either. + public List PendingShots = new List(); + public Subject ShotCommitted = new Subject(); + public Subject ShotResolved = new Subject(); + 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 BeltUpdates = new List(); public float Time @@ -43,6 +68,12 @@ public class Zone public GalaxyZone GalaxyZone { get; } public Galaxy Galaxy { get; } + // Cut 6b (docs/fire-control-cut.md, 6.1, Soul finding 6): one zone, one stable identity -- a galaxy seed + // reproduces it, and nothing else in the zone (an NPC joining, a shop opening, a wormhole exit) can + // perturb it. FireControl.Commit builds its own local generator from this and the shot's own id instead + // of touching a shared stream. + public uint CombatSeed { get; } + public Zone(ItemManager itemManager, PlanetSettings settings, ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy) { _time = pack.Time; @@ -51,9 +82,16 @@ public class Zone Pack = pack; _itemManager = itemManager; Settings = settings; - _random = new Random(galaxyZone?.Name.StableHash() ?? 1337u); + CombatSeed = galaxyZone?.Name.StableHash() ?? 1337u; + _random = new Random(CombatSeed); var cache = itemManager.ItemData; + // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship, + // in the simulation, not only in Unity's own loot-drop subscription. One subscription point covers + // every join, whichever call site adds the entity (deserialization below, a jump, a spawned turret) -- + // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. + Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); })); + foreach (var orbit in pack.Orbits) { Orbits.Add(orbit.Key, new Orbit(Settings, cache.Get(orbit))); @@ -148,6 +186,11 @@ public class Zone agent.Update(deltaTime); foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); + + // Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that + // firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and + // resolves in this same call. + FireControl.Step(this, deltaTime); } // Determine orbital position recursively, caching parent positions to avoid repeated calculations diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c36d5e9f6..2144f718c 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -207,17 +207,39 @@ public static class ZoneGenerator var storyStations = galaxyZone.Locations.Where(story => story.Type == LocationType.Station).ToArray(); var stationCount = (int)(random.NextFloat() * (factionPresence + 1)) + storyStations.Length; + // Enemy count below is based on this pre-override value, so the tutorial entrance doesn't gain an enemy + // just because its station got forced. + var baseStationCount = stationCount; + // Operator ruling, 2026-09-25 (docs/locomotion-cut.md, "Tutorial station"): the entrance zone always + // gets a generated faction station with a docking bay, deterministically. Galaxy.cs halves influence and + // picks the lowest-influence unowned zone for Entrance, which otherwise rolls stationCount 0 on every + // seed. galaxy.Entrance is reference-identity, not a name comparison. + var isTutorialEntrance = isTutorial && galaxyZone == galaxy.Entrance; + if (isTutorialEntrance && stationCount < 1) stationCount = 1; var potentialLagrangePoints = planets .Where(p => p.Parent != null && p.Parent.Children .TrueForAll(c => !(c != p && abs(c.Distance - p.Distance) < .1f))) // Filter Rosettes .OrderBy(p => p.Distance) .ToArray(); + // The entrance's own station count is the floor above; its orbit always comes from this same selection. + // Ordinarily that selection is potentialLagrangePoints (non-rosette planet orbits). If the entrance zone + // has none of those (a boring single-body system, or a system that is entirely one rosette), widen to + // every planet orbit ordinary placement's own candidate pool is drawn from before the rosette filter -- + // still a real, parented planet orbit with a positive Distance -- rather than inventing an unparented, + // zero-distance orbit (that gave PlaceTurret's phase = 20*multiplier/orbit.Distance a division by zero, + // and left an orbit record with no planet behind it). + var lagrangeCandidates = potentialLagrangePoints; + if (isTutorialEntrance && lagrangeCandidates.Length == 0) + lagrangeCandidates = planets.Where(p => p.Parent != null).OrderBy(p => p.Distance).ToArray(); // Pick a selection from the middle of the distribution - var selectedStationOrbits = potentialLagrangePoints - .Skip(potentialLagrangePoints.Length / 2) + var selectedStationOrbits = lagrangeCandidates + .Skip(lagrangeCandidates.Length / 2) .Take(stationCount) .Select(p=>orbitMap[p]) .ToArray(); + if (isTutorialEntrance && lagrangeCandidates.Length == 0) + throw new InvalidOperationException( + "The tutorial entrance zone must always get a station, but it has no candidate orbit at all -- every planet in the zone is a parentless root."); var loadoutGenerators = new Dictionary(); @@ -234,7 +256,7 @@ public static class ZoneGenerator { Parent = baseOrbit.Parent, Distance = baseOrbit.Distance, - Phase = baseOrbit.Phase + PI / 3 * sign(random.NextFloat() - .5f) + Phase = baseOrbit.Phase + PI / 3 * sign(random.NextFloat() - .5f), }; pack.Orbits.Add(cache.Upsert(lagrangeOrbit)); return lagrangeOrbit; @@ -294,7 +316,13 @@ public static class ZoneGenerator var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout(); - if (station == null) continue; + if (station == null) + { + if (isTutorialEntrance) + throw new InvalidOperationException( + $"The tutorial entrance zone must always get a station, but {nearestFaction?.Name} has no station loadout to generate."); + continue; + } station.Orbit = cache.RefOf(lagrangeOrbit); station.SecurityLevel = security; station.SecurityRadius = pack.Radius; @@ -303,7 +331,7 @@ public static class ZoneGenerator PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2); } - var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + stationCount; + var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount; for (int i = 0; i < enemyCount; i++) { var ship = GetLoadoutGenerator(nearestFaction).GenerateShipLoadout(); diff --git a/Assets/Scripts/UI/ConsoleView.cs b/Assets/Scripts/UI/ConsoleView.cs index fcde87e29..64e0a4597 100644 --- a/Assets/Scripts/UI/ConsoleView.cs +++ b/Assets/Scripts/UI/ConsoleView.cs @@ -50,7 +50,9 @@ public class ConsoleView : MonoBehaviour { Keyboard.current.onTextInput += c => { - if (!Visible || c == '`') return; + // Text input also delivers control characters (backspace as '\b', enter as '\r'); editing keys are + // handled in Update, so only printable characters are text. + if (!Visible || c == '`' || char.IsControl(c)) return; InputString += c; }; InputString = ""; @@ -81,7 +83,7 @@ public class ConsoleView : MonoBehaviour { if (!Visible) return; - if(keyboard.deleteKey.wasPressedThisFrame && InputString.Length>0) + if((keyboard.backspaceKey.wasPressedThisFrame || keyboard.deleteKey.wasPressedThisFrame) && InputString.Length>0) { InputString = InputString.Substring(0, InputString.Length - 1); } diff --git a/Assets/Scripts/UI/HUD/SchematicDisplay.cs b/Assets/Scripts/UI/HUD/SchematicDisplay.cs index 65c3b1f7f..e2eb764f7 100644 --- a/Assets/Scripts/UI/HUD/SchematicDisplay.cs +++ b/Assets/Scripts/UI/HUD/SchematicDisplay.cs @@ -106,8 +106,11 @@ public class SchematicDisplay : MonoBehaviour CargoTemperatureLabel.text = "N/A"; } + // Cut 2 (docs/fire-control-cut.md): an enemy schematic shows only what `player` has actually + // resolved (FireControl.IsRevealed) -- the same predicate the selection writer and the AI use. The + // player's own schematic is unfiltered; there is nothing to reveal about your own ship. _schematicItems = entity.Equipment - .Where(x => x.Behaviors.Any(b => b.Data is WeaponData)) + .Where(x => x.Behaviors.Any(b => b.Data is WeaponData) && (!_enemy || FireControl.IsRevealed(player, x))) .Select(x => new SchematicDisplayItem { Item = x, @@ -201,6 +204,13 @@ public class SchematicDisplay : MonoBehaviour // DistanceLabel.text = $"{(int)length(_entity.Position - _player.Position)}"; // } + // Cut 2 (docs/fire-control-cut.md): marks whichever row is the player's current aim point. + // Re-checked every frame (not just on ShowShip) because ResolvedTargetItem can drop on its own + // as info decays, with no event to hook. + if (_enemy && _schematicItems != null) + foreach (var x in _schematicItems) + x.ListElement.SetSelected(x.Item == _player.ResolvedTargetItem); + if(VisibilityLabel) VisibilityLabel.text = ((int)_entity.Visibility).ToString(); diff --git a/Assets/Scripts/UI/HUD/SchematicListElement.cs b/Assets/Scripts/UI/HUD/SchematicListElement.cs index 8b6829379..5603e927e 100644 --- a/Assets/Scripts/UI/HUD/SchematicListElement.cs +++ b/Assets/Scripts/UI/HUD/SchematicListElement.cs @@ -15,8 +15,18 @@ public class SchematicListElement : MonoBehaviour public RectTransform CooldownFill; public RectTransform HeatFill; + // Cut 2 (docs/fire-control-cut.md): marks this row as the current aim point (Entity.ResolvedTargetItem) + // on an enemy schematic. Unassigned in a prefab that has not been wired up in the Unity editor yet + // (unavailable in this pass) -- SetSelected below is a no-op until then, not a null reference. + public GameObject SelectedIndicator; + private List _iconInstances = new List(); + public void SetSelected(bool selected) + { + if (SelectedIndicator != null) SelectedIndicator.SetActive(selected); + } + public void ShowWeapon(WeaponItemData weapon) { foreach(var prototype in _iconInstances) prototype.ReturnToPool(); diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 8e56eb4b4..8ace9b3c3 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using MessagePack; using TMPro; using UniRx; using UnityEngine; @@ -122,13 +123,15 @@ public class MainMenu : MonoBehaviour if (ActionGameManager.PlayerSettings.TutorialPassed) { - Settings.SectorBackgroundSettings.NoisePosition = Random.value * 1000; + var backgroundSettings = MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(Settings.SectorBackgroundSettings)); + backgroundSettings.NoisePosition = Random.value * 1000; ActionGameManager.IsTutorial = false; Task.Run(() => { var sector = new Galaxy( Settings.SectorGenerationSettings, - Settings.SectorBackgroundSettings, + backgroundSettings, Settings.NameGeneratorSettings, ActionGameManager.CultCache, Debug.Log, @@ -142,19 +145,21 @@ public class MainMenu : MonoBehaviour } else { + var backgroundSettings = MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(Settings.TutorialBackgroundSettings)); int iteration = 1; do { - Settings.TutorialBackgroundSettings.NoisePosition = Random.value * 1000; + backgroundSettings.NoisePosition = Random.value * 1000; setState($"Finding Galaxy Position: iteration {iteration++}"); - } while (Settings.TutorialBackgroundSettings.CloudDensity(float2(0.5f)) < .5f); - + } while (backgroundSettings.CloudDensity(float2(0.5f)) < .5f); + ActionGameManager.IsTutorial = true; Task.Run(() => { var sector = new Galaxy( Settings.TutorialGenerationSettings, - Settings.TutorialBackgroundSettings, + backgroundSettings, Settings.NameGeneratorSettings, ActionGameManager.CultCache, ActionGameManager.PlayerSettings, diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 1642dc24c..3b1643dab 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -374,8 +374,7 @@ public class TradeMenu : MonoBehaviour { if (hullData.HullType != HullType.Ship) throw new ArgumentException("Attempted to buy non-ship hull from station, WTF are you doing?!"); - var ship = new Ship(GameManager.ItemManager, GameManager.Zone, item as EquippableItem, GameManager.NewEntitySettings) { IsPlayerShip = true }; - ship.SetParent(GameManager.DockedEntity); + GameManager.CommissionShip(item as EquippableItem); GameManager.Credits -= data.Price; UpdateCreditsLabel(); diff --git a/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs b/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs index 06c406eb6..3e8a3c5ae 100644 --- a/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs +++ b/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs @@ -479,14 +479,11 @@ public class PropertiesPanel : MonoBehaviour statusSheet.AddStat("Durability", () => "Item Destroyed!"); else statusSheet.AddStat("Durability", () => $"{(int)(item.EquippableItem.Durability / gearData.Durability * 100)}%"); statusSheet.AddStat("Temperature", () => ActionGameManager.PlayerSettings.FormatTemperature(item.Temperature)); - - var heatCurve = AddCurveField(); - heatCurve.Show( - "Thermal Performance", - gearData.HeatPerformanceCurve, - t => ActionGameManager.PlayerSettings.FormatTemperature(lerp(gearData.MinimumTemperature, gearData.MaximumTemperature, t)), - true); - RefreshPropertyValues += () => heatCurve.SetCurrent(unlerp(gearData.MinimumTemperature, gearData.MaximumTemperature, item.Temperature)); + // R-heat (docs/stats-and-power-cut.md): the authored shape is min/max/optimum/plateau width, not a + // curve, so the thermal performance panel reads those directly rather than sampling a BezierCurve. + // Cut 8 owns the real UI reconciliation pass; this keeps the panel legible and compiling in the interim. + statusSheet.AddStat("Optimal Temp", () => ActionGameManager.PlayerSettings.FormatTemperature(gearData.OptimalTemperature)); + statusSheet.AddStat("Thermal Performance", () => $"{(int)(gearData.Performance(item.Temperature) * 100)}%"); AddEquippableItemProperties(item.EquippableItem, item.Evaluate); AddSpacer(); @@ -520,12 +517,8 @@ public class PropertiesPanel : MonoBehaviour var gearData = GameManager.ItemManager.GetData(gear); var statusSheet = AddStatSheet(); statusSheet.AddStat("Durability", () => $"{(int)(gear.Durability / gearData.Durability * 100)}%"); - var heatCurve = AddCurveField(); - heatCurve.Show( - "Thermal Performance", - gearData.HeatPerformanceCurve, - t => ActionGameManager.PlayerSettings.FormatTemperature(lerp(gearData.MinimumTemperature, gearData.MaximumTemperature, t)), - true); + // R-heat: see the equipped Inspect() overload above for why this is stats, not a curve field. + statusSheet.AddStat("Optimal Temp", () => ActionGameManager.PlayerSettings.FormatTemperature(gearData.OptimalTemperature)); AddEquippableItemProperties(gear, stat => GameManager.ItemManager.Evaluate(stat, gear)); } diff --git a/Assets/Scripts/UnityHelpers.cs b/Assets/Scripts/UnityHelpers.cs deleted file mode 100644 index dc8439d93..000000000 --- a/Assets/Scripts/UnityHelpers.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using UnityEngine; - -public class UnityHelpers -{ - public static T LoadAsset(string path) where T : Object - { - return Resources.Load(path.Substring("Assets/Resources/".Length).Split('.')[0]); - } -} diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index 09d2c2f3a..0b437dcfe 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -292,7 +292,7 @@ public class ZoneRenderer : MonoBehaviour EntityInstance instance; if (entity is Ship) { - instance = Instantiate(UnityHelpers.LoadAsset(hullData.Prefab), ZoneRoot).GetComponent(); + instance = Instantiate(EngineAssets.Load(hullData.Prefab), ZoneRoot).GetComponent(); if (instance == null) { ItemManager.Log($"Failed to instantiate {hullData.Name} ship with invalid prefab: no ShipInstance component!"); @@ -301,7 +301,7 @@ public class ZoneRenderer : MonoBehaviour } else { - instance = Instantiate(UnityHelpers.LoadAsset(hullData.Prefab), ZoneRoot).GetComponent(); + instance = Instantiate(EngineAssets.Load(hullData.Prefab), ZoneRoot).GetComponent(); if (instance == null) { ItemManager.Log($"Failed to instantiate {hullData.Name} entity with invalid prefab: no EntityInstance component!"); diff --git a/Assets/Shaders/Compute/ShieldField.meta b/Assets/Shaders/Compute/ShieldField.meta new file mode 100644 index 000000000..3932969fb --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8003975577559f64cb70e6aff23e4c9d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl b/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl new file mode 100644 index 000000000..f984b2ff7 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl @@ -0,0 +1,135 @@ +#ifndef SHIELD_COMMON_INCLUDED +#define SHIELD_COMMON_INCLUDED + +#define MAX_VERTS 8 // must match Limits.MaxVerts in TilingBuilder.cs +#define TAU 6.28318530718 +#define PI 3.14159265359 + +// ---- fracture pattern ids (must match FracturePattern in ShieldPanel.cs) +#define PAT_RADIAL 0 +#define PAT_STAR 1 +#define PAT_LANCE 2 +#define PAT_SPIRAL 3 +#define PAT_CRYSTAL 4 +#define PAT_FIZZLE 5 + +struct CellStatic +{ + float2 center; + float radius; + float spawnDelay; + uint vertStart; + uint vertCount; + uint nbrStart; + uint nbrCount; + float toughness; + uint tileType; + uint splitSeed; + float area; // Laplacian denominator + // R7: openW (rim free-surface ghost weight) removed — the panel's emitters hold its edge. + float pad0; + float pad1; + float pad2; + float pad3; +}; + +struct DualEdge +{ + uint nbr; + float2 mid; + float2 dir; + float len; + float w; // finite-volume weight, len / |c_i - c_j| +}; + +struct CellState +{ + float3 vel; + float spin; + float3 pos; // displacement from rest, panel space (z = out of plane) + float rot; + float damage; // 0..1 persistent + float breakTime; // <0 while intact + float glow; // transient crack emission + float growth; // 0..1 materialisation + float temper; // remaining surface compression — the "charge" that resists initiation + float released; // stored energy dumped at break; sets fragment fineness + float peakTension; // running max, for visualisation + float pad2; +}; + +// ------------------------------------------------------------------ +// hashing +// ------------------------------------------------------------------ +uint Hash(uint x) +{ + x ^= x >> 16; x *= 0x7feb352du; + x ^= x >> 15; x *= 0x846ca68bu; + x ^= x >> 16; return x; +} +float Hash01(uint x) { return Hash(x) * (1.0 / 4294967296.0); } +float2 Hash2(uint x) { return float2(Hash01(x), Hash01(x ^ 0x68bc21ebu)); } + +// ------------------------------------------------------------------ +// shard partitioning +// +// A cell is drawn as a fan of vertCount triangles around its centroid. +// A shard is a contiguous run of those triangles. Split boundaries are +// derived from the cell seed, so every shader agrees without storage. +// ------------------------------------------------------------------ +// Fragment count scales with the stored elastic energy released at the moment +// of breaking -- the same reason a highly tempered pane dices into gravel while +// an annealed one splits into a few big shards. `released` is 0 for a cell that +// is merely cracked and not yet broken, so the chords appear before separation. +uint ShardCount(uint seed, float damage, float released, uint vertCount, + float crackStart, uint maxShards) +{ + if (damage <= crackStart) return 0; + float t = saturate(max(damage - crackStart, 0.0) / max(1.0 - crackStart, 1e-4)); + t = saturate(t * 0.4 + released * 1.2); + uint hi = min(vertCount, maxShards); + if (hi < 2) return 0; + uint n = (uint)round(lerp(2.0, (float)hi, t * (0.55 + 0.45 * Hash01(seed)))); + return clamp(n, 2u, hi); +} + +// Returns the shard index for fan triangle `tri`; startTri receives the +// first triangle of that shard (used as the shard's motion reference). +uint ShardOf(uint seed, uint nSplits, uint vertCount, uint tri, out uint startTri) +{ + uint count = 0, prevB = 0, maxB = 0; + [loop] for (uint k = 0; k < nSplits; k++) + { + uint b = Hash(seed ^ (k * 0x9E3779B9u + 0x2545F491u)) % max(vertCount, 1u); + maxB = max(maxB, b); + if (b <= tri) { count++; prevB = max(prevB, b); } + } + startTri = (count == 0) ? maxB : prevB; + return (count == 0) ? (nSplits - 1) : (count - 1); +} + +// Is fan edge `ei` (the radial chord from centroid to vertex ei) a shard boundary? +bool IsChord(uint seed, uint nSplits, uint vertCount, uint ei) +{ + [loop] for (uint k = 0; k < nSplits; k++) + if ((Hash(seed ^ (k * 0x9E3779B9u + 0x2545F491u)) % max(vertCount, 1u)) == ei) return true; + return false; +} + +// ------------------------------------------------------------------ +// easing +// ------------------------------------------------------------------ +float EaseOutBack(float t) +{ + float u = t - 1.0; + return 1.0 + 2.70158 * u * u * u + 1.70158 * u * u; +} +float EaseOutCubic(float t) { float u = 1.0 - t; return 1.0 - u * u * u; } + +float2 Rot2(float2 p, float a) +{ + float s = sin(a), c = cos(a); + return float2(p.x * c - p.y * s, p.x * s + p.y * c); +} + +#endif diff --git a/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl.meta b/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl.meta new file mode 100644 index 000000000..a329d8fdb --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 28966e181234948439910021e253326f +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs b/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs new file mode 100644 index 000000000..f7147b130 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs @@ -0,0 +1,553 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Rendering; + +namespace ShieldField +{ + public enum FracturePattern { Radial = 0, Star = 1, Lance = 2, Spiral = 3, Crystal = 4, Fizzle = 5 } + + public class ShieldPanel : MonoBehaviour + { + // ---------------- authoring ---------------- + [Header("Tiling")] + public TilingKind tiling = TilingKind.PenroseP3; + public float panelRadius = 5f; + public float cellSize = 0.35f; + public bool mergeRhombs = true; // Penrose triangles -> P3 rhombs + public bool takeDual = false; // render the dual tiling instead + public int seed = 1337; + + [Header("Assets")] + public ComputeShader sim; + public Shader panelShader; + + [Header("Materialisation")] + public float growDuration = 0.35f; + public float wavefrontSpeed = 8f; + public float delayJitter = 0.06f; + + [Header("Wave")] + [Tooltip("c^2. Raise for a faster shock; watch the CFL warning in the console.")] + public float waveSpeed2 = 26f; + public float damping = 1.6f; + public float stressClamp = 4f; + [Tooltip("D9: bounds dU/dt so a hard hit cannot pin the field in compression forever. " + + "Too low flattens the wave; too high reproduces the saturation defect.")] + public float velocityClamp = 100f; + [Range(1, 16)] public int substeps = 6; + public float fixedSubstepDt = 0.0025f; + + [Header("Through-thickness echo")] + [Tooltip("Substeps for one round trip through the plate. Small = fast back-face spall.")] + [Range(1, 15)] public int echoDelaySteps = 5; + [Tooltip("Negative: a free back face returns the compressive pulse as tension.")] + public float echoReflect = -0.9f; + + [Header("Material (tempered)")] + public float tensileStrength = 0.2f; + [Tooltip("Surface compression at full charge. This is the shield's hit budget.")] + public float temperInit = 0.55f; + public float temperErosion = 12f; + [Tooltip("How much stored core energy a break dumps back into the field.")] + public float storedEnergyGain = 1f; + public float damageGain = 100f; + [Range(0.05f, 1f)] public float breakAt = 0.08f; + [Range(0f, 0.9f)] public float crackStart = 0.18f; + [Tooltip("Breaks permitted per frame. Without this the cascade finishes in one frame and you see nothing.")] + [Range(0.001f, 1f)] public float breakBudgetFraction = 0.02f; + + [Header("Shards")] + public float radialKick = 1.6f; + public float lateralKick = 2.2f; + public float normalKick = 1.1f; + public float spinKick = 7f; + public float shardLife = 2.5f; + public float shardDrag = 0.7f; + [Range(2, 8)] public int maxShards = 6; + + [Header("Render")] + public float conduitGain = 2.5f; + + // ---------------- runtime ---------------- + struct PendingHit + { + public Vector2 pos, dir; + public float energy, radius, obliquity, spin; + public FracturePattern pattern; + public float starArms, focus, twist; + } + + readonly Queue pending = new(); + + TilingData data; + Material mat; + MaterialPropertyBlock mpb; + + GraphicsBuffer bCells, bVerts, bEdges, bState; + GraphicsBuffer bUA, bUB, bV, bEcho, bFlow, bVisible, bBudget; + GraphicsBuffer argsFill; + + bool uUsingA = true; + int kInit, kInject, kWave, kEcho, kFracture, kUpdate; + int groups, echoSlot; + float simTime; + Bounds worldBounds; + uint[] budgetScratch; // cached to avoid a per-frame allocation (D12) + + // D17/D20 (Cut 3, docs/shield-panel-cut.md): the panel's own arm instant, backdated by + // one growDuration so a cell at the strike centre (spawnDelay ~= 0) is already past the + // KInject/KWaveStep/KFracture growth gate the moment it is struck. See ResetSim. + float panelSpawnTime; + CellState[] growthSeed; // cached per-cell arm state; rebuilt only when the tiling is (re)built, + // re-uploaded (not reallocated) on every ResetSim — no interception allocation. + + const int EchoSlots = 16; + static readonly int MaxV = Limits.MaxVerts; + + // shader ids + static readonly int idCells = Shader.PropertyToID("_Cells"); + static readonly int idVerts = Shader.PropertyToID("_Verts"); + static readonly int idEdges = Shader.PropertyToID("_Edges"); + static readonly int idState = Shader.PropertyToID("_State"); + static readonly int idUIn = Shader.PropertyToID("_UIn"); + static readonly int idUOut = Shader.PropertyToID("_UOut"); + static readonly int idU = Shader.PropertyToID("_U"); + static readonly int idV = Shader.PropertyToID("_V"); + static readonly int idEcho = Shader.PropertyToID("_Echo"); + static readonly int idFlow = Shader.PropertyToID("_EdgeFlow"); + static readonly int idVisible = Shader.PropertyToID("_Visible"); + static readonly int idBudget = Shader.PropertyToID("_BreakBudget"); + + // ============================================================== + // Cut 3 (docs/shield-panel-cut.md §2.4): a pooled panel is SetActive(false)/(true) every + // time it is returned to and taken back out of Assets/Scripts/Prototype.cs's pool, which + // fires OnDisable/OnEnable on every reuse, not just once at load. The pool's entire reason + // for existing is that a rebuild is 1.3-21 ms and a ResetSim is 8-11 us (§2.4); OnEnable + // unconditionally rebuilding and OnDisable unconditionally releasing would pay the + // expensive path on every single interception, silently defeating the pool. So the tiling + // and buffers are built once (guarded by `data == null`) and released only when the + // GameObject is actually destroyed, not merely deactivated -- ShieldInterceptor.ResetSim() + // is what re-arms a reused instance (see ResetSim below), not OnEnable. + void OnEnable() { if (data == null) Rebuild(); } + void OnDisable() { } + void OnDestroy() { ReleaseAll(); } + + // Cut 3 verification surface: counts real tiling builds so the pooling fix (OnEnable only + // rebuilds when data == null) can be pinned against a regression that rebuilds on every + // pooled reuse (see ShieldPanelCut3Verify.CheckPoolReuseDoesNotRebuild). + public int DebugRebuildCount { get; private set; } + + [UnityEngine.ContextMenu("Rebuild")] + public void Rebuild() + { + DebugRebuildCount++; + ReleaseAll(); + if (sim == null || panelShader == null) return; + + var soup = Tilings.Generate(tiling, panelRadius, cellSize, seed); + + if (tiling == TilingKind.PenroseP3 && mergeRhombs) + soup = TilingBuilder.MergeRhombs(soup); + if (takeDual) + soup = TilingBuilder.TakeDual(soup); + + data = TilingBuilder.Build(soup, + weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f), + clipRadius: panelRadius, + wavefrontOrigin: Vector2.zero, + wavefrontSpeed: wavefrontSpeed, + delayJitter: delayJitter, + seed: seed); + + AllocateBuffers(); + CacheKernels(); + ResetSim(); + CheckCfl(); + } + + void AllocateBuffers() + { + int n = data.CellCount; + groups = Mathf.CeilToInt(n / 64f); + + bCells = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, CellStatic.Stride); + bVerts = new GraphicsBuffer(GraphicsBuffer.Target.Structured, data.verts.Length, sizeof(float) * 2); + bEdges = new GraphicsBuffer(GraphicsBuffer.Target.Structured, Mathf.Max(data.EdgeCount, 1), DualEdge.Stride); + bState = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, CellState.Stride); + + bUA = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float)); + bUB = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float)); + bV = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float)); + bEcho = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n * EchoSlots, sizeof(float)); + bFlow = new GraphicsBuffer(GraphicsBuffer.Target.Structured, Mathf.Max(data.EdgeCount, 1), sizeof(float)); + bBudget = new GraphicsBuffer(GraphicsBuffer.Target.Structured, 2, sizeof(uint)); + + bVisible = new GraphicsBuffer(GraphicsBuffer.Target.Append | GraphicsBuffer.Target.Structured, + n, sizeof(uint)); + + bCells.SetData(data.cells); + bVerts.SetData(data.verts); + if (data.EdgeCount > 0) bEdges.SetData(data.dualEdges); + + // One args buffer, sized to the widest pass (Outline: n*2 segments * 6 verts). + // RenderParams exposes no per-pass selector in 6000.3.24f1 (D2), and a multi-pass material + // draws every pass from one indirect call anyway (D5/§2.1.4-5), so the three + // per-pass buffers this shipped with were dead weight, not three draw paths. + argsFill = MakeArgs((uint)(MaxV * 2 * 6)); + + mat = new Material(panelShader) { hideFlags = HideFlags.DontSave }; + mpb = new MaterialPropertyBlock(); + + var b = data.bounds; + float pad = panelRadius * 1.5f + shardLife * 3f; + worldBounds = new Bounds(transform.TransformPoint(b.center), + b.size + Vector3.one * pad); + + budgetScratch ??= new uint[2]; + BuildGrowthSeed(); + } + + // D17/D20: precompute the arm-time CellState for every cell once per tiling build, keyed + // only on each cell's baked `spawnDelay` (TilingBuilder.cs, itself derived from the + // wavefrontOrigin the tiling was built with -- always the panel centre, per Cut 1's note + // that one tiling serves every panel). ResetSim re-uploads this same array on every arm/ + // reuse; nothing here runs at interception time (§3's "no allocation at interception"). + void BuildGrowthSeed() + { + growthSeed ??= new CellState[data.CellCount]; + if (growthSeed.Length != data.CellCount) growthSeed = new CellState[data.CellCount]; + + for (int i = 0; i < growthSeed.Length; i++) + { + growthSeed[i] = default; + growthSeed[i].breakTime = -1f; + growthSeed[i].temper = temperInit; + // Backdate the arm instant by one growDuration (panelSpawnTime, set alongside this + // in ResetSim) so a cell with spawnDelay ~= 0 -- the strike centre -- reads as + // already past the 0.6 growth gate the instant it is struck, while a cell whose + // baked spawnDelay is >= growDuration starts at 0 and grows forward normally as + // simTime advances past panelSpawnTime. This is what fixes D17 (the growth gate + // otherwise swallows the hit that created the panel, because KUpdate -- the only + // writer of `growth` -- does not run until after this same frame's KInject) without + // touching the gate itself or adding a per-interception dispatch. + growthSeed[i].growth = Mathf.Clamp01( + (growDuration - data.cells[i].spawnDelay) / Mathf.Max(growDuration, 1e-3f)); + } + } + + static GraphicsBuffer MakeArgs(uint vertsPerInstance) + { + var a = new GraphicsBuffer(GraphicsBuffer.Target.IndirectArguments, 1, + GraphicsBuffer.IndirectDrawArgs.size); + a.SetData(new[] { new GraphicsBuffer.IndirectDrawArgs { + vertexCountPerInstance = vertsPerInstance, + instanceCount = 0, startVertex = 0, startInstance = 0 } }); + return a; + } + + void CacheKernels() + { + kInit = sim.FindKernel("KInit"); + kInject = sim.FindKernel("KInject"); + kWave = sim.FindKernel("KWaveStep"); + kEcho = sim.FindKernel("KEcho"); + kFracture = sim.FindKernel("KFracture"); + kUpdate = sim.FindKernel("KUpdate"); + + foreach (int k in new[] { kInit, kInject, kWave, kEcho, kFracture, kUpdate }) + { + sim.SetBuffer(k, idCells, bCells); + sim.SetBuffer(k, idEdges, bEdges); + sim.SetBuffer(k, idState, bState); + sim.SetBuffer(k, idV, bV); + sim.SetBuffer(k, idEcho, bEcho); + sim.SetBuffer(k, idFlow, bFlow); + sim.SetBuffer(k, idBudget, bBudget); + } + sim.SetBuffer(kInit, idUOut, bUA); + sim.SetBuffer(kWave, idFlow, bFlow); + sim.SetBuffer(kUpdate, idVisible, bVisible); + } + + [UnityEngine.ContextMenu("Reset Simulation")] + public void ResetSim() + { + // Cut 3: a freshly-instantiated pool instance's OnEnable is not guaranteed to have run + // by the time the interceptor calls ResetSim() on it in the same call stack (observed in + // the batchmode probe: Object.Instantiate's Awake/OnEnable are not synchronous with the + // call that created the clone in every context this runs in). ResetSim is what every + // spawn and reuse already goes through, so it -- not OnEnable's timing -- is what + // guarantees "built before struck". This costs nothing on the pooled-reuse path that + // §2.4's budget is about: data is already non-null there, so this is one null check, not + // a second rebuild. + if (data == null) { Rebuild(); return; } // Rebuild() ends by calling ResetSim() itself + if (sim == null) return; + simTime = 0f; + echoSlot = 0; + uUsingA = true; + pending.Clear(); + + sim.SetInt("_CellCount", data.CellCount); + sim.SetFloat("_TemperInit", temperInit); + sim.SetBuffer(kInit, idUOut, bUA); + sim.Dispatch(kInit, groups, 1, 1); + + // second clear so both halves of the ping-pong start at rest + sim.SetBuffer(kInit, idUOut, bUB); + sim.Dispatch(kInit, groups, 1, 1); + sim.SetBuffer(kInit, idUOut, bUA); + + // D17/D20: KInit above just zeroed every cell's `growth` (and this panel's own local + // clock, simTime, back to 0). Overwrite with the precomputed arm-time state instead of + // leaving it at 0 -- see BuildGrowthSeed's comment for why waiting on KUpdate to catch + // up is one frame too late for a hit queued this same call. `growDuration` may have + // changed in the inspector since the seed was built, but recomputing it here would be a + // per-arm allocation-shaped cost the pool's 8-11 us budget (§2.4) has no room for; + // Rebuild (a manual, non-pooled action) is what re-derives the seed from authored + // fields, matching CheckCfl's existing must-Rebuild-to-see-it contract. + panelSpawnTime = -growDuration; + bState.SetData(growthSeed); + } + + void CheckCfl() + { + float minArea = float.MaxValue; + int maxDeg = 0; + foreach (var c in data.cells) + { + minArea = Mathf.Min(minArea, c.area); + maxDeg = Mathf.Max(maxDeg, (int)c.nbrCount); + } + // rough stability bound for the explicit leapfrog on an irregular graph + float lim = waveSpeed2 * fixedSubstepDt * fixedSubstepDt * maxDeg / Mathf.Max(minArea, 1e-6f); + if (lim > 1.8f) + Debug.LogWarning($"[ShieldPanel] CFL estimate {lim:F2} exceeds ~1.8 and the wave " + + $"will likely diverge. Lower waveSpeed2 or fixedSubstepDt, or raise cellSize. " + + $"(smallest cell area {minArea:E2}, max degree {maxDeg})", this); + } + + // ============================================================== + // Read-only verification surface for the Cut 3 batchmode probe (Tests.asmdef cannot see + // Assembly-CSharp -- the map's Q7 -- so this, like Cut 1/2's probes, is what stands in for + // a unit test). Nothing outside ShieldPanel may WRITE these buffers (the authority map's + // "nothing outside ShieldPanel touches its buffers" is about writers); GraphicsBuffer. + // GetData is a read. + public int DebugCellCount => data?.CellCount ?? 0; + public GraphicsBuffer DebugStateBuffer => bState; + public GraphicsBuffer DebugVField => bV; + public Vector2 DebugCellCenter(int i) => data.cells[i].centroid; + + // ============================================================== + // Cut 3 fix (operator's play report, docs/shield-panel-cut.md): the panel is the one owner + // of "is this point within my own radius" -- both the reuse decision (ShieldInterceptor. + // FindReusable) and the strike gate below (Hit) go through this so a hit outside a panel's + // own tiling can never reach it, however it got routed. `radiusScale` is clamped to at most + // 1 -- ReuseFraction's authored [0,1] range narrows the reuse catchment; it must never widen + // it past the panel's own physical radius, which is what Hit gates on. + public bool ContainsWorldPoint(Vector3 worldPoint, float radiusScale = 1f) + { + Vector3 local = transform.InverseTransformPoint(worldPoint); + float scale = Mathf.Clamp01(radiusScale); + float r = panelRadius * scale; + return local.x * local.x + local.y * local.y <= r * r; + } + + /// Strike the panel. worldDir is the projectile's travel direction. + public void Hit(Vector3 worldPos, Vector3 worldDir, float energy, + FracturePattern pattern = FracturePattern.Radial, + float radius = -1f) + { + if (data == null) return; + // R (operator, playing FieldShieldTest): a hit outside the panel's own radius must not + // reach it at all -- no injection, no temper loss, no tremble. See ContainsWorldPoint. + if (!ContainsWorldPoint(worldPos)) return; + + Vector3 local = transform.InverseTransformPoint(worldPos); + Vector3 ldir = transform.InverseTransformDirection(worldDir).normalized; + + Vector2 flat = new Vector2(ldir.x, ldir.y); + float obliquity = Mathf.Clamp01(1f - Mathf.Abs(ldir.z)); + + pending.Enqueue(new PendingHit + { + pos = new Vector2(local.x, local.y), + dir = flat.sqrMagnitude > 1e-6f ? flat.normalized : Vector2.right, + energy = energy, + radius = radius > 0f ? radius : Mathf.Max(cellSize * 2.5f, panelRadius * 0.09f), + obliquity = obliquity, + pattern = pattern, + starArms = Random.Range(3, 9), + focus = Mathf.Lerp(0.15f, 0.55f, Random.value), + twist = Random.Range(0.4f, 1.6f), + spin = Random.value * Mathf.PI * 2f + }); + } + + // ============================================================== + void Update() + { + if (sim == null || data == null || mat == null) return; + + float dt = Application.isPlaying ? Time.deltaTime : 1f / 60f; + simTime += dt; + + sim.SetInt("_CellCount", data.CellCount); + sim.SetFloat("_Time", simTime); + PushMaterialUniforms(); + + // ---- break budget resets every frame; this is what paces the cascade + uint cap = (uint)Mathf.Max(1, Mathf.RoundToInt(data.CellCount * breakBudgetFraction)); + budgetScratch[0] = 0u; + budgetScratch[1] = cap; + bBudget.SetData(budgetScratch); + + // ---- inject queued hits + sim.SetFloat("_Dt", fixedSubstepDt); + while (pending.Count > 0) + { + var h = pending.Dequeue(); + sim.SetVector("_HitPos", h.pos); + sim.SetVector("_HitDir", h.dir); + sim.SetFloat("_HitEnergy", h.energy); + sim.SetFloat("_HitRadius", h.radius); + sim.SetFloat("_HitObliquity", h.obliquity); + sim.SetInt("_Pattern", (int)h.pattern); + sim.SetFloat("_StarArms", h.starArms); + sim.SetFloat("_PatternFocus", h.focus); + sim.SetFloat("_SpiralTwist", h.twist); + sim.SetFloat("_HitSpin", h.spin); + sim.SetInt("_EchoSlot", echoSlot); + sim.SetBuffer(kInject, idV, bV); + sim.Dispatch(kInject, groups, 1, 1); + } + + // ---- wave substeps (fixed dt: the CFL limit does not care about frame rate) + sim.SetFloat("_WaveSpeed2", waveSpeed2); + sim.SetFloat("_Damping", damping); + sim.SetFloat("_StressClamp", stressClamp); + sim.SetFloat("_VelClamp", velocityClamp); + sim.SetInt("_EchoDelay", echoDelaySteps); + sim.SetFloat("_EchoReflect", echoReflect); + + for (int s = 0; s < substeps; s++) + { + var src = uUsingA ? bUA : bUB; + var dst = uUsingA ? bUB : bUA; + + sim.SetBuffer(kWave, idUIn, src); + sim.SetBuffer(kWave, idUOut, dst); + sim.Dispatch(kWave, groups, 1, 1); + uUsingA = !uUsingA; + + sim.SetInt("_EchoSlot", echoSlot); + sim.Dispatch(kEcho, groups, 1, 1); + echoSlot = (echoSlot + 1) % EchoSlots; + } + + var cur = uUsingA ? bUA : bUB; + + // ---- fracture + // D10: KFracture dispatches once per frame while KWaveStep just integrated + // `substeps` times at fixedSubstepDt. _Dt was still holding that substep dt from + // the injection/wave setup above, so damage and temper erosion accrued at 1/substeps + // of the intended rate. Fracture's clock is the frame, not the substep — it reads + // one settled `u` per frame regardless of how many substeps produced it — so hand it + // the frame dt here. (Dispatching KFracture inside the substep loop instead would be + // the "physically honest" alternative the map names, but at up to 12 live panels + // dispatch count is the measured cost (§2.3), and fracture only needs to see the + // post-wave state once a frame, not mid-integration.) + sim.SetFloat("_Dt", dt); + sim.SetFloat("_TensileStrength", tensileStrength); + sim.SetFloat("_TemperErosion", temperErosion); + sim.SetFloat("_StoredEnergyGain", storedEnergyGain); + sim.SetFloat("_DamageGain", damageGain); + sim.SetFloat("_BreakAt", breakAt); + sim.SetFloat("_CrackStart", crackStart); + sim.SetFloat("_RadialKick", radialKick); + sim.SetFloat("_LateralKick", lateralKick); + sim.SetFloat("_NormalKick", normalKick); + sim.SetFloat("_SpinKick", spinKick); + sim.SetBuffer(kFracture, idUIn, cur); + sim.Dispatch(kFracture, groups, 1, 1); + + // ---- per-frame state advance + visibility + sim.SetFloat("_Dt", dt); + sim.SetFloat("_PanelSpawnTime", panelSpawnTime); + sim.SetFloat("_GrowDuration", growDuration); + sim.SetFloat("_GlowDecay", 3.2f); + sim.SetFloat("_ShardLife", shardLife); + sim.SetFloat("_ShardDrag", shardDrag); + + bVisible.SetCounterValue(0); + sim.Dispatch(kUpdate, groups, 1, 1); + + GraphicsBuffer.CopyCount(bVisible, argsFill, sizeof(uint)); + + Draw(cur); + } + + void PushMaterialUniforms() + { + mat.SetFloat("_CrackStart", crackStart); + mat.SetFloat("_ShardLife", shardLife); + mat.SetInt("_MaxShards", maxShards); + mat.SetFloat("_ConduitGain", conduitGain); + } + + void Draw(GraphicsBuffer curU) + { + mpb.Clear(); + mpb.SetBuffer(idCells, bCells); + mpb.SetBuffer(idVerts, bVerts); + mpb.SetBuffer(idEdges, bEdges); + mpb.SetBuffer(idState, bState); + mpb.SetBuffer(idU, curU); + mpb.SetBuffer(idFlow, bFlow); + mpb.SetBuffer(idVisible, bVisible); + mpb.SetMatrix("_PanelToWorld", transform.localToWorldMatrix); + mpb.SetFloat("_Time01", simTime); + + worldBounds.center = transform.position; + + var rp = new RenderParams(mat) + { + worldBounds = worldBounds, + matProps = mpb, + shadowCastingMode = ShadowCastingMode.Off, + receiveShadows = false, + layer = gameObject.layer + }; + + // RenderParams exposes no per-pass selector in 6000.3.24f1 (D2), and a multi-pass + // material draws every SubShader pass from one indirect call regardless + // (D5) — Fill, Outline and Conduit all run here, pixel-identical to the + // old three-call version (§2.1.5), off one args buffer sized to the + // widest pass (Outline). + Graphics.RenderPrimitivesIndirect(rp, MeshTopology.Triangles, argsFill, 1, 0); + } + + // ============================================================== + void ReleaseAll() + { + void R(ref GraphicsBuffer b) { b?.Release(); b = null; } + R(ref bCells); R(ref bVerts); R(ref bEdges); R(ref bState); + R(ref bUA); R(ref bUB); R(ref bV); R(ref bEcho); R(ref bFlow); + R(ref bVisible); R(ref bBudget); + R(ref argsFill); + + if (mat != null) + { + if (Application.isPlaying) Destroy(mat); else DestroyImmediate(mat); + mat = null; + } + data = null; + } + + void OnDrawGizmosSelected() + { + Gizmos.color = new Color(0.4f, 0.8f, 1f, 0.5f); + Gizmos.matrix = transform.localToWorldMatrix; + Gizmos.DrawWireSphere(Vector3.zero, panelRadius); + } + } +} diff --git a/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs.meta b/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs.meta new file mode 100644 index 000000000..ce9171785 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1b180b85c234a4488cf0662b75ac77f \ No newline at end of file diff --git a/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader b/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader new file mode 100644 index 000000000..33b731b94 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader @@ -0,0 +1,380 @@ +Shader "ShieldField/Panel" +{ + Properties + { + _FillColorA ("Fill Tint A", Color) = (0.15, 0.45, 0.9, 0.28) + _FillColorB ("Fill Tint B", Color) = (0.30, 0.20, 0.8, 0.28) + _LineColor ("Outline", Color) = (0.6, 0.9, 1.0, 1) + _CrackColor ("Crack Emission", Color) = (1.0, 0.55, 0.25, 1) + _CompressColor ("Compression", Color) = (0.1, 0.3, 1.0, 1) + _TensionColor ("Tension", Color) = (1.0, 0.35, 0.15, 1) + _ConduitColor ("Conduit", Color) = (0.35, 0.8, 1.0, 1) + + _LineWidthPx ("Outline px", Range(0.5, 8)) = 1.5 + _CrackWidthPx ("Crack px", Range(0.5, 8)) = 1.0 + _ConduitWidthPx ("Conduit px", Range(0.5, 8)) = 1.0 + _FeatherPx ("Feather px", Range(0.5, 4)) = 1.2 + + _StrainScale ("In-plane strain", Float) = 0.35 + _RippleScale ("Out-of-plane", Float) = 0.30 + _StressColorGain("Stress emission", Float) = 1.6 + _CrackGap ("Pre-break gap", Float) = 0.10 + _ShardSpread ("Shard divergence", Float) = 1.40 + } + + SubShader + { + Tags { "RenderType"="Transparent" "Queue"="Transparent" "IgnoreProjector"="True" } + Blend One One // additive; fragments output premultiplied colour + ZWrite Off + Cull Off + + HLSLINCLUDE + #include "UnityCG.cginc" + #include "ShieldCommon.hlsl" + + StructuredBuffer _Cells; + StructuredBuffer _Verts; + StructuredBuffer _Edges; + StructuredBuffer _State; + StructuredBuffer _U; + StructuredBuffer _EdgeFlow; + StructuredBuffer _Visible; + + float4x4 _PanelToWorld; + float _Time01; // absolute sim time; _Time.y is unreliable for indirect draws + float _CrackStart; + float _ShardLife; + uint _MaxShards; + + float4 _FillColorA, _FillColorB, _LineColor, _CrackColor; + float4 _CompressColor, _TensionColor, _ConduitColor; + float _LineWidthPx, _CrackWidthPx, _ConduitWidthPx, _FeatherPx; + float _StrainScale, _RippleScale, _StressColorGain, _CrackGap, _ShardSpread; + + struct Ctx + { + CellStatic c; + CellState s; + float u; + float age; // seconds since break, 0 while intact + float fade; + uint nSplits; + float fillT, lineT; + }; + + Ctx GetCtx(uint cell) + { + Ctx x; + x.c = _Cells[cell]; + x.s = _State[cell]; + x.u = _U[cell]; + + x.lineT = saturate(x.s.growth / 0.55); + x.fillT = saturate((x.s.growth - 0.30) / 0.70); + + bool broken = x.s.breakTime >= 0.0; + x.age = broken ? max(_Time01 - x.s.breakTime, 0.0) : 0.0; + x.fade = broken ? saturate(1.0 - x.age / max(_ShardLife, 1e-3)) : 1.0; + + x.nSplits = ShardCount(x.c.splitSeed, x.s.damage, x.s.released, + x.c.vertCount, _CrackStart, _MaxShards); + return x; + } + + // Panel-space position of one corner of one fan triangle. + // corner 0 = centroid, 1 = vert[tri], 2 = vert[tri+1]. + float3 CellPoint(Ctx x, uint tri, uint corner) + { + uint n = max(x.c.vertCount, 1u); + float2 p = (corner == 0) + ? x.c.center + : _Verts[x.c.vertStart + ((tri + corner - 1) % n)]; + + float2 local = p - x.c.center; + + // materialisation: the cell inflates from its own centroid + local *= EaseOutBack(x.fillT); + + // the stress wave is visible in the geometry itself: tension dilates + // the cell, compression pinches it + local *= (1.0 + x.u * _StrainScale); + + float z = x.u * _RippleScale; + + if (x.nSplits > 0) + { + uint startTri; + uint sid = ShardOf(x.c.splitSeed, x.nSplits, n, tri, startTri); + + float2 outward = _Verts[x.c.vertStart + startTri] - x.c.center; + outward = normalize(outward + float2(1e-5, 1e-5)); + + uint sSeed = x.c.splitSeed ^ (sid * 0x9E3779B9u); + float2 jit = Hash2(sSeed) - 0.5; + + if (x.s.breakTime < 0.0) + { + // cracked but holding: shards ease apart just enough to open + // the chord lines + float gap = saturate((x.s.damage - _CrackStart) / 0.35); + local += outward * gap * _CrackGap * x.c.radius; + } + else + { + float2 drift = (outward + jit * 0.9) * x.age * _ShardSpread; + local = Rot2(local, x.s.rot * (0.5 + jit.y)); + local += drift * x.c.radius; + z += jit.x * x.age * 0.4; + } + } + + float3 pos = float3(x.c.center + local, z) + x.s.pos; + return mul(_PanelToWorld, float4(pos, 1.0)).xyz; + } + + float3 StressEmission(float u, float glow) + { + float compress = saturate(-u * _StressColorGain); + float tension = saturate( u * _StressColorGain); + return _CompressColor.rgb * compress + + _TensionColor.rgb * tension + + _CrackColor.rgb * glow; + } + + // ---------------------------------------------------------- + // Screen-space line expansion. Both endpoints are projected, the + // perpendicular is taken in pixels, and the offset is scaled by w so it + // survives the perspective divide. Width is therefore exact in pixels at + // any distance or field of view. + // ---------------------------------------------------------- + struct LineVary + { + float4 pos : SV_POSITION; + noperspective float dist : TEXCOORD0; // signed pixels from the line centre + float halfPx : TEXCOORD1; + float4 col : TEXCOORD2; + }; + + LineVary BuildLine(float3 wa, float3 wb, uint k, float widthPx, float4 col) + { + // D15: every path below does set all four fields, but the compute compiler's + // dataflow analysis doesn't trust that across the early-return branch and warns + // "potentially uninitialized variable (BuildLine)". Zero-init once instead of + // relying on the analysis. + LineVary o = (LineVary)0; + float4 ca = UnityWorldToClipPos(wa); + float4 cb = UnityWorldToClipPos(wb); + + // behind the near plane on either end: collapse to a zero-area tri + if (ca.w <= 1e-4 || cb.w <= 1e-4 || col.a <= 0.001) + { + o.pos = float4(0, 0, 0, 1); + o.dist = 0; o.halfPx = 1; o.col = 0; + return o; + } + + float2 halfRes = _ScreenParams.xy * 0.5; + float2 sa = ca.xy / ca.w * halfRes; + float2 sb = cb.xy / cb.w * halfRes; + + float2 d = sb - sa; + float L = length(d); + d = (L > 1e-5) ? d / L : float2(1, 0); + float2 nrm = float2(-d.y, d.x); + + float hw = widthPx * 0.5 + _FeatherPx; + float ext = hw; // overshoot the ends so joins close + + // 6 vertices: (end, side) pairs forming two triangles + static const uint2 QUAD[6] = { uint2(0,0), uint2(1,0), uint2(0,1), + uint2(0,1), uint2(1,0), uint2(1,1) }; + uint2 q = QUAD[k]; + float side = q.y * 2.0 - 1.0; + float along = q.x * 2.0 - 1.0; + + float4 cp = q.x ? cb : ca; + float2 offPx = nrm * side * hw + d * along * ext; + + cp.xy += offPx / halfRes * cp.w; + + o.pos = cp; + o.dist = side * hw; + o.halfPx = widthPx * 0.5; + o.col = col; + return o; + } + + float4 ShadeLine(LineVary i) : SV_Target + { + float a = 1.0 - smoothstep(i.halfPx - _FeatherPx * 0.5, + i.halfPx + _FeatherPx * 0.5, abs(i.dist)); + a *= i.col.a; + clip(a - 0.002); + return float4(i.col.rgb * a, a); + } + ENDHLSL + + // ========================================================== + // Pass 0 — fill + // ========================================================== + Pass + { + Name "Fill" + HLSLPROGRAM + #pragma vertex vert + #pragma fragment frag + #pragma target 4.5 + + struct V2F + { + float4 pos : SV_POSITION; + float4 col : TEXCOORD0; + }; + + V2F vert(uint vid : SV_VertexID, uint iid : SV_InstanceID) + { + V2F o; + uint cell = _Visible[iid]; + Ctx x = GetCtx(cell); + + uint tri = vid / 3, corner = vid % 3; + if (tri >= x.c.vertCount || x.fillT <= 0.0 || x.fade <= 0.0) + { + o.pos = float4(0, 0, 0, 1); o.col = 0; return o; + } + + float3 w = CellPoint(x, tri, corner); + o.pos = UnityWorldToClipPos(w); + + float4 base = lerp(_FillColorA, _FillColorB, (x.c.tileType & 1) ? 1.0 : 0.0); + float a = base.a * saturate(x.fillT) * x.fade; + + // a depleted panel reads dimmer; temper is the visible charge + a *= lerp(0.45, 1.0, saturate(x.s.temper)); + + float3 rgb = base.rgb + StressEmission(x.u, x.s.glow); + o.col = float4(rgb, a); + return o; + } + + float4 frag(V2F i) : SV_Target + { + clip(i.col.a - 0.002); + return float4(i.col.rgb * i.col.a, i.col.a); + } + ENDHLSL + } + + // ========================================================== + // Pass 1 — outline. Perimeter edges plus the radial chords that + // separate shard groups. Chords fade in with damage well before the + // shards actually part, which is what produces the spidered-but-holding + // state between weak hits. + // ========================================================== + Pass + { + Name "Outline" + HLSLPROGRAM + #pragma vertex vert + #pragma fragment frag + #pragma target 4.5 + + LineVary vert(uint vid : SV_VertexID, uint iid : SV_InstanceID) + { + uint cell = _Visible[iid]; + Ctx x = GetCtx(cell); + + uint seg = vid / 6, k = vid % 6; + uint n = x.c.vertCount; + + if (seg >= n * 2 || x.lineT <= 0.0 || x.fade <= 0.0) + return BuildLine(0, 0, k, 1, 0); + + bool radial = seg >= n; + uint ei = radial ? seg - n : seg; + float3 wa, wb; + float4 col; + float width; + + if (radial) + { + bool chord = (x.nSplits > 0) && IsChord(x.c.splitSeed, x.nSplits, n, ei); + float a = chord ? saturate((x.s.damage - _CrackStart) / 0.25) : 0.0; + a *= x.fade; + + wa = CellPoint(x, ei, 0); + wb = CellPoint(x, ei, 1); + col = float4(_CrackColor.rgb * (1.0 + x.s.glow * 2.0), _CrackColor.a * a); + width = _CrackWidthPx; + } + else + { + // during materialisation the ring draws itself on edge by edge + float a = saturate(x.lineT * (float)n - (float)ei) * x.fade; + + wa = CellPoint(x, ei, 1); + wb = CellPoint(x, ei, 2); + col = float4(_LineColor.rgb + StressEmission(x.u, x.s.glow) * 0.7, + _LineColor.a * a); + width = _LineWidthPx; + } + + return BuildLine(wa, wb, k, width, col); + } + + float4 frag(LineVary i) : SV_Target { return ShadeLine(i); } + ENDHLSL + } + + // ========================================================== + // Pass 2 — dual-graph conduits. Centroid-to-centroid arcs lit by the + // stress gradient flowing across them, so you can watch the wave route + // through the lattice and reflect off broken regions. + // ========================================================== + Pass + { + Name "Conduit" + HLSLPROGRAM + #pragma vertex vert + #pragma fragment frag + #pragma target 4.5 + + float _ConduitGain; + + LineVary vert(uint vid : SV_VertexID, uint iid : SV_InstanceID) + { + uint cell = _Visible[iid]; + Ctx x = GetCtx(cell); + + uint seg = vid / 6, k = vid % 6; + if (seg >= x.c.nbrCount || x.s.breakTime >= 0.0 || x.s.growth < 0.6) + return BuildLine(0, 0, k, 1, 0); + + uint ei = x.c.nbrStart + seg; + DualEdge e = _Edges[ei]; + if (e.nbr <= cell) return BuildLine(0, 0, k, 1, 0); // draw each arc once + + CellState ns = _State[e.nbr]; + if (ns.breakTime >= 0.0 || ns.growth < 0.6) return BuildLine(0, 0, k, 1, 0); + + float flow = saturate(_EdgeFlow[ei] * _ConduitGain); + float a = flow * min(x.s.growth, ns.growth); + if (a <= 0.003) return BuildLine(0, 0, k, 1, 0); + + CellStatic nc = _Cells[e.nbr]; + float3 wa = mul(_PanelToWorld, + float4(x.c.center, x.u * _RippleScale, 1)).xyz + x.s.pos; + float3 wb = mul(_PanelToWorld, + float4(nc.center, _U[e.nbr] * _RippleScale, 1)).xyz + ns.pos; + + float4 col = float4(_ConduitColor.rgb * (0.4 + flow * 2.0), _ConduitColor.a * a); + return BuildLine(wa, wb, k, _ConduitWidthPx, col); + } + + float4 frag(LineVary i) : SV_Target { return ShadeLine(i); } + ENDHLSL + } + } + Fallback Off +} diff --git a/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader.meta b/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader.meta new file mode 100644 index 000000000..03995c8e7 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 6a7f034931432c2418a0f0f0b9af0b93 +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/Compute/ShieldField/ShieldSim.compute b/Assets/Shaders/Compute/ShieldField/ShieldSim.compute new file mode 100644 index 000000000..e934ea43a --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldSim.compute @@ -0,0 +1,378 @@ +// ShieldSim.compute +// +// Stress propagates as a wave on the dual graph of the tiling rather than as a +// diffusion. The whole spall effect rests on one convention: +// +// u > 0 is TENSION, u < 0 is COMPRESSION. An impact injects compression. +// Material fails on tension only. +// +// A free surface must hold stress at zero, which is a Dirichlet condition, and +// Dirichlet boundaries reflect with INVERTED sign. So the compressive pulse +// returns from the rim (and from the faces of already-broken cells) as tension, +// and that reflected tension is what spalls. Imposing Neumann here instead -- +// which is what you get by default if you simply omit missing neighbours from +// the Laplacian -- reflects compression as compression and nothing ever breaks. + +#pragma kernel KInit +#pragma kernel KInject +#pragma kernel KWaveStep +#pragma kernel KEcho +#pragma kernel KFracture +#pragma kernel KUpdate + +#include "ShieldCommon.hlsl" + +#define ECHO_SLOTS 16 + +StructuredBuffer _Cells; +StructuredBuffer _Edges; +RWStructuredBuffer _State; + +// wave field, ping-ponged by the host +StructuredBuffer _UIn; +RWStructuredBuffer _UOut; +RWStructuredBuffer _V; // du/dt +RWStructuredBuffer _Echo; // [cell * ECHO_SLOTS + slot] through-thickness delay line +RWStructuredBuffer _EdgeFlow; // per-arc |gradient|, for conduit rendering +AppendStructuredBuffer _Visible; +RWStructuredBuffer _BreakBudget; // [0]=count this frame (atomic), [1]=cap; caps cascade rate per frame + +uint _CellCount; +float _Time; +float _Dt; + +// --- wave +float _WaveSpeed2; // c^2 +float _Damping; +float _StressClamp; +float _VelClamp; // D9: bounds v so a hard hit cannot pin u in compression forever + +// --- through-thickness echo +uint _EchoSlot; // current write slot, advanced by the host each substep +uint _EchoDelay; // slots to wait ~ 2 * thickness / c +float _EchoReflect; // negative: free back face inverts the pulse + +// --- hit +float2 _HitPos; +float2 _HitDir; // in-plane component of the incoming direction +float _HitEnergy; +float _HitRadius; +float _HitObliquity; // 0 head-on, 1 grazing +uint _Pattern; +float _StarArms; +float _PatternFocus; +float _HitSpin; +float _SpiralTwist; + +// --- material +float _TensileStrength; +float _TemperInit; // surface compression at full charge +float _TemperErosion; // how fast sub-critical tension wears the temper down +float _StoredEnergyGain; // fraction of temper released back into the field on break +float _DamageGain; +float _BreakAt; +float _CrackStart; + +// --- shards +float _RadialKick; +float _LateralKick; +float _NormalKick; +float _SpinKick; +float _ShardLife; +float _ShardDrag; + +// --- growth +float _PanelSpawnTime; +float _GrowDuration; +float _GlowDecay; + +// ================================================================== +[numthreads(64,1,1)] +void KInit(uint3 id : SV_DispatchThreadID) +{ + uint i = id.x; if (i >= _CellCount) return; + + CellState s = (CellState)0; + s.breakTime = -1.0; + s.temper = _TemperInit; + _State[i] = s; + + _UOut[i] = 0.0; + _V[i] = 0.0; + for (uint k = 0; k < ECHO_SLOTS; k++) _Echo[i * ECHO_SLOTS + k] = 0.0; +} + +// ================================================================== +// Stamp the initial pulse. The pattern shapes the pulse; the material's +// own anisotropy (baked into the arc weights) decides how it spreads. +// ================================================================== +float InjectionProfile(float2 rel, float r) +{ + float2 rad = (r > 1e-5) ? rel / r : float2(1, 0); + float falloff = saturate(1.0 - r / max(_HitRadius, 1e-4)); + falloff = falloff * falloff; + + float shape = 1.0; + switch (_Pattern) + { + case PAT_RADIAL: + break; + + case PAT_STAR: + { + float ang = atan2(rad.y, rad.x) + _HitSpin; + float spoke = abs(frac(ang * _StarArms / TAU + 0.5) - 0.5) * 2.0; // 0 on a spoke + shape = lerp(1.0, _PatternFocus, spoke); + break; + } + case PAT_LANCE: + { + float a = saturate(dot(rad, _HitDir) * 0.5 + 0.5); + shape = lerp(_PatternFocus, 1.0, a * a); + break; + } + case PAT_SPIRAL: + { + float ang = atan2(rad.y, rad.x); + float target = _SpiralTwist * r; + float d = abs(frac((ang - target) / TAU * _StarArms + 0.5) - 0.5) * 2.0; + shape = lerp(1.0, _PatternFocus, d); + break; + } + case PAT_CRYSTAL: + // leave the pulse round; the grain in the arc weights does the shaping + break; + + case PAT_FIZZLE: + shape = lerp(0.35, 1.0, Hash01(asuint(r * 991.0) ^ asuint(rad.x * 577.0))); + break; + } + + // A grazing hit couples less energy in and skews it downrange. + float oblique = lerp(1.0, saturate(dot(rad, _HitDir) * 0.5 + 0.5) * 1.6, _HitObliquity); + return falloff * shape * oblique; +} + +[numthreads(64,1,1)] +void KInject(uint3 id : SV_DispatchThreadID) +{ + uint i = id.x; if (i >= _CellCount) return; + CellState s = _State[i]; + if (s.breakTime >= 0.0 || s.growth < 0.6) return; + + CellStatic c = _Cells[i]; + float2 rel = c.center - _HitPos; + float r = length(rel); + if (r > _HitRadius) return; + + float amp = InjectionProfile(rel, r) * _HitEnergy; + + // Negative: the strike puts the material into compression. Injecting into + // velocity rather than displacement gives a propagating pulse instead of a + // standing bump. + _V[i] -= amp / max(c.area, 1e-5); + + // Seed the through-thickness delay line at the current slot. + _Echo[i * ECHO_SLOTS + (_EchoSlot % ECHO_SLOTS)] -= amp; +} + +// ================================================================== +// One substep of u_tt = c^2 * lap(u) - damping * u_t +// ================================================================== +[numthreads(64,1,1)] +void KWaveStep(uint3 id : SV_DispatchThreadID) +{ + uint i = id.x; if (i >= _CellCount) return; + + CellStatic c = _Cells[i]; + CellState st = _State[i]; + float ui = _UIn[i]; + + // A broken cell carries no stress and reflects everything back at its neighbours. + if (st.breakTime >= 0.0 || st.growth < 0.6) + { + _UOut[i] = 0.0; + _V[i] = 0.0; + return; + } + + float lap = 0.0; + float flowMax = 0.0; + + for (uint k = 0; k < c.nbrCount; k++) + { + uint ei = c.nbrStart + k; + DualEdge e = _Edges[ei]; + CellState ns = _State[e.nbr]; + + // Ghost value: an intact neighbour contributes its own stress; a broken + // one (or an ungrown one) is a free surface, so the ghost is -ui, which + // pins the face to zero and inverts the reflected pulse. + bool solid = (ns.breakTime < 0.0) && (ns.growth >= 0.6); + float uj = solid ? _UIn[e.nbr] : -ui; + + lap += e.w * (uj - ui); + + float f = abs(uj - ui) * e.w; + _EdgeFlow[ei] = f; + flowMax = max(flowMax, f); + } + + // R7: the rim used to be a free surface with a precomputed ghost term (openW) that + // reflected compression back as tension. It no longer contributes at all — the panel's + // emitters hold the edge, so an unshared boundary edge is simply not in the Laplacian. + // A broken cell's own faces are unaffected: the `solid` check above already treats a + // broken (or ungrown) neighbour as free regardless of position, so the dicing cascade + // survives untouched. + lap /= max(c.area, 1e-5); + + float v = _V[i]; + v += _WaveSpeed2 * lap * _Dt; + v *= exp(-_Damping * _Dt); + + // D9: u was clamped but v was not, so a hard-enough hit drove v far past what one + // substep's u-clamp could absorb; u pinned at -_StressClamp every substep afterward and + // the field never swung back into tension (the measured collapse above energy ~60). + // Clamping v to the same scale keeps it a real integrator input instead of an unbounded + // accumulator. + v = clamp(v, -_VelClamp, _VelClamp); + + float u = ui + v * _Dt; + u = clamp(u, -_StressClamp, _StressClamp); + + _UOut[i] = u; + _V[i] = v; +} + +// ================================================================== +// Through-thickness reflection: the compressive pulse that entered the +// plate returns from the back face, inverted, one round trip later. +// This is the delayed tensile spike co-located with the impact, which is +// why the far face erupts a beat after the near face was struck. +// ================================================================== +[numthreads(64,1,1)] +void KEcho(uint3 id : SV_DispatchThreadID) +{ + uint i = id.x; if (i >= _CellCount) return; + CellState s = _State[i]; + if (s.breakTime >= 0.0) return; + + uint base = i * ECHO_SLOTS; + uint read = (_EchoSlot + ECHO_SLOTS - (_EchoDelay % ECHO_SLOTS)) % ECHO_SLOTS; + + float pulse = _Echo[base + read]; + _Echo[base + read] = 0.0; + if (abs(pulse) < 1e-6) return; + + _V[i] += _EchoReflect * pulse / max(_Cells[i].area, 1e-5); +} + +// ================================================================== +// Fracture. Tension is the only thing that breaks anything. +// +// effective tension = u - temper +// +// temper is the tempered-glass surface compression: it must be cancelled +// before a crack can start. Sub-critical tension erodes it, which is what +// lets a sequence of weak hits eventually produce a break that no single +// one of them could. When a cell finally goes, its stored core energy is +// dumped back into the wave field, and that is the dicing cascade. +// ================================================================== +[numthreads(64,1,1)] +void KFracture(uint3 id : SV_DispatchThreadID) +{ + uint i = id.x; if (i >= _CellCount) return; + + CellState s = _State[i]; + if (s.breakTime >= 0.0 || s.growth < 0.6) return; + + CellStatic c = _Cells[i]; + float u = _UIn[i]; + if (u <= 0.0) return; // compression does nothing + + s.peakTension = max(s.peakTension, u); + + float strength = _TensileStrength * c.toughness; + float net = u - s.temper; // must out-pull the surface compression + + if (net <= 0.0) + { + // Sub-critical: wears the temper down. This is the multi-hit budget. + s.temper = max(0.0, s.temper - u * _TemperErosion * _Dt); + s.glow = max(s.glow, saturate(u / max(strength, 1e-4)) * 0.35); + _State[i] = s; + return; + } + + s.damage = saturate(s.damage + (net / max(strength, 1e-4)) * _DamageGain * _Dt); + s.glow = max(s.glow, saturate(net / max(strength, 1e-4))); + s.temper = max(0.0, s.temper - u * _TemperErosion * _Dt * 3.0); + + if (s.damage >= _BreakAt && net >= strength) + { + uint slot; + InterlockedAdd(_BreakBudget[0], 1u, slot); + if (slot < _BreakBudget[1]) // budget caps the cascade rate per frame + { + s.breakTime = _Time; + + // Stored core energy, released. Sets fragment fineness: a panel that + // was still heavily charged dices finely, a depleted one breaks into + // a few large pieces. + s.released = saturate((s.temper + net) * _StoredEnergyGain); + + float2 rel = c.center - _HitPos; + float r = max(length(rel), 1e-4); + float2 rad = rel / r; + + float2 lateral = rad * _RadialKick + _HitDir * _LateralKick * _HitObliquity; + float2 j = (Hash2(c.splitSeed) - 0.5) * 0.6; + + s.vel = float3(lateral + j, _NormalKick * (0.4 + net)); + s.vel *= (0.5 + saturate(net / max(strength, 1e-4))); + s.spin = (Hash01(c.splitSeed ^ 0x51ed2701u) - 0.5) * _SpinKick * (0.5 + net); + + // Dump the released energy back into the neighbours as tension. This + // is the feedback loop that makes the panel dice rather than chip. + for (uint k = 0; k < c.nbrCount; k++) + { + DualEdge e = _Edges[c.nbrStart + k]; + _V[e.nbr] += s.released * e.w / max(_Cells[e.nbr].area, 1e-5); + } + } + } + + _State[i] = s; +} + +// ================================================================== +[numthreads(64,1,1)] +void KUpdate(uint3 id : SV_DispatchThreadID) +{ + uint i = id.x; if (i >= _CellCount) return; + + CellStatic c = _Cells[i]; + CellState s = _State[i]; + + s.growth = saturate((_Time - _PanelSpawnTime - c.spawnDelay) / max(_GrowDuration, 1e-3)); + s.glow *= exp(-_GlowDecay * _Dt); + + if (s.breakTime >= 0.0) + { + float age = _Time - s.breakTime; + if (age > _ShardLife) return; // dead: not appended, not drawn + + s.pos += s.vel * _Dt; + s.vel *= exp(-_ShardDrag * _Dt); + s.vel.z -= 0.6 * _Dt; // gentle settle, not real gravity + s.rot += s.spin * _Dt; + } + else if (s.growth <= 0.0) + { + _State[i] = s; + return; + } + + _State[i] = s; + _Visible.Append(i); +} diff --git a/Assets/Shaders/Compute/ShieldField/ShieldSim.compute.meta b/Assets/Shaders/Compute/ShieldField/ShieldSim.compute.meta new file mode 100644 index 000000000..18e309e55 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldSim.compute.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1d17c100121b5ba47af9c75de451a2f1 +ComputeShaderImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs b/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs new file mode 100644 index 000000000..4412411aa --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs @@ -0,0 +1,443 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using UnityEngine; + +namespace ShieldField +{ + /// Hard cap on vertices per cell. Must match MAX_VERTS in ShieldCommon.hlsl. + public static class Limits { public const int MaxVerts = 8; } + + [StructLayout(LayoutKind.Sequential)] + public struct CellStatic + { + public Vector2 centroid; + public float radius; // circumradius, used for growth + culling + public float spawnDelay; // materialisation wavefront offset, seconds + public uint vertStart; + public uint vertCount; + public uint nbrStart; + public uint nbrCount; + public float toughness; // per-cell break resistance multiplier + public uint tileType; // author-defined class (thin/fat rhomb, etc) + public uint splitSeed; // stable per-cell RNG seed for shard assignment + public float area; // cell area — denominator of the graph Laplacian + // R7 (docs/shield-panel-cut.md): openW (rim free-surface ghost weight) removed — the + // panel's emitters hold its edge, so the rim is no longer a stress-reflecting boundary. + // No other consumer read this field (grep confirmed); pad0 keeps the stride unchanged + // rather than reshuffling every offset downstream in the HLSL mirror. + public float pad0; + public float pad1; + public float pad2; + public float pad3; + public const int Stride = 4 * 16; + } + + [StructLayout(LayoutKind.Sequential)] + public struct DualEdge + { + public uint nbr; // neighbouring cell index + public Vector2 mid; // midpoint of the shared edge, panel space + public Vector2 dir; // unit direction along the shared edge + public float len; // shared edge length + public float w; // finite-volume Laplacian weight: len / |c_i - c_j| + public const int Stride = 4 * 7; + } + + /// + /// C# mirror of CellState in ShieldCommon.hlsl — field-for-field, same order, same 16-float + /// stride as `bState`'s allocation in ShieldPanel.cs. Cut 3 (docs/shield-panel-cut.md) needs + /// this on both sides of the boundary: ShieldPanel.ResetSim writes it directly to seed each + /// cell's `growth` at arm time (the D17/D20 fix — see the comment on ResetSim), and the Cut 3 + /// batchmode probe reads it back to verify same-frame injection and reuse-driven temper erosion + /// without adding a readback path the running sim doesn't already need. + /// + [StructLayout(LayoutKind.Sequential)] + public struct CellState + { + public Vector3 vel; + public float spin; + public Vector3 pos; + public float rot; + public float damage; + public float breakTime; + public float glow; + public float growth; + public float temper; + public float released; + public float peakTension; + public float pad2; + public const int Stride = 4 * 16; + } + + /// Raw output of a tiling generator: a bag of convex polygons, wound CCW. + public class PolygonSoup + { + public readonly List polys = new(); + public readonly List types = new(); + + public void Add(Vector2[] p, int type = 0) + { + if (p.Length < 3) return; + if (SignedArea(p) < 0f) Array.Reverse(p); // force CCW + polys.Add(p); + types.Add(type); + } + + public static float SignedArea(IList p) + { + float a = 0f; + for (int i = 0, n = p.Count; i < n; i++) + { + Vector2 u = p[i], v = p[(i + 1) % n]; + a += u.x * v.y - v.x * u.y; + } + return a * 0.5f; + } + + public static Vector2 Centroid(IList p) + { + float a = 0f; Vector2 c = Vector2.zero; + for (int i = 0, n = p.Count; i < n; i++) + { + Vector2 u = p[i], v = p[(i + 1) % n]; + float cr = u.x * v.y - v.x * u.y; + a += cr; c += (u + v) * cr; + } + if (Mathf.Abs(a) < 1e-9f) + { + c = Vector2.zero; + foreach (var q in p) c += q; + return c / p.Count; + } + return c / (3f * a); + } + } + + /// Welded, adjacency-resolved tiling ready for GPU upload. + public class TilingData + { + public Vector2[] verts; // flat pool, each cell's ring is contiguous + public CellStatic[] cells; + public DualEdge[] dualEdges; // flat pool, each cell's arcs are contiguous + public Bounds bounds; + + public int CellCount => cells.Length; + public int EdgeCount => dualEdges.Length; + } + + public static class TilingBuilder + { + /// + /// Welds coincident vertices, discards cells outside the clip radius, resolves the dual + /// (cell adjacency) graph, and packs everything into flat GPU-friendly arrays. + /// + public static TilingData Build(PolygonSoup soup, float weldEps = 1e-3f, + float clipRadius = float.PositiveInfinity, + Vector2 wavefrontOrigin = default, + float wavefrontSpeed = 6f, + float delayJitter = 0.08f, + int seed = 12345) + { + var weld = new VertexWelder(weldEps); + + // ---- pass 1: keep cells whose centroid is inside the clip disc, weld their rings + var keptRings = new List(); + var keptTypes = new List(); + for (int c = 0; c < soup.polys.Count; c++) + { + var poly = soup.polys[c]; + if (poly.Length > Limits.MaxVerts) continue; // silently drop; raise MaxVerts if this bites + Vector2 ctr = PolygonSoup.Centroid(poly); + if (ctr.sqrMagnitude > clipRadius * clipRadius) continue; + + var ring = new int[poly.Length]; + bool degenerate = false; + for (int i = 0; i < poly.Length; i++) + { + ring[i] = weld.Get(poly[i]); + for (int j = 0; j < i; j++) if (ring[j] == ring[i]) degenerate = true; + } + if (degenerate) continue; + keptRings.Add(ring); + keptTypes.Add(soup.types[c]); + } + + int cellCount = keptRings.Count; + if (cellCount == 0) throw new InvalidOperationException("Tiling produced no cells."); + + // ---- pass 2: undirected edge -> incident cells (at most two for a manifold tiling) + var edgeMap = new Dictionary(cellCount * 4); + for (int c = 0; c < cellCount; c++) + { + var ring = keptRings[c]; + for (int i = 0; i < ring.Length; i++) + { + long k = EdgeKey(ring[i], ring[(i + 1) % ring.Length]); + if (edgeMap.TryGetValue(k, out var e)) + edgeMap[k] = (e.a, e.b < 0 ? c : e.b); // third incidence would be non-manifold; ignore + else + edgeMap[k] = (c, -1); + } + } + + // Centroids must exist before arcs, since Laplacian weights need + // the distance between the two cells' centroids. + var ctrs = new Vector2[cellCount]; + var areas = new float[cellCount]; + for (int c = 0; c < cellCount; c++) + { + var ring = keptRings[c]; + var pts = new Vector2[ring.Length]; + for (int i = 0; i < ring.Length; i++) pts[i] = weld.Point(ring[i]); + ctrs[c] = PolygonSoup.Centroid(pts); + areas[c] = Mathf.Abs(PolygonSoup.SignedArea(pts)); + } + + // ---- pass 3: build directed dual arcs per cell + var rng = new System.Random(seed); + var vertPool = new List(cellCount * 5); + var edgePool = new List(cellCount * 5); + var cells = new CellStatic[cellCount]; + var bMin = new Vector2(float.MaxValue, float.MaxValue); + var bMax = new Vector2(float.MinValue, float.MinValue); + + for (int c = 0; c < cellCount; c++) + { + var ring = keptRings[c]; + int n = ring.Length; + + int vStart = vertPool.Count; + Vector2 ctr = ctrs[c]; + for (int i = 0; i < n; i++) vertPool.Add(weld.Point(ring[i])); + + float radius = 0f; + for (int i = 0; i < n; i++) + { + Vector2 p = vertPool[vStart + i]; + radius = Mathf.Max(radius, (p - ctr).magnitude); + bMin = Vector2.Min(bMin, p); bMax = Vector2.Max(bMax, p); + } + + int eStart = edgePool.Count; + for (int i = 0; i < n; i++) + { + int ia = ring[i], ib = ring[(i + 1) % n]; + var pair = edgeMap[EdgeKey(ia, ib)]; + int other = pair.a == c ? pair.b : pair.a; + + Vector2 pa = weld.Point(ia), pb = weld.Point(ib); + Vector2 d = pb - pa; float len = d.magnitude; + if (len < 1e-6f) continue; + Vector2 mid = (pa + pb) * 0.5f; + + // R7 (operator, 2026-09-18): the emitters projecting the panel hold its + // rim, so an unshared edge on the panel boundary is not a free surface — + // it carries no ghost term and generates no reflected tension. A broken + // cell's faces are a different case entirely: KWaveStep's per-edge `solid` + // check still treats a broken neighbour as free, so the dicing cascade + // (reflection off actual damage) is untouched by this. + if (other < 0) continue; + + float nbrDist = Mathf.Max((ctrs[other] - ctr).magnitude, 1e-4f); + edgePool.Add(new DualEdge + { + nbr = (uint)other, + mid = mid, + dir = d / len, + len = len, + w = len / nbrDist + }); + } + + float dist = (ctr - wavefrontOrigin).magnitude; + cells[c] = new CellStatic + { + centroid = ctr, + radius = radius, + spawnDelay = dist / Mathf.Max(wavefrontSpeed, 1e-3f) + + (float)rng.NextDouble() * delayJitter, + vertStart = (uint)vStart, + vertCount = (uint)n, + nbrStart = (uint)eStart, + nbrCount = (uint)(edgePool.Count - eStart), + toughness = 0.8f + (float)rng.NextDouble() * 0.4f, + tileType = (uint)Mathf.Max(0, keptTypes[c]), + splitSeed = (uint)rng.Next(int.MinValue, int.MaxValue), + area = Mathf.Max(areas[c], 1e-6f), + pad0 = 0f, + pad1 = 0f, + pad2 = 0f + }; + } + + var data = new TilingData + { + verts = vertPool.ToArray(), + cells = cells, + dualEdges = edgePool.ToArray() + }; + Vector2 size = bMax - bMin; + data.bounds = new Bounds((bMin + bMax) * 0.5f, new Vector3(size.x, size.y, 0.1f)); + return data; + } + + static long EdgeKey(int a, int b) => a < b ? ((long)a << 32) | (uint)b : ((long)b << 32) | (uint)a; + + // ------------------------------------------------------------------ + // Dual tiling: one dual cell per interior primal vertex, its corners + // being the centroids of the primal cells around that vertex. + // ------------------------------------------------------------------ + public static PolygonSoup TakeDual(PolygonSoup soup, float weldEps = 1e-3f) + { + var weld = new VertexWelder(weldEps); + var rings = new List(); + var centroids = new List(); + + for (int c = 0; c < soup.polys.Count; c++) + { + var poly = soup.polys[c]; + var ring = new int[poly.Length]; + for (int i = 0; i < poly.Length; i++) ring[i] = weld.Get(poly[i]); + rings.Add(ring); + centroids.Add(PolygonSoup.Centroid(poly)); + } + + // vertex -> incident cells, and vertex -> boundary flag + var incident = new Dictionary>(); + var degree = new Dictionary(); + var edgeCount = new Dictionary(); + + for (int c = 0; c < rings.Count; c++) + { + var ring = rings[c]; + for (int i = 0; i < ring.Length; i++) + { + if (!incident.TryGetValue(ring[i], out var lst)) incident[ring[i]] = lst = new List(); + lst.Add(c); + long k = EdgeKey(ring[i], ring[(i + 1) % ring.Length]); + edgeCount[k] = edgeCount.TryGetValue(k, out int n) ? n + 1 : 1; + } + } + // a vertex is interior only if every edge touching it is shared by two cells + var onBoundary = new HashSet(); + foreach (var kv in edgeCount) + if (kv.Value < 2) + { + onBoundary.Add((int)(kv.Key >> 32)); + onBoundary.Add((int)(kv.Key & 0xffffffff)); + } + + var dual = new PolygonSoup(); + foreach (var kv in incident) + { + if (onBoundary.Contains(kv.Key)) continue; + var cellsAround = kv.Value; + if (cellsAround.Count < 3) continue; + + Vector2 hub = weld.Point(kv.Key); + var pts = new List(cellsAround.Count); + foreach (int c in cellsAround) pts.Add(centroids[c]); + pts.Sort((u, v) => + Mathf.Atan2(u.y - hub.y, u.x - hub.x).CompareTo( + Mathf.Atan2(v.y - hub.y, v.x - hub.x))); + + dual.Add(pts.ToArray(), cellsAround.Count); // type = vertex figure order + } + return dual; + } + + // ------------------------------------------------------------------ + // Merge triangle pairs into rhombs where the union is equilateral. + // Turns a Penrose half-rhomb (Robinson triangle) soup into P3 rhombs. + // ------------------------------------------------------------------ + public static PolygonSoup MergeRhombs(PolygonSoup soup, float weldEps = 1e-3f, float tol = 0.04f) + { + var weld = new VertexWelder(weldEps); + int n = soup.polys.Count; + var rings = new int[n][]; + for (int c = 0; c < n; c++) + { + var poly = soup.polys[c]; + rings[c] = new int[poly.Length]; + for (int i = 0; i < poly.Length; i++) rings[c][i] = weld.Get(poly[i]); + } + + var edgeMap = new Dictionary(); + for (int c = 0; c < n; c++) + { + if (rings[c].Length != 3) continue; + for (int i = 0; i < 3; i++) + { + long k = EdgeKey(rings[c][i], rings[c][(i + 1) % 3]); + edgeMap[k] = edgeMap.TryGetValue(k, out var e) ? (e.a, c) : (c, -1); + } + } + + var used = new bool[n]; + var outSoup = new PolygonSoup(); + foreach (var kv in edgeMap) + { + var (ca, cb) = kv.Value; + if (cb < 0 || used[ca] || used[cb]) continue; + if (rings[ca].Length != 3 || rings[cb].Length != 3) continue; + if (soup.types[ca] != soup.types[cb]) continue; + + int p = (int)(kv.Key >> 32), q = (int)(kv.Key & 0xffffffff); + int xa = Opposite(rings[ca], p, q), xb = Opposite(rings[cb], p, q); + if (xa < 0 || xb < 0) continue; + + Vector2 P = weld.Point(p), Q = weld.Point(q), A = weld.Point(xa), B = weld.Point(xb); + float e0 = (A - P).magnitude, e1 = (Q - A).magnitude, + e2 = (B - Q).magnitude, e3 = (P - B).magnitude; + float avg = 0.25f * (e0 + e1 + e2 + e3); + if (avg < 1e-5f) continue; + float dev = (Mathf.Abs(e0 - avg) + Mathf.Abs(e1 - avg) + + Mathf.Abs(e2 - avg) + Mathf.Abs(e3 - avg)) / (4f * avg); + if (dev > tol) continue; // not a rhombus, leave them alone + + outSoup.Add(new[] { P, A, Q, B }, soup.types[ca]); + used[ca] = used[cb] = true; + } + + for (int c = 0; c < n; c++) + if (!used[c]) outSoup.Add((Vector2[])soup.polys[c].Clone(), soup.types[c]); + return outSoup; + } + + static int Opposite(int[] tri, int p, int q) + { + for (int i = 0; i < 3; i++) if (tri[i] != p && tri[i] != q) return tri[i]; + return -1; + } + } + + /// Grid-bucketed vertex welder. O(1) lookup, tolerant to floating point drift. + public class VertexWelder + { + readonly float eps, inv; + readonly Dictionary<(int, int), List> buckets = new(); + readonly List points = new(); + + public VertexWelder(float eps) { this.eps = eps; inv = 1f / eps; } + public Vector2 Point(int i) => points[i]; + public int Count => points.Count; + + public int Get(Vector2 p) + { + int gx = Mathf.FloorToInt(p.x * inv), gy = Mathf.FloorToInt(p.y * inv); + float e2 = eps * eps; + for (int dx = -1; dx <= 1; dx++) + for (int dy = -1; dy <= 1; dy++) + if (buckets.TryGetValue((gx + dx, gy + dy), out var lst)) + foreach (int i in lst) + if ((points[i] - p).sqrMagnitude <= e2) return i; + + int id = points.Count; + points.Add(p); + if (!buckets.TryGetValue((gx, gy), out var b)) buckets[(gx, gy)] = b = new List(); + b.Add(id); + return id; + } + } +} diff --git a/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs.meta b/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs.meta new file mode 100644 index 000000000..324c4dab1 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c12b1a6f3efce5245956dc0c796ede86 \ No newline at end of file diff --git a/Assets/Shaders/Compute/ShieldField/Tilings.cs b/Assets/Shaders/Compute/ShieldField/Tilings.cs new file mode 100644 index 000000000..17a47224c --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/Tilings.cs @@ -0,0 +1,402 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace ShieldField +{ + public enum TilingKind + { + Hex, // regular hexagons + TriHex, // 3.6.3.6 — hexagons + triangles (dual: rhombille) + Square, + PenroseP3, // five-fold quasiperiodic rhombs, via substitution + Voronoi, // Lloyd-relaxed jittered lattice + VoronoiIFS // Voronoi over a chaos-game IFS attractor — fractal cell density + } + + /// One affine map of an iterated function system, with its selection weight. + [Serializable] + public struct IfsMap + { + public Vector2 translate; + public float rotateDeg; + public float scale; + public float weight; + + public Vector2 Apply(Vector2 p) + { + float r = rotateDeg * Mathf.Deg2Rad, c = Mathf.Cos(r), s = Mathf.Sin(r); + Vector2 q = new Vector2(p.x * c - p.y * s, p.x * s + p.y * c) * scale; + return q + translate; + } + } + + public static class Tilings + { + public const float Phi = 1.6180339887f; + + public static PolygonSoup Generate(TilingKind kind, float radius, float cellSize, + int seed, IfsMap[] ifs = null) + { + switch (kind) + { + case TilingKind.Hex: return Hex(radius, cellSize); + case TilingKind.TriHex: return TriHex(radius, cellSize); + case TilingKind.Square: return Square(radius, cellSize); + case TilingKind.PenroseP3: return Penrose(radius, cellSize); + case TilingKind.Voronoi: return Voronoi(JitteredSites(radius, cellSize, seed), radius, 3); + case TilingKind.VoronoiIFS: return Voronoi(ChaosGame(ifs ?? Pentaflake(), radius, cellSize, seed), radius, 1); + default: return Hex(radius, cellSize); + } + } + + // ============================================================== + // Periodic lattices + // ============================================================== + public static PolygonSoup Hex(float radius, float size) + { + var soup = new PolygonSoup(); + float w = size * Mathf.Sqrt(3f), h = size * 1.5f; + int nx = Mathf.CeilToInt(radius / w) + 2, ny = Mathf.CeilToInt(radius / h) + 2; + + for (int j = -ny; j <= ny; j++) + for (int i = -nx; i <= nx; i++) + { + Vector2 c = new Vector2(i * w + (j & 1) * w * 0.5f, j * h); + if (c.magnitude > radius) continue; + soup.Add(Ngon(c, size, 6, 90f), 0); + } + return soup; + } + + public static PolygonSoup TriHex(float radius, float size) + { + // Hexagons on a triangular lattice with gaps filled by upward/downward triangles. + var soup = new PolygonSoup(); + float s = size; // hexagon circumradius + float a = s * Mathf.Sqrt(3f); // lattice spacing between hex centres + float w = a * Mathf.Sqrt(3f), h = a * 1.5f; + int nx = Mathf.CeilToInt(radius / w) + 2, ny = Mathf.CeilToInt(radius / h) + 2; + + var hexCentres = new List(); + for (int j = -ny; j <= ny; j++) + for (int i = -nx; i <= nx; i++) + { + Vector2 c = new Vector2(i * w + (j & 1) * w * 0.5f, j * h); + if (c.magnitude > radius + a) continue; + hexCentres.Add(c); + soup.Add(Ngon(c, s, 6, 90f), 0); + } + + // Triangles sit in the interstices: each is bounded by three hex corners. + var weld = new VertexWelder(1e-3f); + var corners = new Dictionary(); + foreach (var c in hexCentres) + for (int k = 0; k < 6; k++) + { + Vector2 p = c + Polar(s, 90f + k * 60f); + corners[weld.Get(p)] = p; + } + + float triSide = s; + var seen = new HashSet(); + foreach (var c in hexCentres) + for (int k = 0; k < 6; k++) + { + // interstitial triangle centre lies between two adjacent hexes + Vector2 dir = Polar(1f, 60f * k); + Vector2 tc = c + dir * (a / Mathf.Sqrt(3f)); + if (tc.magnitude > radius) continue; + long key = ((long)Mathf.RoundToInt(tc.x * 1000f) << 32) ^ (uint)Mathf.RoundToInt(tc.y * 1000f); + if (!seen.Add(key)) continue; + + float rot = (k % 2 == 0) ? 180f : 0f; + var tri = Ngon(tc, triSide / Mathf.Sqrt(3f), 3, rot); + soup.Add(tri, 1); + } + return soup; + } + + public static PolygonSoup Square(float radius, float size) + { + var soup = new PolygonSoup(); + int n = Mathf.CeilToInt(radius / size) + 1; + for (int j = -n; j <= n; j++) + for (int i = -n; i <= n; i++) + { + Vector2 c = new Vector2((i + 0.5f) * size, (j + 0.5f) * size); + if (c.magnitude > radius) continue; + float h = size * 0.5f; + soup.Add(new[]{ c + new Vector2(-h,-h), c + new Vector2(h,-h), + c + new Vector2(h, h), c + new Vector2(-h, h) }, 0); + } + return soup; + } + + // ============================================================== + // Penrose P3 — Robinson triangle substitution. + // Run TilingBuilder.MergeRhombs on the result to recover the rhombs. + // ============================================================== + public static PolygonSoup Penrose(float radius, float cellSize) + { + // D18 fix: the substitution count used to be a fixed authored constant, + // so the whole tiling was just uniformly rescaled to `radius` afterward — + // cell count never grew with panel size. Every other tiling here (Hex, + // TriHex, Square, Voronoi) uses cellSize to hold local cell size constant + // while radius controls how far the tiling extends; derive an iteration + // count that does the same for Penrose. Each substitution divides edge + // length by phi (the inflation factor), and the seed triangles have unit + // edges before the final `* scale` below, so solve for the iteration + // count that brings a unit edge down to approximately cellSize once + // scaled by `radius`. + // + // D21 fix: a quasiperiodic substitution can only land ON one of these + // phi-quantised edge lengths, never between them — `cellSize` for Penrose + // means "the target edge length", and the right answer is the iteration + // count whose resulting edge length is NEAREST that target, not the count + // that guarantees the tiling is at least as fine as requested. The + // original `CeilToInt(...) + 1` always rounded up AND added an extra + // substitution on top, which is why Penrose landed an order of magnitude + // denser than Hex at the same cellSize (D21, §2.2/§2.3): every fractional + // log rounded away from cellSize toward finer, and the +1 then halved the + // edge length again. Rounding to the nearest integer substitution count is + // the fix; every other tiling here already reports the density the caller + // asked for, and Penrose can only ever approximate it to within one + // phi step, same as it does now, just centred on the target instead of + // always overshooting it. + int iterations = Mathf.Clamp( + Mathf.RoundToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)), + 1, 12); + return Penrose(radius, iterations); + } + + /// Penrose substitution with an explicit iteration count. Kept for probes + /// and callers that want to pin the level of detail directly. + public static PolygonSoup Penrose(float radius, int iterations) + { + var tris = new List<(int type, Vector2 a, Vector2 b, Vector2 c)>(); + for (int i = 0; i < 10; i++) + { + Vector2 b = Polar(1f, (2 * i - 1) * 18f); + Vector2 c = Polar(1f, (2 * i + 1) * 18f); + if (i % 2 == 0) (b, c) = (c, b); + tris.Add((0, Vector2.zero, b, c)); + } + + for (int it = 0; it < iterations; it++) + { + var next = new List<(int, Vector2, Vector2, Vector2)>(tris.Count * 3); + foreach (var (type, A, B, C) in tris) + { + if (type == 0) + { + Vector2 P = A + (B - A) / Phi; + next.Add((0, C, P, B)); + next.Add((1, P, C, A)); + } + else + { + Vector2 Q = B + (A - B) / Phi; + Vector2 R = B + (C - B) / Phi; + next.Add((1, R, C, A)); + next.Add((1, Q, R, B)); + next.Add((0, R, Q, A)); + } + } + tris = next; + } + + float scale = radius / 1.0f; + var soup = new PolygonSoup(); + foreach (var (type, A, B, C) in tris) + soup.Add(new[] { A * scale, B * scale, C * scale }, type); + return soup; + } + + // ============================================================== + // Site generators + // ============================================================== + public static List JitteredSites(float radius, float spacing, int seed) + { + var rng = new System.Random(seed); + var pts = new List(); + float w = spacing, h = spacing * 0.866f; + int nx = Mathf.CeilToInt(radius / w) + 1, ny = Mathf.CeilToInt(radius / h) + 1; + for (int j = -ny; j <= ny; j++) + for (int i = -nx; i <= nx; i++) + { + Vector2 p = new Vector2(i * w + (j & 1) * w * 0.5f, j * h); + p += new Vector2((float)rng.NextDouble() - 0.5f, (float)rng.NextDouble() - 0.5f) * spacing * 0.45f; + if (p.magnitude < radius * 1.15f) pts.Add(p); + } + return pts; + } + + /// Chaos game over an IFS. Cell density inherits the attractor's fractal measure. + public static List ChaosGame(IfsMap[] maps, float radius, float spacing, int seed) + { + int target = Mathf.Clamp(Mathf.RoundToInt(Mathf.PI * radius * radius / (spacing * spacing)), 64, 6000); + var rng = new System.Random(seed); + float total = 0f; foreach (var m in maps) total += Mathf.Max(m.weight, 1e-4f); + + Vector2 p = Vector2.zero; + for (int i = 0; i < 64; i++) p = Pick(maps, rng, total).Apply(p); // burn in + + // Accept points onto a grid so we never emit two sites on top of each other. + float minDist = spacing * 0.42f; + var grid = new Dictionary<(int, int), List>(); + float inv = 1f / minDist; + var pts = new List(target); + + for (int i = 0; i < target * 220 && pts.Count < target; i++) + { + p = Pick(maps, rng, total).Apply(p); + Vector2 q = p * radius; + if (q.magnitude > radius * 1.1f) continue; + + int gx = Mathf.FloorToInt(q.x * inv), gy = Mathf.FloorToInt(q.y * inv); + bool tooClose = false; + for (int dx = -1; dx <= 1 && !tooClose; dx++) + for (int dy = -1; dy <= 1 && !tooClose; dy++) + if (grid.TryGetValue((gx + dx, gy + dy), out var lst)) + foreach (var o in lst) + if ((o - q).sqrMagnitude < minDist * minDist) { tooClose = true; break; } + if (tooClose) continue; + + if (!grid.TryGetValue((gx, gy), out var b)) grid[(gx, gy)] = b = new List(); + b.Add(q); + pts.Add(q); + } + return pts; + } + + static IfsMap Pick(IfsMap[] maps, System.Random rng, float total) + { + float r = (float)rng.NextDouble() * total; + foreach (var m in maps) { r -= Mathf.Max(m.weight, 1e-4f); if (r <= 0f) return m; } + return maps[maps.Length - 1]; + } + + /// Five-fold pentaflake attractor. Pairs well with a Penrose outline layer. + public static IfsMap[] Pentaflake() + { + float r = 1f / (1f + Phi); + var maps = new IfsMap[5]; + for (int i = 0; i < 5; i++) + maps[i] = new IfsMap + { + translate = Polar(1f - r, 90f + i * 72f), + rotateDeg = 0f, + scale = r, + weight = 1f + }; + return maps; + } + + /// Sierpinski triangle attractor — sparse, angular density falloff. + public static IfsMap[] Sierpinski() + { + var maps = new IfsMap[3]; + for (int i = 0; i < 3; i++) + maps[i] = new IfsMap + { + translate = Polar(0.5f, 90f + i * 120f), + rotateDeg = 0f, + scale = 0.5f, + weight = 1f + }; + return maps; + } + + // ============================================================== + // Voronoi by half-plane clipping. O(n * k) with k nearest neighbours. + // Brute-force nearest search; fine at init for a few thousand sites. + // ============================================================== + public static PolygonSoup Voronoi(List sites, float radius, int lloydPasses) + { + for (int pass = 0; pass < lloydPasses; pass++) + { + var moved = new List(sites.Count); + foreach (var cell in VoronoiCells(sites, radius)) + moved.Add(cell == null ? Vector2.zero : PolygonSoup.Centroid(cell)); + for (int i = 0; i < sites.Count; i++) + if (moved[i] != Vector2.zero) sites[i] = moved[i]; + } + + var soup = new PolygonSoup(); + foreach (var cell in VoronoiCells(sites, radius)) + { + if (cell != null && cell.Count >= 3 && cell.Count <= Limits.MaxVerts) + soup.Add(cell.ToArray(), cell.Count % 3); + } + return soup; + } + + static IEnumerable> VoronoiCells(List sites, float radius) + { + const int K = 24; + float R = radius * 1.4f; + var box = new List { new(-R,-R), new(R,-R), new(R,R), new(-R,R) }; + var near = new List<(float d, int i)>(sites.Count); + + for (int i = 0; i < sites.Count; i++) + { + Vector2 s = sites[i]; + near.Clear(); + for (int j = 0; j < sites.Count; j++) + { + if (j == i) continue; + near.Add(((sites[j] - s).sqrMagnitude, j)); + } + near.Sort((a, b) => a.d.CompareTo(b.d)); + + var poly = new List(box); + int k = Mathf.Min(K, near.Count); + for (int m = 0; m < k && poly.Count > 0; m++) + { + Vector2 t = sites[near[m].i]; + Vector2 n = t - s; + float len = n.magnitude; + if (len < 1e-6f) continue; + n /= len; + float d = Vector2.Dot((s + t) * 0.5f, n); + poly = ClipHalfPlane(poly, n, d); + } + yield return poly.Count >= 3 ? poly : null; + } + } + + /// Sutherland–Hodgman clip; keeps the side where dot(p, n) <= d. + static List ClipHalfPlane(List poly, Vector2 n, float d) + { + var outp = new List(poly.Count + 2); + for (int i = 0; i < poly.Count; i++) + { + Vector2 a = poly[i], b = poly[(i + 1) % poly.Count]; + float da = Vector2.Dot(a, n) - d, db = Vector2.Dot(b, n) - d; + bool ina = da <= 0f, inb = db <= 0f; + if (ina) outp.Add(a); + if (ina != inb) + { + float t = da / (da - db); + outp.Add(Vector2.Lerp(a, b, t)); + } + } + return outp; + } + + // ============================================================== + static Vector2 Polar(float r, float deg) + { + float a = deg * Mathf.Deg2Rad; + return new Vector2(Mathf.Cos(a) * r, Mathf.Sin(a) * r); + } + + static Vector2[] Ngon(Vector2 c, float r, int n, float rotDeg) + { + var p = new Vector2[n]; + for (int i = 0; i < n; i++) p[i] = c + Polar(r, rotDeg + i * 360f / n); + return p; + } + } +} diff --git a/Assets/Shaders/Compute/ShieldField/Tilings.cs.meta b/Assets/Shaders/Compute/ShieldField/Tilings.cs.meta new file mode 100644 index 000000000..1e0934a49 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/Tilings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 72352dd87781d88499a809cfc695357c \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..16fe9b214 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# Aetheria agent doctrine + +## Data tooling: AetherDb + +`tools/AetherDb` is the agent's tool for reading and writing Aetheria's CultCache stores (`GameData/Aetheria.cc`, +`run.cc`, `player.cc`). The simulation lives in `Aetheria.Shared` (the headless build of +`Assets/Scripts/ServerShared`) and needs no Unity; Unity is only the shell for graphics and input. + +- When a task needs a catalog or save capability AetherDb lacks (authoring or cloning designs, setting fields, + fixtures for a play smoke, a query), add it to AetherDb as a command. Do not write a scratch console project, + a loose `.cs` script, a Python or PowerShell reader of the `.cc` bytes, or a Unity editor script instead. + Loose scripts were retired in `9f9149bd` because they ran against stale schemas. +- A command dry-runs by default and writes only with `apply`, validates with `CultRecordRefs.Validate` and + commits in one `CultCache.Commit` batch, like the existing migrations in `tools/AetherDb/Program.cs`. +- Deep copies of records use CultCache's own options, + `CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly)`; MessagePack's standard options + cannot serialise `CultMath` types or `CultRecordRef`. +- The headless build refuses to compile unless CultLib is a clean checkout at the revisions pinned in + `Directory.Build.props` (`Directory.Build.targets` enforces it). When `F:\Projects\CultLib` is elsewhere, point + `CULTLIB_ROOT` and `CULTMATH_ROOT` at detached worktrees of those revisions; never move the shared checkout. +- CultCache Studio is the operator's GUI over the same files. It is not an agent path. diff --git a/Directory.Build.props b/Directory.Build.props index 22254e6cb..e1f4acc37 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,12 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - a0813c6eed24d30bf88073ef615b633c77ebfcd6 + + 45c2f4006f5f7dab357426cabd3197e8d0a17f66 + $(CULTMATH_ROOT) + $(CultLibRoot) + + 6d5e2096563a320933b64ddfd6ded8f2e846ac91 C:\Program Files\Git\cmd\git.exe git diff --git a/Directory.Build.targets b/Directory.Build.targets index da7151646..c9c079a6c 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,24 +1,39 @@ + + + + + + - - - + + - + - - + - + - + diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 835af760f..200c93e22 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ef6362ff938bcd4e9e947a990b111541aec752cfa90da39f1c446c6a9a25fd4 -size 12683061 +oid sha256:2aa79c0adca4e50b608f1c7154ad847777d62af83b918b6de19eb881e8fdd7e9 +size 12698108 diff --git a/Packages/manifest.json b/Packages/manifest.json index 4acdec7c4..6e31d3b05 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -2,6 +2,7 @@ "dependencies": { "com.unity.2d.sprite": "1.0.0", "com.unity.2d.tilemap": "1.0.0", + "com.unity.addressables": "2.9.1", "com.unity.ai.navigation": "2.0.14", "com.unity.burst": "1.8.30", "com.unity.cinemachine": "2.10.7", @@ -50,8 +51,8 @@ "com.unity.modules.vr": "1.0.0", "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", - "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.3.1", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.59", - "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.2.3" + "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", + "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.2.4" } } diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 0ec40ab46..d58d112e2 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -17,7 +17,7 @@ }, "com.unity.addressables": { "version": "2.9.1", - "depth": 1, + "depth": 0, "source": "registry", "dependencies": { "com.unity.profiling.core": "1.0.2", @@ -165,7 +165,7 @@ }, "com.unity.profiling.core": { "version": "1.0.3", - "depth": 2, + "depth": 1, "source": "registry", "dependencies": {}, "url": "https://packages.unity.com" @@ -183,7 +183,7 @@ }, "com.unity.scriptablebuildpipeline": { "version": "2.6.1", - "depth": 2, + "depth": 1, "source": "registry", "dependencies": { "com.unity.test-framework": "1.4.5", @@ -243,27 +243,27 @@ "url": "https://packages.unity.com" }, "org.gamecult.caching.unity": { - "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.3.1", + "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.59" + "org.gamecult.cultlib": "1.0.60" }, - "hash": "a0813c6eed24d30bf88073ef615b633c77ebfcd6" + "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.59", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", "depth": 0, "source": "git", "dependencies": {}, - "hash": "a0813c6eed24d30bf88073ef615b633c77ebfcd6" + "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" }, "org.gamecult.cultmath": { - "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.2.3", + "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.2.4", "depth": 0, "source": "git", "dependencies": {}, - "hash": "0f2c1f04ef66a8b85c507747c508960ff1417ae0" + "hash": "6d5e2096563a320933b64ddfd6ded8f2e846ac91" }, "com.unity.modules.accessibility": { "version": "1.0.0", diff --git a/ProjectSettings/EditorBuildSettings.asset b/ProjectSettings/EditorBuildSettings.asset index 90a68ccc4..d4d2f0bf4 100644 --- a/ProjectSettings/EditorBuildSettings.asset +++ b/ProjectSettings/EditorBuildSettings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79c6daa230a012219dc0b7705cc8875b44f7b43516f11d69a124403cd718a605 -size 479 +oid sha256:7a750efc6f4323ca67875ae74e397c93da9ef7982619a88cb7bef7c34f8eec76 +size 586 diff --git a/docs/addressables-cut.md b/docs/addressables-cut.md new file mode 100644 index 000000000..fb7b92d08 --- /dev/null +++ b/docs/addressables-cut.md @@ -0,0 +1,413 @@ +# Engine Asset References Through Addressables: Cut Map + +Date: 2026-09-17 + +Status: Imagination pass, cut map. Nothing here has landed. Anchors are against +`codex/item-provenance` HEAD `f8396313` (settings Cut 0 in progress; none of its commits +touch a file named here, checked with `git diff --name-only f13a3356 f8396313`). Ends are +owned by the ruling in `docs/settings-globals-cut.md` (header); this document owns the means. + +Claims marked **(probe)** were run, not read off names: +- a scratch console over `Aetheria.Shared` (sparse scratch worktree of `f13a3356`, CultLib + `a0813c6` as a detached scratch worktree, both removed afterwards; the catalog was a copy) + that opened `GameData/Aetheria.cc` through `AetheriaStores.Open` read-only and walked every + stored document for string members carrying `[CultInspectorAssetPath]`; +- a Python pass resolving each stored path against the main tree and its `.meta` guid, and a + guid closure over the referenced prefabs' YAML; +- reads of `Library/PackageCache/com.unity.addressables@8460f1c9c927` (2.9.1) source and its + `Documentation~`. + +Operator ruling (2026-09-17): engine asset references from CultCache data are Unity +Addressables, stored as the asset's `.meta` GUID and loaded through Addressables by GUID. +One rule for every engine asset reference from data. No dual Resources/Addressables path. + +Rulings: Q1 (A), one `Assets/Content` folder entry (operator, 2026-09-17: "I take your recommendation"). Open: none. +Ruling S (A), sub-assets (operator, 2026-09-17: "A"): a stored reference is the Addressables key, +the bare GUID for a main asset and `guid[subAssetName]` when a sub-asset (e.g. one sprite of a +sheet) is picked. It is what `AssetReference` keeps (GUID plus sub-object name), and +Addressables loads both forms. The drawer writes and reads both; `EngineAssets.Load` passes the +key through. Found by Soul on CultLib `0155ba8`, before release. +Correction: Cut 0's negative grep `AssetPath` also matches Unity's `GUIDToAssetPath`/ +`AssetPathToGUID`; the check is `git grep -n "CultInspectorAssetPath\|\.AssetPath\b"`. + +> **Landed (2026-09-18).** Cut 1 `491811bd`, Cut 2 `67cbf34b`, harness restore `2d348d64`. +> The 587 asset moves out of `Resources` into `Assets/Content` are in `1fdf8151`, whose +> message is about the shield map: Self committed a doc while Hands' `git mv`s were staged in +> the same index, in the same working tree. History is pushed and shared, so it stands as is. +> Catalog rewrite: 30 references, all resolved, 0 unresolved; `EngineAssetCheck.Run` reports +> 30 stored references OK. + +## 0. Target + +Ends: +- A CultCache field that names an engine asset stores that asset's GUID. No stored path. +- One Unity-side loader turns a GUID into an asset. No runtime code calls `Resources.Load` + for data-named assets, and nothing strips an `Assets/Resources/` prefix. +- Every stored GUID resolves to an addressable asset of the type its consumer loads, and a + check proves it. + +Invariants kept: +- ServerShared stays engine-free (`Aetheria.Shared.csproj` builds it without Unity), so the + data carries a string, never a Unity type. +- Catalog globals are authored, never invented; the migration rewrites values in existing + records only. +- One home store per type, one-store commits. + +## 1. Census + +### 1.1 Stored references **(probe)** + +8 attributed members exist (`git grep CultInspectorAssetPath`). No other engine asset string +exists in ServerShared: the only other `*Path` strings are `InputLayout.cs:68,92` (input +control paths) and `WwiseMetadata.cs:37,91` (Wwise object paths), neither a Unity asset. + +| Member (`file:line`) | Stored | Non-empty | Resolves | Dangling | Runtime reader | +|---|---|---|---|---|---| +| `ConsumableItemData.Icon` (`ItemData.cs:352`) | 0 | 0 | 0 | 0 | `ActionBarSlot.cs:114` | +| `EquippableItemData.Schematic` (`ItemData.cs:362`) | 51 | 1 | 1 | 0 | editor only, `Editor/CultCacheDrawers.cs:104` | +| `EquippableItemData.ActionBarIcon` (`ItemData.cs:392`) | 51 | 0 | 0 | 0 | `ActionBarSlot.cs:153` | +| `HullData.Prefab` (`ItemData.cs:499`) | 3 | 3 | 3 | 0 | `ZoneRenderer.cs:295,304` | +| `WeaponData.EffectPrefab` (`Behaviors/Weapon.cs:43`) | 18 | 18 | 18 | 0 | `EntityInstance.cs:195,224` | +| `ThrusterData.ParticlesPrefab` (`Behaviors/Thruster.cs:26`) | 2 | 2 | 2 | 0 | `ShipInstance.cs:67` | +| `AetherDriveData.Particles` (`Behaviors/AetherDrive.cs:50`) | 1 | 1 | 1 | 0 | `ShipInstance.cs:53` | +| `Faction.Logo` (`Corporations.cs:25`) | 12 | 5 | 5 | 0 | none | + +Totals: 138 stored values in 180 documents, 30 non-empty, 27 distinct paths, all 30 resolve +to an existing file with a `.meta` guid, **0 dangling**. Every one of the 27 distinct paths is +under `Assets/Resources/`: 21 prefabs under `Prefabs/`, 1 png under `Schematics/`, 5 pngs under +`Sprites/Icons/{Logos,Tech}` (3 paths are shared by two records each). +The value-by-value list (record, member, path, guid) is the migration's expected report; +Cut 2 regenerates it rather than trusting this copy. + +### 1.2 Load sites (all synchronous, all on the main thread) + +| Site | Loads | Context | +|---|---|---| +| `Gameplay/ActionBarSlot.cs:114` | `Resources.Load` with inline prefix strip | binding ctor | +| `Gameplay/ActionBarSlot.cs:153` | same | binding ctor | +| `Gameplay/EntityInstance.cs:195` | `UnityHelpers.LoadAsset` | `SetEntity`, then `Instantiate` | +| `Gameplay/EntityInstance.cs:224` | `UnityHelpers.LoadAsset` | same | +| `Gameplay/ShipInstance.cs:53` | `UnityHelpers.LoadAsset` | same, `Instantiate` | +| `Gameplay/ShipInstance.cs:67` | same | LINQ select, `Instantiate` | +| `Zone Display/ZoneRenderer.cs:295,304` | `UnityHelpers.LoadAsset` | `LoadEntity`, `Instantiate` | +| `Editor/CultCacheDrawers.cs:104` | `AssetDatabase.LoadAssetAtPath` | Studio drawer | + +`UnityHelpers.cs:7-10` is the only helper and has no other caller. Four sites load a +**component** type. Addressables cannot do that: "You can't load a component of a GameObject +directly through Addressables" (`Documentation~/load-assets.md:60`). The loader must load the +GameObject and take the component. + +### 1.3 `Resources.Load` not tied to data + +One: `Gameplay/ActionGameManager.cs:420` (`:425` before Cut 0) loads +`Sprites/Input/{controlName}` by naming convention. No data names it; it is outside the ruling. +**Default: out of scope**, and `Assets/Resources/Sprites/Input/` stays in Resources. It goes to +follow-ups: if Resources is ever emptied, this becomes a player-store or catalog map of control +name to GUID. + +### 1.4 Resources dependencies of the referenced assets **(probe)** + +The guid closure of the 27 referenced assets is 145 assets; 40 sit in `Assets/Resources`: the +27 seeds, plus 13 dependencies. 8 of those are inside the subtrees Cut 2 moves (`Prefabs/` +top-level Influence, Lightning, PingEffect, Shield, Tractor Beam; `Sprites/Icons/Stroked/` +Satellite, Ship, orbital). 5 stay behind: `Gradients/Ironbow.png` (written there by +`GradientMapper.cs:94-96`), `Sprites/Mote.png`, `Sprites/ringThick.png`, +`Sun Albedo/sunsurface1map 1.png`, `Sun Flow/sunsurface1offset 1.png`. Nothing loads them by +name; left in Resources they ship twice (Resources data and the bundle). That is size, not +correctness. **Default: follow-up**, reported by the Addressables Analyze rule "Check Resources +to Addressable Duplicate Dependencies" (`Editor/Build/AnalyzeRules/CheckResourcesDupeDependencies.cs`). + +## 2. Ownership decisions + +### 2.1 The picker and the attribute belong to CultLib + +`[CultInspectorAssetPath]` is CultLib's (`src/GameCult.Caching/CultInspectorAttributes.cs:65-77`), +read into `CultInspectorMetadata.AssetPath` (`CultInspectorModel.cs:44,56`) and drawn by CultLib's +Studio, not by Aetheria: `src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs:168-176` +turns an `ObjectField` into `AssetDatabase.GetAssetPath`. Aetheria's +`Assets/Scripts/Editor/CultCacheDrawers.cs` claims only its own attributes. No CultLib path +under `src/GameCult.Caching`, `src/GameCult.Unity` or `unity/` changed between `a0813c6` and +CultLib `main` `244154e`. Aetheria is the only consumer in `F:\Projects` (`git grep` over every +repo; the other hits are CultLib's README and voidbot memory dumps). + +Decision: the owner fills the gap. CultLib replaces the path attribute with +`CultInspectorAssetGuidAttribute(Type? assetType = null)`, "a string member holding an engine +asset's stable identity; in Unity, the `.meta` GUID", and its Studio drawer stores +`AssetDatabase.AssetPathToGUID` and reads back through `GUIDToAssetPath`. Reusing the name +`AssetPath` for GUIDs would make the attribute lie, and keeping both leaves a dead path form +for the next field to pick up. Addressables stays out of CultLib: whether an asset is +addressable is Aetheria's content rule (2.3), so the Studio package gains no Addressables +dependency. `AssetType` stays optional; Aetheria cannot pass it (ServerShared is engine-free, +recorded in `docs/cultcache-migration-cut.md:2003`), so the Studio falls back to `Object`. + +Cost: a CultLib release (plugins rebuilt with `scripts/build-unity-package.ps1`), tags +`cultlib-unity-v1.0.60` and `caching-unity-v1.4.0` (removing a public attribute is breaking), +and an Aetheria bump of `Packages/manifest.json` (both URLs), `Packages/packages-lock.json` +and `Directory.Build.props` `CultLibRevision`. Coordination: CultLib `codex/studio-grouping` +(mapped, blocked on the Aetheria lot schema) also edits `CultInspectorModel.cs` and the Studio +drawers; whichever releases second rebases onto the first release. The older CultLib worktree branches +(`CultLib-aetheria-authority`, `CultLib-delvehold-isosurface`, `CultLib-document-variants`) carry +the attribute in its pre-move location (`Runtime/CultCacheInspectorAttributes.cs`); they are +CultLib branches, not consumers, and meet the rename only if rebased. + +### 2.2 Addressables package **(probe)** + +- `com.unity.addressables` **2.9.1** is already resolved and compiled, at depth 1, pulled by + `com.unity.localization` 1.5.8 (`Packages/packages-lock.json:18-33,117-126`). Its + `package.json` needs Unity 2023.1+; the project is `6000.3.24f1`. `Library/ScriptAssemblies` + holds `Unity.Addressables.dll`, `Unity.Addressables.Editor.dll`, `Unity.ResourceManager.dll`. + No script uses Localization and no Localization settings exist (grep of `Assets`, + `ProjectSettings`); that is a follow-up, not this cut. +- No settings exist yet: no `Assets/AddressableAssetsData`, no `com.unity.addressableassets` + in `ProjectSettings/EditorBuildSettings.asset` `m_configObjects`. +- Creating settings (`AddressableAssetSettings.Create`, `:2023-2056`, and `Validate`, + `:1946-1976`) writes: `Assets/AddressableAssetsData/AddressableAssetSettings.asset`, + `DefaultObject.asset`, `AssetGroups/Default Local Group.asset` with + `AssetGroups/Schemas/Default Local Group_BundledAssetGroupSchema.asset` and + `_ContentUpdateGroupSchema.asset`, `AssetGroupTemplates/Packed Assets.asset`, + `DataBuilders/BuildScriptFastMode.asset`, `BuildScriptPackedPlayMode.asset`, + `BuildScriptPackedMode.asset`, each with a `.meta`, plus the config object entry in + `EditorBuildSettings.asset`. It sets `BuildAddressablesWithPlayerBuild = PreferencesValue`, + a per-user preference. +- **GUID keys.** `BundledAssetGroupSchema.IncludeGUIDInCatalog` defaults to `true` + (`BundledAssetGroupSchema.cs:155`); the player catalog then adds each entry's guid as a key + (`AddressableAssetEntry.CreateKeyList`, `:254-261`). Folder entries expand into sub-entries + carrying their own guid (`AddressableAssetEntry.cs:554`). In editor play mode ("Use Asset + Database"), `AddressableAssetSettingsLocator` keys direct entries by guid (`:155`) and resolves + a guid under an addressable folder entry by walking parent folders (`:262-283`). A guid that is + not addressable yields no location and the load fails. So `LoadAssetAsync(guid)` works in + editor and player for direct and folder entries, and fails for non-addressable assets. +- **Resources.** An asset under a `Resources` folder cannot be an entry: the Inspector and Groups + window move it to `Resources_moved` (`AddressableAssetUtility.SafeMoveResourcesToGroup`, + `:309-339`), and moving an entry into Resources unmarks it (`AddressableAssetSettings.cs:2900-2906`). + The programmatic `CreateOrMoveEntry` (`:2541`) does not check, so a script could create an + entry the GUI would refuse; the cut must move assets out of Resources rather than rely on that. + Moving a file together with its `.meta` keeps the guid, so stored GUIDs survive the move. +- **Sync.** `AsyncOperationHandle.WaitForCompletion` (`AsyncOperationHandle.cs:178`) completes a + local load in place; `SynchronousAddressables.md:21` puts the cost near `Resources.Load` for + local bundles and warns that it completes every pending load. +- **Player build.** Bundles build with the player only when `BuildAddressablesWithPlayerBuild` + resolves to build (`build-player-builds.md:5`). Cut 1 sets `BuildWithPlayer` in the project + settings so the rule is committed, not a preference. + +### 2.3 How an asset becomes addressable (Q1) + +Recommended (Q1 A): one folder entry, `Assets/Content`, in `Default Local Group`. Addressable +means "lives under `Assets/Content`". Cut 2 moves `Assets/Resources/Prefabs/`, +`Assets/Resources/Schematics/` and `Assets/Resources/Sprites/Icons/` there with their `.meta` +files, keeping relative paths. `Sprites/Icons/` includes the 33 settings icons settings Cut 1 +will reference, so that cut only stores GUIDs. The 7 `CelestialBodySettings` presets under +`Assets/Plugins/Celestial Body/Solar System/` move into `Assets/Content/Celestial Bodies/` in +settings Cut 1 (that map's old fork B(a) move, retargeted). + +### 2.4 Loading owner + +`Assets/Scripts/EngineAssets.cs` (Assembly-CSharp, replaces `UnityHelpers.cs`), about 30 lines: +- `public static T Load(string guid) where T : Object`. Empty GUID returns `null`. A + `Component` type loads the GameObject and returns `GetComponent()`. +- Loads with `Addressables.LoadAssetAsync(guid).WaitForCompletion()`. All call sites are + synchronous construction paths that `Instantiate` at once. Making them async would reorder + entity construction for 30 local assets. **Default: sync.** +- Caches one handle per `(guid, type)` for the process lifetime and never releases, which is + the lifetime `Resources.Load` has today. It owns every handle; no caller holds or releases one. + A failed handle logs the GUID and returns `null`; callers keep their existing null handling. + +### 2.5 Validation owner + +`Assets/Scripts/Editor/EngineAssetCheck.cs` (Assembly-CSharp-Editor). It needs `AssetDatabase`, +the Addressables editor settings and the consumer component types, so it cannot be a headless +`AetherDb` command or a `Tests.asmdef` test (that assembly cannot reference Assembly-CSharp). +It has a menu item and a batchmode entry (`-executeMethod EngineAssetCheck.Run`, exit 1 on +failure). It opens the catalog read-only through `AetheriaStores.Open`, walks every stored +document for `[CultInspectorAssetGuid]` string members, and for each non-empty value checks: +1. it is 32 lowercase hex characters; +2. `AssetDatabase.GUIDToAssetPath` names an existing file; +3. `AddressableAssetSettingsDefaultObject.Settings.FindAssetEntry(guid, includeImplicit: true)` is non-null; +4. the asset satisfies its member's consumer contract, from one table with one row per member: + `Icon`, `ActionBarIcon`, `Schematic`, `Logo` are `Texture2D`; `HullData.Prefab` is a + GameObject with `EntityInstance`; `WeaponData.EffectPrefab` has `InstantWeaponEffectManager` + on `InstantWeaponData` records and `ConstantWeaponEffectManager` on `ConstantWeaponData` + records; `ThrusterData.ParticlesPrefab` and `AetherDriveData.Particles` have `ParticleSystem`. + An attributed member missing from the table fails the check, so a new field cannot skip it. + +It reports record name, member, GUID and path per failure. The check does not write, and no +repair path exists. + +## 3. Data migration (Cut 2, First) + +- Scratch-only program over `Aetheria.Shared` at the pre-Cut-2 commit (the fork K (a) + precedent: no landed bypass, no one-shot command left behind). It opens a **copy** of the + catalog writable, walks every stored document for `[CultInspectorAssetPath]` string members, + and maps each non-empty path to the `guid:` of `/.meta`. It writes through the + catalog's own types and commits once. +- Report, one line per value: record type, record name, member, old path, GUID. Unresolved + paths are listed and **fail the run with nothing written**; none are expected (1.1). Empty + values stay empty. +- No record is added or removed, and no global is touched. Member names, keys and `JsonProperty` + names are unchanged: the member still names the asset, and the attribute says the form. +- The rewritten `GameData/Aetheria.cc` (LFS) lands in the same commit as the code. A commit + that holds GUIDs with path-loading code, or paths with GUID-loading code, loads nothing. +- Before/after: `AetherDb census` and `dangling` outputs are byte-identical (same capture + method both sides); the report has 30 lines; a scratch re-read finds 30 GUIDs, 108 empty + strings and no string starting with `Assets/`. + +## 4. Cuts + +### Cut 0. CultLib: asset GUID attribute and Studio picker + +- **Repo/branch:** CultLib, a `codex/` branch from `main` `244154e`. Releases before Cut 2. +- **Deletes first:** `CultInspectorAssetPathAttribute` (`src/GameCult.Caching/CultInspectorAttributes.cs:65-77`); + `CultInspectorMetadata.AssetPath` (`CultInspectorModel.cs:44,56`); the path branch in + `CultCacheStudioDrawers.cs:170-176`. +- **Adds:** `CultInspectorAssetGuidAttribute(Type? assetType = null)` with the identity comment; + `CultInspectorMetadata.AssetGuid`; the drawer branch: `GUIDToAssetPath` then + `LoadAssetAtPath(path, assetType)`, then `ObjectField`, storing `AssetPathToGUID(next)` or + `string.Empty`. README `src/GameCult.Unity/Assets/Caching/README.md:153,164`. +- **Release:** rebuild plugins, bump `unity/org.gamecult.cultlib/package.json` to 1.0.60 and + `src/GameCult.Unity/Assets/Caching/package.json` to 1.4.0 (depending on cultlib 1.0.60), tag both. +- **Verification:** + - builds: `GameCult.Caching`, the CultLib test project that covers `CultInspectorModel`, the + Unity package build script. + - tests: a model test pins that a member carrying the GUID attribute exposes `AssetGuid` and + its `AssetType`. + - negative: `git grep -n "AssetPath" -- src unity` finds nothing (checked: today the only hits + are the three files above and the README). + - operator (Unity, after Cut 2's bump): pick a prefab in Studio, the stored value is its GUID, + and reopening shows the same asset. + +### Cut 1. Aetheria: Addressables settings + +- **Repo/branch:** Aetheria `codex/item-provenance`, after settings Cut 0 lands. Independent of Cut 0. +- **First:** Unity closed (operator), because batchmode refuses an open project. +- **Adds:** `"com.unity.addressables": "2.9.1"` as a direct dependency in `Packages/manifest.json`, + since Aetheria code now uses it; the lock entry goes from depth 1 to 0. Settings created by a + scratch-only batchmode `-executeMethod` script, which is not landed: `Create`, then set + `BuildAddressablesWithPlayerBuild = BuildWithPlayer`, create `Assets/Content/` (+`.meta`) and + `CreateOrMoveEntry(guid of Assets/Content, DefaultGroup)`. Operator alternative: *Window > + Asset Management > Addressables > Groups > Create Addressables Settings*, then the same three + edits by hand. +- **Intentional Unity churn (the whole list):** `Packages/manifest.json`, + `Packages/packages-lock.json`, `ProjectSettings/EditorBuildSettings.asset` (one + `com.unity.addressableassets` config object), `Assets/Content.meta`, and under + `Assets/AddressableAssetsData/`: `AddressableAssetSettings.asset`, `DefaultObject.asset`, + `AssetGroups/Default Local Group.asset`, `AssetGroups/Schemas/Default Local Group_BundledAssetGroupSchema.asset`, + `AssetGroups/Schemas/Default Local Group_ContentUpdateGroupSchema.asset`, + `AssetGroupTemplates/Packed Assets.asset`, `DataBuilders/BuildScriptFastMode.asset`, + `DataBuilders/BuildScriptPackedPlayMode.asset`, `DataBuilders/BuildScriptPackedMode.asset`, + and every folder and file `.meta`. Nothing else: the modified materials and render textures + in the worktree today are pre-existing Unity churn and stay unstaged. +- **Harmless alone:** no code loads through Addressables yet, and `Assets/Content` is empty. +- **Verification:** + - operator: Unity opens without Addressables errors; the Groups window shows `Assets/Content` + under `Default Local Group`; the ARPG play smoke is unchanged. + - negative: `git show --stat` lists only the files above. + +### Cut 2. Aetheria: switch every data asset reference to GUIDs + +One commit, or a series whose intermediate commits Soul accepts as unplayable. See section 3 +for why it is atomic. + +- **Depends on:** Cuts 0 and 1, and Q1. +- **First:** the migration report (section 3) against the pre-cut catalog; `AetherDb census` and + `dangling` captures; `git grep -n "Assets/Resources/" -- Assets/Scripts` capture. +- **Deletes first:** + - `Assets/Scripts/UnityHelpers.cs` (+`.meta`), 11 lines. + - `Gameplay/ActionBarSlot.cs:114`, `:153`: the inline `Substring("Assets/Resources/".Length).Split('.').First()` + loads (and `using System.Linq` if nothing else uses it). + - All 8 `CultInspectorAssetPath` usages (`ItemData.cs:352,362,392,499`, `Weapon.cs:43`, + `Thruster.cs:26`, `AetherDrive.cs:50`, `Corporations.cs:25`) become `CultInspectorAssetGuid`. +- **Moves (git mv, file and `.meta` together, guids unchanged):** `Assets/Resources/Prefabs/` to + `Assets/Content/Prefabs/`, `Assets/Resources/Schematics/` to `Assets/Content/Schematics/`, + `Assets/Resources/Sprites/Icons/` to `Assets/Content/Sprites/Icons/`, plus their folder `.meta` files. +- **Adds:** `Assets/Scripts/EngineAssets.cs` (2.4); `Assets/Scripts/Editor/EngineAssetCheck.cs` (2.5); + the migrated `GameData/Aetheria.cc`; the CultLib bump (manifest URLs `#cultlib-unity-v1.0.60` + and `#caching-unity-v1.4.0`, lock, `Directory.Build.props:5` `CultLibRevision`). +- **Per-file changes (against `f8396313`):** + - `ActionBarSlot.cs:114`, `:153`: `EngineAssets.Load(...)`. + - `EntityInstance.cs:195`, `:224`: `EngineAssets.Load` / + `(data.EffectPrefab)`; the error text at `:200`, `:229` says GUID, not path. + - `ShipInstance.cs:53`, `:67`: `EngineAssets.Load`. + - `ZoneRenderer.cs:295`, `:304`: `EngineAssets.Load`. + - `Editor/CultCacheDrawers.cs:104`: `LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(item.Schematic))`. +- **Authority map:** + - Owner: the catalog field holds the GUID. `EngineAssets` owns turning a GUID into a loaded + asset and holding its handle. The `Assets/Content` folder entry owns addressability. + - Inputs: GUID strings from catalog records; the Addressables catalog (editor locator or player catalog). + - Outputs: loaded `Object`s or components, or `null` with a logged GUID. + - Derived state: asset paths are display-only (Studio, check reports). Group entries are + derived from folder location. + - Forbidden writers: `Resources.Load` for data-named assets; any path-prefix stripping; any + runtime or editor code that writes a path into an asset member; per-asset entries created by + scripts outside `Assets/Content`. + - Shared paths: game runtime, editor play mode and player builds all load through + `EngineAssets` and the same GUID keys. Studio authoring and the migration both store GUIDs. + The check reads through the same `AetheriaStores.Open`. + - Deletion line: `UnityHelpers.cs`, the two inline strips, and the path attribute are gone + before `EngineAssets` exists. +- **Verification:** + - builds: `Aetheria.Shared` headless with `-p:CultLibRoot=`; + `tests/Aetheria.Shared.Tests`; Unity batchmode compile. + - tests: `EngineAssetCheck.Run` in batchmode exits 0 on the migrated catalog. Mutation: on a + scratch catalog copy, set one `HullData.Prefab` to a Texture2D GUID, one to a GUID outside + `Assets/Content`, one to a random 32-hex, and one to an old path. Each must fail with its + record named. Also add a scratch attributed member missing from the table; it must fail. + - data: section 3 before/after checks. + - negative (checked against `f8396313` for collisions; each has hits only at the sites above today): + - `git grep -n "Resources.Load" -- Assets/Scripts` gives exactly `ActionGameManager.cs:420`. + - `git grep -n "Assets/Resources/" -- '*.cs'` gives only the generated comments at + `AetheriaInput.cs:5,19`, `GradientMapper.cs:94,96`, and `tools/AetherDb/AuthoredSettings.cs:13` + until settings Cut 1 deletes that file. + - `git grep -n "UnityHelpers\|CultInspectorAssetPath"` gives nothing outside `docs/`. + - `git ls-files Assets/Resources/Prefabs Assets/Resources/Schematics Assets/Resources/Sprites/Icons` is empty. + - operator (Unity): + 1. Addressables Play Mode Script "Use Asset Database": ARPG play smoke. Ships, turret and + Zenith station spawn; thruster and aether drive particles show; fire an instant weapon + (e.g. Autocannon) and a charged one (ChargeBlast SG); the action bar shows icons where + the settings fallback applies. + 2. Play Mode Script "Use Existing Build" after *Build > New Build > Default Build Script*: + the same smoke. This proves the player catalog carries GUID keys. + 3. Addressables *Analyze*: run "Check Resources to Addressable Duplicate Dependencies" and + record the result. The 5 known leftovers (1.4) are expected. + 4. Studio: `LonginusX` schematic still underlays the shape drawer. + +## 5. Subtraction ledger (estimate) + +| Cut | Removed | Added | Dependencies, formats, targets | +|---|---|---|---| +| 0 | path attribute ~12, metadata 2, drawer branch 6 | GUID attribute ~12, metadata 2, drawer branch ~7, test ~15 | CultLib release; no targets | +| 1 | none | Addressables settings assets (Unity YAML, ~12 files) | `com.unity.addressables` becomes direct (already installed); Addressables build step joins player builds | +| 2 | `UnityHelpers.cs` 11, 2 inline strips, 283 assets (584 files with `.meta`) leave `Assets/Resources` (moved, not deleted) | `EngineAssets.cs` ~30, `EngineAssetCheck.cs` ~80, 30 catalog values rewritten | removes the Resources path convention for data; no targets | + +Net code is positive (~+110). It buys the ruled capability (GUID identity that survives moves, +and one loader) and a check that did not exist: today a mistyped path fails only at spawn. + +## 6. Operator forks + +**Q1. How assets become addressable.** Blocks Cut 1. +- (A) One folder entry `Assets/Content`. Addressable means under that folder. Cut 2 moves + `Prefabs/`, `Schematics/` and `Sprites/Icons/` out of Resources into it, and settings Cut 1 + moves the 7 body presets there. New content is addressable by placement, and the group file + does not change per asset. Unreferenced assets in those folders ship too, as they do from + Resources today. +- (B) One entry per referenced asset, assets kept in place, except that the 27 in Resources must + still move (2.2). Only referenced assets ship. Each new pick needs a manual *Addressable* tick + (the check catches a miss), and the group asset changes with every new reference. +- (C) Per-asset entries generated from the catalog by an editor step before play and build. The + group becomes derived state, but a sync step runs at play and build boundaries, and a stale + group between steps fails loads in play mode. +- **Recommend (A).** It makes the invariant structural (location) with no per-asset bookkeeping + and no sync step, and it matches how Resources behaves today. + +## Follow-ups outside this migration + +- `ActionGameManager.cs:420` loads input prompt sprites from `Resources/Sprites/Input` by control + name (1.3). +- 5 Resources textures referenced by moved prefabs stay in Resources and ship twice (1.4). +- `com.unity.localization` 1.5.8 is a direct dependency with no script use and no settings; it + was the only reason Addressables was installed. +- `Faction.Logo` (5 values) has no reader; `EquippableItemData.ActionBarIcon` and + `ConsumableItemData.Icon` hold no values (1.1). +- Doc sweep for Self: `docs/settings-globals-cut.md` still carries path-based text in 2.1 + (`:238`, "Engine asset paths (forks I and B...)"), 3.2 (`:328`), Cut 1, and forks I and B + (`:522-541`). Those become history once this map is ruled. Settings Cut 1's icon and preset + fields use `[CultInspectorAssetGuid]` and `EngineAssets`. diff --git a/docs/build-delivery-cut.md b/docs/build-delivery-cut.md new file mode 100644 index 000000000..160ef2e68 --- /dev/null +++ b/docs/build-delivery-cut.md @@ -0,0 +1,550 @@ +# Build Delivery: Cut Map + +Date: 2026-09-17 + +Status: Imagination pass, cut map. Nothing here has landed. Anchors are against +`codex/item-provenance` HEAD `6dc18607`. Ends are owned by +`docs/build-delivery-target.md`; this document owns the means. + +Claims marked **(probe)** were run, not read off names. What was run: + +- A real batchmode player build of the main tree, Unity closed, output to a + scratch directory outside the repo: + `Unity.exe -batchmode -quit -nographics -silent-crashes -projectPath F:\Projects\Aetheria -buildWindows64Player \playerbuild\Aetheria.exe -logFile \unity-player-build.log`. + No Editor script was added; `-buildWindows64Player` needs none. Exit code 0. + It dirtied one tracked file, `ProjectSettings/UnityConnectSettings.asset`, + which was restored to `6dc18607`; the other 16 modified files in the worktree + predate this pass. Build output and the zip were deleted afterwards. +- A headless run of the produced player: + `Aetheria.exe -batchmode -nographics -logFile \player-run.log`, killed + at 25s. +- Reads of `gamecult-ops` inventory, runbooks, `nginx/`, and its deploy scripts; + Idunn `src/` and `docs/`; `gamecult-site`, `AetheriaLore` and `GameCult-Quartz` + configs and workflows; the `CultLib-semver` worktree. + +No remote host was contacted. No `ssh` was run. + +Rulings (operator, 2026-09-17): **F2, the Aetheria site** (`aetheria.gamecult.org`, +repo `AetheriaLore`) owns the download and changelog pages ("That's the Aetheria site, of +course"); `gamecult.org` links to them. **F1 no** (Idunn stays service-only; the artifact +uses the host's existing static path), **F3** semver for builds, commit recorded beside it, +**F4** hand-written changelog, **F5** agent builds, hashes and records while the operator runs +the remote publish, **F6** zip, **F7** a new `aetheria.release` document: Self's defaults, +stated to the operator, who did not object. Reopen any on request. + +## 0. What the probes changed about the target + +Four findings move the design. Read these before the cuts. + +### 0.1 A player build already works **(probe)** + +The target says Aetheria "has no player build pipeline" +(`docs/build-delivery-target.md:11`). Half true: there is no build *code* — zero +`BuildPipeline`/`BuildPlayer`/`BuildTarget` references in any tracked `.cs`, and +`Build/` holds only Eve asset bundles and logs, never a player. But the project +itself builds clean today with the stock command-line switch. + +- Scripts compiled for StandaloneWindows64 with warnings only (`CS0067` on + `Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs:44-46`). +- ~9 minutes wall on Starfire with a warm `Library/`. +- Output 212 files, **352.4 MB** on disk; **189.8 MB** as a zip + (`Compress-Archive -CompressionLevel Optimal`, 35s). +- Scenes come from `ProjectSettings/EditorBuildSettings.asset:8-13`: + `Assets/Scenes/Main Menu.unity`, `Assets/Scenes/ARPG.unity`, both enabled. +- Scripting backend is Mono, not IL2CPP: `ProjectSettings/ProjectSettings.asset:660-661` + sets `scriptingBackend` for Android only, so Standalone takes the default. +- `Aetheria_BurstDebugInformation_DoNotShip/` is emitted beside the exe and must + not ship. + +So Cut 1 is not "make a build possible". It is "make a build repeatable, +identified, and complete", which is a much smaller cut. + +### 0.2 The catalog gap is silent, not loud **(probe)** + +`ActionGameManager.cs:38-41` resolves `GameDataDirectory` as +`new DirectoryInfo(Application.dataPath).Parent.CreateSubdirectory("GameData")`, +and `:45` builds `CatalogPath` from it. In a player that is +`\GameData\Aetheria.cc`. Nothing in the repo copies the catalog there. + +The dangerous part is what happens next. `AetheriaStores.cs:13-15` promises the +open "Throws when a `[CultGlobal]` type routed to the catalog has no record", and +`:26-31` implements exactly that. But none of the seven catalog types at +`AetheriaStores.cs:9` is `[CultGlobal]`, so the guard selects nothing and the +open succeeds over a catalog file that does not exist. + +Proven, not inferred: the built player launched, reached Main Menu, ran for 25s, +and wrote `\GameData\player.cc` (1,964 bytes) — while `Aetheria.cc` was +absent the whole time. No exception, no log line about the catalog. The only +errors were `Stardust` compute-shader nulls, which are `-nographics` artefacts. + +A player shipped without its catalog therefore does not refuse. It boots to a +menu and fails later, in the ARPG scene, with an empty world. That is worse than +a crash, and Cut 1 has to close it. + +### 0.3 Idunn structurally cannot carry a file artifact + +The target invariant reads "Idunn owns deployment to Yggdrasil" +(`docs/build-delivery-target.md:44`). Idunn cannot own this one. + +- `Idunn/src/deployment.rs:33` — `TargetDeclaration.service` is a plain + `ServiceDeclaration`, not an `Option`. Every deployment unit is a service. +- `Idunn/src/deployment.rs:110-141` — a `ServiceDeclaration` requires an + `executable_artifact`, a transport, and a mandatory `health` declaration; + `:851` refuses a plan whose service executable is not a declared artifact. +- Admission does not complete until the workload publishes its own signed + presence and health (`Idunn/docs/deployment-authority.md:84-92`). A zip cannot + sign presence. +- The only workload drivers are `SystemdTransient` and `HostActuator` + (`Idunn/src/deployment.rs:442-450`) — both process supervisors. There is no + oneshot path. +- The only route drivers are `NginxStreamTcp`/`NginxStreamUdp` + (`Idunn/src/deployment.rs:698-701`), writing stream fragments to + `/etc/nginx/idunn-stream-routes/`. Idunn has no concept of an HTTP `root`, + `try_files`, or a web root anywhere in `src/`. +- Idunn has no push transport at all. It pulls: `GitSourceDriver` + (`Idunn/src/drivers.rs:682,714`) fetches the admitted ref on the host. Even the + remote Windows actuator fetches its own source + (`Idunn/docs/host-actuator.md:27-47`). Bytes never travel *from* Idunn. + +`ArtifactOutput` (`Idunn/src/deployment.rs:91-101`) can carry an arbitrary blob +with an `expected_sha256`, and `required_adjacent_artifacts` (`:115`) ships +non-executable files — but only alongside a supervised executable. There is no +unit shaped like "a file at a URL". + +Meanwhile Yggdrasil already serves static files, twice, through a different and +proven authority (see 0.4). The target invariant should be narrowed to "Idunn +owns *service* deployment to Yggdrasil"; static bytes already have an owner, and +it is not a second deployment authority invented for this migration. See **F1**. + +### 0.4 The site is GitHub Pages; only the bytes can live on Yggdrasil + +`gamecult.org` is GitHub Pages out of `GameCult/gamecult-site` +(`gamecult-ops/inventory.md:728-738`, `runbooks/gamecult-pages-cutover.md:1-19`), +built by the shared engine `GameCult/GameCult-Quartz` through a reusable workflow +(`gamecult-site/.github/workflows/deploy-quartz.yml`, +`GameCult-Quartz/.github/workflows/quartz-pages.yml:24-72`, ending in +`actions/deploy-pages@v4`). `aetheria.gamecult.org` is the same mechanism out of +`GameCult/AetheriaLore` (`AetheriaLore/site/quartz.config.ts:66`, +`gamecult-ops/inventory.md:801-806`). + +A 190 MB zip cannot go in either repo: GitHub blocks files over 100 MB, and +Pages has its own soft limits. So the split is forced and is exactly what the +operator asked for — the *page* is on the site, the *bytes* are on Yggdrasil. + +What Yggdrasil already has: + +- nginx with real static vhosts: `gamecult-ops/nginx/erycina.org.conf:4` + (`root /srv/erycina-site/site/current`), `nginx/velvet.erycina.org.conf:4`. +- An immutable-release store pattern: + `scripts/deploy-erycina-site-yggdrasil.sh:33-50` unpacks into + `/srv//site/releases/` and moves `current` atomically. +- A generic TLS publisher: `scripts/publish-static-site-tls-yggdrasil.sh:1-60`, + parameterised by `SITE_HOST`/`SITE_ROOT`, refusing to run unless DNS already + resolves to this host (`:40-56`). certbot/Let's Encrypt throughout. +- Transport: `scp ... ygg:` then `ssh ygg 'sudo -n bash /tmp/...'` + (`runbooks/erycina-org-site-deploy.md:78-81`). + +One thing does **not** transfer. The site-release pattern rotates: a `current` +symlink plus `KEEP_RELEASES=5` pruning +(`scripts/deploy-erycina-site-yggdrasil.sh:50-56`). The target requires the +opposite — "A published version stays published" +(`docs/build-delivery-target.md:32`). The artifact store is append-only, with no +`current` symlink and no pruning. Cut 4 says so explicitly, because copying the +erycina script wholesale would silently delete published downloads. + +## 1. Authority map (whole migration) + +- **Owner of the release record:** a new CultCache store + `Releases/aetheria.releases.cc` in this repo, one `aetheria.release` document + per published build. The Aetheria repo owns the facts about Aetheria builds. +- **Inputs:** the built artifact's bytes (size, sha256), the commit it was built + from, the platform, the build host, the version, and a hand-authored changelog + entry. +- **Outputs:** the artifact zip; the release document; a derived feed and derived + changelog pages in the site repo. +- **Derived state, never authored:** the browser-facing JSON feed, the download + page, and every changelog page. All are projections of the release store, + regenerated wholesale. Editing them by hand is the failure the target names at + `docs/build-delivery-target.md:30-31`. +- **Forbidden writers:** + - The *game* must never read or write the release store. It is not added to + `AetheriaStores.cs:9-11`, and `ActionGameManager.cs:56` keeps opening exactly + three stores. A release record is publishing state, not game state. + - Idunn does not write the artifact, the feed, or the pages (0.3). + - The site repos never author release facts; their content files for downloads + and changelog are generated and overwritten. + - `Build/` stays gitignored (`.gitignore:8`) and never becomes an artifact + store. +- **Shared paths:** there is exactly one publish primitive. A manual publish, a + re-publish, and any future automated publish all go through the same + `release publish` verb; none of them writes the feed or the pages directly. +- **Deletion line:** the vacuous catalog guard at `AetheriaStores.cs:26-31` is + replaced before Cut 1 adds any build code, and + `docs/build-delivery-target.md:11-14` is corrected once Cut 1 lands. + +**Atomicity, honestly.** The target asks that publishing "publishes the artifact, +the feed entry and the changelog entry together, or it publishes nothing" +(`:28-29`). Three destinations — a remote filesystem, a git-tracked store, and a +second git repo — admit no real two-phase commit. The invariant that actually +protects a visitor is narrower and is the one to enforce: **no feed entry exists +without verified bytes already fetchable at its URL.** Order is artifact → verify +by re-downloading and re-hashing → release document → projection → site push. +A failure before the release document leaves unreferenced bytes, which are inert +and which the next publish of the same version may overwrite only when the hash +matches. A failure after it leaves a pending page, which the next site build +fixes. Neither produces a download link that 404s. + +## 2. Cuts + +### Cut 1. A player build that is repeatable, identified, and carries its catalog + +- **Repo/branch:** Aetheria, `codex/build-delivery` from `6dc18607`. Depends on + nothing. +- **First:** keep the probe numbers in 0.1 as the baseline — 212 files, + 352.4 MB, 189.8 MB zipped, ~9 min warm. A first landed build that differs + wildly means the build options diverged from the stock switch. +- **Deletes first:** + - `Assets/Scripts/ServerShared/AetheriaStores.cs:13-15` (the comment promising + a guard that does not fire) and the guard body at `:26-31`, replaced rather + than supplemented. +- **Adds:** + - `Assets/Editor/AetheriaBuild.cs` — one static entry point for + `-executeMethod`, taking version and output root from the command line. It + goes under the existing `Assets/Editor/` (whose only current file is + `Epiphany/EpiphanyEditorBridge.cs`) and compiles into the existing + `Assembly-CSharp-Editor`; no new asmdef, no new assembly. + - A catalog staging step in that entry point. +- **Per-file changes** (against `6dc18607`): + - `Assets/Scripts/ServerShared/AetheriaStores.cs:16-32`: `Open` refuses a + `catalogPath` that does not exist, unless `catalogWritable` (the seed case + already exempted at `:24-25`). Keep the existing global check as a second, + narrower guard; it is not wrong, it is merely inert for today's type set, and + it will bite the moment a catalog type is marked `[CultGlobal]`. The new + check is the one that protects a shipped player. + - `Assets/Editor/AetheriaBuild.cs` (new): `BuildPlayerOptions` with + `scenes` read from `EditorBuildSettings.scenes` filtered on `enabled` — the + same two scenes at `ProjectSettings/EditorBuildSettings.asset:8-13`; + `target = BuildTarget.StandaloneWindows64`. Sets + `PlayerSettings.bundleVersion` from the passed version, superseding the + stale `0.1` at `ProjectSettings/ProjectSettings.asset:152`. After the build: + copy `GameData/Aetheria.cc`, `GameData/Narrative/**` and + `GameData/SoundbanksInfo.json` next to the exe; delete + `Aetheria_BurstDebugInformation_DoNotShip/`; fail the run on a non-empty + `BuildReport` error list. +- **Authority map:** + - Owner: `AetheriaBuild` owns what a player build contains. + - Inputs: `EditorBuildSettings.scenes`, `GameData/`, a version string, an + output root. + - Outputs: a complete player directory, and the build report. + - Derived: `PlayerSettings.bundleVersion` is now set by the build, not authored + in `ProjectSettings.asset`. + - Forbidden writers: nothing else sets `bundleVersion`; nothing else decides + the scene list. + - Deletion line: the inert-guard comment goes before the entry point is added. +- **Verification:** + - builds: the entry point produces a player; size within ~10% of 352.4 MB. + - negative: `git grep -n "Aetheria_BurstDebugInformation"` finds only the + deletion in `AetheriaBuild.cs`. + - test: a `Aetheria.Shared.Tests` case pins that `AetheriaStores.Open` throws + on a nonexistent read-only catalog path, and still opens a writable empty one + (the seed case at `AetheriaStores.cs:24-25`). This is the regression that 0.2 + proves is live today. + - operator: launch the staged build by double-click, reach Main Menu, start a + game, confirm the world is populated. Then rename `GameData/` beside the exe + and relaunch: it must refuse loudly, not boot to a menu. + +### Cut 2. The release record + +- **Repo/branch:** Aetheria, same branch. Depends on Cut 1 for the fields it + records. +- **Adds:** + - An `aetheria.release` `[CultDocument]` type, declared in the tools assembly, + not in `ServerShared` — the game must not link it. + - `Releases/aetheria.releases.cc`, tracked in git. Small: a few KB per release. +- **Shape.** Model the fields on the one existing GameCult type that already + describes a versioned build artifact, + `CultLib/src/GameCult.Mesh/CultMeshCdn.cs:130-171` + (`gamecult.mesh.cdn_artifact_manifest`): `Version` (`:145`), `ContentHash` + sha256 (`:151`), `SizeBytes` (`:156`), `MimeType` (`:160`), `CreatedAtUtc` + (`:164`). Note `CultMeshCdnArtifactKinds.Build = "build"` at `CultMeshCdn.cs:34` + is documented as "Executable build or install/update payload" — this exact case. + Add what that type lacks and the target requires: `Commit`, `Platform`, + `BuildHost`, `Url`, and the changelog body. See **F7** on reuse vs a new type. +- **Authority map:** + - Owner: the release store is the authority on what exists + (`docs/build-delivery-target.md:30`). + - Inputs: Cut 3's publish verb only. + - Derived: everything a browser sees. + - Forbidden writers: the game (`AetheriaStores.cs:9-11` unchanged, + `ActionGameManager.cs:56` unchanged); the site repos; any hand edit. + - Deletion line: none — nothing exists to replace. +- **Verification:** + - negative: `git grep -n "aetheria.release"` must not match anything under + `Assets/Scripts/ServerShared/`. Checked against the `aetheria.*` schema names + already in that tree so it cannot collide. + - test: reopening the store yields the same documents; a published version is + refused on re-publish with different bytes. + +### Cut 3. One publish primitive + +- **Repo/branch:** Aetheria, same branch. Depends on Cuts 1 and 2. +- **Keeps and reuses:** `tools/AetherDb` — the existing headless .NET console over + Aetheria's CultCache stores, with store-path resolution already written at + `tools/AetherDb/AetherDb.cs:15-32`. A `release` verb group there adds **zero** + build targets and reuses the csproj, the `Aetheria.Shared` reference, and the + path walk. A second console would add a target for nothing. See **F5** on who + runs it. +- **Adds:** `release build`, `release publish`, `release project` verbs, and + `CHANGELOG.md` at the repo root in the Keep-a-Changelog shape CultLib just + adopted (`CultLib-semver/unity/org.gamecult.cultlib/CHANGELOG.md:1`). +- **Per-file changes:** + - `tools/AetherDb/Program.cs`: new verb dispatch. `release build` shells the + Unity entry point from Cut 1 and zips the result, excluding nothing further + (Cut 1 already removed the Burst directory). `release publish` computes + sha256 and size, reads the `CHANGELOG.md` section for the version, writes the + `aetheria.release` document, and emits the projection. `release project` + regenerates the projection alone from the store, so the pages can be rebuilt + without a build. + - Follow the deterministic-packaging precedent in + `CultLib-semver/scripts/build-unity-package.ps1:41-52`, which clears + intermediates and disables source-revision stamping so a rebuild is + byte-comparable. The target asks for reproducibility from a commit + (`docs/build-delivery-target.md:36-37`); say plainly in the doc what Unity + does *not* guarantee there rather than claim byte-identity that Cut 1 has not + demonstrated. +- **Authority map:** + - Owner: `release publish` is the only writer of the release store. + - Derived: the feed JSON and the markdown pages, regenerated wholesale, never + merged into. + - Shared paths: a first publish and a re-publish use the same verb. + - Forbidden writers: no script writes the feed or pages directly. +- **Verification:** + - test: `release project` run twice produces byte-identical output. + - negative: nothing outside `tools/AetherDb` opens `aetheria.releases.cc`. + - operator: none yet; Cut 5 carries the smoke. + +### Cut 4. The artifact host on Yggdrasil + +- **Repo/branch:** `gamecult-ops`, its own branch. Depends on Cut 3 producing a + zip. Independent of Cuts 1-3 landing. +- **Adds:** + - `nginx/.conf` modelled on `nginx/erycina.org.conf`, keeping the ACME + location (`:11-14`) and the security headers (`:7-9`), but with + `root /srv/aetheria-builds`, `autoindex off`, and **no** `try_files` + fallback and **no** `error_page` rewrite — a missing version must 404. + - `scripts/deploy-aetheria-build-yggdrasil.sh`. Modelled on + `scripts/deploy-erycina-site-yggdrasil.sh` but with the rotation removed: + the release path is `/srv/aetheria-builds//`, it **refuses** to + write into a version directory that already exists, there is **no** `current` + symlink, and there is **no** pruning. Copying `:50-56` unchanged would delete + published downloads and break + `docs/build-delivery-target.md:32`. + - A runbook alongside `runbooks/erycina-org-site-deploy.md`. +- **Reuses unchanged:** `scripts/publish-static-site-tls-yggdrasil.sh` for the + vhost install and certbot run — it is already parameterised by + `SITE_HOST`/`SITE_ROOT` (`:1-60`) and already refuses to run before DNS + resolves to this host (`:40-56`). +- **Hazard to carry into the runbook:** five existing `*.gamecult.org` records in + `gamecult-ops/inventory.md` still say `A -> 94.130.223.131` + (`:746, :764, :771, :777, :785`) while Yggdrasil is at `159.195.114.249` + (`:363, :387`). The new record must use the current address, and the stale rows + are a follow-up outside this migration. +- **Authority map:** + - Owner: the release directory tree on Yggdrasil is append-only and root-owned. + - Inputs: a zip and its sha256, scp'd by the operator. + - Forbidden writers: nothing overwrites a published version directory; Idunn + does not touch this tree (0.3); no pruning job exists. + - Deletion line: the rotation and pruning logic is removed when the script is + adapted, not left in and disabled. +- **Verification:** + - operator: `curl -I` the artifact URL returns 200 with the right + `Content-Length`; TLS valid; the parent directory does not list; + a nonexistent version returns 404. + - negative: re-running the deploy script for an existing version exits nonzero + and changes nothing. + - budget: ~190 MB per release on a ~2 TB root + (`gamecult-ops/inventory.md:355-392`). Ten releases is ~1.9 GB. Retention + owner is the operator; append-only is the default and nothing prunes. + +### Cut 5. The download page and the changelog pages + +- **Repo/branch:** the chosen site repo (see **F2**), its own branch. Depends on + Cut 3's projection and Cut 4's URL. +- **Adds:** generated content files under the site's content dir — one downloads + page, one page per release — plus the feed JSON under `static/`. +- **Mechanism.** Do not write a Quartz emitter plugin. The established precedent + in these repos is pre-build generation into committed files: + `gamecult-site/scripts/generate-vn-repo-doc-tree.mjs` and + `generate-vn-eve-surface.mjs` write JSON into `GameCult/static/...`, invoked by + the local launcher (`scripts/quartz/quartz.ps1:38-52`) and **not** by CI, so the + outputs are committed artifacts. Cut 3's `release project` fills that same role. + The site's existing workflow then publishes with no change at all. +- **Brand.** Follow `gamecult-site/docs/brand-design-language.md` and the config + it describes, not invention: Montserrat 100-300 for titles, Ubuntu 300 body, + IBM Plex Mono uppercase and letter-spaced for the structural labels a download + page is mostly made of (version, date, platform, size, hash). Ground `#07111a`, + panels `#16212c`, body `#b7c7d9`, headings `#eef5ff`, accent `#ff8a2a`, links + `#59b7ff`. Reuse the existing component classes rather than adding a page-type + variant: `.gamecult-repo-facts` / `-fact-label` / `-fact-value` + (`gamecult-site/site/quartz/styles/custom.scss:953-1008`) is already a + label/value fact table, and `.gamecult-action` + (`custom.scss:2246-2360+`) is already the button. Note the brand defines no + success/warning/error colour (`brand-design-language.md:135-138`); a download + page needs none. +- **Authority map:** + - Owner: Cut 3's projector owns every byte of these files. + - Derived: all of them. A hand edit is overwritten on the next projection and + that is the intended behaviour. + - Forbidden writers: no human edits the generated pages; changelog prose is + authored in the Aetheria repo's `CHANGELOG.md` and flows through Cut 3. + - Shared paths: the site's existing Pages workflow is untouched. +- **Verification:** + - builds: the site's Quartz build succeeds with the generated pages present. + - negative: re-running `release project` leaves the working tree clean. + - **operator, the real check:** from a machine that has never held the repo — + open the download page on the public site, click the link, let it download, + verify the sha256 shown on the page against the file, unzip it, run + `Aetheria.exe`, start a game, confirm the world is populated. Then confirm + the changelog page for that version says what actually changed. This is the + only check that proves the whole path; every other verification above is a + component test. + +## 3. Operator forks + +**F1. Does Idunn carry the artifact?** *Most blocking — it decides Cut 4.* +- **A:** Extend Idunn with a static-artifact deployment unit (optional `service`, + a file workload driver, an HTTP root route driver). +- **B:** Use the existing `gamecult-ops` static release path — scp, a root-owned + append-only release script, an nginx vhost, certbot — the same authority shape + that already serves `erycina.org` and `velvet.erycina.org`. +- **Recommended: B.** A is a large change in Rust to the one daemon whose whole + design premise is that it depends on no managed target and supervises signed, + health-reporting processes; it would mean making `service` optional + (`Idunn/src/deployment.rs:33`) and inventing an HTTP-root route driver, to make + Idunn do the thing it is deliberately not shaped for. B is not a second + deployment authority — it is the *existing* file-serving authority on that + host. This does mean narrowing the target invariant at + `docs/build-delivery-target.md:44` to name service deployment; that edit + belongs to whoever lands Cut 4. + +**F2. Which site owns the download page?** +- **A:** `gamecult.org` (`gamecult-site`) — what the target's invariant names + (`docs/build-delivery-target.md:46`). +- **B:** `aetheria.gamecult.org` (`AetheriaLore`) — the game's own live site, + same engine, same brand family. +- **Recommended: B, with A linking to it.** A download for Aetheria belongs on + Aetheria's site, and per-release changelog pages will accumulate; parking + dozens of them in the studio site's tree is noise there and natural there. The + honest counter: `AetheriaLore` is a *lore vault* (`Aetheria/Lore`, `Fiction`, + `Worldbuilding`) and release notes are not lore, so B needs a new top-level + section and a masthead entry + (`AetheriaLore/site/quartz/components/AetheriaMasthead.tsx:19-39` is a + hardcoded route list). This one is close; the target's wording points at A and + only the operator can say whether that wording was deliberate. + +**F3. Versioning scheme for game builds.** +- **A:** semver, next is `0.2.0`, commit recorded as a separate field. +- **B:** CalVer, `2026.09.17`. +- **C:** commit-derived, `0.1.0+g6dc1860`. +- **Recommended: A.** It reuses the policy work already in flight: + `CultLib-semver/scripts/check-changelog-semver.mjs` demands exactly one + major/minor/patch step per release (`:39,:62`), refuses a version with no + changelog entry (`:79`), and treats `0.y.z` minor as the breaking lane + (`:98,:139`) — which is exactly right for a game whose saves will break. + Caveat the operator should know: that script is **untracked** on its branch, + and the `docs/semver-policy.md` it cites at `:4` does not exist in any CultLib + worktree. It is package-only and says nothing about builds, so adopting it here + is a decision, not an inheritance. B tells a player nothing about whether their + save survives. C puts a commit in the version string where the target already + wants the commit as its own field (`docs/build-delivery-target.md:26-27`). + +**F4. Hand-written or commit-derived changelog?** +- **A:** Hand-written per release in `CHANGELOG.md`, ingested at publish. +- **B:** Derived from commit subjects between tags. +- **Recommended: A.** B produces a build log, not release notes — and GameCult + already has that experiment running by hand: + `gamecult-site/GameCult/Projects/VoidBot.md:53-57` and `Ghostlight.md:56-57` + are dated commit-subject lists, and they read like machine output. A also + matches what CultLib just adopted for its own packages. The cost is that + publishing blocks on someone writing prose, which is the correct thing for it + to block on. + +**F5. Agent-run or operator-run publishing?** +- **A:** Fully agent-run, including the Yggdrasil push. +- **B:** Agent builds, hashes, writes the record and the projection, and opens + the PRs; the operator runs the two remote scripts. +- **Recommended: B.** `gamecult-ops` already draws this exact line, in a comment + worth quoting: "putting a name on the public internet is a separate decision + from having bytes on disk" + (`scripts/deploy-erycina-site-yggdrasil.sh:9-11`). A published version is + permanent by the target's own rule, so the irreversible step is the one a human + should take. Everything expensive and mechanical stays automated. + +**F6. Zip or installer?** +- **A:** Zip. **B:** An installer (NSIS/Inno/MSI). +- **Recommended: A.** Measured at 189.8 MB **(probe)**. No installer toolchain + exists anywhere in GameCult, and B drags in code signing, an uninstaller, and a + new build dependency for no player benefit while launchers and auto-update are + already out of scope (`docs/build-delivery-target.md:55`). Revisit when there + is a reason, not before. + +**F7. Reuse `gamecult.mesh.cdn_artifact_manifest` or declare `aetheria.release`?** +- **A:** Reuse the CultLib type (`CultLib/src/GameCult.Mesh/CultMeshCdn.cs:130`), + which already has version, sha256, size, mime, created-at, and a `Build` kind. +- **B:** A new `aetheria.release` type that borrows those field names. +- **Recommended: B, citing A.** The CultLib type is CDN-shaped: its `Chunks` list + (`:168`) is the point of it, and content-addressed chunking + (`CultMeshCdn.cs:96-120`) is machinery this migration does not use and should + not fake. A release also needs `Commit`, `BuildHost`, and changelog prose, + which do not belong bolted onto a CDN manifest consumed elsewhere. Borrow the + field shape, not the type. If a CultMesh CDN path ever carries these builds, A + becomes the transport and B stays the record. + +## 4. Subtraction and build budget + +This migration is net additive and cannot honestly be made otherwise: it buys a +capability that does not exist today — nobody outside Starfire can play the game. +What it must not do is buy it twice. + +| Cut | Removes | Adds | Targets | +|---|---|---|---| +| 1 | inert guard + stale comment, `AetheriaStores.cs:13-15,26-31`; the shipped Burst debug dir | one Editor file under the existing `Assets/Editor/` | +0 assemblies (no asmdef; joins `Assembly-CSharp-Editor`) | +| 2 | — | one document type, one tracked `.cc` | +0 | +| 3 | — | verbs inside the existing `tools/AetherDb`; one root `CHANGELOG.md` | **+0 executable targets** | +| 4 | the rotation and pruning logic while adapting the erycina script | one nginx conf, one shell script, one runbook | +0 (no compilation in `gamecult-ops`) | +| 5 | — | generated markdown/JSON in a site repo | +0 (no workflow change) | + +Explicit budget statements: + +- **No new executable target anywhere.** Cut 3 reuses `tools/AetherDb` precisely + so the repo does not gain a second console. If that reuse is rejected, the + budget changes and the fork should be raised. +- **No new Unity assembly**, no new package dependency, no Addressables + dependency — `docs/addressables-cut.md` has not landed and this migration does + not wait on it. The build probed in 0.1 is the pre-Addressables `Resources` + path, and it works. +- **Build host is Starfire, Windows, Unity 6000.3.24f1** + (`ProjectSettings/ProjectVersion.txt:1`). The serving host is Debian 13. The + artifact is Windows-only and produced only on Windows; nothing about Cut 4 + proves anything about the player, and nothing about Cut 1 proves anything about + the server. +- **Disk.** ~190 MB per release on Yggdrasil, append-only, no pruning. The + operator owns retention. +- **A fresh clone is not required per release.** The checkout is 1.78 GiB of + `.git` plus a 2.6 GB `Library/`; the ~9 min build in 0.1 was warm, and a cold + `Library/` import is far worse. Publishing builds from the working Starfire + checkout is the assumption; if that ever moves to a clean builder, the import + cost has to be budgeted then, not assumed away now. + +## 5. Open + +- Every fork in section 3 is unruled. +- `docs/build-delivery-target.md:11-14` and `:44` both need correcting once F1 + and Cut 1 are ruled; the target currently says builds happen by hand into + `Build/` (they do not — nothing produces a player) and that Idunn owns this + deployment (it structurally cannot). +- Follow-up outside this migration: the five stale `A -> 94.130.223.131` DNS rows + in `gamecult-ops/inventory.md:746,764,771,777,785`. +- Follow-up outside this migration: `CultLib-semver/scripts/check-changelog-semver.mjs` + is untracked and cites a `docs/semver-policy.md` that does not exist. F3 leans + on that work; someone should land it there. diff --git a/docs/build-delivery-target.md b/docs/build-delivery-target.md new file mode 100644 index 000000000..95c7437f9 --- /dev/null +++ b/docs/build-delivery-target.md @@ -0,0 +1,61 @@ +# Build Delivery Target + +Date: 2026-09-17 + +Status: target. Operator direction only; no substrate map or cut map yet. This +document owns the ends. A cut map, written after an Imagination pass over the +Body, owns the means. + +## Why + +Aetheria has no player build pipeline and no way for anyone to play it. Builds +are Unity batchmode runs by hand, Windows 64-bit only, and the output never +leaves the machine (`Build/`). Nothing in GameCult publishes a downloadable +artifact: Idunn deploys services to Yggdrasil, not files. + +## Operator direction (2026-09-17) + +- **A build is downloadable from the GameCult site, served from Yggdrasil.** + Operator: "deployment to a build downloadable from our site via Yggdrasil". +- **Public.** No login, no gate. Operator: "public, with a version feed and + changelog pages". +- **A version feed and changelog pages come with it**, not as a later nicety. + +## Ends + +- **A build is an artifact with an identity**: a version, the commit it was + built from, the platform, a size and a hash a downloader can check. +- **Publishing is one action**, and it publishes the artifact, the feed entry + and the changelog entry together, or it publishes nothing. +- **The feed is the authority on what exists.** The site's download page and + the changelog pages are projections of it, never separately edited truth. +- **A published version stays published.** Its bytes and its entry do not + change under it; a mistake is a new version, not an edit. +- **The download page tells a visitor what they are getting**: version, date, + platform, size, and what changed since the last one. +- **A build is reproducible from its commit**: the feed entry names it, and a + build from that commit produces the same artifact contents. + +## Invariants that must survive + +- **State is CultCache, not JSON** (`F:\Projects\CLAUDE.md`). The release + record and the changelog are typed documents. Anything a browser eats is a + projection at the xenos boundary, and is derived, never authored. +- **Idunn owns *service* deployment to Yggdrasil**, and only that. Probed + 2026-09-17 (`docs/build-delivery-cut.md`): every Idunn unit is a supervised + process that must publish signed health, and Idunn pulls rather than pushes, so + it cannot carry a file artifact. Publishing uses the host's existing static-file + path and does not invent a second *service* deployment authority. +- **The site's brand is the site's** (`F:\Projects\gamecult-site`), and the + download and changelog pages follow it. +- **The build host is Windows.** Unity 6000.3.24f1 builds on Starfire; the + serving host is Linux. Say which host produced an artifact. +- **Git LFS and repo size** are part of the problem: the checkout alone is + 1.7 GB. A build path that needs a fresh clone per release has to say so. + +## Not in scope + +- Auto-updating clients, launchers, patching, or delta updates. +- Platforms other than Windows 64-bit, until one is asked for. +- Accounts, entitlements, or telemetry. The download is public and anonymous. +- Selling anything. diff --git a/docs/content-batch-one.md b/docs/content-batch-one.md new file mode 100644 index 000000000..1e14a5a69 --- /dev/null +++ b/docs/content-batch-one.md @@ -0,0 +1,859 @@ +# Content Batch One: Authoring Spec + +Date: 2026-09-19 + +Status: spec. This document says exactly what a later pass writes into +`GameData/Aetheria.cc` through the catalog's own types. It writes no catalog data itself, +and it decides nothing the operator has not already ruled on — the forks it found are +listed at the end, unanswered. + +Source of the batch: `F:\Projects\AetheriaLore\Aetheria\Brainstorming\Corporate Roster and +Item Wishlist.md`. Quoted flavour is verbatim from that roster or from the source +spreadsheet it condenses; anything invented here is marked **(new text)**. + +Every number below was read from the live catalog through `Aetheria.Shared`'s own types +(a scratch reflective dump over `AetheriaStores.Open`, catalog read-only, run against +`F:\Projects\Aetheria-stats\GameData\Aetheria.cc` — byte-identical to the main tree's, +SHA1 `9b10521…`). Where a number has no sibling in the catalog to argue from, it says so. + +--- + +## 0. The finding that reorders the batch + +**The batch's stated priority order cannot be authored in that order, because nothing in +the game can carry a shield or a thruster.** + +`Entity.ItemFits` (`Assets/Scripts/ServerShared/Entity.cs:658`) refuses any non-`Tool` item +whose `HardpointType` does not match a hardpoint on the hull. The catalog holds exactly one +ship hull, LonginusX, and its ten hardpoints are: + + ControlModule, AetherDrive, Energy ×2, Launcher ×2, Radiator ×2, Reactor, Sensors + +There is **no `Shield` hardpoint anywhere in the catalog** — not on LonginusX, not on Zenith +(station), not on Turret. There is no `Thruster` hardpoint either; LonginusX moves on its +AetherDrive (Traction), and the two shipped thruster designs (`deep space burnout`, +`Large Drive`) cannot be equipped on any hull in the game. `AetherDb hardpoint-fit` confirms +both, by omission. + +So: + +- A Megiddo shield authored alone is content that cannot be equipped, tested, or seen. +- Victoire and Prokope authored alone are the same. +- **The hulls are not item 3. They are item 0.** The Zhestokost and AU hulls are the carrier + that makes items 1 and 2 reachable, and they must author `Shield` and `Thruster` + hardpoints or the rest of the batch is shelfware. + +Three items in the batch are playable on LonginusX today with no hull work, because their +hardpoints already exist or they are `Tool` items (which `ItemFits` places anywhere in +interior space): **ChirOptos** (`Sensors`, 1×1 hardpoint exists), **Cottage-pi** +(`Tool`), **Enigma Device** (`Tool`). Those are the batch's only zero-dependency wins. + +**Revised order:** hulls → shields + thrusters → sensor, cloak, capacitor (any time) → +consumables (blocked, see §8). + +--- + +## 1. Conventions every item in this batch follows + +Read from the shipped catalog, not invented here. + +**Stat shape.** `PerformanceStat` is `Min`, `Max`, and a list of `StatTerm { Source, +Exponent, Role }` (`ItemData.cs:612-684`). `Evaluate` multiplies each term's factor, lerps +`Min`→`Max` by the product, then applies scale and constant modifiers. A stat with no terms +resolves to `Max`. + +**Inverted stats are authored `Min > Max`.** Where lower is better the catalog descends: +`ReflectorData.CrossSection` 500→150, `SensorData.PingVisibility` 5000→1000, +`PingCooldown` 20→5, `RadiatorData.EnergyUsage` 3→2. Every stat below follows that. + +**Power supply multiplies (operator, 2026-09-19).** `StatSource.PowerSupply` applies to the +resolved value after interpolation, so zero supply is zero output. Two consequences this +spec obeys: + +- A `PowerSupply` term belongs **only on an ascending stat where more output is the point** + (thrust, pumped heat). On a descending stat it inverts: at zero supply the factor is 0, the + lerp lands on `Min` — the *worse* end — and then the whole thing is multiplied by zero, + which for a stat like `CrossSection` or `PingVisibility` means "unpowered = perfectly + stealthed". **No descending stat in this batch carries a `PowerSupply` term.** This is a + general trap, not a batch detail; §9 F3 raises it. +- Brownout on a buffered consumer needs no term at all. `InputCapacitor.AddCharge` is already + scaled by `Item.PowerSupply` (`Shield.cs:127`), so a half grant simply takes twice as long + to fill. The shields below spend their brownout budget there, not in a stat. + +**Power-request stats may not read power supply.** `StatValidation.PowerRequestFields` +(`ItemData.cs:796`) names `ShieldData.Capacity`, `ThrusterData.EnergyUsage`, +`SensorData.PingEnergy`, `EnergyDrawData.EnergyDraw` and four more. The validator is being +widened to every stat a `PowerRequest` actually reads, which for a shield means +`RefillDuration` and `RestoreDuration` too (both feed `RefreshReserve` → +`InputCapacitor.RequestedFill`). **None of those five shield stats carries a `PowerSupply` +term below.** + +**Heat response** is four floats on `EquippableItemData`: `MinimumTemperature`, +`MaximumTemperature`, `OptimalTemperature`, `PlateauWidth`. `StatValidation.ValidateHeatResponse` +refuses a zero span, an optimum outside the bounds, a negative plateau, or a plateau that pokes +past the bounds. Every set below is checked against those four rules inline. Plateau width is the +operational-lifespan lever: inside it `Performance()` is exactly 1 and `Wear`'s thermal term is +zero, so a wide plateau is a forgiving item and a narrow one burns itself out. That is the +single most expressive number a brand has, and this batch uses it hard. + +**Roles.** A design declares `List`; a `Quality` term names one. +`StatValidation.ValidateRoleUsage` refuses a term naming an undeclared role. The vocabulary is +small and **shared within a kind** (`docs/stats-power-cut7-roles.md`), so this batch reuses +`injector`/`nozzle` for thrusters, `emitter`/`receiver` for sensors, `plating` for hulls, +`converter`/`storage cell` for capacitors, and introduces exactly one new vocabulary — +`emitter`/`reservoir`/`regulator` for shields — shared by both shields in the batch. `Tool` +designs author roles per design (same doc), so the cloak gets its own two. + +**Design text is dry; product text carries the voice** (`docs/item-provenance-target.md`). +Design names are functional nouns in the shipped style (`Large Drive`, `Core Power`, +`Surface Ore Extractor`). Product names are the brand. + +**Energy units, for scale.** Reactor `Charge` is per-second generation (`Reactor.cs:60`): +MoveOnPro 25–125/s, Vulcan 50–200/s, Manhattan 500–1000/s, Notorious 400–1200/s. Capacitor +`Capacity` (PotaT+-) is 50–150. A `6k Shooter` shot costs `Energy` 5. Radiator `EnergyUsage` +1–3/s. Shield reserve sizes below are argued against those. + +**Heat units, for scale.** `6k Shooter` `Heat` 250–500 per shot; `deep space burnout` 4000–12000; +`Cockpit 2x2` 200; radiator `PumpedHeat` 750–5000. + +--- + +## 2. Item 0a — Zhestokost heavy hull + +### Design: `Heavy Combat Hull` (`HullData`) + +Compared against **LonginusX**, the only ship hull: Mass 2500, Price 7,500,000, Durability 500, +Shape 6×17 (66 cells), Armor 10, Drag 0.1, Conductivity 32, ThermalResilience 1, heat +173.15/573.15/275.15/48, `ReflectorData.CrossSection` 500→150 `Durability^1, Quality^2[plating]`. + +| Field | Value | Why | +|---|---|---| +| `Description` | "Heavy combat hull. Thick plating, high mass, a large power and thermal budget, and a radar cross-section it makes no attempt to hide." | Dry. | +| `HullType` | `Ship` | | +| `Mass` | 4200 | 1.7× LonginusX. Zhestokost is the mass end of the axis; the only other ship hull defines the light end. | +| `Price` | 9,500,000 | Above LonginusX (7.5M), below Zenith (10M). | +| `Durability` | 900 | 1.8× LonginusX, matching mass. | +| `Shape` | 7×16, ~84 cells | ~27% more interior than LonginusX's 66, which is what pays for the extra hardpoints below. Exact cell mask is a schematic-authoring job, not a number this spec can fix. **Guess** on the mask; the cell *count* is argued. | +| `Armor` | 28 | LonginusX 10, Zenith 100, Turret 10. 28 is the only interpolation the three shipped values support for "heavy warship, not a station". | +| `Drag` | 0.16 | LonginusX 0.1; Zenith and Turret are 0. **Guess** — one sibling. | +| `Conductivity` | 20 | LonginusX 32, Turret 32, Zenith 1. Lower conductivity means heat pools locally: a brutalist hull that cooks its own gear. Deliberate, mild. | +| `ThermalResilience` | 3 | LonginusX 1, Turret 5, Zenith 15. | +| Heat four | min 173.15, max 623.15, **optimum 293.15, plateau 100** | Valid: 293.15 ± 50 = 243.15…343.15, inside bounds. The widest plateau on any equippable (LonginusX 48, Turret/Zenith 78). This *is* the Zhestokost brand as a number: the hull takes no thermal wear across a 100K band. Overkill, and finesse is for fools. | +| `Roles` | `plating` | Shared hull vocabulary. | +| `Prefab` | **blocked** — see §9 F1 | | + +`ReflectorData`: + +| Stat | Min | Max | Terms | +|---|---|---|---| +| `CrossSection` | 900 | 600 | `Durability^1`, `Quality^1[plating]` | + +Descending (lower is stealthier) like LonginusX, but the whole band sits above LonginusX's +*worst* value of 500, and `Quality^1` (vs LonginusX's `^2`) means even a perfectly built one +barely improves. A Noka MKI is visible from the next system and no amount of workmanship fixes +that. Argued directly off LonginusX's 500→150. + +**Hardpoints.** Sizes are chosen so shipped designs actually fit. `ItemFits` lets a smaller item +sit inside a larger hardpoint (it checks that every item cell lands on a hardpoint cell), so a +1×2 item fits a 2×2 hardpoint; the reverse does not. + +| Type | Shape | Count | Armor | Fits today | +|---|---|---|---|---| +| `ControlModule` | 2×2 | 1 | 50 | Cockpit 2x2 | +| `Reactor` | 3×3 | 1 | 0 | Manhattan, Notorious (9 cells), and all 2×2 reactors | +| `AetherDrive` | 2×2 | 1 | 10 | Traction | +| **`Thruster`** | **2×2** | **2** | 15 | deep space burnout, Large Drive, and both new thrusters | +| **`Shield`** | **2×2** | **2** | 20 | both new shields | +| `Radiator` | 2×2 | 2 | 10 | every shipped radiator (2-cell and 4-cell) | +| `Ballistic` | 2×2 | 2 | 25 | every shipped ballistic (2- and 4-cell) | +| `Launcher` | 1×3 | 1 | 30 | GT 3K, pswarm, scorched void policy | +| `Sensors` | 1×1 | 1 | 5 | not if i see you first, ChirOptos | + +Two `Thruster` and two `Shield` hardpoints are the load-bearing part of this whole document. + +### Product: `Noka MKI`, by Zhestokost + +`Description` — verbatim from the source spreadsheet, newlines restored: + +> You asked, we answered. Presenting the Noka MKI, the biggest, baddest, most unstoppable\* +> hull in the galaxy\*\*. Designed for durability, the Noka MKI sports the thickest hull in the +> galaxy and n mounts compatible with the highest quality weapons Zhestokost has to offer. +> +> \*Regulatory policies require us to state for the benefit of our more pedantic customers that +> the Noka MKI does, in fact, have a functioning brake system +> +> \*\*We are not liable for any injury, bodily or emotional, maiming, unexpected explosive +> decompression, sudden death, or sinus infections caused by attempts to test the statement. + +The source says "ColferV" (the sheet's old name for Zhestokost) and leaves the literal `n` in +"n mounts". Substituting the faction's current name is required; the `n` should stay — it reads +as a mail-merge failure, which is funnier and truer to the brand than any number. Operator fork +F6. + +Role quality: + +| Role | Mean | Dev | Why | +|---|---|---|---| +| `plating` | 0.80 | 0.09 | The one thing Zhestokost is world-class at, on the one role a hull has. Above every shipped mean but Alakrita's branded 0.78 (Arctica fins) and AU's Vulcan core 0.75. The deviation is tight-ish because a slab of steel is hard to get wrong. Note the interaction: excellent workmanship on a design whose `CrossSection` band is 900→600 still yields an enormous signature — which is exactly the brand, a perfectly built bad idea. | + +--- + +## 3. Item 0b — Aeronautics Unlimited all-rounder hull + +### Design: `Medium Utility Hull` (`HullData`) + +| Field | Value | Why | +|---|---|---| +| `Description` | "General-purpose hull. Balanced mass and armour, a mixed hardpoint layout, and interior space for tools and cargo." | Dry. | +| `HullType` | `Ship` | | +| `Mass` | 1900 | Below LonginusX (2500) — AU is the middle, and LonginusX is an Alakrita racing hull, so "lighter than the racer" is wrong for feel but right for role: this is a smaller ship, not a faster one. | +| `Price` | 4,200,000 | Well under LonginusX; AU's "resource acquisition" identity is a hull you buy early. | +| `Durability` | 400 | Slightly under LonginusX's 500. | +| `Shape` | 5×14, ~56 cells | Smaller than LonginusX's 66 — fewer hardpoints, more of them general-purpose. **Guess** on the mask. | +| `Armor` | 12 | Just above LonginusX's 10. | +| `Drag` | 0.12 | **Guess**, one sibling. | +| `Conductivity` | 30 | Near LonginusX's 32. | +| `ThermalResilience` | 2 | | +| Heat four | min 173.15, max 573.15, **optimum 288.15, plateau 60** | Valid: 288.15 ± 30 inside bounds. Wider than LonginusX's 48, far under the Noka's 100. Competent and forgiving; nothing is exceptional. | +| `Roles` | `plating` | | + +`ReflectorData`: + +| Stat | Min | Max | Terms | +|---|---|---|---| +| `CrossSection` | 550 | 220 | `Durability^1`, `Quality^2[plating]` | + +Deliberately close to LonginusX's 500→150 — an unremarkable middle — with the same exponents, so +the only thing separating an AU hull from an Alakrita one on this axis is the product's +workmanship. That is the point of an all-rounder. + +**Hardpoints:** + +| Type | Shape | Count | Armor | +|---|---|---|---| +| `ControlModule` | 2×2 | 1 | 40 | +| `Reactor` | 2×2 | 1 | 0 | +| `AetherDrive` | 2×2 | 1 | 10 | +| **`Thruster`** | **2×2** | **2** | 10 | +| **`Shield`** | **2×2** | **1** | 15 | +| `Radiator` | 2×1 | 2 | 10 | +| `Energy` | 1×2 | 2 | 20 | +| `Launcher` | 1×3 | 1 | 25 | +| `Sensors` | 1×1 | 1 | 5 | + +One shield to the Noka's two; two thrusters each. AU's hull is where a player first meets a +shield and the Zhestokost hull is where they meet two. + +### Product: `Jason`, by Aeronautics Unlimited + +The roster gives AU's voice ("Resource acquisition"; Britain; minimalist) and names the hull and +its upgrade the Argo, "because this ship is fire", but writes no line for Jason itself. + +`Description` **(new text)**: + +> Every expedition needs a ship that comes back. The Jason carries what you find, survives what +> finds you, and asks for nothing you cannot buy at the next station. Upgrade path available. + +The last sentence is the Argo hook, left unbuilt. + +Role quality: + +| Role | Mean | Dev | Why | +|---|---|---|---| +| `plating` | 0.62 | 0.12 | AU's shipped spread sits at 0.58–0.75 mean with 0.10–0.17 deviation across eight products (Earp 0.73/0.68, Vulcan 0.75/0.58, Traction 0.69/0.65, Skiron 0.64, Iapyx 0.61). 0.62 ± 0.12 lands in the middle of their own established band. That consistency *is* the brand: an all-rounder is a company with no peak. | + +--- + +## 4. Item 1 — Shields + +New role vocabulary, shared by both designs: + +- **`emitter`** — the projector that turns stored energy into absorption. Owns `Efficiency`. +- **`reservoir`** — the reserve pool itself. Owns `Capacity`. +- **`regulator`** — the charge controller. Owns `EnergyUsage`, `RefillDuration`, `RestoreDuration`. + +`ShieldData` semantics, from `Shield.cs`: a hit costs `damage × EnergyUsage` from the reserve; +a hit the reserve cannot cover **breaks** the shield, passes through **in full**, and empties +the reserve; a broken shield absorbs nothing until the reserve refills over `RestoreDuration`. +Absorbing adds `damage / Efficiency` heat. + +### 4a. Design: `Heavy Shield Projector` (`GearData`, `Hardpoint = Shield`) + +No shield exists to compare against. Every number is argued from the energy and heat scales in +§1 and from the gear it sits beside on a hull; all of it is first-in-kind and should be read as +the batch's least anchored content. + +| Field | Value | Why | +|---|---|---| +| `Description` | "Projected barrier. Absorbs incoming damage from a stored energy reserve at a heat cost; a hit larger than the reserve breaks the barrier and passes through undiminished." | Dry, and it states the break rule, because a player who does not know that rule cannot read the item. | +| `Mass` | 420 | Between a reactor (Manhattan 3×3) and a thruster (150). A 2×2 defensive plant. | +| `Price` | 3,400,000 | Above every 2×2 gear item shipped (MoveOnPro, ChargeBlast, OK Disperser all ≤ 800k) and well under a hull. Megiddo's boss-faction signature item should be a purchase you plan for. **Weakly argued** — no shield price exists. | +| `Durability` | 250 | High; LonginusX is 500, most gear 50–100. | +| `Shape` | 2×2 | Matches the `Shield` hardpoints authored in §2/§3. | +| `SpecificHeat` | 2 | It absorbs heat by design; giving it thermal mass of its own is the difference between a shield that cooks and one that buffers. | +| `Conductivity` | 4 | Sheds into the hull grid readily so a radiator can reach it. | +| `ThermalResilience` | 8 | Radiators sit at 1–15; a shield lives in the same heat regime. | +| Heat four | min 173.15, max 573.15, **optimum 258.15, plateau 84** | Valid: 258.15 ± 42 = 216.15…300.15, inside bounds. Second-widest plateau in the batch after the Noka hull. Megiddo's brand is *holding*: the barrier does not degrade while the fight lasts. | +| `Roles` | `emitter`, `reservoir`, `regulator` | | + +`ShieldData`: + +| Stat | Min | Max | Terms | Why | +|---|---|---|---|---| +| `Efficiency` | 0.05 | 0.20 | `Heat^0.25`, `Durability^0.5`, `Quality^2[emitter]` | Heat per hit is `damage / Efficiency`. A 30-damage shot (a `6k Shooter` maximum) costs 600 heat at the bad end and 150 at the good end — the same order as a weapon's own 250–500 self-heat, so shielding is a real thermal decision rather than free. `Quality^2` makes the emitter the role a buyer pays for. | +| `EnergyUsage` | 8 | 3 | `Heat^0.25`, `Durability^0.25`, `Quality^1.5[regulator]` | Descending. Reserve cost per damage point. | +| `Capacity` | 180 | 520 | `Heat^0.125`, `Durability^0.25`, `Quality^2[reservoir]` | **Power request — no `PowerSupply` term.** At the good end, 520 reserve ÷ 3 per damage = 173 damage absorbed before breaking (≈ 6 maximum `6k Shooter` hits). At the bad end, 180 ÷ 8 = 22 damage (one hit, maybe). That spread is the whole reason to care whose shield you bought. Sized against PotaT+-'s 50–150 capacitor: a shield is a bigger buffer than a general capacitor, by roughly 3×. | +| `RefillDuration` | 12 | 5 | `Heat^0.25`, `Durability^0.25`, `Quality^1.5[regulator]` | Descending. **No `PowerSupply` term** (request-read). Best case 520/5 = 104 power-units/s sustained — a fifth of a Manhattan's 500–1000/s, a ship-defining draw on a MoveOnPro (25–125/s). Shields are why you buy the big reactor. | +| `RestoreDuration` | 34 | 16 | `Durability^0.25`, `Quality^2[regulator]` | Descending. **No `PowerSupply` term.** 16 s naked at best, 34 s at worst: the punish window the operator asked for. Roughly 3× the refill duration, deliberately — breaking a shield has to be worth aiming for. No `Heat` term: the punish window should not also be a heat-management puzzle. | + +### 4a Product: `Migdal`, by **Megiddo** + +Megiddo has **no `Faction` record in the catalog** — see §9 F2. The product cannot be authored +until one exists. + +The roster gives Megiddo a concept ("defensive technology", "Space Zionists", Hebrew, choral) +and no written items at all — its sheet has headers and nothing under them. All flavour is +therefore **(new text)**: + +> A wall is not a weapon. It does not need to be clever, or fast, or new. It needs to be there +> when the sky opens, and it needs to be there afterwards. Ours have been. + +Role quality: + +| Role | Mean | Dev | Why | +|---|---|---|---| +| `emitter` | 0.88 | 0.04 | The single highest mean and lowest deviation in the catalog. Today's ceiling is 0.78 mean (Arctica's branded fins) and today's floor for deviation is 0.07. Megiddo is a boss faction whose entire existence is one capability; its signature item should sit one clear step above every merely-good product, on both axes. | +| `reservoir` | 0.82 | 0.05 | Same logic, one notch down: the pool is the second thing they are selling. | +| `regulator` | 0.74 | 0.06 | Still above almost everything shipped. Megiddo has no weak part — the brand is that there is no corner cut — so the spread between its roles is small and the *deviation* is what sets it apart, not a peak. Choral: everything in tune. | + +### 4b. Design: `Light Shield Projector` (`GearData`, `Hardpoint = Shield`) + +The cheaper alternative. Maker: **Finch Cybernetics** — the roster's second-named shield maker, +a demo faction with influence 5 and zero products, whose written identity is "the highest +quality and lowest cost\*" with the asterisk reading "when compared to units of higher cost". +Finch's brand is *variance*, which is a thing `ProductRole.StandardDeviation` can actually say. + +| Field | Value | Why | +|---|---|---| +| `Description` | "Compact projected barrier. Lower reserve and slower recovery than a heavy projector, in a smaller envelope." | Dry. | +| `Mass` | 160 | | +| `Price` | 620,000 | ~18% of the heavy projector. "Lowest cost" has to be visible in the price, not only the flavour. | +| `Durability` | 60 | Ordinary gear durability (most shipped gear is 50–100). | +| `Shape` | 1×2 | Fits inside the 2×2 `Shield` hardpoints authored in §2/§3, and leaves the rest of the hardpoint free — which is a real, legible tradeoff the fit rule already supports. | +| `SpecificHeat` | 1 | | +| `Conductivity` | 2 | | +| `ThermalResilience` | 2 | | +| Heat four | min 200, max 440, **optimum 268, plateau 28** | Valid: 268 ± 14 inside bounds. A third of the Megiddo plateau. It holds, then it does not. | +| `Roles` | `emitter`, `reservoir`, `regulator` | Same vocabulary. | + +`ShieldData`: + +| Stat | Min | Max | Terms | +|---|---|---|---| +| `Efficiency` | 0.03 | 0.11 | `Heat^0.5`, `Durability^0.5`, `Quality^2[emitter]` | +| `EnergyUsage` | 11 | 5 | `Heat^0.25`, `Durability^0.25`, `Quality^1.5[regulator]` | +| `Capacity` | 70 | 210 | `Heat^0.125`, `Durability^0.25`, `Quality^2[reservoir]` | +| `RefillDuration` | 16 | 8 | `Heat^0.25`, `Durability^0.25`, `Quality^1.5[regulator]` | +| `RestoreDuration` | 45 | 26 | `Durability^0.25`, `Quality^2[regulator]` | + +Every band is a worse slice of the heavy projector's, with two deliberate exceptions: the +`Heat^0.5` on `Efficiency` (it degrades under heat twice as fast — the cheap part is the thermal +design) and the `RestoreDuration` floor of 26 s, which is *worse than the heavy projector's +worst case*. A broken Finch shield is a long walk home. Best-case sustained draw is +210/8 = 26/s, which a MoveOnPro can actually feed — that, not the absorption, is what makes it +the early-game shield. + +### 4b Product: `Thorax`, by Finch Cybernetics + +The roster's Finch voice is the asterisked claim (the Galapagos hull, "of the highest quality +and lowest cost\*"). **(new text)**, in that register: + +> The Thorax delivers heavy-projector protection\* at a fraction of the price. Fully compatible +> with the Finch upgrade path. +> +> \*per credit spent + +Role quality: + +| Role | Mean | Dev | Why | +|---|---|---|---| +| `emitter` | 0.72 | 0.19 | The clever part is genuinely good — Finch are transhumanists, not hacks — but the deviation is above anything shipped (today's maximum is 0.18). You are buying a lottery ticket with good odds. | +| `reservoir` | 0.44 | 0.21 | The cheap part, and the widest spread in the catalog. The reservoir is where Finch saved the money and where the asterisk lives. | +| `regulator` | 0.61 | 0.20 | | + +Reading the two shields side by side: Megiddo's worst role (0.74 ± 0.06) is better and more +certain than Finch's best (0.72 ± 0.19). That is the price difference made mechanical, and it is +also the first pair in the catalog where two products of *different* designs in the same kind +encode opposite manufacturing philosophies rather than an arbitrary seed. + +--- + +## 5. Item 2 — Thrusters + +Shared vocabulary from `deep space burnout`: **`nozzle`** (thrust, plume visibility), +**`injector`** (the fuel/power feed, whose byproduct is heat). + +Comparables — the only two thruster designs in the catalog: + +| | Thrust | Visibility | Heat | EnergyUsage | Mass | Price | Dur | Heat four | +|---|---|---|---|---|---|---|---|---| +| `deep space burnout` (DME) | 250k→1M `H^.0625 D^.5 Q^2[nozzle] P^1` | 100→400 `H^.25 D^.25 Q^1.5[nozzle]` | 4000→12000 `D^.125 Q^1[injector]` | 0→0, no terms | 150 | 200,000 | 100 | 200/400/251/24 | +| `Large Drive` (unsold) | 250k flat | 100 flat | 2500 flat | 0→0 | 100 | 100,000 | 100 | 200/400/251/24 | + +Two things inherited, both noted rather than changed: + +- **Shipped thrusters request zero power.** `ThrusterData.EnergyUsage` is authored 0→0 with no + terms on both. Authoring a real value on the new thrusters would make them the only thrusters + that cost power. Operator fork **F4**; this spec authors 0→0 to match, so the batch changes no + balance it was not asked to change. +- **Thruster `Visibility` ascends with quality** (100→400, `Quality^1.5`) — a better-built + nozzle is *brighter*. Read as "plume brightness tracks output", that is coherent; read as + "quality makes gear worse", it is a bug. One data point cannot settle it. Operator fork **F5**; + this spec follows the shipped convention and ascends. + +### 5a. Design: `High-Output Thruster` (`GearData`, `Hardpoint = Thruster`) + +| Field | Value | Why | +|---|---|---| +| `Description` | "High-thrust drive. Narrow thermal band, low durability, high output. Requires close thermal management." | Dry. | +| `Mass` | 90 | Below both siblings (150, 100). Alakrita is light and fragile. | +| `Price` | 900,000 | 4.5× deep space burnout. "Ultra high-end… spare parts are hard to come by." | +| `Durability` | 35 | Against 100 on both siblings. The lowest durability on any equippable in the catalog. This is the single number that says "failure prone". | +| `Shape` | 2×2 | Matches both siblings and the new `Thruster` hardpoints. | +| `SpecificHeat` | 1.5 | Less thermal mass than deep space burnout's 2.5 — it heats fast. | +| `Conductivity` | 6 | Slightly above 5: dumps into the hull aggressively, which is a liability as much as a feature. | +| `ThermalResilience` | 2 | Against deep space burnout's 15. | +| Heat four | min 232, max 362, **optimum 268, plateau 10** | Valid: 268 ± 5 = 263…273, inside bounds. The narrowest plateau and narrowest bounds in the catalog by a wide margin (nearest shipped is `The Bat` at 18, `plight` at 16.2). "The perfect alignment of the whole system makes them failure prone" written as a number: outside a 10K window it is taking wear every second. | +| `Roles` | `injector`, `nozzle` | | + +`ThrusterData`: + +| Stat | Min | Max | Terms | Why | +|---|---|---|---|---| +| `Thrust` | 400,000 | 1,500,000 | `Heat^0.25`, `Durability^0.75`, `Quality^2[nozzle]`, `PowerSupply^1` | Top end 1.5× deep space burnout's, floor 1.6× its floor. `Heat^0.25` (vs `^0.0625`) and `Durability^0.75` (vs `^0.5`) are the fragility: it is the fastest thruster while perfect and falls off harder than anything else as it heats and wears. `PowerSupply^1` matches both siblings and is ascending, so the multiplier rule is safe. | +| `Visibility` | 300 | 900 | `Heat^0.25`, `Durability^0.25`, `Quality^1[nozzle]` | Loudest plume in the game. `Quality^1` rather than `^1.5`: Alakrita is not spending workmanship on the exhaust signature, because their customers do not care. | +| `Heat` | 6,500 | 19,000 | `Durability^0.125`, `Quality^1[injector]` | Above deep space burnout's 4000→12000 across the band. The injector is the part Alakrita did not fix. | +| `EnergyUsage` | 0 | 0 | none | Matches shipped thrusters. See F4. | + +### 5a Product: `Victoire`, by Alakrita + +`Description`, verbatim roster line plus the tagline: + +> The most responsive thruster on the market, for the most irresponsible of racing pilots. +> Speed. Elegance. *Alakrita*. + +Role quality: + +| Role | Mean | Dev | Why | +|---|---|---|---| +| `nozzle` | 0.91 | 0.06 | Highest single mean in the batch and in the catalog. Alakrita's established pattern is already visible in the one branded row shipped — Arctica's `fins` 0.78 ± 0.07, high on the thing the brand claims. A thruster is Alakrita's *actual* specialisation where a radiator is adjacent, so the peak goes higher and the deviation stays as tight. | +| `injector` | 0.38 | 0.16 | Below every shipped mean (today's floor is 0.45). The injector decides `Heat`, and heat is what kills the pilot. High on what makes it fast, low on what keeps it alive — stated as the batch's clearest brand contrast with Zhestokost. The wide deviation is the spare-parts problem. | + +The two together are the design intent made buyable: 0.91 nozzle against a `Quality^2` thrust +term gives near-maximum thrust, while 0.38 injector against a `Quality^1` heat term leaves the +heat near the middle of a band that already tops out at 19,000. Fast, and cooking. + +### 5b. Design: `Light Thruster` (`GearData`, `Hardpoint = Thruster`) + +| Field | Value | Why | +|---|---|---| +| `Description` | "Low-cost drive. Modest thrust across a wide thermal band, with low durability." | Dry. | +| `Mass` | 70 | Lightest thruster. | +| `Price` | 140,000 | Between `Large Drive` (100,000, unsold and unbranded) and deep space burnout (200,000). The cheap *branded* option, and a seventh of the Alakrita. | +| `Durability` | 30 | Lower even than the Alakrita. Finch's cost saving is materials. | +| `Shape` | 2×2 | | +| `SpecificHeat` | 1 | | +| `Conductivity` | 4 | | +| `ThermalResilience` | 1 | Catalog floor. | +| Heat four | min 205, max 445, **optimum 262, plateau 46** | Valid: 262 ± 23 inside bounds. Nearly double deep space burnout's plateau of 24 and 4.6× the Alakrita's. Finch's trick is not precision, it is *tolerance*: the cheap thruster is the forgiving one. That inversion is the most interesting thing in this pair. | +| `Roles` | `injector`, `nozzle` | | + +`ThrusterData`: + +| Stat | Min | Max | Terms | +|---|---|---|---| +| `Thrust` | 180,000 | 650,000 | `Heat^0.125`, `Durability^0.375`, `Quality^1.5[nozzle]`, `PowerSupply^1` | +| `Visibility` | 150 | 380 | `Heat^0.25`, `Durability^0.25`, `Quality^1[nozzle]` | +| `Heat` | 3,200 | 8,500 | `Durability^0.125`, `Quality^1[injector]` | +| `EnergyUsage` | 0 | 0 | none | + +Ceiling is 43% of the Alakrita's and 65% of deep space burnout's. Heat is the lowest of any +thruster. The gentle exponents (`Heat^0.125`, `Durability^0.375`) mean it degrades slowly — a +cheap part that keeps working, which is the honest half of Finch's claim. + +### 5b Product: `Prokope`, by Finch Cybernetics + +`Description`, verbatim: + +> 67% fatal and 100% fun. + +Role quality: + +| Role | Mean | Dev | Why | +|---|---|---|---| +| `nozzle` | 0.66 | 0.22 | Middle mean, catalog-widest deviation. Matches the Thorax's Finch signature: the parts are sometimes excellent. | +| `injector` | 0.35 | 0.24 | The lowest mean and highest deviation in the catalog. The injector decides heat; heat decides whether the 67% figure was marketing. | + +--- + +## 6. Item 4a — Finch passive sensor + +`SensorData` carries both the passive half (`Sensitivity`, `SensitivityCurve`) and the active +ping (`PingBoost`, `PingEnergy`, `PingVisibility`, `PingRange`, `PingCooldown`, `PingDuration`, +`PingRadiusExponent`) in one behaviour. A *passive* sensor is therefore a `SensorData` with the +ping stats authored dead — which is a shape the catalog already uses (`Iapyx` and `Skiron` +author four of five radiator stats flat). + +### Design: `Passive Sensor Array` (`GearData`, `Hardpoint = Sensors`) + +Comparables: `The Bat` (Sensitivity 1→2, 2×2, mass 1000, price 7.5M) and `not if i see you +first` (Sensitivity 0.25→1.5, 1×1, mass 200, price 250k). + +| Field | Value | Why | +|---|---|---| +| `Description` | "Passive sensor array. High sensitivity, no active ping; emits no signature of its own." | Dry, and it names the absence, which is the item. | +| `Mass` | 240 | Near `not if i see you first` (200); it is a 1×1 part with more receiver in it. | +| `Price` | 900,000 | 3.6× `not if i see you first`, an eighth of `The Bat`. The best passive sensitivity in the game should not be the cheapest sensor. | +| `Durability` | 50 | Matches both siblings. | +| `Shape` | 1×1 | Fits LonginusX's existing `Sensors` hardpoint — the reason this item is playable today. | +| `SpecificHeat` | 1 | | +| `Conductivity` | 1 | | +| `ThermalResilience` | 1 | | +| Heat four | min 180, max 400, **optimum 236, plateau 44** | Valid: 236 ± 22 inside bounds. Low optimum, wide plateau: a receiver wants to be cold, and Finch built one that tolerates not being. Compare `The Bat`'s 209/18 (cold and fussy) and `not if i see you first`'s 251/24. | +| `Roles` | `receiver` **only** | The active-ping stats are authored flat with no `Quality` term, so by the Cut 7 rule (a role is pointed at a stat only when it carries `Quality` *and* actually varies) there is no `emitter` role to declare. Declaring one would be authoring fiction. | + +`SensorData`: + +| Stat | Min | Max | Terms | Why | +|---|---|---|---|---| +| `Sensitivity` | 0.6 | 3.0 | `Heat^0.5`, `Durability^0.4`, `Quality^2[receiver]` | Top end 1.5× `The Bat`'s 2.0 and double `not if i see you first`'s 1.5 — the best passive sensitivity in the game, which is Finch's stated specialisation. `Heat^0.5` is steep: a hot receiver is a deaf receiver, so this sensor pushes the player toward a cool ship. `Quality^2` (vs `The Bat`'s `^1.5`) makes the receiver the role you buy. | +| `SensitivityCurve` | copy `not if i see you first`'s | | | A `BezierCurve`; no basis to author a new shape and no reason to. | +| `PingBoost` | 0 | 0 | none | No active ping. | +| `PingEnergy` | 0 | 0 | none | **Power request — must carry no `PowerSupply` term** (it carries no term at all). A passive sensor drawing zero bus power is the correct reading of the item. | +| `PingVisibility` | 0 | 0 | none | | +| `PingRange` | 0 | 0 | none | | +| `PingCooldown` | 0 | 0 | none | | +| `PingDuration` | 0 | | | | +| `PingRadiusExponent` | 0.5 | | | Matches both siblings. | + +Hands should confirm that `Sensor.Execute` handles an all-zero ping without dividing by +`PingCooldown` or `PingRange`; if it does not, that is a one-line guard, not a reason to author +a token ping. Flagged as **F7**. + +### Product: `ChirOptos`, by Finch Cybernetics + +`Description`, verbatim fragment from the roster, completed in voice: + +> ChirOptos, with patented EchoLock — so you always fly under the radar. + +Role quality: + +| Role | Mean | Dev | Why | +|---|---|---|---| +| `receiver` | 0.86 | 0.11 | The highest non-Megiddo mean in the batch. Passive sensors are Finch's *named* specialisation in the roster, and this is the flagship — it should beat DME's `not if i see you first` receiver (0.75), which is a sensor made by an explosives company. The deviation is tighter than Finch's cheap gear (0.19–0.24 above) but nowhere near Megiddo's 0.04: Finch's QC improves when the product is the one they are known for, and never becomes German. | + +Finch's three products in this batch are the brand argued three ways: 0.86 ± 0.11 on the thing +they are for, 0.72 ± 0.19 on a clever budget product, 0.35 ± 0.24 on the part they openly did +not fund. That is a manufacturer with a personality, which is what the roles cut said nothing in +the catalog has yet. + +--- + +## 7. Items 4b and 5 — the cloak and the capacitor + +### 7a. Adrasteia's Enigma Device — buildable, but not as invisibility + +**It can be expressed with landed behaviours, and it cannot be a cloak.** The honest version is +a *signature dampener*, and the spec below is written as one. If the operator wants invisibility, +that needs a mechanism that does not exist; §9 F8 names it. + +What exists: + +- `Entity.Visibility` is `VisibilitySources.Values.Sum()` (`Entity.cs:131`) — a sum of + non-negative contributions. Nothing subtracts. +- `VisibilityData` only **adds** a source (`Visibility.cs`). It cannot hide anything. +- `StatModifierData` with `Type = Multiplier` attaches a scale factor to any named + `(BehaviorData, PerformanceStat)` on any equipped item, through `StatResolver.ScaleModifier`, + which multiplies (default 1). It is shipped and in use (MoveOnPro carries one). Targeting + `ReflectorData.CrossSection` reaches the hull, because `EquippedHull` is in `Entity.Equipment`. +- The hull's cross-section **is the dominant source**: `CrossSection` is 150–900 across the + catalog, while the entity's own thermal radiation source is + `Σ_border pow(T, 3) × 1e-8` — about 11 total for a 40-cell border at 300 K, and still under + 150 at 600 K (`Entity.cs:1090-1111`, `HeatRadiationExponent 3`, `HeatRadiationMultiplier 1e-8`). + +So multiplying cross-section down by 0.2 removes roughly 80% of a ship's detectability, which is +a real and significant item. What it cannot do: + +- It cannot suppress thermal radiation, because that source is computed in `Entity` from global + settings and is not a `PerformanceStat` — nothing can modify it. A dampened ship running hot + is still lit up, which is thematically excellent and mechanically a ceiling on the item. +- It cannot suppress weapon or thruster plume visibility without a **separate** + `StatModifierData` per target stat (one `StatReference` each). Firing breaks the effect, which + is the right behaviour and comes free. +- There is no "cannot be locked" state. Detection is one scalar against + `TargetDetectionInfoThreshold` (0.1), so this is a lower number, not a different state. + +#### Design: `Signature Dampener` (`GearData`, `Hardpoint = Tool`) + +`Tool` items place anywhere in interior space, so this is playable on LonginusX today. + +| Field | Value | Why | +|---|---|---| +| `Description` | "Actively cancels the hull's reflected radar return. Does not conceal thermal emission, weapon discharge, or thruster plume." | Dry, and the second sentence is load-bearing: it tells the player the item's actual boundary. | +| `Mass` | 180 | | +| `Price` | 4,800,000 | The most expensive gear item in the batch and above every shipped non-hull design except `The Bat` (7.5M). Adrasteia is expensive and secretive; "harder to obtain than a good reputation" is the roster's own phrasing for their gear. | +| `Durability` | 40 | "Light and Fragile, High Tech" — the roster's own construction note for every Adrasteia item. | +| `Shape` | 2×2 | | +| `SpecificHeat` | 1 | | +| `Conductivity` | 2 | | +| `ThermalResilience` | 1 | | +| Heat four | min 150, max 380, **optimum 214, plateau 30** | Valid: 214 ± 15 inside bounds. The coldest optimum in the batch, one step toward the negent register `docs/stats-and-power-target.md` describes for Adrasteia without being negent gear. | +| `Roles` | `projector`, `controller` | `Tool` roles are per-design (Cut 7). *projector* — the cancellation emitter, which decides how much return is suppressed. *controller* — the timing electronics, which decide what it costs to run. | + +Behaviours — three, because `StatModifierData` names one target stat each: + +1. `StatModifierData` — `Stat = { Target: "ReflectorData", Stat: "CrossSection" }`, + `Type = Multiplier`, `RequireBehavior` empty, + `Modifier`: Min **1.0**, Max **0.12**, terms `Heat^0.5`, `Durability^0.5`, + `Quality^2[projector]`. + Descending (1.0 = no effect, 0.12 = 88% suppression), so **no `PowerSupply` term** — see §1; + a power term here would make an unpowered ship perfectly invisible. `Heat^0.5` is steep: run + hot and the dampener stops working, which couples the two halves of stealth without needing + a new mechanism. +2. `EnergyDrawData` — `EnergyDraw`: Min 90, Max 40, terms `Heat^0.25`, + `Quality^1.5[controller]`; `PerSecond = true`. + Descending. **Power request — no `PowerSupply` term** (validator would refuse it). 40–90/s + is a third to most of a MoveOnPro's whole output: staying dark costs you your guns. +3. `HeatData` — `Heat`: Min 900, Max 400, terms `Durability^0.25`, + `Quality^1.5[controller]`; `PerSecond = true`. + Descending. Cancellation is not free, and the heat it makes is what eventually reveals you + through the one source the item cannot touch. That loop is the item's design. + +**Weakly argued:** no cloak, dampener or signature item exists to compare against. The +`EnergyDraw` figure is anchored to reactor output and the `Heat` figure to `Cockpit 2x2`'s 200/s +and thruster heat; the multiplier band is anchored only to the cross-section range it multiplies. + +#### Product: `Enigma Device`, by Adrasteia + +`Description`, verbatim from the source spreadsheet: + +> A stealth device par excellence. Comes in an unmarked box from an unknown address, with no +> instructions and no receipt. A small uv glow smiley sticker was added after numerous +> complaints of customers being unable to find the device once switched on. + +Role quality: + +| Role | Mean | Dev | Why | +|---|---|---|---| +| `projector` | 0.84 | 0.07 | Expensive and secretive: the thing works, and it works consistently, because Adrasteia's entire market position is that other corporations cannot reproduce it. Second only to Megiddo's emitter. Consistent with their one shipped product, ColdFire's focusing array at 0.68 ± 0.10 — a deliberate step up, because ColdFire is an admitted underperformer ("does less damage than other corp's counterparts") while the Enigma Device is the flagship. | +| `controller` | 0.69 | 0.16 | No instructions, no receipt: the usability half is where the money did not go. Good, not exceptional, and inconsistent. | + +### 7b. NiteLife's Cottage-pi capacitor + +#### Design: `Large Capacitor` (`GearData`, `Hardpoint = Tool`) + +The only capacitor design is `PotaT+-` (1×1, Tool, mass 75, price 75k, Capacity 50→150, +Efficiency 0.5→0.99, roles `converter`/`storage cell`). Cottage-pi must be a **separate design**, +not a second product on PotaT+-: both would be NiteLife, and `census` refuses duplicate +`(maker, design)` pairs because `Brand()`'s tie-break would then be arbitrary. + +| Field | Value | Why | +|---|---|---| +| `Description` | "High-capacity power buffer. Stores bus surplus for peak draw at a conversion loss." | Dry. | +| `Mass` | 210 | ~3× PotaT+- for 4× the cells and ~3.5× the capacity. | +| `Price` | 340,000 | 4.5× PotaT+-. | +| `Durability` | 60 | Near PotaT+-'s 50. | +| `Shape` | 2×2 | `Tool`, so it competes with cargo and gear for interior space — which is the cost of carrying one. | +| `SpecificHeat` | 1.5 | | +| `Conductivity` | 1 | Matches PotaT+-. | +| `ThermalResilience` | 3 | Above PotaT+-'s 1. | +| Heat four | min 200, max 500, **optimum 279, plateau 54** | Valid: 279 ± 27 inside bounds. PotaT+- is 200/500/276.5/36; the same band with a 50% wider plateau. NiteLife's whole pitch is "we keep the lights on" — their flagship buffer should be the one that does not mind the heat. | +| `Roles` | `converter`, `storage cell` | Shared with PotaT+-. | + +`CapacitorData`: + +| Stat | Min | Max | Terms | Why | +|---|---|---|---|---| +| `Capacity` | 180 | 520 | `Heat^0.125`, `Durability^0.0625`, `Quality^2[storage cell]` | 3.5× PotaT+- across the band. Same exponents as PotaT+-, deliberately: this is the same technology, larger. Note it lands on the same numbers as the heavy shield's reserve, which is intentional — one Cottage-pi is roughly "a second shield's worth of buffer", a legible unit. | +| `Efficiency` | 0.55 | 0.97 | `Heat^0.0625`, `Durability^0.25`, `Quality^2[converter]` | Marginally worse ceiling than PotaT+-'s 0.99 and a better floor than its 0.5. Bigger buffers lose a little more at the top; NiteLife's volume product is more consistent than its cheap one. | + +`Capacitor` is **not** an `IPowerConsumer` — the bus reads `ResolveCapacity()` — so neither stat +is a power request and neither needs the `PowerSupply` exclusion. Neither takes one anyway: +`Capacity` ascends but a capacitor that vanishes under brownout would remove the buffer exactly +when it is needed. + +#### Product: `Cottage-pi`, by NiteLife Energy + +The roster writes no line for Cottage-pi itself but fixes its role precisely: the meson +reservoir that MoveOnPro, "the only muon catalyst fusion reactor on the market", requires, and +which only NiteLife sells. Tagline: "We keep the lights on, so you can keep on". **(new text)**: + +> Genuine NiteLife meson reservoir. Accept no substitute — there isn't one. Keeps the lights on, +> so you can keep on. + +Role quality: + +| Role | Mean | Dev | Why | +|---|---|---|---| +| `storage cell` | 0.79 | 0.08 | Power storage is NiteLife's stated specialisation and this is their flagship, so it should decisively beat their own PotaT+- storage cell (0.53 ± 0.11). 0.79 sits at the top of the non-boss band (Arctica's branded 0.78) with a tighter deviation than anything NiteLife ships. | +| `converter` | 0.55 | 0.14 | *Lower* than PotaT+-'s converter (0.67). Deliberate and worth stating: NiteLife's business model is selling the reservoir refill, not the conversion electronics, so the big unit stores beautifully and converts adequately. A second product raising `converter` is the obvious future market segment, which is exactly what the roles cut wants to demonstrate. | + +--- + +## 8. Item 6 — Miss Terri's consumables: blocked, do not author + +**Verified: consumables are unreachable in play, and authoring one would produce a crash rather +than content.** + +- `ItemManager.CreateInstance(int lot)` (`ItemManager.cs:173`) branches on + `EquippableItemData` and falls through to `CompoundCommodity` for everything else. There is + **no `ConsumableItemData` branch.** A lot whose design is a consumable mints a + `CompoundCommodity` whose `Data` points at a `ConsumableItemData` — wrong instance type, no + error, no log. +- `Entity.TryActivateConsumable` (`Entity.cs:437`) then does an **unchecked cast**: + `var item = (ConsumableItem) bay.ItemsOfType[key].First();`. The instance in cargo is a + `CompoundCommodity`, so this throws `InvalidCastException` the first time a player uses the + item. Not a silent no-op: a crash on use. +- `CreateInstance(FactionProductData)` (`ItemManager.cs:191`) delegates to the same path, so a + product selling a consumable inherits the gap. The catalog currently holds **zero** + `ConsumableItemData` records, which is why nothing has hit this. +- The gap is already known and worked around in tests: + `tests/Aetheria.Shared.Tests/LoadoutTests.cs:731-735` hand-constructs a `ConsumableItem` + with a comment saying exactly this. + +So: **no Miss Terri's content in this batch.** The fix is small (one branch in `CreateInstance` +returning `new ConsumableItem { Data = l.Design, Lot = lot }`, plus a decision about what +`Stackable` means for a lot), but it is a code cut and it is not this pass's to make. It should +be its own cut before any consumable is authored; §9 F9. + +Worth noting for whoever takes it: `ConsumableItemData` carries **no heat response** (it derives +from `CraftedItemData`, not `EquippableItemData`), so the thermal four do not apply, and its +behaviours run through `ConsumableItemEffect` with `Item == null` — which is why `Shield`, +`Visibility` and others all carry a `ConsumableItemEffect` constructor. A Miss Terri's stat +booster would be a `StatModifierData` on a `ConsumableItemData` with a `Duration` and an +`Effectiveness` curve, and `StatSource.ConsumableProgress` exists precisely for it. The content +is ready to write the moment the mint path is. + +--- + +## 9. Blockers, mechanism gaps, and operator forks + +**F1 — Resolved by the 2026-09-26 hull restore.** The following was the authoring baseline when this batch was +mapped. Both Longinus and Djinni prefabs are now referenced by live hull records in `GameData/Aetheria.cc`. +`ZoneRenderer.cs:295` instantiates `EngineAssets.Load(hullData.Prefab)` and calls +`GetComponent()`. A hull with no prefab GUID renders nothing and null-refs. The +project holds exactly two ship prefabs: `Assets/Content/Prefabs/Ships/Longinus.prefab` (used by +LonginusX, GUID `4a3db6090789ca24cb57c43666754017`) and +`Assets/Content/Prefabs/Ships/Djinni.prefab` (GUID `79024f635e46f5546b670b58b07fa037`, +**unused**, carries a `ShipInstance` component and named hardpoint transforms: eight thruster +mounts, `Fire Point L`, `Barrel R`, `Nostril L`/`R`, a `Hardpoints` group). +**Fork:** which hull gets the Djinni, and does the other ship without art (invisible in the +zone) or wait for a model? The Djinni's eight thruster transforms and two nostril mounts read +closer to the AU utility hull than to a Zhestokost slab, but that is an art judgement. + +**F2 — Megiddo has no `Faction` record.** The catalog holds twelve factions and Megiddo is not +one of them (`AetherDb factions`). Authoring the Migdal requires creating it first: +`Name`, `ShortName`, `Description`, `InfluenceDistance` (boss-scale; Lucent and Zhestokost are +6, the median is 4), `PrimaryColor`/`SecondaryColor`, `Personality` weights, and a +**`GeonameFile`** — every shipped faction has one, and `AetherDb factions` reports it as a +generation-critical link. Megiddo's is Hebrew place-names; the `NameFile` record does not exist +either. `Logo` is an asset GUID with no asset. `BossHull` is unset on every faction today, so +leaving it unset is normal. **Fork:** create Megiddo as part of this batch, or give the heavy +shield to a faction that exists (Zhestokost is the roster's plausible fallback: "no such thing +as overkill" reads fine on a shield) and hold Megiddo for a proper faction pass. + +**F3 — `PowerSupply` on a descending stat is a footgun the validator does not catch.** Under +the 2026-09-19 multiplier ruling, a `PowerSupply` term on a stat authored `Min > Max` means +zero supply resolves to zero, which for `CrossSection`, `PingVisibility`, `PingCooldown`, +`EnergyUsage` or `WasteHeat` is the *best possible* value. Nothing refuses it. Worth a +validation rule ("a `PowerSupply` term on a stat whose `Min > Max` is an authoring error") in +whichever cut widens the request-independence check. No shipped stat currently trips it — +checked all 51 designs. + +**F4 — Shipped thrusters request zero power.** Both `deep space burnout` and `Large Drive` +author `ThrusterData.EnergyUsage` 0→0 with no terms, so thrust is free and the `PowerSupply^1` +term on `Thrust` can only ever read a full grant. This spec matches them rather than making the +two new thrusters uniquely expensive. **Fork:** author real thruster draw across all four in a +separate balance pass, or leave thrust free. + +**F5 — Thruster `Visibility` ascends with quality.** `deep space burnout` authors 100→400 with +`Quality^1.5[nozzle]`: a better-built nozzle is brighter. Coherent if plume brightness tracks +output; a bug if quality is meant to improve every stat. One data point. This spec follows the +convention. **Fork:** confirm or invert, catalog-wide. + +**F6 — The Noka MKI ad copy says "ColferV" and contains a literal `n`.** Substituting +"Zhestokost" is required; whether to fix "n mounts" is a taste call. Recommendation: keep it. + +**F7 — An all-zero ping may not be safe.** `Sensor.Execute` should be read before the passive +sensor is authored, to confirm nothing divides by `PingCooldown` or `PingRange`. A one-line +guard if it does; not a reason to author a token ping. + +**F8 — There is no cloaking mechanism, only signature reduction.** Named precisely, what does +not exist: (a) any way to reduce `Entity`'s own thermal-radiation visibility source, which is +computed in `Entity.cs:1090-1111` from global settings and is not a `PerformanceStat`; (b) any +"undetectable" or "cannot be locked" state — detection is one scalar against a threshold; (c) +any single item-level visibility multiplier, so suppressing several sources needs one +`StatModifierData` per target stat. §7a's dampener is what the landed behaviours can honestly +express. **Fork:** ship the dampener as specified, or hold the Enigma Device until a real +stealth mechanic exists. + +**F9 — Consumables cannot be minted or used.** §8. Blocks all Miss Terri's content behind a +small code cut. + +**F10 — A hull has exactly one role, by construction.** `Armor`, `Drag`, `Mass` and +`Durability` are plain floats on `HullData`, not `PerformanceStat`s, so the only quality-bearing +stat a hull owns is `ReflectorData.CrossSection` → `plating`. Every hull product in the game can +therefore differ in exactly one dimension. If hulls are meant to carry manufacturer identity the +way gear does, `Armor` and `Drag` need to become stats. Not this batch; named because this batch +is the first time two hulls exist to compare. + +--- + +## 10. Products for existing unsold designs + +Fourteen designs no product sells (`AetherDb census`). Not all are cheap wins: six have **no +behaviours at all** and two more are authored entirely flat, so a product on them sells an item +that does nothing. + +**Author now — four, all with real stats and an already-written voice:** + +| Design | Kind, shape | Maker | Why | Flavour | +|---|---|---|---|---| +| `plight` | Energy, 1×1 | **Lucent Media** | The cheapest win in the catalog: its `Description` is *already* the roster's verbatim TaranisLG lightning-gun copy, naming DragOnBreath as "a subsidary of Lucent Media". The design exists, is fully authored with `focusing array`/`power coupling` roles, and **fits LonginusX's 1×2 `Energy` hardpoints today**. Lucent ships one energy weapon; lasers are their stated specialisation. | Move the existing text to the product as `TaranisLG`; give the design a dry one. | +| `pswarm` | Launcher, 1×1 | **Aeronautics Unlimited** | Dumbfire swarms are AU's stated specialisation and the roster has the name and line written: *Leonid*, "A rain of pain that falls mainly on space planes." Fully authored with `guidance system`/`thruster`/`warhead` roles, and **fits LonginusX's 1×3 `Launcher` hardpoints today**. Also fixes a content bug: `pswarm`'s current description is DME's `scorched void policy` lyric, copied onto the wrong design. | Roster line, verbatim. | +| `Core Power` | Reactor, 2×2 | **Rossum & Douglas** | Doubles the reactor choice on the only playable hull: LonginusX's `Reactor` hardpoint currently has two matching designs and one seller. R&D is "Mom's Friendly Robot Company", Germany, muzak — and the design's existing description is "It gets the job done, but that's all." A beige reactor is already written; it just needs the right company's name on it. R&D ships two products. | Product text in the reassuring-corporate register. | +| `Large Drive` | Thruster, 2×2 | **Lightsail Express** | The roster writes Lightsail a thruster (`True North`) and Lightsail's whole identity is "speed and reliability, is in actual fact neither". `Large Drive` is authored entirely flat — every stat `Min == Max` — which is *perfect* for a product whose joke is that it is unremarkable, and it declares no roles, so no role spread is needed. Becomes equippable the moment §2/§3's hulls land. | "We'll lose our lives before we lose your cargo" is the tagline; the thruster is `True North`. | + +**Defer — ten.** `Autocannon` and `SRMM72`/`LRMM72` are authored fully flat with no roles, so a +product adds a name and nothing else, and none of them fits a hardpoint that exists (Turret's +8-cell `Ballistic` mounts match no design at all). `Tractor Beam`, `Refinery`, `Shipyard`, +`Assembly Line`, `Surface Ore Extractor`, `Deep Ore Extractor` and `Industrial Thermostatic +Heater` have `Price = 0`, `Durability = 0` and (bar the heater) **no behaviours whatsoever** — +they are placeholders, not designs. The tractor beam in particular is named in the wishlist as +Ewan Hart's "Space Tractor: It's a tractor. In space.", and Ewan Hart has influence and zero +products, so it is the most tempting of these — but the design carries no behaviour, so selling +it would put a dead object in a player's hold. It becomes a real cheap win the moment the pickup +capability (`docs/shield-presentation-contract.md`) gives it something to do. + +--- + +## 11. What a later pass writes + +In one commit, through `Aetheria.Shared`'s own types via `CultRecordRefs.Upsert` (so +`ValidateHeatResponse`, `ValidateStatModifiers`, `ValidateNoPowerSupplyOnRequest` and +`ValidateRoleUsage` all run — **not** through `CultCache.Commit` directly, which bypasses them): + +1. `Faction` **Megiddo** + its `NameFile` — or the F2 fallback. +2. Designs: `Heavy Combat Hull`, `Medium Utility Hull`, `Heavy Shield Projector`, + `Light Shield Projector`, `High-Output Thruster`, `Light Thruster`, `Passive Sensor Array`, + `Signature Dampener`, `Large Capacitor`. **Nine.** +3. Products: `Noka MKI`, `Jason`, `Migdal`, `Thorax`, `Victoire`, `Prokope`, `ChirOptos`, + `Enigma Device`, `Cottage-pi`, plus `TaranisLG`, `Leonid`, the R&D reactor and `True North`. + **Thirteen.** +4. Dry `Description` rewrites on `plight` and `Large Drive` (their current text is product voice + moving to the product), and a correction to `pswarm`'s wrongly-copied description. + +Then `AetherDb census` must report: 60 designs, 50 products; **zero** `(product, role)` gaps; +**zero** duplicate `(maker, design)` pairs; the unsold list down from 14 to 10. And +`AetherDb hardpoint-fit` must show `Shield` and `Thruster` hardpoints on both new hulls with +matching sold designs — which is the one check that proves this batch actually reached the game. diff --git a/docs/cultcache-migration-cut.md b/docs/cultcache-migration-cut.md index 1259c4513..16e885490 100644 --- a/docs/cultcache-migration-cut.md +++ b/docs/cultcache-migration-cut.md @@ -145,8 +145,15 @@ spec says, so a generation fault loses that run. defaults and the username, and about 65 earlier versions are already in LFS history, so history is left alone. -Awaiting: -- the operator play smoke; +The operator play smoke passed (2026-09-17) and both branches merged to master at +`9b85211f`: new game, combat, loot, save and Continue with lot brand and quality intact, +death clearing the run across relaunch, stance toggle, `FieldShieldTest`. It found one +migration defect: CultMath types are not Unity-serializable, so Unity-serialized CultMath +fields load as zero. Mouse sensitivity (lost look control) moved to `PlayerSettings` in +`00ebe46a`; the rest, including the black rarity colours, is `docs/settings-globals-cut.md`. +Scene materials are broken by the dead Substance plugin, not by this migration. + +History, before the smoke: - nothing else from agents. The preset rework's Soul findings were fixed in `d503f076`, `d90a65a9` and `20f0f3d7`: 29 tests, 18 of 19 mutations caught (the survivor is the one noted above), and Unity batchmode reports 0 errors. @@ -271,7 +278,6 @@ Recorded follow-ups, outside 8a: - **`asuint` is untested on signaling-NaN payloads.** Open: -- The operator play smoke. - Whether the `CultLib-delvehold-isosurface` worktree carries the iso-surface zero-normal code that 0.2.2 guards. diff --git a/docs/faction-territory-target.md b/docs/faction-territory-target.md new file mode 100644 index 000000000..c86b52ece --- /dev/null +++ b/docs/faction-territory-target.md @@ -0,0 +1,93 @@ +# Faction Territory Target + +Date: 2026-09-19 + +Status: target, operator direction only. No map, no schedule. This document owns +the ends; a cut map will own the means. + +## Why + +A faction's territory today is a set of zones carrying a Markov-generated name. +`Galaxy.cs:340` feeds one chain per faction from its `GeonameFile` (a massaged +GeoNames extract), `:348` stamps the result on each owned zone, and `:352` gives +everything unowned an `EAC-####` serial. Nothing else distinguishes one faction's +space from another's. A Zhestokost sector and an Alakrita sector differ only in +the phonotactics of a string. + +Operator, 2026-09-19: "Namefiles were how we fed Markov chains to generate +faction-flavored names, the limitations of the geonames database I massaged to +create them were reflected in the naming choices. We can do much better with +generative models, even giving sectors and their stations roles in a faction's +colonization timeline and supply chain, with names and backstories that match. +Giving each faction such a mapping will also gives each faction a recognizable +shape to their territory." + +## Where it sits + +**After fire control** (operator, 2026-09-19: "slot it in right after fire control, first step +is to get the new shape of the combat working"). Combat's new shape — rolls from weapon, +targeting and sensor stats, resolution on arrival, the pre-impact commit window — comes first; +territory is the campaign after it. Territory is what finally makes the economy and provenance +work visible in play, so it does not drift further than that. + +## Ends + +- **A place has a job.** Every owned sector, and every station in it, carries a + role in its owner's supply chain — extraction, refining, fabrication, + logistics, administration, defence, research, disposal — and a position in that + faction's colonization timeline: founding wave, expansion, boom, retrenchment, + whatever that faction's history was. +- **Territory has a shape you can read.** The role map is spatial, so a faction's + space looks like what it does. Lightsail is corridors between other people's + markets; Zhestokost is a fortified frontier with depth behind it; Adrasteia is + scattered deniable nodes with no obvious centre; Aeronautics Unlimited is an + extraction belt feeding a few refineries. A player who has flown two sectors of + a faction's space should be able to guess the third. +- **Names and backstories match the job and the era.** A founding-wave refinery + and a late boom-town dormitory do not sound alike, and neither sounds like the + other faction's equivalent. The name says which faction, which era, which role. +- **The supply chain is real, not decorative.** Roles connect: an extraction + sector feeds a refinery, which feeds fabrication, which feeds a market. Those + links are data, so the economy's backward generation + (`docs/item-provenance-target.md`) can walk them, a lot's `Produced` origin can + name the station that made it, and piracy has routes worth interdicting rather + than an abstract risk number. +- **Generation stays deterministic.** A galaxy seed produces the same territory + every time. Nothing calls a model at runtime. + +## Constraints + +- **Authoring happens ahead of play, into the catalog.** Generative models write + typed catalog data that the operator reviews, exactly like roles and product + text; the game then reads authored data. No runtime inference, no network + dependency, no per-run variation that the seed does not explain. +- **Volume has to be honest.** A galaxy has many sectors. Whatever is authored + must either cover the count or compose deterministically from authored parts; + a bank of a hundred hand-written names behind a thousand sectors will repeat + visibly, and that has to be a decision rather than an accident. +- **The faction record owns its territory rules.** Today `Faction.GeonameFile` + points at a `NameFile`; whatever replaces it belongs on the faction, and + `NameFile` and `MarkovNameGenerator` go when the last consumer does. +- **Unowned space needs an answer too.** `EAC-####` is a placeholder, and + unclaimed sectors are most of the map in some galaxies. +- **Zone generation already has settings and a seed** (`ZoneGenerationSettings`, + `StableHash`); territory roles hang off the same generation, not a second pass. + +## Open questions for the map + +- Authored bank versus composable grammar versus a hybrid, and what repetition + rate is acceptable at real galaxy sizes. +- Whether a station's backstory is authored text, a few typed facts the UI + renders, or both — and where it is shown, since a name nobody reads is + cheaper than prose nobody reads. +- How a faction's colonization timeline interacts with galaxy generation's + existing spatial logic (megaplex, boss zones, entrances). +- Whether roles are assigned to sectors, to stations within them, or both. +- What happens to a sector when its owner changes hands, and whether the + history it carries survives the change. + +## Not in scope + +- Runtime model calls, for anything. +- Rewriting galaxy topology. This assigns meaning to what generation already + produces; it does not redesign the map. diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md new file mode 100644 index 000000000..00d783dc6 --- /dev/null +++ b/docs/fire-control-cut.md @@ -0,0 +1,3376 @@ +# Fire Control: Cut Map + +Date: 2026-09-19 + +Status: Imagination pass, cut map. Nothing here has landed. Ends are owned by +`docs/three-gates-scope.md` ("Fire control") and +`docs/shield-presentation-contract.md` ("The commit window"); this document owns +the means. + +Anchors are against `codex/item-provenance` HEAD `8a00fc92`, read-only. Nothing was +built in the main tree and Unity was not run. Claims marked **(probe)** were measured +by running `tools/AetherDb` (`census`, `hardpoint-fit`) against the live catalog +`GameData/Aetheria.cc` from the free worktree `F:\Projects\Aetheria-stats`, CultLib +pinned at the shared `45c2f40` worktree. Claims marked **(read)** come from reading +source at the cited anchor. + +**This map supersedes Cut 2 of `docs/headless-playground-cut.md`** +(`:490-810`, anchored to `562cdcf2`). That draft is now history for three reasons: +its anchors predate the stats-and-power campaign, which moved most of the code it +cites; it predates the commit-window ruling of 2026-09-18, which changes the shape of +a shot from an instant to a lifecycle; and it predates the per-hardpoint arc override +and 360-degree turret rulings. Its substrate probes are still good and are carried +forward below with citations. Where the two disagree, this document wins; nobody +should be reading two live designs. + +**Scope: fire control only.** What a presentation does with a committed outcome is the +capability/presentation seam's business (`docs/shield-presentation-contract.md`). +Faction territory is the campaign after this. §6 names every seam and stops there. + +--- + +## Rulings this map is built on (do not re-litigate) + +From `docs/three-gates-scope.md:47-99` and `docs/shield-presentation-contract.md:34-57`: + +- **R1. Hit detection goes.** Operator, 2026-09-17: "We wanted to get rid of hit + detection anyway... Just roll the dice based on weapon stats, targeting system stats + (new subsystem) and sensor state." +- **R2. Targeting systems are a new subsystem**, interior `Tool` gear. +- **R3. A shot resolves on arrival**, hit chance reduced by how far the target deviated + from the predicted intercept. Manoeuvring is evasion. +- **R4. The outcome commits a short fixed time before impact.** Operator, 2026-09-18: + "That hybrid is the way." Deviation feeds the roll up to that horizon; after it the + result stands and the rest of the flight is choreography. The horizon is one authored + fire-control setting. A commit is authoritative; presentations perform it and never + predict. +- **R5. Subsystems reveal between `TargetArmorInfoThreshold` and + `TargetGearInfoThreshold`**, and both the player and the AI may aim at a revealed + subsystem. +- **R6. Per-hardpoint firing arcs.** Mount direction from the item's `ItemRotation`, as + reaction thrusters do; default 120 degrees from `GameplaySettings`; an optional + per-hardpoint override; turrets 360. +- **R7. The simulation is 2D.** The 3D is set dressing. +- **R8. Unity weapon effects and `HullCollider` become presentation only.** Their + `Physics.*` queries stop deciding damage. +- **R9. Every weapon type in the catalog's history stays** + (`docs/headless-playground-cut.md` fork E). Nothing is deleted for being unused. + +From `docs/stats-and-power-cut.md:996-1030`, the seam the stats campaign left: + +- **R10. Fire control must snapshot what it rolled over.** The resolver hands out a + current value; it does not promise that value is still current a second later. +- **R11. A starved weapon does not fire** (Cut 4 of that campaign: no fraction of a + shot). A degraded shot instead is a ruling against that map, not a thing to work + around here. See Q6. + +--- + +## 0. Substrate + +### 0.1 What decides a hit today, end to end + +Nothing in `ServerShared` does. The whole chain is Unity **(read)**: + +1. `InstantWeapon.Execute` (`Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs:216-237`) + walks the burst and raises `OnFire` (`:228`), a parameterless `event Action` declared + at `:73`. It carries no outcome and nothing in `ServerShared` subscribes. +2. `Assets/Scripts/Gameplay/EntityInstance.cs:203-204` subscribes and calls + `InstantWeaponEffectManager.Fire(weapon, item, source, target)` + (`Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs:7`). The constant + family goes the same way at `:232-235`. +3. The manager spawns an effect and copies the weapon's resolved numbers onto it. + `ProjectileManager.cs:18-25` jitters the barrel forward vector by + `Random.Range(±Spread/2)` on all three Euler axes — that is the entire aiming model. +4. The effect raycasts. `Projectile.cs:58`, `GuidedProjectile.cs:153`, `Laser.cs:40`, + `Lightning.cs:34`, `ConstantLaser.cs:74`, `ConstantLightning.cs:54`, + `HitscanEffect.cs:31`, `Mine.cs:68,92`, plus `Projectile.cs:114` for airburst. +5. On a hull collider it calls `HullCollider.SendHit` + (`Assets/Scripts/Gameplay/HullCollider.cs:18-30`), or `SendSplash` (`:32-41`). +6. `EntityInstance.cs:316-396` subscribes to those subjects, converts a UV texture + coordinate into schematic cells, expands by `DamageSpread`, marches a penetration ray, + and calls the local function `DamageSchematic` (`:277-314`), which is the only writer + of `Armor`, item `Durability` and `Hull.Durability` from combat. +7. Death: `EntityInstance.cs:405-439` subscribes to `HullDamage`, rolls loot with + `UnityEngine.Random`, and calls `entity.Zone.Entities.Remove(entity)` (`:437`). + +**Authority versus presentation in that chain.** Everything from step 3 down is +authority wearing a presentation costume. The shield branch repeated in all seven +effects (`Projectile.cs:60-80` and its copies) is the absorb rule, duplicated seven +times. `DamageSchematic` is the damage rule. `EntityInstance.cs:437` is the death rule. +Genuine presentation is narrow: trail rendering, line renderers, the lightning compute +animation, hit effect prefabs, `ShieldManager.ShowHit`, and the destroy effect. + +**The 2D/3D violation.** `EntityInstance.cs:323` and `:380` call +`transform.InverseTransformDirection` to get a hit direction into the ship's frame. That +is a 3D rotation standing in for the planar rotation R7 says the simulation is made of. + +### 0.2 What already exists of targeting + +- `Entity.Target` — `Assets/Scripts/ServerShared/Entity.cs:46`, a + `ReactiveProperty`. Cleared when the target leaves `VisibleEnemies` (`:196`, + `:222`). `TargetRange` is recomputed each tick at `:920`. +- `Entity.EntityInfoGathered` — `Entity.cs:57`, a `ReactiveDictionary` in + [0,1], accumulated with decay by `Sensor.Execute` + (`Assets/Scripts/ServerShared/Behaviors/Sensor.cs:152-184`). +- `TargetDetectionInfoThreshold` gates `VisibleEnemies` at `Entity.cs:232-246`. +- **`TargetArmorInfoThreshold` and `TargetGearInfoThreshold` are authored and read by no + code.** They are declared at `Assets/Scripts/ServerShared/Settings.cs:212-213` and + appear nowhere else in `Assets/Scripts` **(read)**. R5's reveal tiers are unwired + settings. +- `LockWeapon` — `Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs`. Integrates a + 0..1 `_lock` scaled by `pow(EntityInfoGathered[target], SensorImpact)` (`:92`), gated by + a `LockAngle` cone off `Entity.LookDirection` (`:88-89`). `CanFire` (`:56`) requires + `_lock > .99f` and range within `[MinRange, Range]`. This is the closest thing in the + codebase to a to-hit probability, and it is per-weapon rather than per-ship. +- `TurretController` — `Assets/Scripts/ServerShared/Behaviors/TurretController.cs`. + `TurretControllerData` has no fields (`:15-25`). It writes `Entity.LookDirection` to a + first-order intercept (`:67-72`) and fires each weapon when + `dot(x.Direction, Entity.LookDirection) > .99f` (`:81-83`). +- `Sensor` — sensitivity, ping boost/energy/visibility/range/cooldown, all + `PerformanceStat` (`Sensor.cs:16-34`). +- Player target selection: `Assets/Scripts/Gameplay/ActionGameManager.cs:368-403` + (reticle, nearest, next, previous). Player fire is not arc-gated at all. +- **No targeting-system item, behavior or stat exists anywhere in code or catalog.** A + repo-wide grep for `Targeting|FireControl|Accuracy|Aim` finds one `#region Targeting` + label at `ActionGameManager.cs:368` and nothing else; the same grep over the catalog + returns zero **(probe)**. + +### 0.3 Where hardpoint rotation and arcs live + +- `HardpointData` — `Assets/Scripts/ServerShared/ItemData.cs:538-545`: `Type`, + `Position`, `Shape`, `Transform` (a prefab node name), `Rotation` (`ItemRotation`), + `Armor`. **There is no arc field on `HardpointData`, `WeaponData`, or any effect.** + A per-hardpoint override attaches here, as catalog data already authored per hull. +- `ItemRotation` is four-way (`Assets/Scripts/ServerShared/Enums.cs:7`); + `Extensions.cs:135-166` gives `None` = forward, `Reversed` = aft, `Clockwise` = right, + `CounterClockwise` = left. `Agent.cs:72` reads `Clockwise` as right and `Thruster` uses + the same convention for mount direction, which is the precedent R6 names. +- `Behavior.Direction` (`Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:24-44`) has + two paths. The first (`:30-34`) reads `Entity.HardpointTransforms`; the second + (`:37-38`) is the item-rotation fallback R6 wants. +- **`Entity.HardpointTransforms` (`Entity.cs:58-59`) is written only by Unity**, at + `EntityInstance.cs:514-517`, from the first `WeaponHardpoint.FiringPoint` barrel's + world forward. Headless it is empty. That single dictionary is why + `Combat.cs:100-102` throws `KeyNotFoundException` on the first combat tick outside + Unity, and why the simulation's own notion of where a gun points is a readback from a + renderer. +- Barrel swing is `ArticulationPoint`, yawing and pitching toward `LookAtPoint` + (`EntityInstance.cs:400-403`, `:519-520`). Authored limits on the Longinus energy + barrels are yaw [-1, 1] — effectively fixed forward — while the Turret prefab's yaw + pivot is [-720, 720] (`docs/headless-playground-cut.md:533-545`, prefab probe, not + re-measured here). That is presentation and stays. +- `AgentFiringMinDot` (`Settings.cs:218`) is **declared and read by nothing** **(read)**. + +### 0.4 What the catalog ships in weapons + +**(probe)**, `tools/AetherDb census` and `hardpoint-fit` over `GameData/Aetheria.cc`: +51 designs, 37 products, of which 18 are weapons. + +| Hardpoint kind | Designs | Sellers | +|---|---|---| +| Ballistic | 6 | Zhestokost 2, Lightsail 1, AU 1, DME 1, unsold 1 | +| Energy | 7 | NiteLife 3, Lucent 1, Adrasteia 1, Alakrita 1, unsold 1 | +| Launcher | 5 | DME 1, R&D 1, unsold 3 | + +Unsold, therefore unspawnable: `Autocannon`, `SRMM72`, `LRMM72`, `plight`, `pswarm`. +The earlier behaviour-class breakdown (8 `ProjectileManager`, 3 `LaserManager` at +velocity 0, 5 `GuidedProjectileManager`, 1 `LightningGunManager`, and **no design using +`ConstantWeaponData`, `HitscanEffect`, `Mine`, airburst or MIRV**) is carried from +`docs/headless-playground-cut.md:500-506` and was not re-measured; the census agent +could not reproduce it because `census` buckets by `HardpointType` and the one command +that prints behaviour fields was blocked. **Cut 4's safety argument depends on that +claim, so Hands re-measures it before Cut 4** (§4, First). + +**Stats a roll can read today** (`Weapon.cs:22-68`, all `PerformanceStat` unless noted): +`Damage`, `Penetration`, `DamageSpread`, `MinRange`, `Range`, `Energy`, `Heat`, +`Visibility`, `Spread` (an angular cone in degrees), `Velocity`; plus `DamageCurve` +(a `BezierCurve`, damage against normalized range), `MagazineSize`, `ReloadTime`, +`AmmoType`. `InstantWeaponData` adds `Count`, `BurstTime`, `Cooldown` +(`InstantWeapon.cs:13-22`). `LockWeaponData` adds `LockSpeed`, `SensorImpact`, +`LockAngle`, `DirectionImpact`, `Decay` (`LockWeapon.cs:13-25`). The item carries +`WeaponRange`, `WeaponCaliber`, `WeaponType`, `WeaponFireTypes`, `WeaponModifiers` +(`ItemData.cs:476-492`). + +Note against a misreading: **`DamageSpread` is not damage variance.** It is the +footprint, in schematic cells: `EntityInstance.cs:372-375` expands the hit shape +`round(hit.Spread)` times, where `hit.Spread` is the weapon's `DamageSpread`. + +**What is missing from the authored data for a roll to be meaningful:** + +- **No accuracy stat on anything.** No weapon, hull, cockpit or sensor contributes a + to-hit number. This is what R2's targeting system exists to supply, and it is the + whole reason the subsystem is required content rather than a nice-to-have. +- **No target-size term.** A roll that ignores how big the target is will feel wrong at + every range. The hull `Shape` and a cell size give an angular radius; `SchematicCellSize` + does not exist and must be authored (Q4). +- **No tracking or angular-velocity stat**, so nothing yet reads R3's deviation. The + targeting system supplies it. +- **Beams have nothing beam-specific.** `ConstantWeaponData` adds only `AmmoInterval` + (`ConstantWeapon.cs:15`); `Spread` is inherited and unread by any beam code, and + `Velocity` is meaningless for one. There is no dwell or wander stat. Unused today, so + Cut 4 gives beams the plainest possible rule and authors nothing new. +- **`ChargedWeaponData.ChargeFiringSpreadMultiplier` is a plain `float`** + (`ChargedWeapon.cs:34`), not a `PerformanceStat`, so unlike every other combat number + it cannot vary with quality or wear. Noted, not fixed here. +- **Lock is binary at 0.99** (`LockWeapon.cs:56`). There is no stat turning partial lock + into hit chance, and `GuidedWeaponData` (`Launcher.cs:37-55`) has no lock stats at all. + +### 0.5 Can the headless sim step a full engagement today + +No, and two specific things stop it **(read)**: + +1. `Combat.cs:100-102` indexes `HardpointTransforms`, which only Unity writes (§0.3). + `Minion` enters `CombatState` as soon as `VisibleEnemies` is non-empty, so any NPC + that sees an enemy throws on the tick it sees them. +2. There is no owner between `InstantWeapon.OnFire` and `Entity.HullDamage`. The only + `ServerShared` writers of durability are the repair consumable + (`Behaviors.cs:67,72-73`). + +Everything else is already there and already exercised. +`tests/Aetheria.Shared.Tests/IffAndCombatTests.cs:52` (`BuildWorld`) opens the real +catalog, builds an `ItemManager` and a `Zone`, and `:73` (`NewShip`) equips a gun on a +hardpoint and activates the ship — two ships, positions, look directions, sensor info, +targets and IFF, headless. `:241` and `:255` assert on `weapon.Progress` after +`Activate()`, because a cooldown starting is the only observable consequence of firing +that exists. That fixture is this campaign's harness; it does not need to be built. + +`Zone.Update` (`Zone.cs:126-151`) steps orbits, belts, agents, then entities. +`ItemManager.Random` is still clock-seeded (`ItemManager.cs:19`), so a seeded-roll test +needs a seed argument — see Q5. + +The xunit project is `tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj` +(net10.0, xunit), referencing `Aetheria.Shared/Aetheria.Shared.csproj` +(netstandard2.1, `noEngineReferences` mirrored by +`Assets/Scripts/ServerShared/Aetheria.Shared.Unity.asmdef`). The mutation harnesses are +`tests/mutation_tests_*.py`, 17 of them, each patching a named source anchor and +asserting a named test goes red with a no-op control. + +--- + +## Rulings (operator and Self, 2026-09-19) + +- **Q4, unaided accuracy: really, really bad.** Operator: "Targeting with the mark one eyeball + should be really, really bad." A ship with no targeting system can hit something close, + slow and unaware, and little else. That makes the targeting subsystem **required equipment** + rather than an upgrade: every hull needs somewhere to put one, the catalog needs them at + several price points, and a stripped hull is a genuine handicap. Hands picks the other two + balance numbers; this one is authored to make unaided fire a last resort, and the operator + tunes it in play. +- **Q1, turrets: author `FiringArc: 360`.** No new type for what a number already says. +- **Q2, player fire is arc-gated** like everyone else's. Manual and programmatic must not be + two truths. +- **Q3, death removes the ship**, and loot stays as it is until the pickup cut. +- **Q5, seed the rolls in this campaign.** Two lines here beats waiting on an unstarted cut. +- **Q6, snapshot at fire, not at commit** (Self, accepting Imagination's counter-proposal to + the stats map's §8 seam). A shot carries the gun that fired it: its parameters freeze when + the trigger is pulled, and nothing about the shooter's heat, power or wear changes them + mid-flight. Evasion still decides the outcome, because deviation is measured at arrival + against the predicted intercept. `docs/stats-and-power-cut.md` §8 said "snapshot at commit"; + that is superseded, and the resolver's promise (a current value, not a stable one) is + honoured either way. +- **Q7, mine proximity physics: leave it.** + +## 0b. Identity, lifecycle, authority + +One row per kind this campaign introduces. No cut below is mapped with a cell empty. + +| Kind | What names it | What happens to it over time | Who decides | +|---|---|---|---| +| **A shot in flight** | `PendingShot`, a struct in `Zone.PendingShots` (a `List`), identified by a monotonic `int ShotId` from `Zone`. Nothing outside `Zone` holds a reference. | Born in `FireControl.Fire` when a weapon's burst step fires, carrying a payload snapshot frozen at that instant. Ages by `dt` in `FireControl.Step`, called from `Zone.Update` after entities. Reaches its commit horizon, then its arrival time, then is removed the same tick it resolves. A shot whose source or target entity leaves the zone resolves as a miss and is removed. Never serialised: a zone saved mid-flight loses its shots, which is correct — a save is a scene boundary. | `Zone` owns the collection and the identity. `FireControl` owns the transitions. Nothing else may add, remove or mutate one. | +| **A roll** | Nothing. It is not a thing; it is one draw from `ItemManager.Random` inside `FireControl.Commit`, and only its result is named. | Exists for the duration of one call. Its inputs are the payload snapshot plus deviation measured at that instant; its output is written into the shot's `Outcome` and never recomputed. | `FireControl` alone. No behaviour, agent, presentation or UI may draw for a hit. | +| **A commit** | `ShotOutcome { int ShotId; Entity Source; Entity Target; EquippedItem Weapon; bool Hit; bool Shielded; EquippedItem Aimed; int2 Cell; float ArrivalIn; DamageType DamageType; }`, published on `Zone.ShotCommitted` (a `Subject`). | Created once, at `ArrivalTime - CommitHorizon`, or at fire time when the flight is shorter than the horizon. Immutable from then on. Published once. Read by presentations and by nothing else. Dies with the shot. | `FireControl` creates it and is the only writer. Presentations are the only readers. **Nothing outside presentation may read a commit to change state** (`docs/shield-presentation-contract.md:57`). | +| **A targeting solution** | Not a stored object. `FireControl.HitProbability(weapon, source, target)` is a pure function, recomputed on demand. | Has no lifetime. AI reads it to decide whether to fire; the HUD reads it to draw a number; `FireControl.Fire` reads it once per shot and freezes the result into the payload. Two reads a tick apart may differ; nothing caches one. | `FireControl`. The targeting-system behaviour owns only its own resolved stats; it does not compute probability. | +| **Target data / knowledge of a subsystem** | `Entity.EntityInfoGathered[other]` (existing, `Entity.cs:57`) is the knowledge. `FireControl.IsRevealed(observer, item)` is the derived predicate over it. The current aim point is `Entity.TargetItem`, a `ReactiveProperty`. | Info accumulates and decays continuously under `Sensor` (`Sensor.cs:152-184`); ownership of that does not change. Reveal is **derived on every read**, never stored and never cached, so it falls back the moment info decays. `TargetItem` is written only by `Entity.TrySelectTargetItem`, nulled when `Target` changes, and **read through a derived accessor that returns null if the item is no longer revealed or no longer belongs to the target** — no loop clears it. | `Sensor` owns info. `FireControl` owns the reveal rule and the thresholds it reads. `Entity` owns the selection slot; `TrySelectTargetItem` is its only writer. Player input, `CombatState` and the test harness are its three callers. | +| **A hardpoint arc** | `HardpointData.FiringArc`, a `float` on catalog data (`ItemData.cs:538-545`), zero meaning "use the default". Resolved through `FireControl.ArcFor(item)`. | Authored once, in the catalog. Immutable at runtime. Read per shot and per AI fire decision; never stored on the entity. Mount direction is likewise derived per read from `Behavior.Direction`'s item-rotation path. | The catalog authors it; `FireControl` resolves and applies it; `GameplaySettings.FiringArc` supplies the default. **No prefab, transform, articulation limit or renderer may influence it.** | + +--- + +## Cut order + +1. **Mount direction and arc become simulation data.** Deletes `HardpointTransforms` + and the Unity readback; adds the arc gate; unbreaks headless combat. +2. **Targeting system and reveal.** The new subsystem, the reveal rule, the selection + slot, and the two dead settings wired up. +3. **Hit authority moves.** The Physics path in the four effects the catalog actually + uses is deleted and `FireControl` plus the shot lifecycle replaces it, in one cut, so + damage is never applied twice. +4. **The unused kinds join the same path.** Beams, hitscan, mines, splash and airburst + stop querying Physics. They keep existing (R9). + +Cuts 3 and 4 cannot be swapped, and 3 cannot be split into "roll" and "delete the old +path": a cut boundary between them is a cut boundary with two damage owners. + +--- + +## Cut 1. Mount direction and arc become simulation data + +- **Repo/branch:** `Aetheria`, branch `codex/fire-control` from `codex/item-provenance` + `8a00fc92`. Depends on nothing. +- **First:** update `F:\Projects\Aetheria-stats` to HEAD (it sits 11 commits behind at + `c4f6029e`); capture `AetherDb census`, `loadout 1`, `dangling` as the before side. + +**Deletes first:** + +- `Assets/Scripts/ServerShared/Entity.cs:58-59` — `HardpointTransforms` (2). +- `Assets/Scripts/Gameplay/EntityInstance.cs:514-517` — its only writer (4). +- `Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:30-35` — the readback branch in + `Behavior.Direction` (6). The item-rotation path at `:37-38` becomes the whole body. +- `Assets/Scripts/ServerShared/Agents/States/Combat.cs:100-102` — the + `HardpointTransforms` dot test (3). +- `Assets/Scripts/ServerShared/Behaviors/TurretController.cs:81-83` — the `.99f` dot + test (3). +- `Assets/Scripts/ServerShared/Settings.cs:218` — `AgentFiringMinDot`, read by nothing + (1). + +**Adds:** + +- `Assets/Scripts/ServerShared/FireControl.cs` — a new static class, this cut holding + only geometry: `MountDirection(EquippedItem)` (the planar unit vector from + `Behavior.Direction`), `ArcFor(EquippedItem)` and + `bool InArc(EquippedItem weapon, float3 toTarget)`, which tests + `dot(normalize(weapon.Direction.xz), normalize(toTarget.xz)) >= cos(radians(arc/2))`. + The test is planar (R7); target height never enters it. An arc of 360 or more passes + unconditionally. +- `HardpointData.FiringArc` (`ItemData.cs:545`, next `Key`) — full width in degrees, + `0` meaning "use `GameplaySettings.FiringArc`". +- `GameplaySettings.FiringArc = 120` (`Settings.cs`, after `:214`). +- `GameplaySettings.AgentMinHitProbability` is **not** added here; Cut 1's AI gate is + `InArc` alone, which is strictly closer to today's behaviour than a probability the + roll does not yet produce. + +**Per-file changes (against `8a00fc92`):** + +- `Combat.cs:100-102`: `shouldFire = FireControl.InArc(testWeapon.Item, toTarget)`. The + intercept prediction at `:84-98` stays — it still aims `LookDirection`, and under R3 it + becomes the predicted intercept a deviation is measured against. +- `TurretController.cs:81-83`: the same call. Under the default arc a turret hull, whose + `Direction` never rotates (`TurretController` writes only `LookDirection`), would + engage only within 60 degrees of its spawn facing. R6 says turrets are 360; the turret + hull's hardpoints author `FiringArc: 360`. See Q1. +- Catalog: author `FiringArc` on the `Turret` hull's two Ballistic hardpoints. Follows + the Cut C precedent — a Hands scratch console upserts, Unity and Studio closed. + +**Authority map:** + +- **Owner:** `FireControl` owns whether a weapon bears on a point. `HardpointData` + authors the arc; `ItemRotation` authors the mount direction. +- **Inputs:** the equipped item's hardpoint rotation, `HardpointData.FiringArc`, + `GameplaySettings.FiringArc`, the planar vector to the target. +- **Outputs:** one bool, consumed by `CombatState`, `TurretController`, and from Cut 3 + by `FireControl.Fire`. +- **Derived state:** `Behavior.Direction` becomes purely derived from item rotation. +- **Forbidden writers:** no Unity transform, `ArticulationPoint`, `WeaponHardpoint` or + prefab node may influence aim. `Barrels`, `GetBarrel` and `ArticulationPoint` survive + as presentation and must never be read by `ServerShared`. +- **Shared paths:** player fire, AI fire and turret fire all go through `InArc`. Player + fire is arc-gated for the first time; see Q2. +- **Deletion line:** `HardpointTransforms` and its writer and readers are gone before + `FireControl.cs` is written. + +**Verification:** + +- builds: `Aetheria.Shared/Aetheria.Shared.csproj`, `tests/Aetheria.Shared.Tests`, + `tools/AetherDb`. +- tests, each named with the rule it pins: + - `ArcFollowsMountRotation` pins *mount direction is the item's rotation, not the + hull's facing*: a fixture hull with one weapon on a `None` hardpoint and one on a + `Clockwise` hardpoint; a target 90 degrees to starboard passes only for the + `Clockwise` weapon, a target dead ahead only for the `None` weapon. Mutation: make + `InArc` use `Entity.Direction`; swap `Rotate`'s `Clockwise`/`CounterClockwise` cases. + - `ArcBoundaryIsHalfWidth` pins *`FiringArc` is full width*: at 120, a target 59 + degrees off passes and one at 61 does not. Mutation: compare against + `cos(radians(arc))`. + - `ArcIsPlanar` pins R7: a target inside the arc horizontally but far above the firer + still passes. Mutation: use the 3D vector. + - `HardpointOverrideBeatsDefault` pins *the per-hardpoint override wins*: the same + weapon on a hardpoint with `FiringArc: 360` passes at 170 degrees, and fails there on + a hardpoint with `0`. Mutation: ignore the field; treat `0` as an arc of zero. + - `CombatStateStepsHeadless` pins *the simulation decides aim without Unity*: a + `Minion` with a target in range steps through `Zone.Update` without throwing. + Mutation: restore the `HardpointTransforms` read. +- negative: `rg -n "HardpointTransforms" Assets/Scripts` empty; + `rg -n "ArticulationPoint|FiringPoint|WeaponHardpoint" Assets/Scripts/ServerShared` + empty; `rg -n "AgentFiringMinDot" Assets/Scripts` empty. (`\.99f` is **not** a safe + negative grep — it occurs legitimately in `LockWeapon.cs:56`. Grep the two files by + name instead.) +- operator: in Unity, side-mounted weapons still fire at targets abeam; a turret still + tracks all the way around; nothing fires through the hull. + +**Risk:** this cut changes what the AI fires at before the roll exists, so combat feel +shifts once and shifts again in Cut 3. That is the price of deleting the obsolete +authority first, and it is the right price. + +--- + +## Cut 2. Targeting system and reveal + +- **Repo/branch:** continues Cut 1. Depends on Cut 1 (uses `FireControl.cs`), and on + provenance Cut C if that has not landed, because Cut C re-upserts every `ItemData`. +- **First:** `AetherDb census` and `loadout 1` over several seeds, as the before side. + +**Deletes first:** nothing. This cut is entirely additive, which needs its account +(CODE IS A LIABILITY): the invariant it buys is R5, which cannot be purchased by moving +authority, because no authority for it exists — two authored settings are read by +nothing and there is no subsystem to carry accuracy. Cut 3 is where the subtraction is. + +**Adds:** + +- `Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs` — + `TargetingSystemData : BehaviorData` (next free `Union` index on `BehaviorData`; Hands + reads the current list in `Behaviors.cs` rather than trusting the number 39 quoted in + the superseded draft) plus a `TargetingSystem : Behavior` that resolves and exposes: + - `Accuracy` (0..1) — the ceiling on hit probability. + - `Resolution` (detection threshold..1) — the info level at which sensor state stops + limiting hits. + - `Precision` (0..1) — the chance a hit lands on the aimed item rather than a random + cell. + - `Tracking` — how much deviation it forgives (consumed in Cut 3). + + All four are `PerformanceStat`, so lot quality, wear, heat and `PowerSupply` reach them + through `EquippedItem.Evaluate` — that is the brownout ruling applied to accuracy, and + it is why a starved targeting system rolls worse instead of switching off. Energy and + heat come from the existing `EnergyDraw` and `Heat` behaviours on the item; the + subsystem adds no power machinery of its own. +- `GameplaySettings.UnaidedAccuracy` — what an entity with no working targeting system + fires with. Such an entity uses `Resolution = 1` and `Precision = 0`: it can still + select a revealed item, and its hits scatter. +- `FireControl.IsRevealed(Entity observer, EquippedItem item)` — derived, never cached: + 1. Rank the target's non-hull equipment: hardpoint-mounted first, then size + descending, then equipment index. + 2. Item `i` of `N` is revealed when + `info >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1))`. + + This is the first and only reader of `Settings.cs:212-213`. +- `Entity.TargetItem` (`ReactiveProperty`, beside `Target` at + `Entity.cs:46`) and `bool Entity.TrySelectTargetItem(EquippedItem)`, the only writer. + It accepts null, or an item of `Target.Value` that `IsRevealed`. A `Target` change + nulls it. The read accessor returns null when the item is no longer revealed, so decay + drops the aim point without a loop. +- Player input: a select/cycle command beside `ActionGameManager.cs:368-403`, calling + `TrySelectTargetItem`. The target schematic + (`Assets/Scripts/UI/HUD/SchematicDisplay.cs:109-119`, which today lists only weapon + items) shows revealed items and marks the selection. That is presentation; it decides + nothing. +- AI: `CombatState` selects the revealed target `Weapon` item with the highest + `RangeDamagePerSecond(range)`, or null. +- Catalog: two targeting-system designs, 1-cell and 2-cell, each with products from at + least the manufacturers that sell the capacitor, so `IsAvailable` finds one in every + galaxy. `LoadoutGenerator.FillInterior` picks one with `required: true` for every + entity with `Weapons` — the starting LonginusX, NPC ships and turrets. Zenith, with no + weapon hardpoints, gets none. + +**Authority map:** + +- **Owner:** `FireControl` owns the reveal rule. `Entity` owns the selection slot. + `TargetingSystem` owns nothing but its own resolved stats. +- **Inputs:** `EntityInfoGathered`, the two threshold settings, the target's equipment + list and item sizes. +- **Outputs:** a bool per (observer, item); a nullable `EquippedItem` per entity. +- **Derived state:** reveal is derived on read. `TargetItem`'s *validity* is derived; + only the slot is stored. +- **Forbidden writers:** the HUD may not write `TargetItem` except through + `TrySelectTargetItem`; no code may store a reveal result; no code may read the two + thresholds except `IsRevealed`. +- **Shared paths:** player selection, AI selection and the test harness use one writer. +- **Deletion line:** none, and that is stated rather than hidden. + +**Verification:** + +- builds: as Cut 1. +- tests: + - `SelectionNeedsReveal` pins *you cannot aim at what you have not resolved*: below + the item's tier `TrySelectTargetItem` returns false; above it, true; a target change + nulls it; info decaying below the tier makes the read null. Mutation: `IsRevealed` + returns true; the `Target` change skips the null; the accessor returns the stored + value without re-checking. + - `RevealOrder` pins *the big obvious things resolve first*: hardpoint items reveal + before interior items, larger before smaller. Mutation: drop the ordering. + - `RevealSpansBothThresholds` pins *the tiers are the authored ones*: with N items, + the first reveals at `TargetArmorInfoThreshold` and the last at + `TargetGearInfoThreshold`. Mutation: swap them; use the detection threshold. + - `UnaidedFiresWorse` pins *the subsystem is what supplies accuracy*: an entity with + no targeting system resolves `UnaidedAccuracy` and `Precision` 0; one with a + destroyed or offline system resolves the same. Mutation: fall back to the system's + stats when offline. + - `StarvedTargetingRollsWorse` pins the brownout ruling on accuracy: with an + `Accuracy` stat carrying a `PowerSupply` term, a half-grant halves it rather than + switching the item off. Mutation: gate on `PowerSupply <= 1e-4`. + - `EveryArmedLoadoutGetsATargetingSystem` pins the required-item rule across seeds. + Mutation: drop `required: true`. +- negative: `rg -n "TargetArmorInfoThreshold|TargetGearInfoThreshold" Assets/Scripts` + returns `Settings.cs` and `FireControl.cs` **only**. +- operator: `AetherDb census` shows 2 more designs; `loadout 1` shows one more interior + item per armed entity; `dangling` is 0; reopen reports 0 schema issues; CultCache + Studio renders the new behaviour union (an operator check, because Studio's handling of + a new union index is not something this map can promise). + +**Risk:** `LoadoutGenerator.EquipHardpoints` can already fit the Tractor Beam into +LonginusX's only Sensors slot, leaving an NPC that gathers no info and therefore reveals +nothing and hits nothing (`docs/headless-playground-cut.md:520-523`). Check `loadout` +over several seeds. The fix is data or `EquipHardpoints`, not fire control. + +--- + +## Cut 3. Hit authority moves + +The rebuild. The old path is deleted and the new owner added in the same cut, because +any boundary between them is a boundary with two damage owners. + +- **Repo/branch:** continues Cut 2. Depends on Cuts 1 and 2. +- **First:** capture a Unity play smoke of current combat feel (kill time against one + NPC, roughly) as the before side, since this cut changes feel more than anything in + the campaign. + +**Deletes first** (paths and line counts against `8a00fc92`): + +- `Assets/Scripts/Gameplay/EntityInstance.cs:277-396` — `DamageSchematic` and both + `HullCollider` subscriptions (120). +- `Assets/Scripts/Gameplay/HullCollider.cs:13-14,18-41,48-65` — `Hit`, `Splash`, + `SendHit`, `SendSplash` and both argument classes (~45 of 65). `OnCollisionEnter` + (`:43-46`) stays; ship collision is a deferred Unity-physics surface. +- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:57-103` — the raycast, shield branch and + `SendHit` (47). `:109-122` (airburst) is Cut 4's. +- `Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs:152-189` — the same (38). +- `Assets/Scripts/Gameplay/Weapons/Laser.cs:40-75` — the same; the `LineRenderer` + endpoint writes at `:59,70,77` are presentation and are rebuilt from the outcome (36). +- `Assets/Scripts/Gameplay/Weapons/Lightning.cs:34-72` — the same; the endpoint capture + at `:67-71` stays as a visual (39). +- `Assets/Scripts/Gameplay/EntityInstance.cs:411-431` — the `UnityEngine.Random` loot + roll, **only if Q3 is answered (a)**; otherwise this cut leaves it and the seam is + named in §6. + +**Adds:** + +- `Entity.DamageSchematic(float damage, Shape hitShape)` — the rule moved verbatim from + `EntityInstance.cs:277-314`, now a method on `Entity` in `ServerShared`, writing + `Armor`, item `Durability`, `Hull.Durability` and raising the existing `ArmorDamage`, + `ItemDamage` and `HullDamage` subjects. Not a redesign: the same arithmetic, the same + order, the same thresholds. +- `Entity.ApplyHit(...)` — the shape construction moved from + `EntityInstance.cs:334-394`, with two changes forced by R7: the hit cell comes from the + rolled `Cell` rather than a UV texture coordinate, and the penetration march rotates + the firer-to-target vector into the target's frame with a planar rotation by + `-target.Direction` instead of `transform.InverseTransformDirection`. +- `FireControl.HitProbability(weapon, source, target)` — pure, no draw: + - Zero, with no draw consumed, when there is no target; the target is not in + `VisibleEntities`; range is outside `[MinRange, Range]`; a `LockWeapon` is not + locked; or `InArc` fails. + - Otherwise `p = Accuracy * pSensor * pSpread`, where + `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info))` and + `pSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1`, with + `angularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range))`. + - `SchematicCellSize` is the one new tuning constant (Q4). +- `FireControl.Fire(weapon, item, source)` — called once per burst step from + `InstantWeapon.Execute` (replacing the bare `OnFire?.Invoke()` at + `InstantWeapon.cs:228`). It computes the predicted intercept, the flight time + (`range / Velocity`, or zero when `Velocity` is at or near zero), **freezes the payload + snapshot** (R10) and appends a `PendingShot` to `Zone.PendingShots`. The snapshot holds + `Damage`, `Penetration`, `DamageSpread`, `DamageType`, `Aimed`, the resolved targeting + stats, and the intercept point. Nothing downstream re-evaluates a stat. +- `FireControl.Step(Zone, dt)` — called from `Zone.Update` after the entity loop + (`Zone.cs:150`). For each pending shot: age it; at `ArrivalTime - CommitHorizon` + compute deviation, roll, write `Outcome`, publish `Zone.ShotCommitted`; at + `ArrivalTime` apply the outcome and remove the shot. A shot whose flight is shorter + than the horizon commits at fire time, in the same call as `Fire` — that is every + velocity-0 weapon, which is three of the catalog's eighteen. +- `GameplaySettings.CommitHorizon` — R4's authored horizon, one number, in seconds. +- The deviation term: `pDeviation` folds in how far the target is from the predicted + intercept at commit time, forgiven by the targeting system's `Tracking`. This is R3's + evasion, and the commit horizon is what bounds how late it counts. +- The roll: one draw against `p`. On a hit, a second draw against `Precision` decides + where — on `Aimed`'s footprint cells on success, otherwise on one cell drawn uniformly + over the hull `Shape`. An active shield that `CanTakeHit` takes the hit instead, and + the outcome is marked `Shielded`; that rule now exists once, in `FireControl`, rather + than seven times in the effects. +- `Zone.ShotCommitted` and `Zone.ShotResolved` (`Subject`), the presentation + feed. +- `InstantWeapon.OnFire` becomes `event Action` carrying the `ShotId`, so the + Unity manager can bind its effect to the shot it belongs to. + +**Per-file changes:** + +- `InstantWeapon.cs:228`: `OnFire?.Invoke(FireControl.Fire(this, Item, Entity))`. + Charge multipliers already fold into `Damage` and `Spread` before this point. +- `Combat.cs:100-102`: + `shouldFire = FireControl.HitProbability(...) >= Settings.AgentMinHitProbability`, + replacing Cut 1's bare `InArc` (which the probability already subsumes, returning zero + out of arc). `AgentMinHitProbability` is added here, not in Cut 1. +- `TurretController.cs:81-83`: the same gate and threshold. +- `EntityInstance.cs:203-204,232-235`: the manager call takes the `ShotId` and looks the + outcome up, or subscribes to `ShotCommitted`. Effects fly to the rolled cell's world + point or to a miss offset and play the impact on arrival. **They apply nothing.** +- `Zone.cs:150`: `FireControl.Step(this, deltaTime)` after the entity loop. + +**Authority map:** + +- **Owner:** `FireControl` owns the engage gate, hit probability, the roll, the cell, the + shield branch, and when each of those happens. `Entity` owns the application of damage + to its own schematic. `Zone` owns the pending-shot collection. +- **Inputs:** weapon stats through `EquippedItem.Evaluate`; targeting-system stats; + `EntityInfoGathered`; `Target` and `TargetItem`; both entities' positions, velocities + and planar directions; mount direction and arc; `ItemManager.Random`. +- **Outputs:** `ShotOutcome` on two subjects; `Entity.DamageSchematic`; shield + `TakeHit`; `Entity.IncomingHit`. +- **Derived state:** `Behavior.Direction`, reveal, hit probability. Everything a + presentation shows about a shot is derived from the outcome. +- **Forbidden writers:** after this cut no `Physics.*` query in + `Assets/Scripts/Gameplay/Weapons` (except the unused four Cut 4 handles) may reach + durability; `HullCollider` may not raise a hit; `EntityInstance` may not damage a + schematic; no effect may call `TakeHit`; nothing outside `FireControl` may draw for a + hit; nothing outside presentation may read a commit to change state. +- **Shared paths:** player fire, AI fire, turret fire, instant, charged, guided and + locked weapons all reach damage through `FireControl.Fire` and `FireControl.Step`. + There is exactly one commit primitive. +- **Deletion line:** the six deletions above land before `FireControl.Fire` exists. + +**Verification:** + +- builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`; and Unity + batchmode with the editor closed (`docs/item-provenance-cut.md` §6) reporting no + `error CS`. +- tests, each with the mutation that must kill it: + + | Test | Pins | Mutation | + |---|---|---| + | `RollsAreSeeded` | *a fight is reproducible* — two zones, same seed, 50 shots, identical outcome sequences | `FireControl` draws from `new Random()` | + | `ProbabilityFollowsInputs` | *the roll reads what the ruling says it reads* — zero below detection and outside range; rising with info to `Resolution`; falling with `Spread`; capped at `UnaidedAccuracy` with no system | drop `pSensor`; drop the range gate; drop the cap | + | `OutOfArcConsumesNoDraw` | *the gate is a gate, not a penalty* — the RNG sequence is unchanged by an out-of-arc trigger | roll first and multiply by zero | + | `ShotResolvesOnArrival` | R3 — no durability change before `range / Velocity`; a target removed mid-flight takes none | apply at fire time | + | `OutcomeCommitsBeforeImpact` | R4 — `ShotCommitted` fires exactly `CommitHorizon` before `ShotResolved`, once per shot | publish at arrival; publish twice | + | `EvasionCountsUntilCommitAndNotAfter` | R4's second half — a target that jinks before the horizon changes the outcome distribution; the identical jink after the horizon does not | measure deviation at arrival; measure it at fire | + | `ShortFlightCommitsAtFire` | *the horizon degrades gracefully* — a velocity-0 weapon commits and resolves in one tick, still publishing both events in order | skip the commit for short flights | + | `OutcomeIsSnapshotNotReread` | R10, the seam the stats campaign named — a weapon whose `Damage` collapses (power cut, heat spike) between fire and arrival still lands the damage it rolled | re-evaluate `Damage` at arrival | + | `AimedHitLandsOnSelectedItem` | R5's payoff — `Precision` 1 and `p` 1: only the aimed item's durability falls | the roll ignores `Aimed` | + | `HardpointHitDamagesItemThenHull` | the damage rule survived the move verbatim — damage above armor plus item durability zeroes the item and the remainder hits the hull | armor not subtracted; hull skipped | + | `PenetrationMarchIsPlanar` | R7 — the march rotates by the target's planar direction and gives the same cells whatever the height difference | use a 3D rotation | + | `ShieldTakesHit` | *the absorb rule has one owner* — an active shield absorbs and the schematic is untouched | remove the shield branch | + | `DeadEntityStopsTakingShots` | *a shot cannot damage a corpse* — a target destroyed before arrival takes nothing and the shot is removed | resolve against a removed entity | + +- negative greps, each checked against legitimate names before publishing: + - `rg -n "Physics\." Assets/Scripts/Gameplay/Weapons/Projectile.cs Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs Assets/Scripts/Gameplay/Weapons/Laser.cs Assets/Scripts/Gameplay/Weapons/Lightning.cs Assets/Scripts/Gameplay/HullCollider.cs` + — empty. (A repo-wide `Physics\.` grep is **not** valid until Cut 4, and never for + `TractorBeam.cs`, which stays.) + - `rg -n "SendHit|SendSplash|DamageSchematic" Assets/Scripts --glob '!**/ServerShared/**'` + — empty. + - `rg -n "TakeHit\(" Assets/Scripts --glob '!**/ServerShared/**'` — empty. Note + `CanTakeHit` shares the substring; the trailing `(` on `TakeHit` distinguishes them, + and the `Can` prefix must be checked by eye in the first run. +- operator play smoke: shots show rolled impacts and misses; a miss reads as a + deliberate near-miss rather than a bug; damage matches the HUD; aiming at a revealed + subsystem visibly concentrates damage there; kill time is in the same order as before. + +**Risks.** `UnaidedAccuracy`, `SchematicCellSize`, `CommitHorizon` and the targeting stat +ranges are first guesses. The headless fixture is the tuning harness, and the operator +smoke is the arbiter. Second: `Combat.cs:84-98` aims `LookDirection` at a first-order +intercept, and R3's deviation is measured against `FireControl`'s own predicted +intercept; if the two predictions differ, an AI will systematically miss. They must be +one function. Hands makes `FireControl` own the prediction and `CombatState` call it. + +--- + +## Cut 4. The unused kinds join the same path + +R9 keeps every weapon type. This cut makes the ones no design currently ships obey the +same owner, so that authoring one later does not resurrect Unity hit detection. + +- **Repo/branch:** continues Cut 3. Depends on Cut 3. +- **First:** **re-measure the claim this cut's safety rests on** — that no live design + uses `ConstantWeaponData`, `HitscanEffect`, `Mine`, airburst or MIRV (§0.4, carried + from a superseded map and not reproduced by this campaign's probe). A behaviour-class + census needs `AetherDb roles-migrate` as a dry run, which the permission classifier + blocked; the operator can unblock it or Hands can add a read-only `behaviors` command. + **If any of these is live, it moves into Cut 3 instead.** + +**Deletes first:** + +- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:109-122` — airburst `OverlapSphere` and + `SendSplash` (14). +- `Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs:74-110` (~37), + `ConstantLightning.cs:54-70` (~17), `HitscanEffect.cs:31-55` (~25), + `Mine.cs:68-75,92-117` (~34), `ConstantParticleWeapon.cs:72` and its `_hull` plumbing + (~8) — every remaining `Physics.*` hit query and `SendHit`/`SendSplash` call in + `Assets/Scripts/Gameplay/Weapons`. + +**Adds:** + +- `ConstantWeapon` rolls once per `GameplaySettings.BeamResolveInterval` for + `Damage * interval`, through the same `FireControl.Fire`/`Step` pair with a flight time + of zero, raising `OnBeamShot(int shotId)`. About ten lines; no design uses it yet, and + it authors no new stat. +- Splash — airburst and mine blast — as `FireControl.Splash`, applying the existing + `DamageSchematic` over the directional half-shape the deleted + `EntityInstance.cs:318-329` computed, against every entity in radius. This is the one + genuinely new rule in the cut, and it exists because R9 keeps the flak cannon and the + mine. +- Nothing else. `Mine`, `TractorBeam` and ship collision stay Unity-physics surfaces for + *placement and contact*, which is not hit detection; only their damage application + moves. + +**Authority map:** unchanged from Cut 3 except that the forbidden-writer list now covers +every file in `Assets/Scripts/Gameplay/Weapons`, and `FireControl` gains splash. + +**Verification:** + +- builds: as Cut 3. +- tests: + - `BeamRollsPerInterval` pins *a beam is a sequence of rolls, not a continuous truth*: + over one second a beam produces `1 / BeamResolveInterval` outcomes totalling + `Damage`. Mutation: roll per tick; roll once. + - `SplashHitsEveryEntityInRadius` pins *splash is one rule, not per-effect*: two + targets in radius both take damage, one outside takes none. Mutation: damage only + the nearest. + - `SplashIsDirectional` pins that the moved half-shape rule survived: cells facing the + blast take it, cells facing away do not. Mutation: damage the whole shape. +- negative: `rg -n "Physics\." Assets/Scripts/Gameplay/Weapons` returns **only** + `Mine.cs`'s placement query, if Q7 keeps it — otherwise empty. `TractorBeam.cs:21` is + outside this directory and stays. + `rg -n "SendHit|SendSplash" Assets/Scripts` empty. + `rg -n "class HullHitEventArgs|class HullSplashEventArgs" Assets/Scripts` empty. +- operator: nothing to check in play, because nothing in the catalog spawns these. The + check is a Studio authoring pass: author one beam product and one flak product onto a + test hull and confirm they damage. + +--- + +## 5. Subtraction ledger + +Estimates, to be compared with reality in the postmortem. + +| Cut | Removed | Added | Deps / targets / formats | +|---|---|---|---| +| 1 | 19 production (Entity 2, EntityInstance 4, Behaviors 6, Combat 3, TurretController 3, Settings 1) | ~60 production (`FireControl.cs` geometry ~45, `HardpointData.FiringArc` 1, `GameplaySettings.FiringArc` 1, call sites ~13), ~90 test | +1 catalog field, authored on one hull. No new project, package or dependency. | +| 2 | 0 | ~140 production (`TargetingSystem.cs` ~55, reveal ~25, `Entity.TargetItem` ~25, AI selection ~10, player input ~15, `LoadoutGenerator` ~12), ~160 test | +1 behaviour union index, +2 designs and their products, +1 setting. | +| 3 | ~285 production (EntityInstance 120, HullCollider 45, Projectile 47, GuidedProjectile 38, Laser 36, Lightning 39) | ~330 production (`Entity.DamageSchematic`/`ApplyHit` ~130 moved, `FireControl` roll and lifecycle ~140, `Zone` pending shots ~35, call sites ~25), ~280 test | +2 settings. **Seven duplicated copies of the shield-absorb rule collapse to one.** | +| 4 | ~135 production | ~70 production, ~70 test | +1 setting. | +| **Total** | **~439** | **~600 production, ~600 test** | | + +Net production about +160. The account: it buys the invariant that the simulation +resolves combat without Unity — `docs/three-gates-scope.md:101-110` calls today's state +a velocity compromise — and it collapses seven copies of the absorb rule and one copy of +the damage rule into single owners. It does **not** buy that with a registry, an adapter, +a mode flag or a compatibility layer: the old path is deleted in the cut that replaces +it, and no cut leaves two writers of damage. + +The line count is the wrong thing to stare at here. The structural delta is: one new +owner (`FireControl`), one new subsystem (`TargetingSystem`), one field on catalog data, +four settings, and **minus one whole category of authority** — Unity physics deciding +gameplay. + +--- + +## 6. Build budget + +- **Packages and targets.** `Aetheria.Shared/Aetheria.Shared.csproj` (netstandard2.1), + `tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj` (net10.0, xunit), + `tools/AetherDb/AetherDb.csproj`. Three projects, no new ones. Debug only; no release + profile, no feature flags, no code generation, no new target platform. +- **Build host and target are the same machine**: a Windows workstation producing + managed assemblies for the same runtime. Nothing here is cross-platform, so no claim + about a Linux artifact is made or needed. +- **Where.** `F:\Projects\Aetheria-stats` (the free worktree; it must first be advanced + from `c4f6029e` to HEAD). Never the main tree — the operator plays there. Never + `F:\Projects\Aetheria-tiers`. CultLib via + `-p:CultLibRoot=\cultlib-45c2f40`, the shared pinned worktree, which is + never removed. `--artifacts-path` into scratch so no `bin`/`obj` lands in a working + tree. +- **Footprint.** Three managed projects plus their test host; on the order of a few + hundred MB of artifacts, comparable to the stats campaign. No expected growth: this + campaign adds no project, package reference or native component. +- **Unity.** Batchmode compile check with the editor closed, per + `docs/item-provenance-cut.md` §6, once per cut that touches `Assets/Scripts/Gameplay` + (Cuts 1, 3, 4). The operator's play smoke is the only Unity *run*. Imagination did not + run Unity and did not build. +- **Mutation harnesses.** One per cut, in the established shape + (`tests/mutation_tests_fire_control_cut1..4.py`): patch a named anchor, run + `dotnet test tests/Aetheria.Shared.Tests --cultlib-root `, assert the named test + goes red, with a no-op control. Note the scar from `8dddda4d`: later cuts move earlier + anchors, so Cut 4 re-anchors Cuts 1-3's harnesses before it lands. + +--- + +## 7. Seams this campaign names and stops at + +- **Presentation of shots.** `Zone.ShotCommitted` and `Zone.ShotResolved` are the whole + contract. What a projectile, panel, whip or near-miss *does* with a commit belongs to + `docs/shield-presentation-contract.md` and the panel and Lariat maps. This campaign + guarantees only that the commit exists, is authoritative, arrives `CommitHorizon` + before impact, and carries what the contract says it carries: what happens, where, + when it arrives, and which capability answers it — not who fired, not damage numbers. + The contract's open question "where the commit window lives" is answered here: + `FireControl`, with the horizon in `GameplaySettings`. +- **Death, removal and loot.** `Entity.Death` is already composed in `ServerShared` + (`Entity.cs:363-366`), but `Zone.Entities.Remove` and the loot roll live in Unity + (`EntityInstance.cs:411-437`) and use `UnityEngine.Random`. Fire control makes a + headless kill *happen*; it does not make the corpse go away. Q3. +- **Pickup.** `ShieldManager.cs:40-51` stores loot on trigger contact. Untouched. +- **Ship collision, `TractorBeam`, mine placement.** Deferred Unity-physics surfaces + (`docs/three-gates-scope.md:98-99`). Untouched. +- **The navigation planner.** R3 makes manoeuvring evasion, which changes what a good + flight path is. The planner is a separate item in the shipping scope and this map does + not anticipate it. +- **Stance and IFF.** Ruled and partly built (`Weapon.StanceAllowsFire`, + `Weapon.cs:101`). Fire control reads the gate and does not redesign it. +- **Faction territory.** The campaign after this. + +--- + +## 8. Operator questions + +Most blocking first. + +**Q1. What makes a turret 360?** R6 says turrets are 360 degrees, but a turret hull's +`Direction` never rotates — `TurretController` writes only `LookDirection` — so under a +120-degree default a turret would only engage within 60 degrees of its spawn facing. +**A:** author `FiringArc: 360` on the `Turret` hull's hardpoints; the override R6 already +grants does the whole job and no code knows what a turret is. **B:** add +`HardpointType.Turret` and give it an implicit 360. **C:** give the arc to the hull +rather than the hardpoint. **Recommended: A**, because it buys the invariant with +authored data instead of a new type, and because a future partially-traversing turret +(a 270-degree sponson) is then already expressible. B makes the arc a property of a +category and loses that. + +**Q2. Is player fire arc-gated?** Today it is not gated at all; the player fires whatever +is equipped, wherever they point. Cut 1 makes the arc a shared rule. **A:** yes, gate the +player exactly as the AI — a side-mounted gun simply does not fire forward, and the HUD +shows which groups bear. **B:** gate the AI only, leaving the player's rear gun able to +shoot forward. **Recommended: A.** Under R1 aiming is gone, so what the player does with +a weapon group *is* the decision, and arcs are what make that decision interesting. B +also splits the shared path the doctrine cares about: manual and programmatic fire would +be two truths. It is a real difficulty change and should be felt in the smoke before the +campaign closes. + +**Q3. Does this campaign move death removal and loot, or leave them in Unity?** A +headless kill currently leaves the corpse in `Zone.Entities`, because `Entities.Remove` +is at `EntityInstance.cs:437`. **A:** move removal and the loot roll into `Zone` in +Cut 3, using `ItemManager.Random` — about 50 lines, and it makes a headless kill a +complete event. **B:** move only the removal; leave loot to the run-structure work. +**C:** leave both; fire control's tests assert on durability, not on removal. +**Recommended: B.** Removal is the half without which the simulation is lying about its +own contents, and a seeded loot roll pulls in drop velocity, bay capacity and pickup — +the run-structure campaign's material. A is more complete but widens this campaign; C +leaves `ServerShared` unable to say who is alive. + +**Q4. Three balance numbers need first values:** `CommitHorizon` (R4's "short fixed +time"), `SchematicCellSize` (metres per schematic cell, which sets how much target size +matters), and `UnaidedAccuracy`. **A:** Hands picks defaults (0.3s, measured off the +LonginusX prefab, 0.35) and the operator tunes them in the smoke. **B:** the operator +states them now. **Recommended: A** for the first two and **B for `UnaidedAccuracy`**, +because it is not a tuning number — it decides whether a targeting system is a +requirement or an upgrade, which is a design ruling. If it is low, Cut 2's required-item +rule is load-bearing; if it is high, the subsystem is a nice-to-have and Cut 2 shrinks. + +**Q5. What seeds the rolls?** `ItemManager.Random` is clock-seeded +(`ItemManager.cs:19`), so `RollsAreSeeded` cannot pass without a seed argument. +**A:** add the seed parameter here, as a two-line change. **B:** wait for the `Run` +lifecycle work that owns it (`docs/headless-playground-cut.md` Cut 1, unlanded). +**Recommended: A.** A reproducible fight is how this campaign is tuned and how a +regression is ever found again; waiting on an unstarted cut for two lines is the +compensator pattern in miniature. + +**Q6. When is the snapshot taken — fire, or commit?** `docs/stats-and-power-cut.md:1020` +says "at commit time". **A:** one snapshot, at fire: the shot carries what left the +barrel, and only deviation is measured up to the commit. **B:** two snapshots — payload +at fire, targeting stats at commit — so a targeting system knocked out mid-flight +degrades the shot already in the air. **Recommended: A.** One rule, one freeze point, and +over a horizon measured in tenths of a second B's extra fidelity is invisible while its +extra state is permanent. B is the more simulationist answer and is worth having if the +operator wants killing the gunnery computer to spoil shots already fired. + +**Q7. Mines.** Cut 4 moves mine *damage* to `FireControl.Splash` but leaves +`Mine.cs:68`'s `OverlapSphere` for proximity triggering, which is contact detection +rather than hit detection. **A:** leave it, alongside `TractorBeam` and ship collision, +as a named deferred physics surface. **B:** move proximity into `Zone` too, so the last +`Physics.*` in the weapons directory goes. **Recommended: A.** No design ships a mine, +and B buys a clean grep rather than an invariant. Revisit when a mine is authored. + +--- + +## Cut 5: the rules that landed in one path and not its twin + +Date: 2026-09-19. Branch `codex/fire-control-5` off `ca57819d`. + +Soul's pass over the whole campaign (2026-09-19) confirmed eleven findings. Most +of them are the same shape: a rule this map declared, implemented on one path, +and left off the path beside it. Splash breaks a shield the discrete roll does +not. `InstantWeapon` gates on arc and `ConstantWeapon` does not. Unity removes a +corpse from the zone and the simulation does not. This cut closes the twins. +Catalog authoring (Soul's findings 1 and 2) is Cut 6; the shared-RNG stream +(finding 6) and airburst (finding 5) are Cut 6 as well; the missing mutation +harnesses (finding 8) are Cut 7. + +### 5.1 Tracking is a floor, not a cliff (Soul finding 3) + +`FireControl.cs:102-106` falls back to `Tracking = 0` with no targeting system, +and `:273-275` turns that zero into `deviation < .01f ? 1f : 0f`. A probe fired +334 shots at a target drifting at 5 u/s with `Accuracy` forced to 1 and landed +none. Q4 asked for unaided fire to be really, really bad; this is a wall. + +- `Settings.cs`, beside `UnaidedAccuracy`: add `public float UnaidedTracking = 10f;` + with the same authoring note. Ten world units of deviation forgiveness is the + first guess and the operator's knob: a target that has strayed five units from + its fire-time projection halves an already-5% chance. +- `FireControl.Tracking` falls back to `settings.UnaidedTracking`, exactly as + `Accuracy` falls back to `UnaidedAccuracy`. One shape for both. +- `Commit` then deletes the zero branch outright: `p *= saturate(1f - deviation / shot.Tracking)`. + Tracking is now always positive, so the branch protects nothing. + +**Authority:** `Settings` owns the unaided figures; `FireControl.Tracking` is the +only reader; `Commit` no longer carries a special case for a value that cannot occur. + +### 5.2 A shot the shield cannot absorb breaks it (Soul finding 4) + +`Commit:299-302` sets `Shielded` only when `CanTakeHit` passes, and `Apply:311-326` +routes the rest to the hull -- neither calls `Break()`. `Splash:365` does. The +deleted Unity path did (`git show b7743789^:Assets/Scripts/Gameplay/Weapons/Projectile.cs`). +So the F4/F5 shield-break mechanic is unreachable for every weapon in the catalog: +an unabsorbable 5000-damage hit leaves `Broken == false` and the shield absorbs the +very next shot, refilling at `RefillDuration` instead of the punitive `RestoreDuration`. + +- `ShotOutcome` gains `public bool ShieldBroken;`. +- `Commit` decides it where it already asks `CanTakeHit`: shield present, active, + and `CanTakeHit` false means `ShieldBroken = true`. The decision is frozen with + the rest of the outcome (R4) -- a shield that recharges during the flight does + not retroactively survive. +- `Apply` performs it: `if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();` + before the hull damage. `CanTakeHit` is still called exactly once per shot. +- Presentation gains the signal for free; nothing is required to read it yet. + +### 5.3 Continuous weapons obey their arc (Soul finding 9) + +`ConstantWeapon.cs:91,99` gate on `StanceAllowsFire` alone. A side-mounted beam +fires forward. Q2 says manual and programmatic must not be two truths; instant and +continuous currently are. + +- Both gates become `StanceAllowsFire && ArcAllowsFire`, matching `InstantWeapon.cs:105`. + +### 5.4 One bearing test, including at zero range (Soul finding 11) + +`Weapon.cs:117` allows a zero-length bearing; `FireControl.InArc:47` normalizes the +zero vector into NaN and `HitProbability` returns 0. The player's trigger says fire +and the probability says impossible, so AI and turrets refuse to fire at a +co-located target. + +- `InArc` takes the rule: a planar bearing shorter than `1e-6` returns true -- + point-blank cannot fail a bearing test. +- `Weapon.ArcAllowsFire` drops its own `lengthsq` special case and simply calls + `InArc`. One owner, one answer. + +### 5.5 A destroyed subsystem stops being the aim point (Soul finding 10) + +A destroyed item is never removed from `Equipment` (`Entity.cs:475` only observes +it), so `IsRevealed` still ranks it and `ResolvedTargetItem` still returns it -- +`Precision` keeps concentrating hits onto a dead item's footprint. + +- `FireControl.IsRevealed` returns false for an item whose + `EquippableItem.Durability < .01f`, and the ranking excludes destroyed items so + the reveal tiers of the survivors close up rather than leaving a gap. +- `Entity.ResolvedTargetItem` therefore drops a destroyed aim point on its next + read. Nothing caches it, so no clearing pass is needed. + +### 5.6 Death removes the ship, in the simulation (Soul finding 7) + +Operator ruling Q3 says death removes the ship. `Zone.Entities.Remove` for death +exists only at `EntityInstance.cs:324`, inside Unity's loot-drop subscription. So +headless, `Step`'s `targetGone` guard never fires, shots land on corpses, `Splash` +keeps hitting them, the AI keeps engaging them, and the tuning harness this map +calls its arbiter cannot end a fight. `DeadEntityStopsTakingShots` passes only +because it calls `Entities.Remove` by hand. + +- `Zone` subscribes each entity's `Death` observable when the entity joins, and + removes it from `Entities` on death. The simulation owns the corpse. +- `EntityInstance.cs:324` deletes its `Entities.Remove` call and keeps the loot + drop and destroy effect, which are its own business. Forbidden writer: no + presentation removes an entity from the zone. + +### 5.7 Delete the two carried-and-unread fields (Soul finding 12) + +`PendingShot.PredictedIntercept` and `PendingShot.FlightTime` are written at +`FireControl.cs:204-205` and read nowhere. The intercept in particular is +carry-weight from Cut 3's named risk: `Combat.cs:109` and `Fire` do share +`FireControl.PredictedIntercept`, but the roll judges deviation against a +straight-line projection from fire position, so the stored copy decides nothing. + +- Both fields are deleted from the struct. The local `flightTime` stays; it still + computes `ArrivalTime` and `CommitTime`. + +### Verification + +Each of 5.1-5.7 gets a test that fails under its own mutation, and the mutation +harness is committed as `tests/mutation_tests_fire_control_cut5.sh` with a no-op +control. In addition this cut pins four of the five mutations that survived 197 +green tests in Soul's pass, because they are rules 5.1-5.6 touch: + +- `FireControl.cs:365` -- delete `shield.Break()` from `Splash`. Must die. +- `Weapon.cs:117` -- `ArcAllowsFire => true`. Must die. +- `InstantWeapon.cs:105` -- delete the `if (!ArcAllowsFire) return;` player arc gate + (Q2, the ruling Cut 1 deferred to Cut 3 and nothing verifies). Must die. +- `FireControl.cs:270` -- `elapsed = shot.FlightTime` in place of `now - shot.FireTime`. + This is the map's own declared mutation for `EvasionCountsUntilCommitAndNotAfter`, + and it survives because that test's fixture gives the target zero velocity, so + `FireTargetVelocity * elapsed` is identically zero and the mutation is a no-op + inside it. Give the fixture a moving target. (After 5.7 deletes `FlightTime` the + mutation's spelling becomes `elapsed = shot.ArrivalTime - shot.FireTime`; the rule + it attacks is the same.) + +The fifth survivor -- freezing `source.TargetItem.Value` instead of +`ResolvedTargetItem` at `FireControl.cs:193`, bypassing the reveal re-check -- is +Cut 7's, alongside the missing Cut 1-3 harnesses. + +Negative checks: no `Entities.Remove` for death outside `Zone`; no `Tracking` read +that can return zero; no `PredictedIntercept` or `FlightTime` field on `PendingShot`. + +--- + +## Cut 6: the dice stop being shared, and the flak cannon works again + +Date: 2026-09-19. Branch `codex/fire-control-6` off Cut 5. + +Cut 6a (catalog authoring, worktree `Aetheria-catalog`, branch +`codex/fire-control-6-catalog`) runs in parallel and is specified by Soul's +findings 1 and 2 plus the Cut 2 authoring spec above. This section is Cut 6b, the +two remaining code findings. + +### 6.1 A shot's dice belong to the shot (Soul finding 6) + +`Commit:266,304` draws from `ItemManager.Random`, a single stream shared with +`ActionGameManager.cs:552,558,567,686,787` (wormhole exit velocity, loadout +generation, travel) and `Combat.cs:144` (`SampleDps`, drawn per AI per +evaluation). Q5's reproducible fight therefore holds only inside a hermetic +fixture: in play, adding an NPC or opening a shop changes every subsequent die. +Soul's determinism probes passed for exactly that reason, which makes them a +demonstration of the fixture rather than of the rule. + +The fix is a deletion, not a second stream. A stream makes the roll depend on +*how many* draws came before it; what this campaign actually promised is that a +shot's outcome is a function of the shot. So make it one. + +- `Zone` exposes `public uint CombatSeed { get; }`, set in the constructor from + the same expression that already seeds `_random`: + `galaxyZone?.Name.StableHash() ?? 1337u`. One zone, one stable identity — a + galaxy seed reproduces it, and nothing else in the zone can perturb it. +- `Commit` stops touching `ItemManager.Random` entirely. It builds its own local + `CultMath.Random` from the zone seed and the shot's own id: + `var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);` + The `| 1u` guards the degenerate zero seed. Every draw the commit makes — the + hit roll, the precision roll, the cell pick — comes from that one local + generator, and it dies with the call. +- The write-back at `:304` (`shot.Source.ItemManager.Random = random;`) is + deleted with it. + +**Authority:** the roll's randomness is a pure function of `(zone identity, shot +id)`. **Forbidden writers:** nothing outside `Commit` may seed or advance a +combat draw, and `Commit` may not read a shared stream. **Consequence worth +stating:** two shots that happen to share a `ShotId` across two zones roll +differently, because the zone seed differs; two runs of the same fight from the +same galaxy seed roll identically no matter what the UI did in between, which is +the promise Q5 actually made. + +### 6.2 Airburst resolves in the simulation (Soul finding 5) + +Cut 4 deleted `Projectile.cs`'s `OverlapSphere`/`SendSplash` and added +`FireControl.Splash` "because R9 keeps the flak cannon and the mine". Only the +mine was wired (`Mine.cs:99`). `Projectile.cs:16-17` still carries +`AirburstDistance`/`AirburstRange`, still written by `ProjectileManager.cs:32`, +read by nothing — so a flak round flies and does nothing. The stale comment at +`Projectile.cs:7-8` calls airburst "Cut 4's — untouched here". + +The wiring does **not** go back into `Projectile`. R8 says presentation does not +decide damage, and a projectile MonoBehaviour calling `Splash` would be exactly +the authority Cut 3 spent itself deleting. Airburst is a property of the weapon, +so the simulation resolves it: + +- `PendingShot` regains a burst point — `public float3 BurstPosition;` — frozen + at fire like every other payload field, from `PredictedIntercept` (which Cut 5 + deletes as unread; this is its live consumer, and it comes back named for what + it is rather than for what computed it). +- `PendingShot` gains `public float BurstRadius;`, frozen from the weapon's + authored airburst range, and zero for a weapon without the `Airburst` flag + (`Enums.cs:101`). +- `FireControl.Fire` sets both when the weapon's `WeaponData` carries `Airburst`. +- `FireControl.Step`, at arrival, resolves an airburst shot by calling + `Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType)` + **instead of** `Apply`. An airburst round does not also roll a discrete hit: + it is an area effect, which is the whole point of the flag, and applying both + would be the double-application Soul was told to hunt for. +- `Projectile.cs` deletes `AirburstDistance` and `AirburstRange` and the stale + comment; `ProjectileManager.cs:32` deletes the line that wrote one. The + projectile's remaining job is to fly and disappear, which is all presentation + should have been doing. + +**Open tuning question, recorded not asked:** the authored airburst radius has no +sibling in the catalog to argue from, because no product has ever used it. Cut 6a +or a later authoring pass sets it; until then the flag is wired and unexercised, +and the report must say so rather than implying a working flak cannon. + +### Verification + +Committed harness `tests/mutation_tests_fire_control_cut6.sh`, no-op control +required, same byte-exact I/O rules as Cut 5. + +- `SameFightRollsSameThroughUnrelatedDraws` pins 6.1: run a fixed fight twice from + one zone seed, drawing an arbitrary number of values from `ItemManager.Random` + between the two runs, and the outcomes must be identical. Mutation: restore the + `ItemManager.Random` draw. Must die — and note that this is the test Soul's own + probe could not be, because a hermetic fixture cannot see the defect. +- `ShotIdDecidesTheDie` pins that the roll is a function of the shot: two shots + with the same frozen payload and different ids may differ; the same id in the + same zone always gives the same result. Mutation: drop `ShotId` from the seed. +- `AirburstSplashesAndDoesNotAlsoRoll` pins 6.2: an airburst shot damages + everything in its radius, and its target takes area damage rather than a + discrete cell hit. Mutation: call `Apply` as well as `Splash`. Must die. +- `NonAirburstNeverSplashes` pins the other side. Mutation: splash unconditionally. +- negative: `ItemManager.Random` appears nowhere in `FireControl.cs`; + `Airburst` appears nowhere in `Assets/Scripts/Gameplay`. + +--- + +## Cut 6c: the formula gets fixed, and the single point of failure + +Date: 2026-09-19. Branch off Cut 6a (`e5f1318a`) once Cut 5 has merged — this +touches `FireControl.cs`, which Cut 5 owns until then. + +Cut 6a landed the catalog data that Cut 2 never shipped: 53 designs, 43 products, +`loadout` green on ten seeds, turret arcs migrated. Reviewing it turned up two +defects it introduced and one it inherited. + +### 6c.1 Resolution becomes reciprocal, so the name stops lying + +`HitProbability` computes `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution(source), info))`. +With the live `TargetDetectionInfoThreshold` of `.1`, a **higher** Resolution +means **more** gathered info is needed before sensor state stops limiting hits. +Resolution is a cost. The field's own comment says so accurately -- "the info +level at which sensor state stops limiting hits" -- and the word "Resolution" +says the opposite, because in every other context a higher resolution is a +better instrument. + +Cut 6a authored the 1-cell `Targeting Computer` at `.3` and the 2-cell +`Fire Control Array` at `.75`. Against a target scanned to info `.3`, that gives +the cheap design `pSensor = 1.0` and the 150000c design `pSensor = 0.31`. The +premium item is strictly worse until the player has scanned hard, which is not a +tradeoff anyone authored -- it is the formula misleading an author who had read +it and said so in the same paragraph. + +Operator ruling, 2026-09-19: "When the formula is unintuitive, we don't make the +handle more opaque for correctness, we fix the formula. Plenty of core gameplay +formulas are reciprocal just for this reason." An earlier draft of this cut +proposed renaming the field to `SensorDemand`; that is superseded. The name +stays `Resolution`, higher stays better, and the formula takes the reciprocal: + +- `HitProbability` derives the ceiling rather than reading it directly: + `var demandCeiling = detection + (1f - detection) / max(Resolution(source), 1e-3f);` + then `pSensor = saturate(unlerp(detection, demandCeiling, info))`. +- Resolution `1` therefore means "needs complete information before sensor data + stops penalising this system" -- which is precisely what the unaided fallback + of `1f` should mean. That fallback stays as it is and is now the floor by + construction instead of by coincidence. The guard against a zero or negative + authored Resolution is the `max`, and no authored value should ever reach it. +- Re-author both designs on the new scale: 1-cell `2` (sensor-limited until info + reaches `.55`), 2-cell `4` (until `.325`). Both comfortably better than unaided, + the premium one better than the cheap one, and the numbers now read the way a + designer reaching for the knob expects. +- `TargetingSystemData.Resolution`'s comment gains one sentence naming the + direction and the reciprocal, so the next author does not have to derive it. + +### 6c.2 One seller is a die roll against ship generation + +Cut 6a sold both targeting designs from NiteLife Energy alone, satisfying the +Cut 2 spec's letter ("at least the manufacturers that sell the capacitor") while +missing what that clause was for. `Galaxy.cs:109` selects +`factions.OrderBy(random).Take(settings.MegaCount)` — a **random subset** of the +twelve factions — plus the quest faction and the authored neutrals. A galaxy that +does not draw NiteLife has no targeting product, `IsAvailable` +(`LoadoutGenerator.cs:154`) filters every candidate out, and +`FillInterior`'s `required: true` throws `InvalidLoadoutException` again. Cut 6a's +ten green seeds do not disprove this; they sample the draw. + +Two changes, because content coverage and structural fragility are different +problems and only one of them is fixed by authoring: + +- **The generator degrades instead of throwing.** A `required: true` interior + item with no available product leaves the entity without one rather than + failing generation outright. This is only safe *because* Cut 5.1 gave unaided + fire a floor: such a ship now fires badly instead of not at all. The generator + logs the gap so it surfaces as content debt rather than silence, and `AetherDb + loadout` reports it per seed. +- **Thematic sellers, which also buy coverage.** NiteLife Energy is the roster's + power-generation faction; targeting belongs to Finch Cybernetics (passive + sensors) and Lucent Media (active sensors), both of which the roster names for + exactly this. Each design gains products from both. That is three sellers per + design, and it puts the item in the hands of the factions whose brand explains + why they make it. + +### 6c.3 Recorded, not fixed + +`Large Drive`'s new product declares no roles because the design declares none. +That is honest, and it is also a design with no role axes in a game where roles +are how products differentiate. It belongs to the content pass, not here. + +### Verification + +- A test pins 6c.1's direction: two entities, identical but for `Resolution`, at + the same info level -- the **higher** Resolution has the higher + `HitProbability`, at every info level between detection and full. Mutation: + drop the reciprocal and read Resolution as the ceiling directly (the shipped + behaviour). Must die. This is the assertion whose absence let a premium item + ship strictly worse than a cheap one. +- A test pins 6c.2: a galaxy containing no seller of any targeting design still + generates every hull, and those entities resolve `UnaidedAccuracy`. Mutation: + restore the throw. Must die. +- `AetherDb loadout` across at least 50 seeds, plus a run with the faction set + forced to exclude all three sellers. Report the gap count, not just "no throw". +- Census unchanged at 53 designs; products rise by 4 (two more sellers per design). + +--- + +## Cut 7: the harnesses that were prose + +Date: 2026-09-19. Branch `codex/fire-control-7` off Cut 6. + +`git log --all -- tests/mutation_tests_fire_control*` returns exactly one commit, +`034e8d3e`, adding `cut4` alone. §6 of this map declares `cut1..4`. So the Cut 3 +mutation table above (seeding, the commit horizon, the frozen snapshot, the +shield branch, the penetration march, the roll itself) is prose: it names the +mutations that should die and nothing has ever run them. + +That is not a paperwork gap. Soul wrote five mutations against rules this map +claims to pin and **all five survived 197 green tests**, including +`InstantWeapon.cs:105`, which is operator ruling Q2 — the one thing Cut 1 +explicitly deferred to Cut 3. Cut 5 pins four of them because it touches their +rules. This cut pins the fifth and builds the harnesses that should have existed +since Cut 1, so that every verdict this campaign has recorded becomes reproducible +rather than remembered. + +### 7.1 The fifth survivor: the reveal re-check + +`FireControl.cs:193` freezes `Aimed = source.ResolvedTargetItem`. Mutating it to +`source.TargetItem.Value` bypasses the reveal re-check — you could aim at a +subsystem you have lost resolution on — and 197 tests stayed green. + +- A test pins that an aim point whose reveal has decayed below its tier is **not** + frozen into the shot: the shot still fires, at a random cell, with no `Aimed`. + Mutation: read the raw `TargetItem.Value`. Must die. +- Note the interaction with Cut 5.5: a *destroyed* aimed item must fall out by the + same path, through `IsRevealed`, not through a second check. If pinning this + needs two tests, the rule has two owners and that is itself the finding. + +### 7.2 Harnesses for Cuts 1, 2 and 3 + +Three committed scripts — `tests/mutation_tests_fire_control_cut1.sh`, `cut2.sh`, +`cut3.sh` — each with a no-op control, byte-exact I/O, anchors matching exactly +once, and a reverse-write restore. Each runs the mutation table its cut already +declares in this document; do not invent a new table, and do not quietly +substitute an easier mutation for one that will not die. + +**Every survivor is a finding to report, never a test to weaken.** The expected +cause, seen twice in this campaign already, is a fixture too weak to distinguish +the mutant: `EvasionCountsUntilCommitAndNotAfter` survived its own declared +mutation because its target had zero velocity, so the sabotage was arithmetically +a no-op. When a mutation survives, first ask whether the fixture differs from the +rule's actual precondition, fix the fixture, and say so in the report. + +Where a declared mutation genuinely cannot be reached by the current suite, write +it up as **not yet reached**, never as unreachable, and name what reaching it +would take. Both times that call was made elsewhere in this project it was wrong, +and a fifteen-line probe killed the mutant. + +### 7.3 What the harnesses are allowed to assume + +Cut 6a authors the first targeting-system designs into the catalog. A harness that +depends on catalog content is a harness that breaks when content changes, so +these suites build their own fixtures through the same synthetic-upsert path +`TargetingSystemTests.cs:368-403` already uses. That file is also a cautionary +example: it passes by upserting a synthetic design into a throwaway cache, which +pins the generator and says nothing about whether the shipped catalog can +generate a ship. Both are worth having; only one of them was, and the map claimed +the other. + +So: **one test in this cut asserts against the live catalog** — that +`AetheriaStores.Open` over `GameData/Aetheria.cc` yields a targeting system the +player's own hull can be generated with. That is the assertion whose absence let +Cut 2 ship code without data. + +### Verification + +- All four harnesses (cut1-3 plus the existing cut4) run clean: every declared + mutation dies, every no-op control leaves the suite green. +- The report carries the full table — cut, mutation, died/survived, and for any + survivor the fixture defect behind it. +- `dotnet test` green, with the count. + +--- + +## Cut 6d: where a hit lands is a dart throw, not a coin flip + +Date: 2026-09-19. Operator direction, 2026-09-19. + +### The defect + +`FireControl.Commit` places a hit like this: + +```csharp +if (aimedCells != null && aimedCells.Length > 0 && random.NextFloat() < shot.Precision) + cell = aimedCells[random.NextInt(aimedCells.Length)]; +else + cell = coords[random.NextInt(coords.Length)]; +``` + +`Precision` is a coin flip between *exactly on the aimed item* and *uniformly +anywhere on the hull*. Aim at a stern thruster with Precision `.35` and the 65% +of hits that miss it are spread evenly over the bow, the cockpit and everything +else — no nearer the thruster than if the player had never aimed at all. There is +no spatial term anywhere in it. + +This is the same shape as 5.1's Tracking cliff: a quantity that should be a +gradient implemented as a binary, so the failure case is maximally wrong rather +than slightly wrong. + +Operator, 2026-09-19: "I would presume that aiming for an item in the stern would +still tend hit in that general area even if it misses the item. Like throwing a +dart at the grid." + +### The rule + +One Gaussian kernel over the target's hull schematic, in cell units: + +- **Aim point.** The centroid of the aimed item's cells, or the hull's + `CenterOfMass` when nothing is aimed at. One path — the uniform-random branch + is deleted, not demoted. +- **Sigma.** Derived from the frozen `shot.Precision`, higher Precision giving a + tighter group. Authored so that the unaided floor (`UnaidedPrecision`, joining + `UnaidedAccuracy` and `UnaidedTracking` in `GameplaySettings`) is a sigma wide + enough to read as spraying at the silhouette. +- **Weights.** `w(cell) = exp(-d² / 2σ²)` for each occupied hull cell, `d` the + distance from the aim point. + +### The tradeoff, and why it cannot be a second miss stage + +Operator, 2026-09-19: "It would also mean that aiming can be a tradeoff; if +you're not precise enough, you're better off aiming center mass than risking +misses on a thin limb." + +That tradeoff only exists if a stray dart can miss the ship. An earlier draft of +this section clamped a stray to the nearest occupied cell, which destroys it: +aiming at a wingtip would cost nothing and merely redistribute damage inward. + +But resolving the stray as a *second* miss stage would break R3 — one roll +decides damage — and make `HitProbability` a lie, since a shot could pass the +roll and then fizzle on placement. So the off-hull mass is priced into the single +roll instead: + +``` +pOnHull = Σ w(cell) / (2πσ²) +``` + +the share of the kernel's total mass that lands on metal. + +- `HitProbability` gains `pOnHull` as a factor. Aiming at a thin limb with a + loose group drops the number **on the HUD, before the trigger** — the tradeoff + is legible at decision time rather than discovered afterwards. +- `Commit` draws the cell as a weighted pick over the same `w(cell)`. A hit + always lands on metal, because the chance of not landing on metal was already + charged at the roll. + +One kernel, computed by one function, used for both the probability and the +placement. Cut 3's named risk was two functions answering "where will this shot +go" and eventually disagreeing; this does not reintroduce it. + +`pOnHull` depends only on the aim point, the hull shape and the frozen +`Precision`, so it folds into `PBase` at fire time and the freeze discipline +(R10, Q6) is unchanged. + +### Consequences, named rather than discovered + +- **Every hit in the game moves**, not only aimed ones: unaimed fire becomes + "aim at the centre of mass with the worst sigma", so big central sections soak + more fire than extremities. That is the intended change, and it is a balance + change. +- **A fat hull is inherently easier to hit than a needle**, for free. +- **The two targeting products' authored `Precision` values (`.05-.2` and + `.35-.55`) become meaningless as written**, because they are probabilities and + this makes Precision a grouping tightness. They are re-authored in the same + pass as Cut 6c's reciprocal `Resolution`. +- **Double-charging risk.** `HitProbability` already carries `pSpread`, which + penalises a small target against a wide weapon spread. `pOnHull` also + penalises small targets. Check whether the two charge the same thing twice + before authoring numbers; if they do, one of them owns it and the other goes. +- **AI willingness shifts.** `Combat.cs` and `TurretController.cs` gate on + `AgentMinHitProbability` against this number, so NPCs will hold fire more than + they used to the moment `pOnHull` lands. That is a tuning pass, not a bug, but + it will show up in the first smoke and should not be mistaken for broken AI. + +### Verification + +- `AimingAtTheSternHitsTheStern`: with a mid-range Precision, hits aimed at a + stern item land in the stern half far more often than the bow half. Mutation: + restore the uniform-random branch. Must die. This is the assertion the shipped + coin flip would fail. +- `ThinLimbCostsHitChance`: `HitProbability` against an aim point on a + single-cell extremity is strictly lower than against the centre of mass, at the + same Precision — and the gap widens as Precision falls. Mutation: drop + `pOnHull` from the probability. Must die. +- `PlacementAndProbabilityShareOneKernel`: the empirical on-hull rate over many + seeded draws matches the `pOnHull` the probability reported, within tolerance. + Mutation: perturb sigma in one of the two call sites only. Must die — this is + the test that would catch the two functions drifting apart. +- `EveryHitLandsOnMetal`: no committed hit ever resolves to an unoccupied cell, + across seeds and aim points including extremities. + +### 7.4 Amendment (2026-09-19): the live-catalog test has a second job + +7.3 above asks for one test that opens the real `GameData/Aetheria.cc` through +`AetheriaStores.Open`, because Cut 2 shipped a `required: true` rule with no data +to satisfy it and every green suite in the campaign missed it. + +Merging Cuts 6b and 6c gave that test a second and larger job. 6b added +`WeaponItemData.AirburstRange` as a non-nullable `float` at a new MessagePack +key. Every weapon record in the shipped catalog was serialized before that key +existed, so the reader was handed `nil` for the absent slot, a `float` cannot +take nil, and `AetheriaStores.Open` threw outright — the game could not read its +own catalog. Fixed at `c896489b` by making the field `float?`. + +Neither cut could have caught it. 6b verified with unit tests over synthetic +fixtures and never opened the shipped catalog; 6c opened the catalog constantly +but on a branch without the field. The defect existed only in the merge, and +only when something read real data. + +So the live-catalog test is not a content check that happens to touch a file. It +is **the campaign's only assertion that the shipped data is readable by the +shipped code at all**, and it is the one test whose absence let a branch that +bricks the catalog pass every gate. Write it so it fails loudly on a +deserialization throw, not only on a missing design: + +- `ShippedCatalogOpensAndGeneratesAnArmedHull`: open the real catalog read-only + through `AetheriaStores.Open`, enumerate every `EquippableItemData` so every + document type is actually deserialized (a lazy read that never touches + `WeaponItemData` would have passed on the broken merge), and generate a + loadout for `LonginusX`. Any exception is a failure. +- Mutation: none needed for the throw path — the shipped catalog either + deserializes or it does not. For the generation half, the Cut 2 mutation + applies: drop the targeting designs' products and the test must go red. + +**The standing rule this cut records**, because it will recur every time a field +is added: a new non-nullable value-type field on a persisted CultCache document +cannot read records written before it existed. Nullable, or a migration that +rewrites every affected record — and the migration cannot run while the read +still throws, so tolerant deserialization comes first either way. + +--- + +## Cut 8: a removed entity is a dead entity, and the anchors the diagnostics moved + +Date: 2026-09-22. Branch `codex/fire-control-8` off `72c0109c`. + +Operator play smoke, 2026-09-20: heat spike on entry with the ship unable to +move, a nearby ship likewise glowing hot and motionless, shots that registered no +hits, and finally a NullReferenceException out of `LockWeapon` reaching +`Entity.EntityInfoGathered` as the other ship vanished. Diagnosis (Codex, relayed +by the operator): the other ship's behaviors updated after the entity had been +cleaned out. Not reproducible afterwards, which makes it a hidden bug rather than +a fixed one -- the diagnostics commit `72c0109c` changed timing and allocation, +not the cause. + +### 8.1 Removal and deactivation are one transition + +`Entity.Deactivate` (`Entity.cs:445`) disposes every subscription, clears +`EntityInfoGathered`, `VisibleEntities`, `VisibleEnemies` and `VisibleFriendlies`, +and sets `_active = false`. `Zone.TryDock` (`Entity.cs:943`) has always paired the +two: `Zone.Entities.Remove(ship); ship.Deactivate();`. Removal from the zone means +the entity stops living there. + +Cut 5.6 added the death path and did only half of it. `Zone.cs:78`: + + Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => Entities.Remove(add.Value))); + +The entity leaves the collection and stays **active**, holding live subscriptions +and stale references, while every surviving entity's `ObserveRemove` handler +(`Entity.cs:200-213`) nulls its `Target` and drops it from `EntityInfoGathered`, +`EntityHostility` and the visibility sets. Half the world has forgotten it and it +does not know it is dead. + +- The death subscription becomes `Entities.Remove(e); e.Deactivate();`, matching + `TryDock`'s existing pairing exactly. One transition, one owner. +- **Ordering risk to verify, not assume:** `EntityInstance` also subscribes to + `Entity.Death` (its loot drop and destroy effect, `EntityInstance.cs:300-325`). + Subscription order decides whether loot drops before or after `Deactivate`. + `Deactivate` does not touch `Equipment` or `CargoBays`, so loot should survive, + but this must be confirmed in play rather than reasoned about -- if loot stops + dropping, that is this cut's fault and the deactivate moves after it. + +### 8.2 A deactivated entity does not update + +`Zone.Update` (`Zone.cs:172`) iterates a snapshot: + + foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); + +so an entity removed part-way through a frame still receives its `Update` in that +same frame, after the rest of the world has dropped its references. `Entity.Update` +(`Entity.cs:1031`) never consults `_active`. Every behaviour then runs against +torn-down state, and `LockWeapon.cs:98` reaches `Entity.EntityInfoGathered[Entity.Target.Value]` +through a raw indexer. + +- `Entity.Update` returns immediately when `!_active`. + +This is the fix, and the raw indexer is deliberately **not** the fix. Guarding +`LockWeapon` with `TryGetValue` would silence this one call site and leave every +other behaviour running on a corpse -- the compensator the doctrine warns about. +Liveness is the entity's own property and the entity is the one place to enforce +it. `IsActive` already exists at `Entity.cs:116`; nothing was asking it. + +### 8.3 The other symptoms are not explained by this, and are not closed + +The heat spike, the motionless ships and the shots that registered no hits are +**unexplained**. One coherent story fits all three -- an item's heat driving the +targeting system offline makes `FireControl.Accuracy` fall back to +`UnaidedAccuracy` (`.05`), which reads exactly like "I can fire but nothing +lands," and an overheated thruster reads as "I cannot move" -- but that is a +hypothesis, not a finding, and nothing here has reproduced it. + +What can be said from the source: `ActionGameManager.cs:1281` passes +`Time.deltaTime` into `Zone.Update` unclamped, and `Zone.cs:160` divides by it +(`orbit.Value.Velocity = (Position - PreviousPosition) / deltaTime`). Unity caps +`Time.deltaTime` at `Time.maximumDeltaTime`, so a large spike is bounded, but a +**zero or near-zero** first frame is not bounded from below and that division is +unguarded. Worth a probe before it is worth a fix. + +This cut does not chase it. It records the symptom, fixes the defect that is +proven, and leaves the rest open with the evidence that exists -- an unreproducible +heat spike is not something to patch speculatively. + +### 8.4 Re-anchor the four harnesses the diagnostics refactor stranded + +`72c0109c` routed `HitProbability` through a new `FireControl.Inspect`, replacing +five early returns with flags and one gate. That is the campaign's own doctrine +applied correctly -- the operator's comment says it outright, that keeping the +calculation here stops the debug HUD growing a second, subtly different fire +control model -- and it rewrote the exact lines four harnesses anchor into. + +Seven anchors now fail: `cut3` (2), `cut5` (1, its **no-op control**), `cut6c` +(2, including its control), `cut6d` (2). Every one reports `tree-clean: PASS`, so +nothing leaked, but a harness whose control cannot run is defending nothing. + +- Re-anchor all seven onto the current spelling in `Inspect`. The rules they + attack are unchanged; only the text moved. +- A control that cannot find its anchor must stay a hard failure. Do not make + anchors fuzzy to survive refactors -- loudly stale is the property that makes + these harnesses trustworthy, and it is what surfaced this within one run. + +### Verification + +- `DeadEntityDoesNotUpdate`: an entity killed mid-frame receives no further + `Update`, and a `LockWeapon` on a ship whose target dies the same frame does not + throw. Mutation: drop the `_active` guard. Must die. +- `DeathDeactivates`: a dead entity reports `IsActive == false` and holds no live + subscriptions. Mutation: remove the `Deactivate()` call. Must die. +- `LootStillDropsOnDeath` if it can be reached headlessly; otherwise an explicit + operator check, named as such rather than assumed. +- All seven harnesses clean with tree-clean PASS, controls included. +- 222 tests plus the new ones; catalog reads; Unity batchmode exit 0. + +--- + +## Cut 9: the die is not a die + +Date: 2026-09-22. Branch `codex/fire-control-9` off `dcb8c448`. + +Soul pass over `ca57819d..dcb8c448` (2026-09-22). Ten findings; this cut takes the +three that block play. The rest are Cut 10. + +### 9.1 The hit roll has a range of 0.125 (Soul C1). **Blocking.** + +`FireControl.cs:356`: + + var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u); + ... + var hit = p > 0f && random.NextFloat() < p; + +`CultMath.Random` is xorshift32 and `NextFloat()` returns the top 24 bits of the +**first** round (`Random.cs:22-42`). One round does not carry a low-bit seed +difference into the high bits. `ShotId` varies only in the low bits, so the top +three bits of the first draw are fixed by the zone seed alone. + +Measured, 2000 shot ids per zone: **every zone's roll spans 0.1249**. Reproduced +independently with a different hash function — the span is structural, not a +property of `StableHash`. The consequence is that a shot is a step function of +`p` whose threshold is fixed when the zone is created: below the band it never +hits, above it always does. An unaided shooter hit 0 of 18,926 shots against the +shipped catalog. Roughly one zone in eight cannot land a fully aided shot at +p = .74. + +This is the operator's "I can target and fire with no hits registering at all," +and it is stable per zone, which is why it came and went. + +It is also this map's own prescription (Cut 6b, §6.1), written by Self, shipped +through three cuts, and never once measured. + +**The rule:** the roll must be uniform on [0,1). Not merely deterministic — +determinism is what a constant already gives, and is exactly why three +determinism tests passed over it. + +- Mix the seed before the first draw, with a standard 32-bit finalizer + (`fmix32`/`splitmix32`: xor-shift, multiply, xor-shift, multiply, xor-shift). + The per-shot seed derivation is unchanged; only its diffusion into the first + output changes. Determinism, the `(zone, shot id)` purity and the freeze + discipline all survive untouched. +- **This is a caller's obligation, not a CultMath defect.** Seeding xorshift with + a structured value and drawing once is the documented way to get a correlated + first output. Record a follow-up to give `CultMath.Random` a mixed-seed entry + point so no other GameCult consumer re-derives this the hard way — that is a + CultLib cut with its own release, not this one. + +**Verification, and the test whose absence caused this:** + +- `TheDieIsUniform`: fire many shots across several zone seeds at an + intermediate `p` (say .3 and .6) and assert the observed hit fraction is near + `p`, and that the observed rolls span substantially more than 0.2. Mutation: + remove the finalizer. Must die. +- No test anywhere in this campaign measures a distribution. Every behavioural + fixture uses `accuracy: 1` or `accuracy: 0`, which is why a die stuck on one + face satisfied all of them. That gap is the finding, not the line of code. + +### 9.2 `pOnHull` collapses to zero above Precision ~4 (Soul C2). **Blocking.** + +`FireControl.cs:497-516`. `Sigma = 1/Precision`, and once sigma falls below about +half a cell every `exp(-d²/2σ²)` underflows against an aim point that is not +exactly on a cell centre — which is **every shipped hull**, since no centre of +mass is integral (LonginusX 2.5/6.697, Zenith 5.5/5.5, Turret 3.5/3.5). Measured +`pOnHull` on LonginusX: `.516` at Precision .3, `1.000` at 1.19, `.112` at 5, +`.000` at 10 and beyond. + +So a better targeting system makes you miss, and past Precision ≈ 5 the weapon +cannot hit anything. Authored content sits at .5-1.3, just under the cliff, with +a margin of about 4x — and the operator is about to tune this number. + +- Clamp sigma at a floor of about half a cell, or normalize by the discrete sum + rather than the continuous 2πσ². Either makes `pOnHull` monotonic in Precision + over the whole domain instead of only below the cliff. +- `WeightedPick` (`:535-545`) carries a comment claiming it "can never fall + through." It can, whenever `totalWeight == 0`, returning a fixed corner cell. + It is unreachable today only because `pOnHull == 0` forces `PBase == 0` first. + Fix the cause and delete the false comment. +- Cut 6d's map section claims to name its consequences; it does not name this. + Add it. + +### 9.3 The LockWeapon crash is still reachable (Soul C3). **High.** + +Cut 8 fixed a real liveness defect and did **not** fix the operator's crash. +`LockWeapon.cs:98` only runs on an *active* entity, so 8.2's `!_active` guard +cannot be what prevents it — `tests/mutation_tests_fire_control_cut8.sh:17-19` +says as much outright, and I merged it anyway. + +The live precondition is `Target.Value` set but absent from `EntityInfoGathered` +on an active entity, and docking still reaches it: `Deactivate` (`Entity.cs:445`) +disposes the `Entities.ObserveRemove` subscription that is the only thing nulling +a stale `Target`, and nulls nothing itself; `Activate` (`Entity.cs:167`) reseeds +`EntityInfoGathered` and likewise leaves `Target` alone. Dock, let the target die +or leave, undock, and the first frame throws. + +- `Deactivate` nulls `Target` (and `TargetItem`), or `Activate` reconciles it + against the reseeded `EntityInfoGathered`. One of the two owns it; say which. +- Still not a `TryGetValue` in `LockWeapon`. The invariant is that an active + entity's `Target` is always a live entity it has info on. +- Pin it with the dock/die/undock sequence, not with a unit test on the guard. + +### Verification + +- The three tests above, each dying under its own mutation, in a committed + `tests/mutation_tests_fire_control_cut9.sh` with a no-op control, byte-exact + I/O, sha256-verified restore and an explicit tree-clean verdict. +- All nine harnesses sequentially, one at a time, all clean. +- 226 tests plus the new ones; catalog reads; `dangling` 0. +- After 9.1 lands, re-measure the fight: Soul's probe, run with a corrected die, + gave 60.8% hit rate and a 4.3 s median time to kill for an aided shooter, 2.6% + unaided. Confirm the shipped build now reproduces that, because those are the + numbers the operator will tune against. + +### Recorded for Cut 10, not fixed here + +Soul C4 (a committed hit on a target that dies mid-flight publishes as a hit), +C5 (`Inspect` is numerically exact but costs 9.87 µs and 448 B per call on paths +that used to bail — the operator's own comment on the hot path looking like a +diagnostic one has an empirical answer now), C6 (`RollsAreSeeded` is obsolete — +Cut 7 retired one such test and missed this one), C7 (`FireControlCut6cTests` +authors `Precision = 0`, making six of seven checks vacuous), C8 (`Precision = +1000` fixtures make several kernel-shape mutants unkillable, and `pOnHull`'s +deletion is caught by exactly one test), C9 (`EveryHitLandsOnMetal` runs one +configuration, not the range it claims), plus the harness-audit items: the +tree-clean verdict restores before it measures, a red result cannot distinguish a +killed mutant from a compile failure, and the no-op control covers only one file +per harness. + +--- + +## Cut 11: mutation testing moves to Stryker, and the survivors it found + +Date: 2026-09-22. Operator, on the hand-written anchor harnesses: "What is the +point of those mutation anchors? Tests should cover how the code behaves, not how +it is shaped." And on Stryker: "sounds good; we don't need to run with every +mutation active, right? > to >= on float thresholds is indeed petty." + +### Why + +The Eureka principle (2026-09-15) is sound and stays: every rule needs a test that +fails when the rule breaks. The mechanism that accreted on it over the following +week -- literal-text anchors, perl substitution, per-cut bash and Python harnesses +-- coupled mutation testing to code shape. It stranded anchors on every refactor +(`72c0109c`, Cut 9, Cut 10), and at Cut 10 an anchor kept matching exactly once +while silently re-targeting onto `Inspect`, mutating code the tests no longer ran. +Soul's harness audit also found the tree-clean verdict restoring before it +measured, and a compile failure counting as a kill. + +A Stryker spike against `FireControl.cs` alone: 276 mutants, 7m18s, 208 killed, +67 survived. It killed every mutation the hand-written harnesses defended, never +edits a source file, and has nothing to re-anchor. + +### 11.1 Retire the ServerShared harnesses + +Delete the 23 hand-rolled harnesses (5,816 lines) whose targets are all under +`Assets/Scripts/ServerShared` -- every `mutation_tests_fire_control_*`, every +`mutation_tests_stats_power_*`, `mutation_tests_shield_reserve.py`, +`mutation_tests_condition_ratio_cut8.py` and `mutation_tests.py`. Stryker's scope +covers all of their targets; the behavioural tests they measured stay. + +**Kept, recorded, not yet answered:** `mutation_tests_addressables_cut.py` and the +three `mutation_tests_shield_panel_cut*.py`. They target Unity Editor probes +(`EngineAssetCheck.cs`, `ShieldPanelCut*Verify.cs`) that Unity compiles outside +`Aetheria.Shared`, so Stryker cannot reach them. They carry the same anchor +fragility; the right answer for GPU and editor code is probably that the batchmode +probes are themselves the behavioural tests, but that is a separate decision. + +### 11.2 Scope, pinned + +`tests/Aetheria.Shared.Tests/stryker-config.json` scopes mutation to +`Assets/Scripts/ServerShared/**` and excludes plugins. Per cut, run against the +cut's own diff: + + dotnet tool restore + cd tests/Aetheria.Shared.Tests + CULTLIB_ROOT= CULTMATH_ROOT= dotnet stryker --since: + +Each root must sit at its pinned revision in `Directory.Build.props` (see the Cut 12.0 +status note). + +**Float-threshold boundary flips are known-equivalent.** Stryker files `>`→`>=` +under the same Equality mutator as `==`→`!=`, and the latter catches real bugs, so +the mutator stays on. A survivor that only moves a float comparison across its +boundary is triaged as equivalent and never chased. For the same reason the raw +mutation score is not a gate; the survivor list is the signal. + +### 11.3 Degenerate fixtures + +Most of the 67 survivors trace to three constants shared across the fire-control +fixtures, each of which makes a family of mutants arithmetically identical to the +original -- the same defect class as the zero-velocity target and the durability +coincidence, found here as a pattern instead of one at a time: + +- **Shooter at `float3.zero`.** `target - source` equals `target + source`, so four + separate position mutations survive (`FireControl.cs` HitProbability, Inspect, + Fire, Apply). +- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time + mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does + not defend the rule Cut 5 gave it a moving target to defend. +- **Spread zero everywhere.** `PSpread` returns 1 on every fixture, so it is + effectively untested, and `*` versus `/` against it is indistinguishable. + +Move shooters off the origin, fire at nonzero times, and give some fixtures real +spread. Fix the fixture, never weaken the assertion. + +### 11.4 Behavioural tests the survivors point at + +- **Splash handedness.** Flipping the sign in `var right = float2(forward.y, + -forward.x)` passes every test. The operator's "damage on the wrong side of the + ship" was a legacy Unity bug; this line has read the same since `482c47bf`, so + the rewrite never shipped it, but nothing defended against it either. Self said on 2026-09-19 that Cut 6b would add a test pinning + that a port blast damages port cells; it never landed and nothing checked. Pin it. +- **The shield loses energy when it absorbs.** Deleting `Shield.TakeHit` from the + discrete path survives, because tests assert the hull was untouched and never + that the shield paid for it. +- **Splash's shield-absorb branch** has no coverage at all. +- **Zones roll differently.** `CombatSeed *` → `/` makes the seed zone-independent + and survives; `TheDieIsUniform` checks uniformity within a zone only. Tighten it + enough that the `MixSeed` weakenings that survive it (`>>`→`<<`, `^=`→`|=`) die. +- **A shot whose target left the zone resolves as a miss** (Soul C4). Today only + the uncommitted branch rewrites the outcome; a committed hit on a target that + died mid-flight is published as a hit. Fix the code and pin it. + +### Verification + +Stryker over `FireControl.cs` before and after: every non-equivalent survivor +above killed, the remainder triaged by name. 231 tests plus the new ones; Unity +batchmode clean. + +### Status (2026-09-22) + +Landed on `codex/fire-control-10`: `8ef3b34c` (Stryker tool), `b305debd` (map, +config), `724ec5e7` (11.1), `a6543af7` (11.4 and the C4 fix), `1b187db4` (11.3), +`b0fd3cd0` (second round, below). 244 tests. + +Stryker over `FireControl.cs`, 278 mutants per run: + +| Run | Killed | Survived | Score | +|---|---|---|---| +| Spike, before Cut 11 | 208 | 67 | 74.6% | +| After 11.4 | 219 | 58 | 78.6% | +| After 11.3 fixtures | 225 | 51 | 81.1% | +| After the second round | 225 | 43 | 83.3% | + +**The second round** came from reading the 51. Four were real undefended +behaviour and one was a duplicated formula: + +- `Inspect` recomputed `PBase` as its own product of the factors, so the gates and + the product had two owners, and a `*`→`/` mutant of the HUD's copy survived the + agreement test (every fixture had spread 0, so `PSpread` was 1). `PBase` is now + `HitProbability`'s own answer; the test became + `TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not + fill the silhouette. `Fire`'s redundant `target != null ?` guard around + `HitProbability` went with it. +- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed + the expected damage from its own roll count, a tautology, now deleted. + `ADamageOverrideIsWhatTheShotCarries`. +- `Tracking` could ignore the fitted targeting system: no fixture's target ever + moved off its predicted line. `AFittedTargetingSystemForgivesAJink`. +- A shot orphaned before commit could skip `ShotCommitted`, or publish its miss as + shielded. `ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss`. +- `ArrivalIn` (the HUD countdown) was unchecked; asserted in the C4 test. + +**The 43 that remain, triaged:** + +- *Boundary flips, equivalent (23):* L52, 54, 56, 75, 135 (two), 157 (two), 193 + (two), 223, 273, 410, 418 (two), 465 (two), 503, 518, 524, 586, 605, 608. +- *`MixSeed` shift and xor weakenings, measured equivalent (4):* L382, 384, 386 + (two). 11.4 asked for them to die; measured, the die stays uniform and + zone-distinct under each, so there is no behaviour to pin. They change how the + mixer is built, not whether the die is fair. +- *Unreachable through the live callers (8):* L240 and L241 + (`DeviationProbability`'s null-target branch; the engine guards it, only the HUD + can pass null), L283 (three; airburst has no authored radius anywhere, recorded + since Cut 6b), L320 (empty-list early return, an optimisation), L410 `||` (a + nonzero `PBase` implies a target), L158 and L610 (no coverage: an unlocked lock + weapon reaching this line, and the last-cell fallback of the weighted pick). +- *Recorded gaps, not yet reached (8):* L135 ternary (two; no test that the AI in + `Combat.cs` leads a moving target), L435 (three; the aimed flag on an outcome), + L464 and L465 negate (the `Apply` hit direction through the `FireControl` + path), L585 and L587 (the aimed item's centroid as aim point). These are "not + yet reached," not "unreachable." + +## Cut 12: shots arrive edge-on + +Date: 2026-09-22. Imagination pass, revised the same day after the second round of rulings +below. Anchors are against `origin/codex/item-provenance` `cd846916` (Cuts 10 and 11 merged). +Claims marked **(probe)** were measured in a scratch copy of the test project +(`scratchpad/cut12probe`, the Cut 11 fixture plus probe methods) or by a lane-model script +(`scratchpad/exposure.py`), in both cases against a copy of `GameData/Aetheria.cc`. Claims +marked **(read)** come from source at the cited anchor. + +### The defect + +The impact cell is chosen as if every shot drops in from overhead. +`FireControl.HullKernel` (`FireControl.cs:558-578`) throws a 2D Gaussian at the top-down +schematic, centred on the aim point. `POnHull` (`:233-234`), `WeightedPick` (`:601-611`) and +`ResolveAimPoint` (`:583-589`) all read that kernel, and none of them takes the shooter's +bearing. **(probe)** `Probe3_BearingBlind`: pOnHull is `0.8748` at target facings (0,1), +(1,0) and (.6,.8) alike. + +Bearing is only used after the cell has been chosen: `Entity.ApplyHit` (`Entity.cs:381-409`) +walks penetration from that cell, and `Splash` (`FireControl.cs:515-527`) picks the half of +the hull that faces the blast. So armour facing does nothing. A wedge whose bow carries all +the plate gets hit in its soft stern from dead ahead. The game is planar (R7), so a shot +travels in the plane and should arrive edge-on. + +The damage rule makes it worse. `Entity.DamageSchematic` (`Entity.cs:336-374`) splits the +damage evenly over every cell of the hit shape. **(probe)** `Probe1`: 30 damage with +penetration 3 into column 1 of the 5x3 fixture, with 10 armour on the front cell only, puts +10 on each of the three path cells. The plate absorbs its 10, the item behind it still takes +10, and the stern cell passes 10 to the hull. The item receives exactly what it would have +with no plate in front of it. + +### Rulings (operator, 2026-09-22) + +First round: + +- **Model adopted: throw the dart at the silhouette the shooter sees.** Scatter is 1D and + perpendicular to the bearing. Project the hull's occupied cells onto the lateral axis and + throw a 1D Gaussian at that projection, with the same sigma rule, centred on the aim + point's projection. pOnHull is the Gaussian's mass on that shadow. Depth comes from + geometry: walk the ray in from outside along the bearing, and the first occupied cell is + the impact cell. Penetration continues along the same ray. +- Operator: "No, you're right, this is fine. Armor absorbs first. Shields remain + omnidirectional. Map it." + - **Armor absorbs first.** Damage runs down the ray in order. At each cell the armour + absorbs first, then the item, and only the remainder travels on. The even split over + the hit shape goes, for the penetration path and the DamageSpread expansion alike. + - **Shields remain omnidirectional.** The shield stays one whole-ship reserve with no + facings. +- **Rejected:** any fraction of shots arriving top-down to preserve cockpit sniping. The + operator accepted that cockpit shots become positional: the cockpit must be exposed from + your bearing, or you must flank, or penetration must reach it with what the armour did not + absorb. Cockpit destruction is still instant death (`Entity.cs:494`). + +Second round: + +- **Q12-1 = B: the bearing that places a hit is taken at Commit.** Operator: "Of course we + must use the target's facing at arrival, tanking a hit where you have armor is basic + maneuvering." After Self explained the commit horizon: "at commit." The target's facing + therefore stays live until Commit, as deviation already does. See **Bearing timing**. +- **Q12-2 = A: spread is width.** It becomes 2n+1 parallel lanes, and each lane is absorbed + from its own facing cell. Operator: "Spread is width makes sense for shotguns, but what + about explosive penetrators? Burrowing in before splashing radially would be a satisfying + way to damage vulnerable internals." That remark became sub-cut 12.4. + - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at + angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the + shared armour and mirror-image shots did different damage in ~30% of angled spread hits. + Operator: "I would prefer if the overlapping damage cells were diffused sideways to + thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): + **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` + width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly + that wide and half-open, so no two lanes can strike the same cell; the footprint widens at + angles instead of doubling up, and axis-aligned shots are unchanged. +- **Q12-3 = A (direct hits):** a lane's remainder goes into the hull wherever the lane + ends. +- **Q12-4 = A:** delete `ShotOutcome.Aimed`. +- **Penetration retune is a follow-up.** Operator: "Retuning penetration is a follow up." + See F12-2. Until it lands, interior cells are reached only by blasts. +- **The detonation primitive (12.4): a blast is an area, not a bundle of rays.** The first + draft of this section cast rays; the operator cut that. Operator, 2026-09-22: "sorry, why + are we doing blast rays instead of areas? I'd say distribute the damage over the affected + area, but that only works for areas". So a blast at a schematic point P with radius r + covers the cells within r of P, and **damage is distributed over the disc's area**: a cell + takes the share of the disc that lies inside it. The share of the disc that falls outside + the hull's occupied cells is lost. Each covered cell absorbs independently, in the + existing order (armour, then item, then hull). Nothing travels, nothing is occluded, and + no remainder is routed anywhere. (Superseded for items by the 2026-09-25 one-path + ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) + + **A shot has a direction; a blast does not.** That sentence is the rule behind the split: + direct hits keep the lane model of 12.2 and 12.3, and blasts are areas. Where P sits: + - proximity fuse: the intercept point outside the hull (today's airburst); + - contact fuse: the impact cell; + - delayed fuse or penetrator: the end of the penetration walk. + + All three are weapons that carry a blast radius. A weapon without one is not a blast at + any penetration: it keeps 12.3's lane (see **Where 12.3 and 12.4 meet**). + + 12.4 replaces `FireControl.Splash` and its half-hull footprint, which absorbs F12-1. +- **WeaponModifiers are labels, not behaviour.** Operator: "The AP modifier was merely + there as a label. Like the Incendiary modifier which doesn't actually determine that the + weapon does Thermal damage, but exists to mark weapons that do. Modifiers show up in + schematics as a shorthand for the weapon type." Detonation behaviour comes from weapon + data fields only. +- **Not ruled:** a HUD showing which target items are exposed from the current bearing + (Q12-5). + +### What the probes found + +- **Penetration does nothing in the shipped catalog.** **(probe)** The largest authored + Penetration is `0.25` (Autocannon, SRMM72, LRMM72). `ApplyHit` only walks when + `penetration > .5f` (`Entity.cs:392`), and `WeaponData.Penetration` is inspector-ranged + `0..1` (`Behaviors/Weapon.cs:28`). +- **LonginusX's cockpit cannot be reached edge-on until penetration reaches about two + cells.** **(probe)** `exposure.py` walked lanes 0.01 cells apart at every whole degree of + bearing over the real 6x17 schematic. The ControlModule (cockpit) and the AetherDrive are + never the first cell in any lane. They are still never reached at penetration 0.25 or + 1.0, and are first reached at 2.0. The Turret hull's ControlModule is enclosed too. + Until F12-2 lands, the only way to kill a LonginusX through its cockpit is a blast + delivered inside the hull (12.4). +- **DamageSpread is live content.** **(probe)** GT 3K authors 1-3.25, plight 1-3.3, and + pswarm and scorched void policy 0.9-1.5. `Shape.Expand` is an 8-neighbour ring + (`ItemData.cs:164-174`), so spread 3 produces a 7x7 footprint today. +- **No shipped weapon carries a blast, or the Airburst label.** **(probe)** `Probe4`: + every `WeaponItemData.AirburstRange` is null, and no `WeaponModifiers` includes Airburst. + The labels in use are RapidFire, Cluster and Incendiary. All three Thermal weapons carry + Incendiary, and no non-Thermal weapon does. No weapon carries ArmorPenetrating, + including the three with penetration 0.25. So the only live consequence of demoting the + Airburst label is the code path at `FireControl.cs:282`. +- **The handedness history.** `right = float2(forward.y, -forward.x)` has not changed since + `b7743789`/`482c47bf` (`git log -S`). No sign error ever shipped. What survived was the + *mutant* `-forward.x` → `forward.x`, which is invisible when |fy| > |fx|, and Cut 11 pinned + it. The formula is duplicated at `Entity.cs:394-396` and `FireControl.cs:516-520`, and 12.1 + collapses the two copies. +- **The code has two cell conventions.** The kernel, `Shape.CenterOfMass` + (`ItemData.cs:121-123`) and `ResolveAimPoint` put cell *i* at the integer point *i*. + `ApplyHit` instead starts from `cell + .5` and truncates (`Entity.cs:398-404`), which + treats cell *i* as the square [i, i+1). **(probe)** On bearing (.8,-.6) from (1,2), the + 0.5-step march went (1,2)→(2,1) and skipped (2,2), a cell the ray actually crosses. +- **Nothing reads `ShotOutcome.Aimed`.** `rg "\.Aimed\b"` finds only `FireControl.cs:292` + (PendingShot, which stays), `:432`, `:435` and `:481`, plus `FireControlCut7Tests.cs:190` + (which also reads PendingShot). +- **`Apply` reads live positions at arrival** (`FireControl.cs:463-465`), which is an R4 + leak and Stryker's L464/L465. 12.2 removes it. +- **No simulation code places the schematic in the world.** **(read)** `SchematicCellSize` + (`Settings.cs:255`, default 2) is read only by `PSpread`, and ApplyHit and Splash convert + directions only. A blast needs world↔schematic *points*, so 12.4 names that rule. + `HullCollider` is a physics surface only (`Gameplay/HullCollider.cs:4-8`). +- **Renaming slot 29 is compatible drift, not a break.** **(probe + read)** The catalog + embeds each type's schema descriptor, including `{"slot":29,"name":"AirburstRange", + "type":"System.Nullable"}`, and the wire format is positional + (`Probe5_Wire`: `[…,12.5,50,20]`, with no field names on the wire). On a content-hash + mismatch, `CultCache.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` + (`:672-752`), which compares slot type, reference, cardinality, target schema, name + semantics and index alias, **not the member name**. So renaming slot 29 opens as + `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` + warning. The warnings persist until the catalog is next written with the new descriptor. + Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, + 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs + (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: + no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as + `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. + +### Frame, geometry and the bearing (the rules every sub-cut reads) + +- **Schematic frame, one owner.** `Entity.ToSchematic(float2 worldPlanar) → float2` maps + world to schematic with x = starboard and y = bow: `forward = normalize(Direction)`, + `right = float2(forward.y, -forward.x)`, returning `float2(dot(v, right), dot(v, forward))`. + This is the transform ApplyHit and Splash already use, with the same sign. The Cut 11 + fixture comment and the LonginusX schematic (nose at y 11-16, reactor and radiators at + y 0-3) agree with it. 12.4 adds the point forms `ToSchematicPoint(float2 worldPlanar) = + ToSchematic(worldPlanar - Position.xz) / SchematicCellSize + Shape.CenterOfMass` and its + inverse `ToWorldPoint`, so the entity's position is the schematic's centre of mass. + Operator confirmed 2026-09-22, from the schematic images: "bow is indeed +y on the + schematic images." Still on the operator's check list after 12.4 lands, because it is the + half that only a running blast can show: that blasts land where the model shows them, + which is what confirms the centre-of-mass anchor. +- **A cell is the unit square centred on its integer coordinate.** This matches + `CenterOfMass`, the aim point and the old kernel. The `+ .5` convention dies with ApplyHit. +- **Travel direction.** `FireControl.TravelDirection(Weapon, Entity source, Entity target) → + float2` is the planar world direction of `PredictedIntercept(weapon, source, target) - + source.Position`. That intercept is Cut 3's one function, and for a beam it reduces to the + target position. Below a length of 1e-6 it falls back to world +z, which is today's + `Apply` fallback (`:463`). It belongs to the shot: Fire freezes it into `PendingShot`. +- **Bearing.** `bearing = normalize(target.ToSchematic(travelDirection))`. This is the only + place a world direction meets a target's facing. +- **Lateral axis** `ℓ = float2(-b.y, b.x)`. The sign is fixed once, here. +- **Shadow.** Each occupied cell *c* projects to `[dot(c,ℓ) - h, dot(c,ℓ) + h)`, with + `h = (|ℓ.x| + |ℓ.y|) / 2`. The shadow is the sorted and merged union of those intervals; + merging matters because off-axis intervals overlap. Squares are used rather than points + because a line at lateral offset *s* crosses square *c* exactly when *s* falls in *c*'s + interval. "s lies in the shadow" is therefore the same event as "this lane has metal", so + pOnHull is exactly that event's probability, and placement drawn from the shadow always + lands on metal. +- **`Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float + precision)`** replaces `HullKernel` and is the one function. It returns the merged + intervals, `a = dot(aimPoint, ℓ)`, sigma, `Span = hi_max - lo_min`, and + `POnHull = Σ_k Φ((hi_k - a)/σ) - Φ((lo_k - a)/σ)`, where `Φ(z) = ½(1 + erf(z/√2))`. + `aimPoint` comes from `ResolveAimPoint` unchanged; only its lateral projection matters. + `Sigma` keeps its rule (`FireControl.cs:549-550`). The 9.2 rationale in its comment is + gone, because an exact 1D integral does not undershoot, so rewrite it: the floor is now a + design minimum on group tightness (F12-3). The function allocates only on the ungated + path: one interval array of length N (≤128 on shipped hulls) plus a sort. +- **PSpread** keeps its formula, but takes values instead of a `Weapon`: + `PSpread(float spread, float span, float range, float cellSize)`. The half-extent becomes + `.5f * span * cellSize` instead of `max(Width, Height)` (`:224`). PSpread and pOnHull read + one silhouette, so they cannot disagree about the target's size. +- **Lateral draw.** One `NextFloat` u, taken after the hit roll (the draw `WeightedPick` used + to take). Walk the cumulative interval mass to `u · POnHull` to select interval k, then + invert inside it with `s = a + σ√2 · erfinv(2p - 1)`. Clamp into [lo_k, hi_k) to absorb + rounding. The clamp is a guard, not an invariant. +- **Lane walk** `Lane(HullData hull, float2 b, float s, buffer)` collects the occupied cells + whose interval contains s, each with slab entry and exit parameters `t` along b, where + `t = dot(point, b)`. Cells are ordered by entry, with ties broken by `dot(c, b)` and then + cell index. It is exact slab traversal and replaces the 0.5-step sampling. The result goes + into a caller-supplied pooled buffer (`ArrayPool.Shared`; netstandard2.1 has + `System.Buffers`). A direct hit starts from t → -∞ (outside), so its first element is the + impact cell. It is the walker's only caller: blasts are areas and do not traverse. + + Metal is continuous while `next.entry ≤ current.exit + 1e-4`, and the first gap ends the + walk. That keeps today's rule at `Entity.cs:401`. +- **Penetration along a direct-hit lane.** A cell is reached when + `entry - impact.entry < penetration`, in cells. The impact cell is always reached. The + `> .5f` threshold (`Entity.cs:392`) is deleted. + +**Bearing timing (Q12-1 = B).** A shot is split into what the shooter decided and what the +target is doing: + +- **Frozen at Fire:** everything the shooter decided (R10). `PendingShot.PBase` is renamed + `PFire` because its meaning changes: it is `Accuracy × PSensor` behind the same gates, and + it is 0 when any gate fails. Also frozen: `TravelDirection`, `Precision`, `Aimed`, the + weapon's `Spread`, and `FireRange` (the range at Fire, which PSpread reads). +- **Live until Commit:** everything the target is doing. That was already its position + (deviation); now it is also its facing. `Commit` computes, in this order: + 1. `pDeviation`, as today. + 2. `bearing = normalize(shot.Target.ToSchematic(shot.TravelDirection))`, from the + target's facing at the commit tick. + 3. `sil = Silhouette(target, hull, shot.Aimed, bearing, shot.Precision)`. + 4. `p = PFire × pDeviation × PSpread(shot.Spread, sil.Span, shot.FireRange, cellSize) × + sil.POnHull`. + 5. The one roll against p. On a hit, the lateral draw from `sil`, and the impact cell + from `Lane`. + + Steps 2-5 are one function, `FireControl.CommitProbability(PendingShot, float now, + out Silhouette)`. The debug HUD calls it, which retires the inline estimate at + `ActionGameManager.cs:1317-1318` (`shot.PBase * pDeviation`, which would otherwise be a + second, wrong copy of the model). +- **The outcome freezes the committed geometry** (R4). `ShotOutcome` gains `float2 Bearing` + (schematic frame, as committed) and `float Lateral`, and keeps `Cell`. `Apply` walks + `Lane(hull, outcome.Bearing, outcome.Lateral)`. After Commit, turning the target changes + nothing, and Apply reads no position or facing. +- **HitProbability and Inspect** report `PFire × PSpread × pOnHull` at the *current* + travel direction and facing. That is a forecast, in the same way the HUD already treats + deviation. `Inspect.PBase == HitProbability` still holds, because both are the forecast. + The hot path still gates first (`:150-159`) and builds the silhouette only after the gates. + +What this does to the earlier rules: + +- **R3 (one roll, and a hit always lands on metal) still holds.** The factor the roll is + charged with, pOnHull at the commit bearing, comes from the same `sil` that placement + draws from, in the same call. Placement is exactly consistent with the pOnHull applied, + and it always lands on metal. What changes is that HitProbability's number is no longer + the exact roll price when the target turns during flight. It is the price only if the + target holds its facing, just as it already is only if the target holds its course. +- **Cut 5's deviation factor keeps its ordering and meaning.** It is still measured at the + commit tick against the fire-time projection, and it is still multiplied into p before + the single roll. The two silhouette factors join it in the same product. The Cut 7 guard + (`p > 0f && shot.Target != null`, `:410`) extends to cover them: when `PFire × pDeviation` + is 0, Commit builds no silhouette. Multiplication order has no numerical effect. The guard + exists only to skip work. +- **6d's premise ("pOnHull depends only on the aim point, the hull shape and the frozen + Precision, so it folds into PBase at fire time") is retired.** pOnHull now depends on the + target's facing, which is live target state like position, so it is priced at Commit. + R10 is untouched: every *shooter* stat is still frozen at Fire, and the target's facing + was never a shooter stat. +- **Cut 10 still holds:** gated HitProbability allocates nothing. Commit is not on the + hot path. + +### Sub-cut order + +| Sub-cut | Repo | Nature | Depends on | +|---|---|---|---| +| 12.0 | CultLib | CultMath gains `erf` and `erfinv` (a gap filled in its owner) | none | +| 12.1 | Aetheria | subtraction and frame owner; no behaviour change | none | +| 12.2 | Aetheria | where a hit lands: silhouette at Commit, Apply stops reading positions | 12.0, 12.1 | +| 12.3 | Aetheria | how a direct hit travels: exact lane walk, sequential absorption, spread lanes | 12.2 | +| 12.4 | Aetheria | the detonation primitive (areas), fuse data, the Airburst label demoted; Splash deleted | 12.3 | + +The five are kept apart so that Soul falsifies each question on its own: where a shot +lands, what a direct hit does, and what a blast does. In 12.2, `ApplyHit` survives for one +more cut with a changed signature: it takes the schematic bearing and loses its own +rotation, while its march and its even split are left untouched until 12.3. + +--- + +### Cut 12.0. CultMath gains `erf` and `erfinv` + +- **Repo/branch:** `F:\Projects\CultLib`, off current `main`. +- **Adds:** `math.erf(float)` and `math.erfinv(float)` in + `packages/cultmath/src/CultMath/math.cs`, as standard single-precision approximations (for + example Abramowitz-Stegun 7.1.26 or a minimax fit; Giles 2010 for erfinv), with tests in + `packages/cultmath/tests/CultMath.Tests`. Neither is an HLSL intrinsic, so there is no + shader-parity obligation. +- **Release:** run `packages/cultmath/scripts/build-unity-package.ps1`, bump + `unity/org.gamecult.cultmath/package.json` from 0.2.3 to 0.2.4, and tag + `cultmath-unity-v0.2.4`. Aetheria then bumps `Packages/manifest.json:56` and the headless + `CultLibRoot` pin. +- **Verification:** + - `ErfMatchesReferenceValues`: erf at 0, ±0.5, ±1, ±2 and ±3 matches tabulated values + within 2e-6; erf is odd; the limits are ±1. + - `ErfinvInvertsErf`: over y ∈ [-0.999, 0.999]. + - The CultLib mutation run, with every survivor triaged by name. + +**Status: the code closes, the release does not.** Landed on CultLib +`hands/cultmath-erf`: `87b95ae`, `b688cb2` (erf, erfinv, tests), then the fix batch +`fc9ce97`, `4cdc02b`, `6e8362b`, `cc71f17`. 102 tests. + +Two verification lines above were stale when written and are corrected here. CultLib had +no mutation tooling at all, so there was no "CultLib mutation run"; Stryker.NET was adopted +in its own cut (`hands/adopt-stryker`) and its CultMath baseline is 2243 mutants, 350 +survived, triaged in that cut's report. The headless pin needed more than a bump. `Directory.Build.props:5` pins one CultLib revision +for every package, and that revision (`45c2f40`) is exactly Unity's `caching-unity-v1.4.0` / +`cultlib-unity-v1.0.60`; moving it to the release would have put six CultNet commits of newer +CultCache, including a breaking registration change, under the headless tests while Unity +stayed on 1.4.0. So the headless build now pins per package, mirroring the manifest: +`CultMathRoot`/`CultMathRevision` at the 0.2.4 tag's merge (`6d5e2096`), `CultLibRoot`/ +`CultLibRevision` still at `45c2f40` for CultCache. A package's pin moves only when its +manifest tag moves. (Self first repeated Soul's claim that there was no pin, from a read of +lines 3-4; line 5 is the pin, and the build refused the first attempt to use the release.) The +Unity manifest lives at `packages/cultmath/unity/org.gamecult.cultmath/package.json`, not +at the repo root. + +Soul's first pass found the defect this cut would otherwise have shipped: `erfinv(±1)` +returned the **wrong-signed** infinity, so a sample drawn for the far edge of an interval +was clamped onto the near edge. Reachable in ordinary play — `erf` saturates to exactly 1 +at 5.543σ, so any interval edge past that yields `p = 1`. Fixed with two guards; outside +the domain is NaN, now documented and pinned. Soul also measured the reported accuracy +figures 1.5× optimistic (true worst error: erf 6.621e-7, erfinv 5.066e-7, round trip +6.109e-7, all inside the 2e-6 bar), found `erfinv` had no accuracy test at all (the round +trip was blind by 300-400×, proven with a coefficient mutant), and found +`ErfinvIsMonotonicallyIncreasing` asserting a property neither function has (9662 backward +steps in `erfinv` at float granularity; the fixture's 0.001 step hid every one). The second +pass re-derived all 19 new reference values by a different method and closed the cut. + +**Cost, for 12.2 to answer rather than assume:** `erf` is 16.3 ns per call and +allocation-free, but a silhouette costs one `erf` per interval edge — ~4.2 µs per +evaluation on a 128-interval hull, 1.06 µs on 64, and worse under IL2CPP. The AI evaluates +`HitProbability` constantly. 12.2 must measure the new placement against the old kernel per +gated-in evaluation and report both, not assume 1D beats 2D. + +**Released 2026-09-24:** CultLib `20e6da5` (release) and `8c86bc9` (the changelog meta), merged to +main at `6d5e2096`, tagged `cultmath-unity-v0.2.4`. Release Soul pass: the DLL rebuilt byte- +identical from source (sha256 `2470cd48…`), public surface +2 (`erf`, `erfinv`), shipped +`erfinv(±1)` correctly signed; its one finding, `CHANGELOG.md` without a Unity `.meta`, was fixed +before tagging. Aetheria repinned in `2a68b4c0` (manifest and Unity-written lock, hash +`6d5e2096`); batchmode resolve and compile clean, no missing-meta warning. Follow-up for CultLib: +`build-unity-package.ps1:89-90` checks metas only for code and binary files, so it could not see +the missing `.md` meta. + +**Was owed (kept for the record):** Unity +consumes a *precompiled* `CultMath.dll` pinned at `cultmath-unity-v0.2.3` +(`Packages/manifest.json:56`), so no Unity-side fire-control code can call `erf` until the +package is rebuilt, bumped to 0.2.4, tagged and repinned. The headless tests see the +sources directly, which is why nothing failed. 12.2 must not land in Unity before that. + +**Recorded, not done:** `erf`/`erfinv` are scalar-only while every other scalar in +`math.cs` ships float2/3/4 overloads. The consumer is scalar; vector overloads are a +separate decision. + +### Cut 12.1. One frame owner, and the dead outcome flag + +- **Repo/branch:** Aetheria, off the Cut 11 merge. No behaviour change. +- **Deletes first:** + - The two inline frame copies: + - `FireControl.cs:516-520` becomes `target.ToSchematic(toTarget)`, normalized, keeping + the zero-length fallback. The whole of Splash dies in 12.4, but the frame owner lands + first. + - `Entity.cs:394-396` becomes `ToSchematic(hitDirection)`. + - `ShotOutcome.Aimed` and its plumbing (Q12-4 = A): + - the field, `FireControl.cs:702`; + - the `aimed` parameter of `MakeOutcome` (`:470`, `:481`); + - `Commit`'s `aimed` local and flag (`:420`, `:435`); + - the `AimedCells` half of `ResolveAimPoint`'s tuple (`:583-589`), which now returns + `float2`. +- **Adds:** `Entity.ToSchematic(float2)`. +- **Verification:** + - Builds: headless `Aetheria.Shared`, plus a Unity batchmode compile by Self. + - All 244 tests pass unchanged. `SplashDamagesTheSideTheBlastCameFrom` and + `PenetrationMarchIsPlanar` pin the frame through its new owner. + - Negative greps: + - `rg -n "forward\.y, -forward\.x" Assets/Scripts` returns exactly one hit, in + `Entity.cs`. + - `rg -n "Outcome\.Aimed|outcome\.Aimed|o\.Aimed" Assets/Scripts tests` returns + nothing. + + Both were checked against the current tree: the first finds today's two copies, and the + second finds nothing. + +**Status (2026-09-24): landed** at `b851b0d1` (+21/-18 across `Entity.cs` and +`FireControl.cs`). 244 tests pass, both greps as specified (the one frame copy is +`Entity.cs:383`, inside `ToSchematic`), Unity batchmode compile clean, and nothing outside +`FireControl.cs` read `ShotOutcome.Aimed` (the HUD holds outcomes but never touched it). +Stryker on the changed lines: the frame's sign mutant dies; the only survivors near the diff +are `ResolveAimPoint`'s pre-existing centroid arithmetic (`aimedCells.Length > 0` → `>= 0`, +`/` → `*`), which 12.2's `AimingAtAnItemCentresTheScatterOnItsLane` is specified to close. +`--since` does not work in this repo (Stryker's own diff comes back empty), so runs use +`--mutate` on the touched files and filter the report to changed lines. No separate Soul pass: +a no-behaviour cut this size folds into 12.2's. + +### Cut 12.2. Where a hit lands + +- **Repo/branch:** Aetheria, on top of 12.1, with the 12.0 pin in place. +- **Deletes first:** + - `HullKernel` and its comment block (`FireControl.cs:531-578`). + - `WeightedPick` (`:591-611`). + - `POnHull` (`:229-234`). + - Apply's live direction (`:463-465`). + - The bounding half-extent (`:224`). + - The 9.2 SigmaFloor rationale (`:542-548`). Rewrite it as described above. + - The ActionGameManager inline estimate (`:1317-1318`). +- **Keeps:** `Sigma`, `SigmaFloor`, `ResolveAimPoint`, `CellsOf`, the gate order + (`:150-159`) and `Inspect.PBase = HitProbability(...)` (`:204`). +- **Adds:** `TravelDirection`, `Silhouette`, `CommitProbability`, the lateral draw, `Lane` + (only its first element is used in 12.2), and these fields: + - `PendingShot`: `TravelDirection`, `Spread` and `FireRange`; `PBase` is renamed to + `PFire`. + - `ShotOutcome`: `Bearing` and `Lateral`. +- **Per-file changes:** + - `FireControl.cs:161-167`: after the gates, `PFire(...) × PSpread(...) × + Silhouette(...).POnHull`. The bearing comes from `TravelDirection` and the target's + current facing. Nothing new runs before `:159`. + - `:197-202`: Inspect uses the same functions. It gains nothing new beyond + `diagnostic.PFire` if the HUD wants it (optional; presentation). + - `:263`, `:286-308`: Fire freezes `PFire`, `TravelDirection`, `Spread = weapon.Spread` + and `FireRange`. + - `:409-435`: Commit takes steps 1-5 of **Bearing timing** through `CommitProbability`. + - `:463-467`: Apply passes `shot.Outcome.Bearing` (schematic frame) to `ApplyHit`. For + this one cut, ApplyHit's parameter becomes a schematic bearing and the `ToSchematic` + call 12.1 put inside it is removed. + - `ActionGameManager.cs:1317-1318`: the pending line prints + `FireControl.CommitProbability(shot, now, out _)` instead of `shot.PBase * pDeviation`. + - `Combat.cs:117` and `TurretController.cs:84` need no change. They read HitProbability, + which is now a facing-aware forecast. Expect the AI to prefer broadside shots on long + hulls, and targets that turn to lose fewer hits. That is tuning, not a defect. +- **Authority map:** + - Owner: + - `FireControl.Silhouette` owns scatter. + - `FireControl.CommitProbability` owns the commit-time roll price and placement + source. + - `FireControl.TravelDirection` owns the shot's world direction. + - `Entity.ToSchematic` owns the frame. + - Inputs: + - Frozen: `PFire`, `TravelDirection`, `Precision`, `Aimed`, `Spread`, `FireRange`. + - Live at Commit: target position (deviation) and target facing (bearing). + - Outputs: p for the one roll, then `Bearing`, `Lateral` and `Cell` in the immutable + outcome. + - Derived state: + - HitProbability and Inspect are a forecast of the commit price and decide nothing. + - `ShotOutcome.Cell` is display-only, for the HUD (`ActionGameManager.cs:1323`) and + the tests. + - Forbidden writers: + - Nothing else computes a sigma, a projection or a bearing. + - Nothing after Commit reads a target's facing or anyone's position for placement or + damage direction. + - The HUD computes no probability of its own. + - Shared paths: InstantWeapon (`InstantWeapon.cs:248`), beam rolls + (`ConstantWeapon.cs:181`, where zero flight time makes the fire tick the commit tick), + the AI, turrets and the HUD. + - Deletion line: `HullKernel`, `WeightedPick`, `POnHull` and the HUD estimate are gone + before `Silhouette` has a caller. +- **Verification.** Fixtures follow 11.3: the shooter is off the origin, fire times are + nonzero, facings include ones where |fx| > |fy|, and weapon spread is nonzero wherever + PSpread is asserted. + - `BroadsideIsEasierThanHeadOn`: on a 2x12 hull, pOnHull and PSpread head-on are strictly + below broadside, at facings (0,1) and (1,0). It kills the bearing being ignored and the + old `max(W,H)`. + - `TurningArmourIntoTheShotTakesItOnTheArmour` (the ruling's own case): + - Setup: a slow shot (flight time well above `CommitHorizon`) at the soft stern of a + wedge with bow-only plate. The target turns its bow to face the shot's + `TravelDirection` before commit. + - Pass: every hit's `Cell` is on the bow row. + - Kills: freezing the bearing at Fire; reading the facing at Fire. + - `TurningAfterCommitChangesNothing` (R4, and closes L464/L465): + - Setup: through `Fire`. After commit and before arrival, the target turns 90° and the + shooter moves. + - Pass: the damaged cells equal those implied by the committed `Bearing`/`Lateral`. + - Kills: Apply reading live positions or facing. + - `PlacementAndProbabilityShareOneSilhouette` replaces 6d's + `PlacementAndProbabilityShareOneKernel` (`FireControlCut6dTests.cs:318`). + - Setup: a static target at a diagonal bearing, so the intervals overlap and the + forecast equals the commit price. + - Pass: `CommitProbability` equals a pOnHull that the test computes independently, by + numerically integrating the Gaussian over a union of intervals the test builds itself. + The empirical `Lateral` distribution matches the per-interval shares, and the + empirical hit rate matches p. + - Kills: sigma or bearing perturbed at one site; the union merge skipped; ℓ swapped + with b. + - `TheHudEstimateIsTheCommitPrice`: for an in-flight shot, the value the HUD prints and + the p Commit rolls against are the same number at the commit tick. It kills a second + formula. + - `HitsLandOnTheFacingEdge` supersedes `EveryHitLandsOnMetal` (`:357`): on the concave + fixture, each hit's `Cell` is occupied, and nothing occupied precedes it in its own lane. + The test recomputes the lane from `Bearing` and `Lateral`. + - `AimingAtAnItemCentresTheScatterOnItsLane` closes L585/L587 and replaces + `AimingAtTheSternHitsTheStern` (`:213`), whose fixture fires from the target's stern + (`FireControlCut6dTests.cs:163-164`) and would pass for the wrong reason. + - From broadside, hits aimed at the stern land mostly in the stern half, and hits aimed + at the bow land mostly in the bow half. + - From dead astern, hits aimed at the bow land on the stern edge. + - `TheSigmaFloorHoldsAtHalfACell`: pOnHull at Precision 1000 equals pOnHull at Precision 2 + on a one-cell lane. The exact integral no longer collapses, so this test, not the cliff + test, is now what kills "delete the floor". Keep the cliff test and rewrite its + mutation comment. + - `ShieldIsOmnidirectional`: an active shield absorbs the same shot identically from bow, + stern and beam. + - Kept green: + - `GatedOutHitProbabilityAllocatesNothing`. + - `TheHudShowsTheFactorsTheSimulationMultiplies`, re-read as forecast factors. + - `PSpreadIsTheSilhouettesShareOfTheSpreadCone`, which fires from astern at a 5-wide + hull. There the span is 5, equal to the old `max(5,3)`, so its numbers stand. + - `ThinLimbCostsHitChance`, with its comment rewritten in 1D terms. + - Every test that reads `shot.PBase`, updated to `PFire` with its expectation + unchanged. + - Stryker: `--mutate "**/FireControl.cs" --since:<12.1 head>`. Survivors are triaged by + name, and float boundary flips count as equivalent (11.2). +- **Operator:** + - A smoke against a LonginusX-class AI from the bow and then the beam. The HUD `hull` + factor should read higher from the beam. + - Turn your armoured face into a slow missile and watch where it lands. + +**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: +`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` +(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. + +What the Soul passes found, in order, because two of them are lessons for 12.3: +- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, + and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so + every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause + found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; + `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration + could follow a turn made after commit; multi-cell aimed items were untested. +- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. + The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result + into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root + cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, + `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the + two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an + `IComparer` in a delegate per sort). +- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the + slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all + cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and + float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration + found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact + arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. + The `Commit` invariant throw is unreachable. + +Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX +from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside +fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an +item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per +`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. + +**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at +b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant +survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of +their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in +`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make +`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through +visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` +import. + +### Cut 12.3. How a direct hit travels + +- **Repo/branch:** Aetheria, on top of 12.2. +- **Deletes first:** `Entity.DamageSchematic` (`Entity.cs:333-374`) and `Entity.ApplyHit` + (`Entity.cs:376-409`). Going with them: + - the 0.5-step march; + - the `+ .5` cell convention; + - the `> .5f` threshold; + - the Expand spread footprint. +- **Adds:** + - `Entity.Absorb(int2 cell, float damage) → float`, moved verbatim from + `Entity.cs:348-366`. Armour absorbs up to its value, then the item when `d > .1f`, and + the remainder is returned. It emits `ArmorDamage((cell, incoming))` and + `ItemDamage((item, incoming))` with today's meaning, and only for incoming > 0. + - `Entity.DamageHull(float)`, moved from `Entity.cs:369-373` (`> .1f`, one `HullDamage` + event). +- **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches + unchanged: + 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. + 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's + rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and + **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per + cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two + derivations disagreed at cell edges and struck a cell twice). + 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. + 4. Each lane walks from its own facing cell, clipped to the penetration depth, with + `rem = target.Absorb(cell, rem)` at each cell. + 5. Each lane's final remainder goes to the hull, where the lane ends: penetration + exhausted, a gap, or the far side (Q12-3 = A). The remainders are summed and passed to + `DamageHull` once. + 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead + (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. + + **Where 12.3 and 12.4 meet.** Which model a shot uses is decided by the blast radius, not + by its penetration: + - **No blast radius — a dumb AP round.** Steps 1-5 are the whole model, whatever the + penetration. The shot travels its lane and is absorbed cell by cell in order, with the + remainder carrying on until it is spent or the lane ends (Q12-3). Damage in a line. More + penetration spreads the same damage over more cells; it never concentrates it at depth. + Q12-8 says nothing about this case. + - **With a blast radius — an explosive penetrator.** The shot reaches its fuse depth + without spending damage on the way, and then detonates as an area (12.4). Q12-8 governs + only this case, because the payload is the explosion, not the dart. + + The two roles that follow are the design intent: the AP round is the pinpoint component + sniper, a thin line of damage through whatever it crosses, and the explosive penetrator + is the interior wrecker, a disc across a whole section. + + Splash is untouched in 12.3 and deleted in 12.4. Until then it calls `Absorb` per + footprint cell and passes the summed remainder to `DamageHull`, which is arithmetically + identical to today. +- **Authority map:** + - Owner: `Entity.Absorb` owns what one cell does with incoming damage. + `FireControl.Apply` owns the order and the share per lane. + - Inputs: the committed `Bearing`, `Lateral` and `Cell`, plus the frozen `Penetration`, + `DamageSpread` and `Damage`. + - Outputs: `Armor[]`, item `Durability` and `Hull.Durability`, plus the events + `IncomingHit`, `ArmorDamage`, `ItemDamage` and `HullDamage`. Their consumers: + `ActionGameManager.cs:1070`, `EntityInstance.cs:292`, `InventoryPanel.cs:470-482`, + and `Entity.cs:488-494` (ItemDestroyed, HullArmorDepleted, Death). + - Forbidden writers: no weapon hit writes armour or item durability except through + `Absorb`. `Behaviors.CauseDamage` (`Behaviors/Behaviors.cs:62-73`) is self-damage and + out of scope. + - Deletion line: both Entity methods are deleted before `Absorb` gets a second caller. +- **Verification:** + - `DamageIsAbsorbedInOrderAlongTheRay` replaces `HardpointHitDamagesItemThenHull` and + `PenetrationMarchIsPlanar` (`FireAuthorityTests.cs:481-526`, which call the deleted + `ApplyHit`). + - Setup: through `Fire` at facing (1,0). The lane runs through a 10-armour front cell, + then a marker, then a soft cell, with damage 30 and penetration 3. + - Pass: armour −10, marker −20, soft cell untouched, hull −0. Today it would be + 10/10/10 **(probe)**. + - Kills: an even split; items before armour; no carry-forward. + - `ArmourFacesTheShot`: + - Setup: a wedge with bow-only plate, penetration 0, many shots. Facings (0,1), (.8,.6) + and one where |fx| > |fy|. + - Pass: from ahead only bow cells lose armour, and from astern the reverse. + - `BuriedCockpitNeedsPenetration`: a cockpit enclosed by one ring of cells. + - At penetration 0 it is never damaged from four bearings. + - At a penetration that clears the ring, it takes exactly `damage/lanes` minus what the + ring absorbed. + - Enough damage fires `Death` with `CockpitDestroyed`. + - `SpreadWidensTheImpactAcrossLanes`: spread 1 at penetration 0 puts damage/3 on each of + three facing-edge cells and nothing behind them. + - `ARayThroughACornerCrossesTheCornerCell`: the diagonal case from `Probe1` reaches (2,2). + - `LaneRemainderReachesTheHull`: a ray that exits a thin hull delivers its remainder to + `Hull.Durability` (Q12-3 = A). + - Kept green: every Splash test, and `AirburstSplashes…` (Cut 6). + - Negative: `rg -n "DamageSchematic|ApplyHit\(" Assets/Scripts tests` returns nothing. + Today it matches `Entity.cs`, `FireControl.cs`, `FireAuthorityTests.cs`, and two comment + lines at `Gameplay/EntityInstance.cs:280-281`. Rewrite those comments and + `FireControl.cs:492`. + - Stryker: `--since:<12.2 head>` over `FireControl.cs` and `Entity.cs`. +- **Operator:** a smoke with a GT 3K or pswarm launcher into a LonginusX bow, then from a + flank. The schematic display should pulse the facing edge only. + +**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: +- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. +- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. +- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. +- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. +- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. +- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. + +297 tests. Unity batchmode is clean at `cccf3a2a`. + +Proportional absorption, passes 4 and 5: +- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` + was already ruled out by the operator. Soul found three more defects: + - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. + - The pooled resolve skipped the item's `.1f` threshold. + - The model sweep still passed with proportionality deleted. +- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. + `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is + Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. +- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled + bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. + Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and + stale comments. All are fixed, and each mutant dies to a committed test. + +Recorded, not fixed: +- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, + which only a private seam offers. The operator rule forbids reflection, and no test seam is added + for them. + - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. + - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). + - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). +- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. + +What the three Soul passes found: +- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of + 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an + angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. + It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on + (Q12-2 amendment: no doubling up, the line thickens). +- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits + struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first + structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the + centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit + with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float + drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two + derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, + the `ceil` empty centre); the cure each time was one owner. +- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two + lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by + construction. Residual float effects (a side lane starting one cell deeper, a column falling + between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage + is never lost. + +**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike +exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% +of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing +ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since +it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. + +**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the +first-processed lane drains its durability and the second carries the leftover into its own deeper +cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on +a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs +from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. +Alternative: keep the processing order and pin it. + +**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell +item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane +carries on with a leftover in proportion to what it brought. The result must not depend on the order +lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. + +**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an +exception, this should be one code path." The first implementation (`56757913`) surveyed each shot +for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split +authority, and the second reachability derivation it needed is the same failure as the 12.2 empty +lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of +the lanes that reach it, a pool of one lane included. The survey and the sequential path die. + +**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. + +### Cut 12.4. The detonation primitive + +**Status (2026-09-29, Self): closed.** 12.4 landed at `2625768f`, `7c99909d` and `7e63d744`. + +Soul pass 1 (Opus) found three defects: +- **F1:** a tangent disc created damage, 127 of 100. +- **F2:** a candidate cull measured from the centre of mass missed hull corners. +- **F3:** a lethal blast threw mid-loop and detonated again. + +It also found four rules pinned only by tests that could not fail. + +The fixes landed at `8b4bf01e`, `76e98598` and `fd792013`: +- the tangent comparisons now include equality; +- the cull and the `radius <= 0` guard are deleted, so `RectDiskOverlap` is the only decider; +- the blast loop iterates a snapshot. + +Soul pass 2 closed it. Conservation held over 1,944 exact-lattice discs, with a worst error +of 2e-5. A blast costs 0.3-0.7 ms in a zone of 40-100 entities. + +Pins landed at `4629bc67` and `c7e20f08`. `Death` fires once per entity (`Entity.cs`, `.Take(1)`), +which also fixed a direct hit on a corpse raising `Death` again. A three-kill blast test was added. +362 tests pass. + +**Ruled (operator, 2026-09-30): "fused weapons without a lock explode at max range."** This is no longer +open. Landed at `8c002637` (fix) and `3e8ee022` (tests), and Soul confirmed it. That Soul pass raised four +follow-up rulings, all given on 2026-09-30: +- **The aim is `LookDirection`, restricted by the mount's arc.** The operator notes this was already the case; + the fix had used the hull-fixed mount. +- **An invalid target counts as no lock.** A target that is selected but out of range, out of arc, not + visible, or not locked takes the no-lock path. Nothing bursts beyond max range. +- **A no-lock round of any fuse stops on the first hull on its aim line**, computed at commit, and detonates + there. This covers contact, proximity and delayed fuses (operator, 2026-09-30, extending the contact-only + ruling). A round fired along a clear line still bursts at max range (bomb fishing). Mining adds chunks to + this through a named seam. +- **Airburst follows player intent (operator, 2026-09-30):** "If I don't have targeting data but decide + to shoot anyway, my intent is likely to be bomb fishing. If I fire with a target but that target warps + away, I likely to not want to explode whatever was behind it, and the projectile shouldn't burst." + - A round fired without valid targeting data bursts at max range. + - A round fired at a valid target that later leaves or becomes invalid does not burst. It resolves + with no detonation. + - Validity is judged on the targeting data at fire time. +- **Out of arc (operator, 2026-09-30):** a fused weapon whose selected target is out of arc still fires + (bomb fishing). It bursts at the target's range, clamped to max range, along the arc-clamped aim. + Non-fused weapons refuse. +- **Arming distance (operator, 2026-09-30; refined the same day):** it is literal. A round never bursts + closer than `BlastRadius` to the ship's centre at fire time (`FireOrigin`), whatever the hull size or the + aim. Close bursts are pushed out to that distance, and fire is refused when `Range < BlastRadius`. A large + hull can take splash from its own close bursts, and that is accepted. +- **Demoted rounds keep their target (operator, 2026-09-30):** a round fired at a valid target closer than + the blast radius flies to the arming point but stays targeted. If the target leaves before resolution, + the round does not burst. A pilot who flies into their own distant burst + takes the damage. Fire is refused when the live Range cannot clear BlastRadius. +- **Turret arc ownership (operator, 2026-09-30):** the simulation's arc is authoritative, and Unity barrels + derive their aim from it. The true owner of an arc is the **pivot**, not the hardpoint. The arc stays on + the hardpoint until the moddable-ships campaign adds pivots to `HullData`. Until then, a pivot carrying + several hardpoints turns within the **intersection** of their arcs. +- **AI and turrets (operator, 2026-09-30):** each weapon decides for itself, not the group's first weapon. + A fused weapon fires whenever its target is designated (visible, in range, locked), whatever the arc. A + non-fused weapon keeps the hit-chance threshold. The AI never fires at nothing, so AI does not bomb-fish. +- **HUD forecast (operator, 2026-09-30):** for a fused weapon, the forecast shows the outcome (Direct, Burst + at N m, Refused). `Fire` and `Inspect` compute it with the same helper. Direct-hit weapons keep the + percentage. +- **A refused round is free (operator, 2026-09-30):** it spends no ammo or energy, and makes no sound, heat, + wear or visibility. **It does not start the cooldown either** (operator, same day: "triggering cooldown on + refusal would be mostly neutral but punishing for long cooldown weapons, worth the code to fix"). +- **Batch 5 Soul pass, 2026-09-30: fix first.** `Solve` is the one decision, and `Fire`, `Inspect` and + `Refuses` all read it. Findings in fix batch 6 (Hands, `hands/fuse-fix2`): + - Contact and delayed rounds detonate on the hull and ignore the arming push-out. + - A refused beam blinks start and stop. + - A throwing damage observer re-applies a blast every tick. + - The AI selects refused fused weapons. + - A single-ammo burst pays, then refuses. + - The guided projectile's overshoot explosion ignores the simulation. + - Two copies of the range and designation predicates remain. + + An out-of-arc round with a selected target bursts after that target leaves. That is ruled behaviour: an + out-of-arc target is invalid, so the round takes the no-lock path. +- **Landed, 2026-09-29: batches 5-8 merged at `06001b57`.** 476 tests pass on the merged tree, after Soul + passes 5-7. Recorded follow-ups: + - The push-out follows the flight line, not the ray to the hull point. + - The HUD's burst range is an upper bound when a no-lock round stops at an earlier hull. + - The first-hull stop reads poses at fire time. + - A burst whose first round fails its energy or ammo spend retries every tick, bounded by capacitor refill. + - The first-round cooldown divides by `Cooldown` even when the magazine empties (a reload quirk that + predates this work). +- **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go + nowhere. + +Soul also found two more defects: +- A fused weapon with `Range <= BlastRadius` bursts on its shooter. The fix is to refuse it when the catalog + opens. +- A burst without a target reads as MISS in the debug HUD. + +Fix batch 2 covers all six, in Hands on `hands/fuse-fix2`. The stale lines about "`BurstPosition` is read +only for a proximity fuse" and the Apply rules below it are superseded by these rulings. +Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship +still takes blast damage. + +Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path +rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. +Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see +Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. + +- **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: + - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no + weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has + not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). + - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. + +#### Deletes first + +In (a): + +- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), + `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. + `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. +- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The + initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a + proximity fuse. +- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. + +In (b): + +- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. +- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. + `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). +- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell + separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. + **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` + the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. + 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. +- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended + answer. It is not deleted: it is gated on `shot.Fuse == null`. +- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one + reach function (see **The penetrator**). +- Stale comments that name a deleted owner as live: + - `Entity.cs:391` and `:417`; + - `FireControl.cs:1201`; + - `Gameplay/EntityInstance.cs:280-283`; + - `Gameplay/HullCollider.cs:4-6`; + - `Gameplay/Weapons/Projectile.cs:8-9`; + - `Gameplay/Weapons/Mine.cs:90-95`. + +#### Data + +The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). + +- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name + `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache + opens the catalog as compatible drift (see 0b). +- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, + Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: + null means no blast, and every shipped record reads as null. +- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when + `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing + re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius + without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is + not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on + `BlastRadius` alone (Soul, `2625768f`). +- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That + write is content and changes no data. It belongs to F12-2. +- **Readers to update:** + - `FireControl.cs:386-388`. + - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` + parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, + because Q12-6 ruled that nothing checks labels against behaviour. + +#### Which model a shot uses + +`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: + +- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. +- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which + `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, + `AirburstSplashesEvenOnAGuaranteedMiss`. +- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P + is**). On a miss nothing happens. + +Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse +into Apply's switch, so `Step` has no damage branch left. + +`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and +only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one +consumer is the player's hit marker (`ActionGameManager.cs:1070`). + +This is a default, not a ruling. + +#### Where P is + +The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as +`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). + +**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: + +- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 + from its centre; +- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. + +So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. + +- **Proximity:** `BurstPosition.xz`, a world point. +- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and + `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. +- **Delayed:** the schematic point `point(t_fuse)`, with + `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that + 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, + whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last + reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. +- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already + proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not + read. The disc's radius is its width. This is a default, not a ruling. +- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the + host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like + every other entity in the radius: its cells are found again by `ToSchematicPoint`. + - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for + it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. + - Bystanders are judged against where the host actually is at arrival. That is the same live rule a + proximity burst already applies to everyone. + + There is exactly one input shape, a world point. There is no host-only schematic entry. + +#### The penetrator (Q12-8) + +A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` +the shot's whole `Damage`**. + +That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the +explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. + +The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at +`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → +int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while +`entry - impact.entry < penetration`. + +Two callers read it: + +- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; +- the delayed fuse point, which calls it on the centre lane. + +No second "lane damage with a blast" path exists, because no shot does both: + +- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; +- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. + +A shot takes one branch of Apply's switch or the other, never both. + +The mutant this must kill is a second reach derivation: for example, a delayed P computed as +`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. + +#### Area, per entity: `FireControl.Detonate` + +The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, +public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). + +- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes + `position.ToCultMath().xz`). +- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / + SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the + frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and + `SchematicCellSize` is `Settings.cs:255`. + +For each entity in the zone: + +1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. + Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a + long hull whose end lies inside the blast. +2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For + every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × + overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a + cell being the unit square centred on its integer coordinate. + - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing + computes an overlap twice. + - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. +3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, + under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact + or delayed blast, under Q12-9's recommended answer. + - If the shield absorbs, no cell of that entity takes anything. + - If it cannot, it breaks, and the cells take their shares. +4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects + its own cell's share and nothing else. +5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in + hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has + deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join + the hull total. +6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). + +Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the +other (F12-8). + +**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, +and no operator question is needed: + +- "Armor absorbs first", from the first round of rulings; +- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; +- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where + incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after + `ArmorAbsorb`; +- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." + +The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: +an item's cells deposit into one pool, and they do not absorb independently. + +**One owner, and why there is no walk.** + +- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on + the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. +- `ArmorAbsorb` is the one owner of what armour does. +- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, + `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the + item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no + cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the + reachability test and the cycle rule have nothing to decide. + +The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull +with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell +with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 +discs. Its hull total differed only by float summation order, at most 3.8e-6. + +`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a +starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. + +**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same +per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only +order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. + +**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the +disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` +between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it +conserves exactly up to float rounding. + +- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at + radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double + precision is not needed. +- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. +- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc + smaller than a cell, or for one straddling a cell boundary. + +**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than +128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not +the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. + +**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an +area is occluded. A bulkhead protects its own cell's share and no other. Falloff toward the rim is F12-7, not +this cut. + +**HitProbability and Inspect are unchanged.** A contact or delayed blast still needs the shot to hit, so the +forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. +That is pre-existing, and it is F12-5. + +#### Authority map + +**Owners:** + +- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. +- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the + per-entity armour→pool→hull pass. +- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with + 12.3's walk. +- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. +- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. +- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. + +**Inputs:** + +- P, the radius, the damage and the type; +- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; +- the fuse and the radius, frozen at Fire; +- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. + +**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, +`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. + +**Derived state:** + +- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, + `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. +- `BurstPosition` is read only for a proximity fuse. +- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. + Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` + finds only `FireControl.cs`). + +**Forbidden writers:** + +- No label decides behaviour. +- Nothing but `Detonate` applies area damage. +- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. +- Nothing but `Reach` decides how deep a lane reaches. +- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). +- `Step` has no damage branch. +- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they + decide no damage themselves. + +**Shared paths:** + +- the proximity burst: Apply at arrival, with no roll; +- the contact and delayed blasts: Apply on a committed hit; +- `Mine.Explode`; +- the tests, which call `Detonate` directly (see **Test seam**). + +**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a +caller. + +#### Test seam + +`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen +intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production +interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from +`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is +committable with no seam and no reflection. + +It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at +an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes +a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and +exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: + +- angled direct-hit correctness is covered by the model sweep and + `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; +- P1 and P2 stay uncommitted. + +#### Verification + +These fixture rules apply to every blast test that touches the frame: + +- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a + deleted `/ SchematicCellSize` survives. +- The target is off the origin. +- The facings include one with |fx| > |fy|. +- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. + +Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 +absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. + +**Geometry.** + +- `TheDiscIsConservedOverTheGrid`. + - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius + under one cell, one centred on a cell corner, and one off the grid. + - Pass: the total equals `damage`. + - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box + that clips the rim cells; a dropped chord breakpoint. +- `EachCellTakesItsShareOfTheDisc`. + - Setup: a solid hull with no armour and no items. + - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. + - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the + cell's centre. +- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). + - Setup: a blast straddling the hull's edge. + - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The + shares sum to the blast's damage minus what falls off the hull. + - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. +- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. + - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. + - Pass: the near end takes damage. + - Kills: Splash's centre-distance cull. + +**Absorption order.** + +- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` + (`FireControlCut123Tests.cs:750`). + - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage + levels of the old test: below the armour, between the armour and armour plus durability, and above both. + - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. + - Kills: the item absorbing before the armour, and armour being skipped. +- `ArmourProtectsOnlyItsOwnCellsShare`. + - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. + - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. + - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat + cell B's share. +- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. + - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but + more than `.1` on the two together (the `ProbeC2` geometry). + - Pass: the item absorbs the sum. + - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or + a pool that resolves before every covered cell has deposited. + + This test is also what pins that the result does not depend on which cell is considered first. As shown + above, the threshold is the only order-sensitive rule a blast has left. +- `ItemDamageFiresOncePerCoveredCell`. + - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive + post-armour deposit, and each event carries that cell's deposit. + - Kills: one event per item, and events for zero-deposit cells. + +**Symmetry and frame.** + +- `MirroredBlastsDoMirroredDamage`. + - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of + world points mirrored across the target's bow axis, with the mirror computed by the test from the facing + and not through `ToSchematic`. + - Pass: per-cell armour, item durability and hull damage mirror. + - Kills: + - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); + - a missing or wrong `CenterOfMass` anchor; + - any scheduling rule that depends on enumeration order. + + It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test + does. +- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` + (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). + - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's + width. + - Pass: port cells are damaged, and starboard cells are untouched. + - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. + +**Shields** (under Q12-9 = A). + +- `ShieldPaysForWhatReachesIt`. + - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. + - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` + (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the + covered share, never loosened. + - Kills: charging the full damage. +- `ABlastShotsShieldIsDecidedAtDetonation`. + - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at + arrival. + - Pass: the shield is charged the covered share. + - Second setup: a shield that could absorb the whole shot at commit. + - Pass: the shot still detonates, and the shield pays its covered share. + - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the + host. + +**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). + +- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit + behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. + - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour + of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. + - A contact fuse with the same stats leaves the cockpit untouched. + - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in + order, and the cockpit takes only what the ring left (12.3). Damage in a line. + - Kills: + - absorbing along the burrow; + - a contact fuse reading penetration; + - routing every penetrating shot through the fuse path; + - a fuse without a radius detonating. +- `TheFusePointStaysOnMetal`. + - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap + behind the first run. + - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the + gap's near edge). + - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. +- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. + - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. + - Pass: the host's per-cell damage equals that of the same shot with no turn. + - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. +- `LabelsDoNotDecideBehaviour`. + - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with + `Fuse = Proximity` and no label detonates. +- `AFuseWithoutARadiusIsADirectHit`. + - Pass: `Fuse = Contact` with a null or zero radius does lane damage. + - Kills: Fire freezing `Fuse` without the radius check. +- Re-fixtured to `Fuse`, with their behaviour unchanged: + - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes + `ProximityDetonatesEvenOnAGuaranteedMiss`; + - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); + - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; + - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes + `ABlastHitsEveryEntityItCovers`. +- `AContactBlastReportsTheHit`. + - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. + - Kills: `IncomingHit` moved inside the direct-hit branch. + +**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: + +- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes + `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with + a remainder of 0. +- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool + of `{ .1f }`. + +**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the +clip rule are otherwise untouched. + +**Negative greps:** + +- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` + returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and + `FireControlCut4/5/6/11/123Tests.cs`. + - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are + unrelated. + - Exclude `StrykerOutput`, `bin` and `obj`. +- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the + stale-comment list above. + +**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. +`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). + +#### Operator + +- Author one proximity weapon and one delayed-fuse weapon in a scratch catalog. +- Watch an airburst beside a LonginusX and a penetrator into its nose, and check that the damage lands where + the model shows the blast. That verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of + the frame that only a running blast can show. + +#### History (superseded 2026-09-25, kept for the record) + +The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: + +- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** + Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour + stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. +- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with + `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. +- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** + Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then + `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit + had judged unshielded: two authorities over one shield. +- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and + `Step` has no damage branch. +- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one + `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. +- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was + `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. + +### 0b. Identity, lifecycle, authority + +Two persisted changes, both on `aetheria.weaponitemdata` v1 and both in 12.4: + +| Field | Slot | Change | Lifecycle | Reader risk | +|---|---|---|---|---| +| `BlastRadius` (was `AirburstRange`) | 29 | rename, same `float?` type | authored catalog | compatible drift: `CompareSchemaShapes` ignores member names (`CultCache.cs:672-752`, CultLib `45c2f40`); drift warnings until the next catalog write | +| `Fuse` | 32 | new, `WeaponFuse?` | authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule | + +Runtime-only and never serialised (0b table, `FireControl.cs:626-629`, `:686-689`): + +- `PendingShot`: `PFire`, `TravelDirection`, `Spread`, `FireRange`, `Fuse`, `BlastRadius`. +- `ShotOutcome`: `Bearing`, `Lateral`. + +Nothing in saves changes shape. + +### Operator questions + +Ruled 2026-09-22 (the words are recorded under **Rulings**): + +- **Q12-1: B**, the bearing is taken at Commit. +- **Q12-2: A**, spread is width. +- **Q12-3: A**, the lane remainder goes into the hull where the lane ends (direct hits). +- **Q12-4: A**, `ShotOutcome.Aimed` is deleted. +- **Q12-6: no.** Operator: "12-6, nope". No catalog test ties a label to behaviour. + `WeaponModifiers` stays authored metadata that nothing checks and nothing reads in the + simulation, and a weapon whose label disagrees with its data is authoring's business. +- **Q12-7: dissolved, not answered.** It asked where a blast ray's remainder goes. With + blasts as areas there are no rays and no remainder to route, so the question has no + subject. The ray model's shadowing goes with it (see **Accepted loss** in 12.4). +- **Q12-8: A, and it is scoped to weapons that carry a blast.** Nothing is absorbed along + an *explosive penetrator's* burrow: it reaches its fuse depth without spending damage, + and the explosion is the payload. Penetration is a reach decision there — how deep the + fuse point can sit — not a damage budget. + + The first wording ("the burrow is travel, not damage") read as if it covered every + penetrating shot. It does not. Operator, 2026-09-22: "Q12-8 is odd. Like, penetration is + still a thing even if it's not a warhead. For a dumb AP round, you'd expect it to do + damage in a line, not dump all its damage at max depth, that would make AP rounds even + better at component sniping than AP explosives". Agreed, and the map never meant + otherwise: a weapon with no blast radius takes 12.3's lane, absorbed cell by cell in order + with the remainder carrying on, and Q12-8 does not reach it. See **Where 12.3 and 12.4 + meet**. + + So "armour absorbs first" holds everywhere it was ruled: down the lane for a direct hit, + and per covered cell for a blast. Against an explosive penetrator, armour protects by the + reach gate and by absorbing its own cell's share of the disc. The consequence to watch + when F12-2 tunes penetration is that an explosive penetrator with reach pays nothing for + the plate it crosses, while an AP round pays for every plate it passes. + +Open: + +**Q12-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. + +**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a +blast that needs a hit?** + +The current map lets two places decide it: + +- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. +- `Detonate` then decides every shield in the radius live, and that includes the host. + +**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so +the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can +absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split +this campaign has cut three times. + +- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield + for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the + radius, live at detonation. + - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no + direction", applied to shields. + - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that + reaches the hull. For a contact detonation on the hull's face, that is roughly half. + - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads + them. +- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole + and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" + parameter: a compensator that keeps two owners alive, one per entity. + +What depends on it: + +- the gate on Commit's shield block; +- whether `Detonate` takes a host parameter; +- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. + +Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half +of (b) can proceed while this waits. + +**Defaults taken, not rulings.** The operator may overrule any of these: + +- A blast shot ignores `DamageSpread`, and P sits on the centre lane. +- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. +- The delayed fuse point is clamped to the last reached cell's exit. +- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. + +- **Q12-5 (not ruled). Should the HUD show which target items are exposed from the current + bearing?** + - **Recommended:** a follow-up after 12.3. It would be a presentation read of `Silhouette` + and `Lane`, the way Inspect is: for each revealed item, is it first in some lane of the + current shadow. It belongs on the target-item cycling UI. + +### Follow-ups recorded, not in this cut + +- **F12-2. Penetration retune** (operator: "Retuning penetration is a follow up."). + - Shipped values are ≤ 0.25 against an inspector range of 0..1, and enclosed cockpits + need about 2 cells. + - The same pass decides which weapons get fuses and blast radii (none have one today), + and writes the catalog once to clear the slot-29/30 drift warnings. + - Until it lands, interior cells are reached only by blasts. +- **F12-3. SigmaFloor's purpose changed.** It was a numerical guard and is now a design + minimum on group tightness. Revisit it when Precision is next tuned. +- **F12-4. Double charging.** 6d's note that pSpread and pOnHull both price target size + still stands. Both now read one Span and shadow, so a later merge is mechanical. +- **F12-5. AI and proximity blasts.** `Combat.cs:117` gates a proximity weapon on a + direct-hit forecast that does not decide its damage. This is pre-existing. +- **F12-6. Mine's blast inputs.** The prefab `BlastRange` and the arming OverlapSphere + (`Mine.cs:69-78`, `:99`) are presentation-side (Cut 4 Q7). Mine has no catalog weapon + record carrying `Fuse`/`BlastRadius`. It can move when it ships (R9). +- **F12-7. Blast falloff toward the rim.** Every cell takes its area share flat, so a cell + at the rim is hurt as hard per unit of area as the one under the fuse. A radial falloff is + a tuning decision for the same pass that authors radii, and it changes only the weight + inside the integral, not the owner. +- **F12-8. Blasts do not shadow.** Nothing inside an area is occluded, so a bulkhead + protects only its own cell, and two ships caught by one blast do not shade each other. + This is the accepted cost of areas over rays. Reopen it only if play shows interiors are + too soft. +- **Behaviors.CauseDamage** (`Behaviors/Behaviors.cs:62-73`) writes durability and + `HullDamage` without `Absorb`. It is self-damage, not a hit, and is left alone. +- **Orphaned `Airburst*` YAML** in nine weapon prefabs. It is harmless, and Unity drops it + on the next save of each prefab. + +### Subtraction ledger (estimates, with actuals) + +| Sub-cut | Removed | Added | Notes | +|---|---|---|---| +| 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | +| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | +| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | +| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | +| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | +| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | + +Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: + +- 12.1: `b851b0d1^..b851b0d1`; +- 12.2: `b851b0d1..c7b6e80c`; +- 12.3: `c7b6e80c..d402283c`. + +The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: + +- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; +- 12.3: sequential, proportional, order-free absorption; +- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, + `Absorb` and `Step`'s branch. + +12.4's own delta is ~+60, and that is the estimate to hold it to. + +**Section history:** Cut 6d's "The rule" (the 2D kernel) and its premise that pOnHull folds +into PBase at Fire, 9.2's SigmaFloor rationale, and Cut 4's Splash rule (`FireControl.cs:946-959`) +describe models this cut replaces. Self should mark them as history in the status header +as 12.2 and 12.4 land. diff --git a/docs/locomotion-cut.md b/docs/locomotion-cut.md new file mode 100644 index 000000000..84a143ea9 --- /dev/null +++ b/docs/locomotion-cut.md @@ -0,0 +1,940 @@ +# Locomotion: Control Allocation and Capability-Aware AI + +Date: 2026-09-22 + +Status: target and cut map, from an Imagination pass revised on 2026-09-22. Cut 1 (the Longinus and Djinni hulls, +five thruster designs, and tutorial entrance station rule) landed on `codex/locomotion-cut1` and was merged into +`codex/fire-control-12` at `e160bbe5` on 2026-09-26. Cuts 2 to 5 remain target work. The operator's Q1, Q2, +Q3 and Q5 rulings below still govern that work. **The cuts were renumbered by the 2026-09-22 revision**: the +content restore became Cut 1, and what the first committed version called Cuts 1 to 4 became Cuts 2 to 5. +Historical anchors below are against `origin/codex/item-provenance` at `cd846916`; the then-current working tree +on `codex/fire-control-10` (`e8fbb6a6`) had an identical tree for every file named below. The first half of this +document is the **target** (the ends). The second half is the **cut map** (the means). When the campaign closes, +split the target out as +`docs/locomotion-target.md` and keep this file as history. + +Claims marked **(probe)** come from running code: scratch builds of `Aetheria.Shared` from `cd846916` against +CultLib `45c2f40`, run in the session scratchpad. That covers catalog dumps, a legacy-msgpack decode, base-commit +flight measurements and a solver comparison. None of the probe sources are kept. Each result below says what was +run. The base suite passes 244/244 in that scratch copy. + +## Rulings + +Operator, 2026-09-22: + +- **Problem statement.** "We're gonna need much smarter locomotion AI if they're to survive getting a thruster + shot out. Longinus (non X) had one big thruster in the back and two attitude thrusters in the front. Losing an + attitude thruster means you can't rotate that way anymore. Losing a thruster on the Djinn usually means some form + of crab walking just became optimal. Naively trying to use a thruster that's been shot out makes them real easy + to cheese." +- **R1, two layers.** (1) **Control allocation owns which thrusters fire.** Each live actuator contributes a + body-frame wrench (Fx, Fy, τ) per unit throttle, with throttle in [0,1]. A bounded least-squares solve finds the + throttles for the desired wrench (Johansen & Fossen 2013 control-allocation survey). This replaces the rotation + buckets and the strafe torque compensation. Thrusters that are destroyed, unpowered, disabled or degraded drop + out of the matrix or scale down in it. (2) **The AI plans with the allocator's achievable set.** For a desired + Δv it scores candidate headings by achievable acceleration along Δv plus turn time in the directions the ship can + still rotate. Crab walking and the long way round emerge from that scoring; neither is special-cased. +- **R2, the same-inputs invariant.** "Player and AI are controlling the same ship with the same inputs, this is an + Aetheria invariant." The player's controls go through the same allocator, through the same input surface the AI + writes. Any second path that bypasses it is a forbidden writer. +- **R3.** A capability-aware **combat facing/tactics** layer is wanted as its own later cut. Example: a one-way + turner keeps its target drifting the way it can turn and leans on its turret arcs. This document maps it at + target-shape level only. +- **R4.** If CultMath lacks a bounded least-squares solver, CultMath gets one, as a CultLib cut. Aetheria does not + grow a local helper. CultMath has none (probe: `packages/cultmath/src/CultMath` at `45c2f40` holds vector types, + `math`, `BatchMath`, `Voronoi` and `Random`, with no linear solver of any kind). + +Self, relaying an operator answer, 2026-09-22. This is the layer split, stated precisely: + +- **Below the inputs**, shared by player and AI, is the ship's own controller plus the allocator. It turns toward + `LookDirection` and realises `MovementDirection` with whatever actuators are live. **Choosing a feasible turn + direction belongs here**, including the long way round when one rotation direction is dead. If it lived in the + AI planner, a player ship with a dead attitude thruster would still stick when looking toward its dead side. + Holding heading against torque coupling (strafing with an off-centre thruster) also belongs here. +- **Above the inputs**, AI only, is the heading planner. It decides what to write into + `LookDirection`/`MovementDirection`, for example to turn sideways and crab-walk. The player makes that choice by + hand. +- The long-way test therefore belongs to the shared-controller cut and drives `LookDirection` directly, as the + player would. The crab-walk test belongs to the planner cut. + +Operator rulings on this map, 2026-09-22, relayed by Self: + +- **Q1 = A, the divergence is accepted.** "The divergence is fine." A small divergence from base behaviour on + intact thruster ships is accepted: the Longinus's forward creep of about 10.7 m/s² while turning in place, and + the Djinn's roughly 9% faster strafe. Base is pinned exactly only where the old mixer and the allocator agree, + and the accepted divergences are named with their measured magnitudes in Cut 3's verification, so Soul can tell + an accepted divergence from a regression. **No per-thruster role rules are reintroduced to match base.** +- **Q2 = B, differential main thrust is allowed.** "That's absolutely viable and can be a better decision than + turning around the long way, I wouldn't introduce a thruster exclusion for this." Any live actuator's torque may + be spent on rotation, mains included. A damaged ship turning toward its dead side lurches forward, and the + planner may prefer that to the long way round when it is faster. +- **Q3, fixtures follow the data.** "Good catch on the Longinus main thrusters, I was misremembering." Two aft 2×2 + mains, as both legacy catalogs author them. +- **Restore the legacy thruster hulls.** Asked whether the lost layouts could come back: "can we restore these + layouts?" They become Cut 1, the campaign's first cut, ahead of the solver. + +Second round, same day: + +- **Cut 1 is approved as inventoried, optional gear included.** "Yes and yes": the Longinus and Djinni hulls, + Medium Drive, and Small Drive, Victoire, Talaria and RevvITup 2.0 as well. +- **The second hull behaviour is not `VelocityConversion`.** "I don't think VelocityConversion was on either + hull." The decode agrees: it is `VelocityLimitData`. See Cut 1. +- **Q5 is dissolved, not answered: the rotation demand is not sized at all.** "It should only do that if the + ship's controller also *wants* to accelerate forwards that much, but I'm not sure how to encode that while + allowing the lurch. Like, lurch burn to compensate for a missing attitude thruster is also only a good move when + you both need to turn a bit and don't mind being all the way over there after. It doesn't help when, say, + there's a guy to port and your aft thruster is out, because you'd just end up turning in a great big circle + without changing your bearing at all." On the shape that replaced it: "Yeah, that's better." + - **Cut 3** now runs one weighted least-squares solve over the whole desired wrench (Fx, Fy, yaw) at once, + under throttle bounds. No separate rotation demand, no `TorqueFloor`-sized demand, no piecewise fall-back, and + no branch anywhere on whether a ship can turn a given way. The weights are the design knob, and both error + terms share a currency because each axis is normalized by what that ship can actually achieve on it. + - **Cut 4** chooses manoeuvres by predicted outcome over a short horizon, simulating each candidate, never by a + rule. The operator's case above is its test. + +--- + +# Part I: Target + +## Objective + +A ship that loses actuators keeps flying as well as its remaining actuators allow, whether a player or an AI is +flying it. The AI picks headings and thrust directions from what the ship can still do, so shooting out a thruster +creates a real handicap and not an exploit. + +## Invariants + +1. **Same inputs.** A ship is commanded only through `Ship.MovementDirection` (body frame, each component a + fraction of the ship's capacity along that half-axis) and `Entity.LookDirection` (world-frame heading command; + the controller reads only its `xz`). The player writes both (`Gameplay/ActionGameManager.cs:1254`, `:1263`). The + AI writes the same two fields. Nothing else commands a ship's actuators. Identical inputs to identical ships give + identical actuator throttles, whoever wrote them. +2. **One owner for actuator commands.** The ship controller (new, `ShipControl`, below) is the only writer of + `Thruster` and `AetherDrive` throttles. `Ship.cs`'s mixer, its rotation buckets and its strafe compensation stop + existing, and do not survive as a mode or a fallback. +3. **The allocator reads live actuator state every tick.** An actuator's column is derived each tick from its live + stats and liveness: `EquippedItem.Active` (enabled, thermally online, durability online), a power grant above the + same epsilon `Thruster.Execute` uses, and the current resolved `Thrust` (or the drive's own stats) with its + durability, heat and power curves applied. No bucket, cache or event-pruned set decides whether an actuator + counts. A repaired or re-powered actuator rejoins by the same read that dropped it. +4. **Manual and programmatic inputs share one commit path.** A player keypress, an AI state write, and any future + scripted or network input all land in the same two fields, and they reach the actuators by the same + `ShipControl` step inside `Ship.Update`. No input source writes `Thruster.Axis`, `AetherDrive.Axis` or + `Entity.Direction` directly. +5. **Heading changes only through actuators.** `Entity.Direction` is written by actuator execution + (`Thruster.Execute`, `AetherDrive.Execute`), by wormhole scripting (`Ship.ExitWormhole`), by spawn placement + (`ActionGameManager.cs:848`) and by save unpack (`EntitySerializer.cs:66`). The controller does not write it. + Today's snap-to-look lerp (`Ship.cs:280-284`) is a free, thrusterless rotation, and it goes. +6. **Kinematic rotation is retained.** Rotation stays kinematic: yaw rate is proportional to live throttle + (`Thruster.cs:106-107`, `AetherDrive.cs:154-155`), with no angular momentum. Nothing found here argues for + raising it. It keeps turn time exact (angle divided by rate), which is what lets the planner compare a long + way round against a slow lurching turn without simulating either. +7. **A dead actuator never receives throttle.** A destroyed, disabled or unpowered actuator gets zero throttle, + not a saturated request it cannot honour. +8. **Capability is one model.** What a ship can do is computed in one place (`ShipControl`), from the same live + column matrix the allocator solves with: the achievable extremes, the turn-direction comparison, and the solve + itself. The AI reads those and predicts with the controller's own step; it never keeps a private estimate of + what the ship can do, and never branches on which actuators are missing. + +## Canonical implementations + +- **Solver:** CultMath (`F:\Projects\CultLib\packages\cultmath`), a box-constrained linear least-squares solver, + added by Cut 2. It is the only numerical authority. Aetheria composes the problem and calls it. +- **Allocation reference:** T. A. Johansen and T. I. Fossen, "Control allocation: A survey," *Automatica* 49(5), + 2013. Its weighted-least-squares form with priorities is what `ShipControl` states. The active-set method follows + O. Härkegård, "Efficient active set algorithms for solving constrained least squares problems in aircraft control + allocation," CDC 2002, which is bounded-variable least squares in Stark and Parker's sense. + +## Out of scope + +- **Fire-control Cut 12** (edge-on hit placement in `FireControl.cs`, `Entity.ApplyHit`, `DamageSchematic`) is + being mapped concurrently by another Imagination pass. Cuts 2 to 4 here touch none of those files. The allocator + is a read-only downstream consumer of item destruction (`ItemDamage`, `Entity.cs:488`). **Overlap:** Cut 5 (combat + facing) will rewrite `Agents/States/Combat.cs:88-127`, and presenting armour edge-on is exactly what Cut 12's hit + placement rewards. Cut 5 must be mapped after Cut 12 lands. Cut 1 restores catalog content, and the only file it + and Cut 12 could both touch is `GameData/Aetheria.cc`; Cut 1 must not be in flight while another cut writes the + catalog. +- **The old AetheriaEve/CultMesh/daemon rebuild** is taxidermy. It is not precedent and not a consumer. +- Thruster geometry (the normalized moment-arm `Thruster.Torque`, `Thruster.cs:64-69`, which ignores lever length + and uses unweighted shape cells for centre of mass) is the game's model and stays as authored. Arrival braking in + `MoveToState`, docking approach and formation flight are also out. + +## Not consumers + +- `Assets/Scripts/Gameplay/*` presentation (`ShipInstance` particles, audio parameters) reads throttles and never + writes them. +- `TurretController` writes `Entity.LookDirection` for turret entities. That is the AI above the inputs for a + non-ship, and it does not drive ship actuators. See the audit. + +## 0b: identity, lifecycle, authority + +Nothing persistent changes. Actuator wrench columns, capacities, the chosen turn direction and planner state are +runtime-derived per tick, or per planner cadence, and are never saved. + +| Kind | Named by | Lifecycle | Decides | +|---|---|---|---| +| Actuator column (runtime) | the `Thruster`/`AetherDrive` behaviour instance on an `EquippedItem` | rebuilt every tick from live stats; appears and disappears with liveness | `ShipControl` derives; item state (durability, heat, power, `Enabled`) is owned by `EquippedItem`/`PowerBus` as today | +| `MovementDirection`, `LookDirection` (runtime) | fields on `Ship`/`Entity` | overwritten each frame by whoever flies the ship | player (`ActionGameManager`) or AI (`Agent` layer), never both on one ship (`Zone.cs:111` gives agents only to `!IsPlayerShip`) | +| Committed turn direction, planner plan (runtime) | fields on `ShipControl` / `Agent` | reset on wormhole, dock, spawn | `ShipControl` / `Agent` | +| `ThrusterData`, `AetherDriveData`, `HullData.Hardpoints/Shape` (persisted, catalog) | catalog record key | authored | unchanged; read only | +| `GameplaySettings.TorqueMultiplier`, `AetherTorqueMultiplier` (authored, `Settings.cs:206-207`; values in `Assets/Resources/Settings.asset`: 0.1, 0.1) | field name | authored | unchanged. `TorqueFloor` (`:205`, authored 0.5) loses its last consumer in Cut 3 and is deleted with its authored value | + +This campaign adds no authored **field**. Cut 1 adds authored **records** (hulls, thruster designs and their +products) to `GameData/Aetheria.cc` using the schema as it stands. If Hands finds it needs a tunable, that is an +operator question, and any new field on a persisted CultCache document must be nullable (MessagePack nil rule). + +--- + +# Part II: Body map (what the probes established) + +## The mixer today + +`Ship.Update` (`Ship.cs:251-292`) runs before `base.Update` executes behaviours. The agents' writes come earlier +still, because `Zone.cs:170-173` updates agents before entities. The steps: + +1. `RecalculateThrust` (`Ship.cs:147-247`) sums per-bucket thrust over thrusters whose item is `Active`. +2. It zeroes every thruster's `Axis` (`:256`). +3. Strafe: the right and left buckets get `±MovementDirection.x`, minus a torque compensation for the thrusters + whose torque sign matches the bucket total (`:257-274`). +4. Forward and reverse buckets add `±MovementDirection.y` (`:275-276`). +5. Rotation: `deltaRot = sqrt(|dot(look, right)|)·sign` (`:278-285`) is added to the clockwise bucket and subtracted + from the counter-clockwise bucket (`:287-288`). +6. `AetherDrive.Axis = (MovementDirection.y, MovementDirection.x, deltaRot)` (`:291`). There is no allocation for + drives at all. + +Buckets are built once in `Activate` from mount rotation and from `Thruster.Torque` against +`TorqueFloor` (`:102-118`). `RemoveThruster` prunes them permanently on `ItemDestroyed` (`:123`, `:133-143`). + +`Thruster.Axis` saturates to [0,1] (`Thruster.cs:45-49`). `Execute` (`:94-115`) applies `Velocity -= facing·input· +Thrust/Mass·dt`, then rotates `Direction` kinematically by `input·Torque·Thrust·TorqueMultiplier/Mass·dt`. Its gate +is `input > .01 && PowerSupply > 1e-4`. + +## Findings (probe) + +Fixtures here are built from the legacy catalog (next section): hull shape, hardpoints, thruster items with +constant `Thrust` (Large Drive 250000, Medium Drive 200000; mass 100 each), `TorqueFloor` 0.5, `TorqueMultiplier` +0.1. Each ship sits at (37, −12) facing `normalize(0.8, 0.6)`, runs 3 warm-up ticks, is reset, and is measured over +one tick at dt 0.02. Body-frame acceleration is (r, f) in m/s². Heading rate is compass degrees per second, +clockwise positive. + +**Torque factors** (`Thruster.Torque`, the sine of the moment arm): Longinus mains ±0.159 (below the floor, so +never used for rotation), attitude thrusters ±0.998. Djinni stern 0 / ±0.161, laterals ±0.727 (aft) and ±0.867 +(fore), bow 0. + +**Base flight table** (`cd846916`, after warm-up). These are the Cut 3 pins: + +| Input | Longinus (r, f, °/s) | Djinni (r, f, °/s) | +|---|---|---| +| forward `(0,1)` | 2.37, 172.38, 0 | 0.11, 55.56, 0 | +| reverse `(0,−1)` | 0, 0, 0 | 0, −18.52, 0 | +| strafe right `(1,0)` | 0, 0, 0 (compensation zeroes the lone attitude thruster) | 34.03, 0.42, 0 (axes 1.00 / 0.84) | +| strafe left `(−1,0)` | 0, 0, 0 | −34.03, 0.42, 0 | +| look 90° right | 68.97, 0, +394.33 | 0.01, 0.59, +169.13 | +| look 90° left | −68.97, 0, −394.33 | 0.01, −0.50, −169.13 | +| look 30° right | 48.77, 0, +278.84 | 0, 0.30, +119.59 | +| look directly behind | 0, 0, 0, **and `|Direction|` falls to 0.96** | same | +| forward+strafe `(.707,.707)` | 1.18, 121.89, 0 | 24.12, 39.49, 0 | + +The small cross terms (2.37, 0.42) are real. `Execute` rotates `Direction` thruster by thruster within one tick, so +later thrusters push along an already-rotated nose. The allocator cannot see this, so pins need a tolerance for it. + +**Defects the table exposes:** + +- **Dead-side stick.** On a Longinus with `Th.CCW` destroyed, looking 90° left gives a heading error of 90.0° + after 30 s. It never turns. Looking right, it reaches within 2° in 0.32 s, the same as intact. +- **Main-thruster cheese.** On a Djinni with all three stern thrusters destroyed, `Agent` plus `MoveToState` toward + a point 300 m to port leaves the ship at speed 0 after 120 s. `Agent.Accelerate` points at Δv and burns forward + thrusters only (`Agent.cs:64-68`), and it never reaches the strafe branch. Intact, the same run arrives in + 4.34 s. With only Stern 2 lost it arrives in 4.98 s; with only Port Aft lost, 4.40 s. +- **Snap-to-look.** Inside `|sin error| < .01`, `Ship.cs:283` lerps `Direction` toward the look vector, which is a + rotation no thruster pays for. When the look is directly behind, the lerp goes through the origin and shrinks + `|Direction|`, and thruster force scales with that length (`Thruster.cs:105` uses the unnormalized item + direction). Base also turns *slower* as error passes 90°: `deltaRot` follows `sin`, so it is zero at 180°. +- **First tick.** Items are not yet `Active` on the first `Update` after `Activate`, so the first-tick strafe + compensation divides by a zero count (NaN, unused) and the strafe fires uncompensated. +- **`TurnTime` is wrong and unused.** `Ship.cs:61-66` divides angle by the sum of torque *factors* over mass, which + is unitless nonsense: 261535 s for a Longinus 90° turn. It has no consumer. +- **Permanent pruning.** `RemoveThruster` never restores a thruster whose durability comes back, and `Recalculate*` + reads `Active` while the mixer still commands inactive thrusters. +- **The live catalog's only ship flies on a drive.** `GameData/Aetheria.cc` holds one ship hull, **LonginusX** + (mass 2500, drag 0.1, 6×17), with an `AetherDrive` hardpoint and **no `Thruster` hardpoint**. It moves on + "Traction" (rotor mass (30,20,5), λ (0.5,1,1), MaxRpm 40000..80000). Two thruster designs exist ("deep space + burnout", "Large Drive"), but no live ship can mount them (`docs/content-batch-one.md` §1 says the same). + **AetherDrive is therefore the load-bearing actuator for the player today**, and the allocator must reproduce its + base behaviour exactly. Cut 1 restores the thruster hulls, which is what makes any play check on a thruster ship + possible at all. +- **Existing tests sit on an off-centre thruster.** `BrownoutTests`/`ConditionRatioTests` equip a one-cell thruster + by `TryFindSpace` onto a 5×5 hull. It lands at (1,1) with `Torque` −0.707, and `MovementDirection (0,−1)` fires it + while it spins the ship. Under an allocator that holds heading, a lone off-centre thruster cannot fire without + rotating, so those fixtures must be re-seated (Cut 3). + +## Legacy hull layouts (probe) + +The live catalog is the post-breach minimal rebuild, so absence there proves nothing. The layouts come from the +git-LFS objects of `GameData/Legacy/AetherDB.2021-03-05.msgpack` (`142998fd…`) and `AetherDB.2021-04-14.msgpack` +(`21ce7bd2…`), both present in `.git/lfs/objects`, decoded with MessagePack `ConvertToJson`. Shape cells are +`bool[w,h]`, flattened as `x*h + y`, with y = 0 at the stern. + +**Longinus** (2021-04-14: mass 2500, drag 0.1, 6×17; the 2021-03-05 copy has mass 5000). The shape matches the +live LonginusX's. Rows from y = 0: `.####.` ×4, `######` ×5, `.####.` ×2, `..##..` ×6. + +| Hardpoint | Pos | Shape | Rotation | +|---|---|---|---| +| Th.L | (1,0) | 2×2 | Reversed (pushes forward) | +| Th.R | (3,0) | 2×2 | Reversed | +| Th.CW | (2,14) | 1×2 | CounterClockwise (pushes starboard, nose right) | +| Th.CCW | (3,14) | 1×2 | Clockwise (pushes port, nose left) | + +**The data has two aft 2×2 thrusters, not one.** The operator remembered "one big thruster" and ruled on it, +2026-09-22: "Good catch on the Longinus main thrusters, I was misremembering." Fixtures follow the data. + +**Djinni** (mass 10000, drag 0.2, 14×17). Rows from y = 0: `....######....`, `...########...`, +`..##########..`, `.############.` ×2, `##############` ×5, `.############.`, `...########...` ×3, +`....######....`, `.....####.....`, `......##......`. + +| Hardpoint | Pos | Shape | Rotation | +|---|---|---|---| +| Stern 1 | (6,0) | 2×1 | Reversed | +| Stern 2 | (5,1) | 2×1 | Reversed | +| Stern 3 | (7,1) | 2×1 | Reversed | +| Port Aft | (4,4) | 1×2 | CounterClockwise | +| Port Fore | (4,11) | 1×2 | CounterClockwise | +| Starboard Aft | (9,4) | 1×2 | Clockwise | +| Starboard Fore | (9,11) | 1×2 | Clockwise | +| Bow | (6,14) | 2×1 | None (pushes aft) | + +Legacy thruster items used for the fixtures: Large Drive 2×2, 250000 thrust, mass 100. Medium Drive 2×1, thrust +100000..300000 (fixtures use a constant 200000), mass 100. Small Drive 1×1, 75000. + +## Solver choice (probe) + +The comparison ran on the fixtures' own normalized columns, plus a 16-column doubled Djinni: 2000 random wrench +demands each, cost measured against a 20000-iteration projected-gradient reference, double precision, +allocation-heavy scratch C#, Release build. + +| Method | Worst excess cost | Suboptimal cases | Mean time | +|---|---|---|---| +| Primal active set (bounded-variable LS, Härkegård WLS form) | ≤ 7e-15 | 0 in every case | 1.8–9.5 µs (n 3–8), 32–38 µs (n 15–16); ≤ 30 iterations cold | +| Redistributed pseudo-inverse | up to 4e3 | 270–1564 of 2000 (n ≤ 8) | about half the active set's time | + +**Pick the active set.** It is exact, its cold-start cost is bounded, and it warm-starts from last tick's solution. +The redistributed pseudo-inverse saves about half the time and is wrong in up to 78% of cases on these very +matrices. A production float implementation without allocations will be faster than the scratch numbers. + +## Consumer audit + +`cd846916`, every `*.cs` outside `Assets/Plugins`. "Dies" means deleted, not deprecated. + +| Surface | Readers / writers | Fate | +|---|---|---| +| `Ship.MovementDirection` (`Ship.cs:27`) | W: `ActionGameManager.cs:1263` (player), `Agent.cs:67,73,77` (AI); tests `BrownoutTests.cs:143,402`, `ConditionRatioTests.cs:184,193,243,258`. R: `Ship.cs:261-291` | **Survives** as the translation input. Its meaning is defined by `ShipControl` (fraction of capacity per half-axis) | +| `Entity.LookDirection` (`Entity.cs:55`) | W: `ActionGameManager.cs:1254` (player), `Agent.cs:66`, `MoveTo.cs:26`, `Combat.cs:126`, `TurretController.cs:72,75` (turret entities; no `EntityTypeRestriction`, `TurretController.cs:14-15`). R (as heading): `Ship.cs:278`. R (as aim): `LockWeapon.cs:94`, `GuidedProjectileManager.cs:56`, `EntityInstance.cs:404`, `ShipInstance.cs:117` (tractor), `ZoneRenderer.cs:432`, `ActionGameManager.cs:376` (reticle target pick) | **Survives** as heading command *and* aim. `MoveTo.cs:26` dies in Cut 4 (a double writer: `Accelerate` overwrites it when Δv > 20). The heading/aim conflation is Cut 5's question (Q4) | +| `Thruster.Axis` (`Thruster.cs:45-49`) | W: `Ship.cs:256-288` only. R: `ShipInstance.cs:139` (particles), `Thruster.cs:88` (power request), `:96` (audio), `:102-111` | Getter **survives**. The public setter **dies**; only `ShipControl` commands throttle | +| `AetherDrive.Axis` (`AetherDrive.cs:81-85`) | W: `Ship.cs:291` only. R: drive internals, audio `:170` | Same as `Thruster.Axis` | +| `IAnalogBehavior` (`Behaviors.cs:96-99`) | implemented by `Thruster.cs:40`; consumer only a comment, `Entity.cs:2007` | **Dies** (Cut 3 deletes first) | +| Bucket sets `_forward/_reverse/_right/_left/_clockwise/_counterClockwiseThrusters`, `_thrusterItems`, `_aetherDrives`, `_aetherDriveItems` (`Ship.cs:30-39`) | `Ship.cs` only | **Die** | +| `ForwardThrust`, `ReverseThrust`, `LeftStrafeThrust`, `RightStrafeThrust`, `ClockwiseTorque`, `CounterClockwiseTorque`, `Left/RightStrafeTotalTorque`, `Left/RightStrafeTorqueThrusters` (`Ship.cs:50-59`) | written by `RecalculateThrust`, read by the mixer; **no reader outside `Ship.cs`** (grep, including Unity side and tests) | **Die** | +| `Ship.TurnTime` (`Ship.cs:61-66`) | no consumer | **Dies**; replaced by `ShipControl.TurnTime` (Cut 3), consumed in Cut 4 | +| `RemoveThruster`, `RemoveAetherDrive`, their `ItemDestroyed` subscriptions (`Ship.cs:123-143`) | internal | **Die**; liveness is read live (invariant 3) | +| `Agent.Accelerate` (`Agent.cs:58-79`), `FORWARD_DELTA_THRESHOLD`/`THRUST_DELTA_THRESHOLD` (`:16-17`) | called by `MoveTo.cs:27`, `Combat.cs:127` | **Rewritten** in Cut 4 (planner). Signature kept | +| `Agent.TopSpeed` (`Agent.cs:26`) | `MoveTo.cs:25`, `Combat.cs:127`. No live catalog item carries `VelocityLimit` or `VelocityConversion` (probe), so it is 100 everywhere | Survives | +| `GameplaySettings.TorqueFloor` (`Settings.cs:205`) | `Ship.cs:115,118` only | **Dies** in Cut 3. The full-wrench solve has no threshold to apply it to, and nothing else reads it | +| `TorqueMultiplier`, `AetherTorqueMultiplier` | `Thruster.cs:107`, `AetherDrive.cs:155` | Survive; also enter the yaw-rate row of each column | +| `Entity.Direction` writers | `Thruster.cs:106`, `AetherDrive.cs:154`, `Ship.cs:79` (wormhole), `Ship.cs:283` (snap), `ActionGameManager.cs:848` (spawn), `EntitySerializer.cs:66` | `Ship.cs:283` **dies** (invariant 5); the others survive | +| `Entity.Velocity` external writers | `HullCollider.cs:15` (collision impulse), `ShieldManager.cs:79` (bounce), `VelocityConversion.cs:41`, `VelocityLimit.cs:48` | Not control; these are external forces and constraints. Untouched | +| Undock capability check (`ActionGameManager.cs:913`) | `GetBehavior() == null` | Survives. It could later read the capability model; not this campaign | +| Mining, Survey, HaulingTask, StationTowing (`Agents/Tasks/*.cs`) | data-only task classes; no movement code (grep) | Not consumers. `PatrolOrbitsState` drives `MoveToOrbitState`, which Cut 4 covers | +| Player input path | `Input.Player.Look` → `_entityYawPitch` → `LookDirection` (`:1250-1254`); `Input.Player.Move` → `MovementDirection` (`:1261-1264`). No other action writes motion (grep of `Input.Player.*`: targeting, heat, shield, stance, tractor, ping, UI) | **Already on the shared surface**. The only bypasses are the ones `Ship.cs` itself holds (the snap, direct `Axis` writes), and both die in Cut 3 | + +Out-of-scope note: the working tree has an uncommitted change to `Assets/Scripts/AetheriaInput.cs` (a "Cycle Target +Item" action). That is fire-control work, and it does not touch motion. + +--- + +# Part III: Cut map + +## Status header + +Status: cut map. Ends are owned by Part I above; this section owns the means. + +Cut 1 landed and is merged into `codex/fire-control-12` at `e160bbe5`. Cuts 2 to 5 have not landed. + +Rulings (operator, 2026-09-22): **Q1 A** ("The divergence is fine"), **Q2 B** ("that's absolutely viable and can +be a better decision than turning around the long way, I wouldn't introduce a thruster exclusion for this"), **Q3** +fixtures follow the data ("Good catch on the Longinus main thrusters, I was misremembering"), and Cut 1 exists +because of "can we restore these layouts?". The Q2 text below is marked as history; the live design is in Cut 3. + +Second-round rulings (operator, 2026-09-22): Cut 1 approved as inventoried, optional gear included ("Yes and +yes"); the second hull behaviour is not `VelocityConversion` ("I don't think VelocityConversion was on either +hull") and the decode names it `VelocityLimitData`; **Q5 is dissolved** by the full-wrench solve ("Yeah, that's +better"), so the Q5 text below and every "sized demand" design it belonged to are history. + +Open: **Q4** (heading versus aim) blocks only Cut 5. **Q6** (the one weight in Cut 3's solve) does not block +Hands, who starts at the recommended default; the operator settles it by feel in the Cut 1 play check. + +**Tutorial station (operator, 2026-09-25):** "What we're testing is going to be the tutorial level, right? Let's drop a station in there." The live catalog already has a station hull: Zenith, sold by Aeronautics Unlimited. The story-station path is dead because `StoryProcessor` has been commented out at `Galaxy.cs:242` since `ce1a0a46` (2021). Ruled: **faction station now**. The tutorial's entrance zone (`Galaxy.Entrance`) always gets a generated faction station with a docking bay. That is deterministic, not merely likely, and the test pins it on the real tutorial construction path. Reviving the Ink story stations belongs to a later narrative campaign, which must first find out why they were switched off. + +Legacy hazard, recorded 2026-09-26 (Soul, Cut 1 fix batch 2): `VelocityLimit` is a hull behaviour gated by the hull's `Active` (`Entity.cs:1624`, `:1636`, `VelocityLimit.cs:45-49`). Thrusters are items with their own gating. Any path that takes the hull offline while the thrusters keep firing removes the cap. Under frozen fixture physics, with the player's override on the thrusters, speed reached 1237. On the shipped settings the hull did not go offline in 300 s. The behaviour dates from 2020-04 and 2021-03. Cut 3 and Cut 4 measure arrival times under this cap, so they must not assume it always holds. + +Follow-ups outside this campaign: +- `TurretController` has no `EntityTypeRestriction`, so equipping it on a ship would create a third + `LookDirection` writer racing the player or agent. Restrict it to non-ship hulls when turret work next opens + (`TurretController.cs:14`). +- `Thruster.Execute` applies rotation per thruster inside the tick, which couples later thrusters' force to earlier + thrusters' rotation. It is a small artefact, and it is `Thruster.cs`'s to fix, not the allocator's. +- `AetherDrive.Execute` logs "FUCK FUCK FUCK FUCK" on NaN velocity (`AetherDrive.cs:157-158`), a leftover probe. + +## Cut 1. Restore the legacy thruster ship hulls to the live catalog + +A pure content cut, separable from every code cut, so Soul can falsify it on its own. Operator, 2026-09-22: "can +we restore these layouts?", and on the inventory below, optional gear included: "Yes and yes". **The list is +approved**; what is left for review is anything Hands finds that this map did not name. + +- **Repo/branch:** Aetheria, implemented on `codex/locomotion-cut1` and merged into `codex/fire-control-12` at + `e160bbe5`. The restored hulls are now the real fixtures for Cuts 3 and 4. The catalog mutation is complete. +- **Standing rule** (memory: `aetheria-content-audits-pre-breach`): `GameData/Aetheria.cc` is the post-breach + bare-minimum rebuild, so absence there is not evidence of absence, and **the operator's word is the authority** + on what the pre-breach content was. The list below is therefore an **operator review item before Hands writes + anything into the catalog**. + +### What the legacy record actually holds (probe) + +Sources, all decoded in the scratchpad: the git-LFS objects behind `GameData/Legacy/AetherDB.2021-04-14.msgpack` +(`21ce7bd2…`, 76 KB) and `AetherDB.2021-03-05.msgpack` (`142998fd…`, 59 KB), and the last +`GameData/AetherDB.msgpack` before the Cut 9 import (`8963a686…`, 46 KB, the commit's own parent at `70fbaca1^`). +All three are present in `.git/lfs/objects`; nothing had to be fetched. + +The comparison explains the loss. The 2021-04-14 record carries 5 ship-class hulls and 45 gear designs. The import +source carries 3 hulls (LonginusX, Zenith, Turret) and 25 gear. **Longinus, Djinni and every thruster below the +2×2 class were already gone from the file Cut 9 imported**, which is why the live catalog has no thruster ship. + +**Recoverable in full from 2021-04-14** (name, shape, mass, price, hardpoint list with position, shape, rotation +and armour, behaviours, temperature band, manufacturer, prefab and schematic paths): + +| Record | Kind | Legacy detail | Notes | +|---|---|---|---| +| **Longinus** | ship hull | 6×17, mass 2500, drag 0.1, price 7,500,000, maker Alakrita | The shape is byte-identical to the live LonginusX's. 4 thruster hardpoints (`Th.L`/`Th.R` 2×2 aft, `Th.CW`/`Th.CCW` 1×2 in the nose) plus control module, 2 energy, 2 launcher, 2 radiator, reactor, sensors | +| **Djinni** | ship hull | 14×17, mass 10000, drag 0.2, price 10,000,000, maker Rossum & Douglas | 8 thruster hardpoints (3 stern, 4 lateral, 1 bow) plus 4 launcher, 2 ballistic, reactor, control module, 4 radiator, sensors, and a shield hardpoint | +| **Medium Drive** | thruster gear | 2×1, mass 100, thrust 100000..300000, price 50,000, no maker | **The one item both restored hulls need.** Fits the 2×1 stern/bow hardpoints and, rotated, the 1×2 lateral and nose ones | +| **Small Drive** | thruster gear | 1×1, mass 50, thrust 75000, price 25,000, no maker | Optional; the cheap end of the line | +| **Victoire** | thruster gear | 2×1, mass 25, thrust 200000..1500000, maker Alakrita | Optional; the racing thruster, and a natural Longinus fit | +| **Talaria** | thruster gear | 2×1, mass 150, thrust 75000..250000, maker Aeronautics Unlimited | Optional | +| **RevvITup 2.0** | thruster gear | 2×2, mass 150, thrust 250000..1000000, maker NiteLife Energy | Optional; a second 2×2 beside the two the catalog already ships | + +Everything those records depend on is **already in the live catalog**: all 12 factions (Alakrita and Rossum & +Douglas included), both existing 2×2 thrusters, and the Unity presentation. The prefabs still carry the hardpoint +transforms by their legacy names — `Th.L`, `Th.R`, `Th.CW`, `Th.CCW` in `Assets/Content/Prefabs/Ships/ +Longinus.prefab` (GUID `4a3db609…`, already the hull the live LonginusX points at) and `Thruster Stern 1..3`, +`Thruster Port/Starboard Fore/Aft` and `Thruster Bow` in `Djinni.prefab` (GUID `79024f63…`). The schematics exist +too (`schema_Longinus.png` `51702555…`, already referenced; `djinni_schematic_transparent.png` `66f3acdd…`, unused +today). So no art, no prefab work and no faction work is needed. + +**Not recoverable, because it post-dates the breach and has to be authored:** + +- `HardpointData.FiringArc` (Key 6) for the Djinni's weapon hardpoints. The legacy hardpoint record stops at + armour. 0 means "use `GameplaySettings.FiringArc`"; the `firing-arc-migrate` command in `tools/AetherDb` is the + precedent for deriving one from mount rotation. +- `ItemRole` lists on the restored designs, and `ProductRole` qualities on their products. + `RoleAuthoringTests` refuses a stat naming a role its design lacks, and `docs/content-batch-one.md` fixes the + nomenclature (`injector`/`nozzle` for thrusters, `plating` for hulls). The live "deep space burnout" carries + `injector, nozzle`; "Large Drive" carries none. +- A `FactionProductData` per restored design. `LoadoutGenerator` picks products, not designs, so a design without + one exists but is never generated or sold. The catalog holds 47 products today. +- Any `PowerSupply` term on the restored thrusters' stats (the stats-and-power cut's brownout curve). The shipped + thrusters request zero energy, so matching them is the safe default and is a content decision. +- Provenance is runtime, not authored: `ItemManager` mints the `Lot` (`docs/item-provenance-target.md`). + +**The second hull behaviour, identified** (probe; the operator was right that it is not `VelocityConversion`). +Both legacy hulls carry a behaviour LonginusX does not, union tag 11 in the legacy record. The union table is in +`Assets/Scripts/ServerShared/Behaviors/Behaviors.cs` at the legacy-project commit `7006a6b0` and unchanged at +`b4c06b50`: **tag 11 is `VelocityLimitData`**; `VelocityConversionData` is tag 10, and it is on neither hull. The +table cross-checks against the live catalog on every record that survived: tag 6 `ReflectorData` (live LonginusX +has exactly that and nothing else), 8 `ThrusterData` and 9 `WearData` ("deep space burnout" carries both), 3 +`RadiatorData` (Arctica), 7 `ShieldData` (legacy "Shield"), 31 `CapacitorData` (legacy "Capacitor"). + +The payload is a single `PerformanceStat` in `Key(1)`, `VelocityLimitData.TopSpeed`, authored `50..50` with the +legacy exponents `1, 0, 1.5` on both hulls. (Corrected 2026-09-26: the decode behind the restore gives Longinus +`100..100` with exponents `1, 0, 0` and Djinni `50..50` with `1, 0, 1.5`. The catalog carries those values.) So **both restored hulls are capped**, where a hull +without the behaviour leaves `Agent.TopSpeed` at its 100 default (`Agent.cs:26`) and leaves the player uncapped. +`VelocityLimit.Execute` clamps `Entity.Velocity` (`VelocityLimit.cs:48`), so this is a real handling difference on +the restored hulls and Cut 4's arrival times are measured with it. + +**The legacy stat shape differs from today's.** A legacy `PerformanceStat` is +`[Min, Max, HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent]`; today it is `Min`, `Max` and +a `Terms` list of `StatTerm` (`ItemData.cs:632-643`, keys 2 to 4 retired). Every restored stat has to be +translated, not copied. `docs/stats-power-cut1-migration.md` is the precedent for that mapping and the authority +on what each legacy exponent becomes. + +### The cut + +- **Deletes first:** nothing. This is content restoration. +- **Adds:** the two hull records and all five thruster designs (`Medium Drive`, `Small Drive`, `Victoire`, + `Talaria`, `RevvITup 2.0`), approved 2026-09-22; a `FactionProductData` for each; roles for each; firing arcs on + the Djinni's weapon hardpoints; each hull's `VelocityLimit` behaviour with its translated stat. +- **Mechanism:** a one-shot command in `tools/AetherDb` (`restore-hulls [apply]`), in the shape of the existing + `*-migrate [apply]` commands: dry run by default, opens the catalog through `AetheriaStores.Open(catalogWritable: + true)` only when applying, writes every record in one `Commit`, and refuses to run twice. The Cut 9 importer + (`tools/AetherDb/Import.cs`, added in `70fbaca1` and deleted by Cut 10) is the precedent for reading the legacy + `[tag, payload]` records and rewriting them by the target type's `[Key]` shape; read it out of git history rather + than re-deriving the mapping. **Delete the command in the same campaign, as Cut 10 deleted the importer**, so the + carrying cost of the restore is zero once it has run. +- **Authority map:** + - Owner: the catalog (`GameData/Aetheria.cc`) owns authored content; the operator owns what the content *is*. + - Inputs: the legacy LFS records, the existing factions and assets, the operator's review of the list above. + - Outputs: new `HullData`, `GearData` and `FactionProductData` records. + - Derived state: none. Runtime `Lot`s and provenance are minted as for any other design. + - Forbidden writers: nothing else may write the catalog while this cut is in flight; the one-shot command must + refuse a second apply rather than upsert twice. + - Deletion line: the command goes once the records are in. +- **Verification:** + - `FireControlCut7Tests.ShippedCatalogOpensAndGeneratesAnArmedHull` still passes unchanged: the real catalog + opens, every `EquippableItemData` deserializes, and LonginusX still generates an armed loadout. + - New `RestoredHullsTests` against the real catalog, read-only: each restored hull exists, has the expected + thruster hardpoint count (Longinus 4, Djinni 8), every thruster hardpoint accepts a catalog thruster design + through `TryEquip` at that hardpoint's own position, and the ship then moves and turns under + `MovementDirection`/`LookDirection` for a few ticks. Before Cut 3 this runs on the old mixer, which is the + point: it proves the content, not the controller. + - `LoadoutGenerator` generates each restored hull when filtered to it, which is the check that the products and + roles are authored correctly. + - `dotnet run --project tools/AetherDb -- census` names the two hulls with their makers, and `dangling` reports + no new dangling reference. + - Operator: the Studio click-through, then a play check flying a restored hull. That play check is what + unblocks the rest of this campaign, and it is where Q6's weight gets settled by feel. +- **Operator questions:** none open. The list is approved and tag 11 is identified. +- **Ledger estimate:** no source removed. About 3 to 7 catalog records added, plus their products, plus a one-shot + command of roughly 150 lines that is deleted again in this campaign. Net carrying cost: the content, and nothing + else. + +## Cut 2. CultMath: bounded least squares (CultLib) + +- **Repo/branch:** `F:\Projects\CultLib`, a branch from `45c2f40` (Aetheria's pinned `CultLibRevision`, + `Directory.Build.props`). Depends on nothing. +- **First:** run `dotnet test packages/cultmath/tests/CultMath.Tests` green at base and record the count. +- **Deletes first:** none. This is a pure addition, and it is the ruled owner fill (R4). +- **Adds:** one C# file in `packages/cultmath/src/CultMath/` with a static solver for + `min ||A x − b||²` subject to `lo ≤ x ≤ hi`. It serves a dense, small problem (m ≤ ~24 rows, n ≤ ~24 columns). + Rules: + - The method is a primal active set (bounded-variable LS). Each iteration solves the free set by Cholesky on the + normal equations, or by QR if Hands prefers conditioning. It terminates on KKT: at each bound the gradient sign + must be consistent with that bound. + - `float` in, `Span`/`ReadOnlySpan` arguments, caller-supplied workspace. No allocation per call. + - `x` is both warm start and result. The solver clamps it to the box first. + - It returns an iteration count or status, has a bounded iteration cap, is deterministic, and keeps its + last feasible iterate on a cap hit. A rank-deficient free set is handled (the caller adds a small ridge row, + and the solver must not throw on a singular pivot). + - Naming stays boring and HLSL-adjacent per `docs/design.md` Rules, for example a + `CultMath.BoundedLeastSquares` static class. It is C#-only like the intercept helpers (`math.cs`). No Rust or + HLSL mirror, because it is not a batch kernel and no shader needs it. +- **Tests** in `packages/cultmath/tests/CultMath.Tests`: + - `InteriorOptimumEqualsNormalEquations`: a problem whose unconstrained optimum lies inside the box returns it. + Pins the free-set solve. + - `KktHoldsOnRandomBoxedProblems`: randomized problems up to 24×16 are checked against a projected-gradient + reference to 1e-4 relative cost, and each active bound's gradient sign is checked. Pins optimality and the + multiplier test. + - `AllBoundsActive`, `ZeroColumn`, `DuplicateColumns`: degenerate columns return without throwing and are + optimal. Pins rank handling. + - `WarmStartMatchesColdStart`: a solution from a perturbed warm start equals the cold solution. Pins + that the start does not bias the result. + - `AsymmetricBounds`: `lo = −1, hi = 1` on some columns and `[0,1]` on others. Pins bidirectional actuators. + - Fixtures must not be degenerate: non-square, non-identity A, and b not in A's range. +- **Release:** build the Unity package (`packages/cultmath/scripts/build-unity-package.ps1`), bump + `unity/org.gamecult.cultmath/package.json` to 0.2.4, and tag `cultmath-unity-v0.2.4` by CultLib's own release + path. In Aetheria, bump `Packages/manifest.json`'s `org.gamecult.cultmath` tag and `Directory.Build.props` + `CultLibRevision` in one commit. +- **Authority map:** Owner: CultMath. Inputs: A, b, bounds, warm start. Outputs: x and status. Forbidden writers: + no least-squares or linear-solve code in `Aetheria/Assets/Scripts/**` (negative grep in Cut 3). +- **Verification:** `dotnet build packages/cultmath/src/CultMath/CultMath.csproj`; `dotnet test` of CultMath.Tests. + Aetheria's `dotnet test tests/Aetheria.Shared.Tests` still passes 244 after the bump. Unity compiles the new + package, and only the operator can check that. +- **Ledger estimate:** +150..220 lines of source and +150 lines of test in CultLib; Aetheria gets a 2-line bump. + +## Cut 3. `ShipControl`: shared controller and allocator replace the mixer + +- **Repo/branch:** Aetheria, a branch from the Cut 2 bump commit. Depends on Cut 2. Cut 1 is not a + prerequisite for the code, but its restored hulls are what the operator's play check needs. +- **First:** before editing, commit the base flight numbers above as test constants, reproduced by the fixture spec + in "Findings". They are computed at `cd846916`, never captured from new code. Also run a LonginusX-with-Traction + pin at base: `AetherDrive.Axis` for each probe input is `(my, mx, sqrt(|sin e|)·sign)`, including e = 30°, which + gives `Axis.z = 0.7071`. +- **Deletes first** (separate commit, build and tests green, no behaviour change): + - `Ship.cs:61-66` `TurnTime` (6 lines). + - `Behaviors.cs:96-99` `IAnalogBehavior` (4 lines) and its mention in the `Thruster` declaration (`Thruster.cs:40`). + - `Entity.cs:2007`, the commented `IAnalogBehavior` line. +- **Deletes (behaviour commit):** + - `Ship.cs:30-39` (bucket and item sets, 10 lines). + - `:50-59` (capacity properties and strafe lists, 10 lines). + - `:96-118` (bucket construction; `Activate` keeps `base.Activate()` and gathers actuators, about 20 lines). + - `:123-124` (subscriptions) and `:127-143` (`RemoveAetherDrive`, `RemoveThruster`, 17 lines). + - `:145-249` (the `ThrustCalculation` region, 105 lines). + - `:255-291` (mixer and snap, 37 lines). + - The public `Axis` setters: `Thruster.cs:45-49` and `AetherDrive.cs:81-85` become getters plus a command method + only `ShipControl` calls. + - Total is about 200 lines out of `Ship.cs`. +- **Keeps:** `Ship.cs:253` gate (active, not in a wormhole); `:294-310` drag, position, gravity and rotation + quaternion; wormhole code; `Thruster.Execute`/`AetherDrive.Execute` physics unchanged. The throttle is still + `_input`/`_axis`, so power requests, audio and particles read what they read today. +- **Adds:** `ShipControl` in `Assets/Scripts/ServerShared/ShipControl.cs`, owned by `Ship` and stepped from + `Ship.Update` where the mixer was. It is one file and one owner. Types and rules: + - **Actuator columns.** A narrow interface implemented by `Thruster` and `AetherDrive`. There are two + implementers and one consumer; this replaces `IAnalogBehavior`. + - Each actuator reports its columns every tick: body-frame (Fx, Fy) in m/s² and yaw rate in rad/s + (clockwise positive) per unit throttle, plus bounds. It then accepts its throttles back. + - **Thruster:** one column, [0,1]. F is the negative item facing (`Rotate` of (0,1) by mount rotation) times + live `Evaluate(Thrust)/Mass`. Yaw is `Torque·Thrust·TorqueMultiplier/Mass`. + - **Zeroing:** the column is zero when `!Item.Active.Value` or `Item.PowerSupply ≤ 1e-4`, which is the same + predicate `Execute` gates on. `Thruster.Thrust` (stale, updated only in `Execute`) is not the source. + - **AetherDrive:** three axes. Translation is split into ± columns, [0,1] each, because the drive's efficiency + depends on sign and speed (`AetherDrive.cs:138`). Torque is one column, [−1,1]. + - **Drive authority is nominal.** Effectiveness is the drive's force at `MaximumRpm` from its own stats (rotor + mass, λ, λ multiplier, coupling efficiency, `AetherTorqueMultiplier`), scaled by the same liveness and by its + condition. It is not the instantaneous `Rpm`. Rotor spin-up is the drive's internal dynamics, not an + allocation decision. This choice is what keeps LonginusX exact: for a lone drive, self-consistent capacity + cancels the scale, so throttle equals input. + - **One solve over the whole wrench.** There is no rotation stage and no translation stage. Each tick + `ShipControl` builds a desired wrench `v = (Fx_d, Fy_d, ω_d)` and solves, once, + + min over u in [lo, hi] of Σ_axis ( w_axis · (B_axis · u − v_axis) / cap_axis )² + ridge · |u|² + + where `B` is the live column matrix, `cap_axis` is that axis's achievable extreme, and `ridge` is about 1e-3. + The normalization is what lets a rotation error and a translation error be compared: each residual reads as a + fraction of what this ship can do on that axis. + - **Achievable extremes.** `cap` for an axis is the largest value that axis can take inside the throttle box, + which for a box constraint is just the sum of the positive entries of that row (the negative ones for the + other half-axis). Six numbers, no solve: `+x`, `−x`, `+y`, `−y`, clockwise, counter-clockwise. They are also + what `MovementDirection` is a fraction of. Probe values: Longinus `+y` 172.41, `±x` 68.97, yaw 8.25 rad/s + (472.9°/s); Djinni `+y` 55.56, `−y` 18.52, `±x` 37.04, yaw 3.25 rad/s (186.2°/s). + - **Demand.** `F_d = (mx · cap(±x), my · cap(±y))` from `MovementDirection`. `ω_d = min(profile(e) · cap(dir), + e / dt)` toward the `LookDirection` heading, `profile(e) = sqrt(sin e)` for e ≤ 90° (base's own curve) and 1 + beyond, with the `e/dt` term landing the heading without overshoot in place of the deleted snap. A zero + `LookDirection` means hold heading: no demand, no NaN. + - **Direction choice** stays a time comparison, `angle_in_that_direction / cap(dir)`, infinite where `cap` is + zero, with the committed direction held unless the other is faster by a margin. It is not a branch on whether + the ship "can" turn: under Q2 = B `cap(dir)` is almost always non-zero, and the comparison is what makes the + long way round appear. + - **Weights.** Translation rows weigh 1. The yaw row weighs `w`, the single knob (Q6), default **3**. There is + no other tuning constant in the objective. + - **The three regimes fall out of that solve; none of them is a rule** (closed-loop probe, below): + - (a) *Turning while forward thrust is also wanted.* The mains are already firing for `F_d`, so their torque + costs nothing extra, and the ship turns with them. + - (b) *Turning in place.* `F_d` is zero, so every newton of main thrust is pure error, and the solver spends it + only to the extent the remaining yaw residual is worth more. The intact Longinus is no longer forced into + the 84 m/s² burn the earlier sized-demand design produced. + - (c) *No attitude authority in that direction.* The only way to cut the yaw residual at all is the mains, so + the solver takes the lurch, and the direction comparison decides whether that beats the long way round. + - **`TorqueFloor` has no consumer left.** It was read only at `Ship.cs:115,118`. The field goes from + `GameplaySettings` (`Settings.cs:205`) along with its authored value in `Assets/Resources/Settings.asset`; + `TorqueMultiplier` and `AetherTorqueMultiplier` stay, because they are physics. + - **Budget.** One solve per ship per tick, plus six sums. The earlier design's four extra capacity solves are + gone. The fix for cost is solver efficiency, not a cache, because a cache would be a second truth for liveness + (invariant 3). + +### Closed-loop probe behind Cut 3 + +The single-tick table in Part II is measured against the real engine at `cd846916`. The numbers in this section +come from a **separate scratch model**: the fixture columns above, kinematic rotation, `dt` 0.02, the objective as +stated, and the base mixer re-implemented against the same integrator so the two are compared like for like. They +are design evidence and test targets, not engine pins; Hands reproduces them against the real engine and reports +the differences. + +| Manoeuvre (Longinus fixture) | base mixer | solve at w = 3 | +|---|---|---| +| Turn 90° in place | 0.32 s, 13.9 m/s of drift | 0.30 s, 16.9 m/s of drift, main peaks at 0.73 throttle | +| Turn 90° at full forward | 0.32 s | 0.30 s | +| Strafe right, 1 s, look held | no strafe at all | 10.7 m/s gained, heading holds within 1.1° | +| Djinni turn 90° in place | 0.68 s, no drift | 0.68 s, no drift, no stern throttle | + +The drift in a turn is dominated by the nose thruster, which pushes sideways whatever the controller does: base +pays 13.9 m/s for a 90° turn and the solve pays 16.9 m/s, in about the same time. That is the honest shape of +regime (b): the main does contribute torque, but the turn does not become a forward burn. + +The yaw weight trades heading-holding against how hard the mains are spent, and on this hull both come from the +same pair of actuators: + +| w | strafe gained in 1 s | heading deviation while strafing | 90° turn in place | main peak | +|---|---|---|---|---| +| 0.5 | 0.1 m/s | 179° (heading lost) | 1.62 s | 0.14 | +| 1 | 46.4 m/s | 59° | 0.60 s | 0.37 | +| 2 | 13.7 m/s | 3.8° | 0.36 s | 0.63 | +| **3** | **10.7 m/s** | **1.1°** | **0.30 s** | **0.73** | +| 5 | 9.9 m/s | 0.4° | 0.28 s | 0.79 | + +Damaged cases at w = 3: a Longinus with `Th.CCW` destroyed reaches a heading 90° to port **the long way round** +(0.74 s, sweeping +269°) and a heading 20° to port **the short way, on differential main thrust** (1.18 s, sweeping +−18°, gaining 19.7 m/s). A hull whose single main sits on the centre line has no counter-clockwise authority at +all and always takes the long way (0.86 s). A Longinus with both nose thrusters gone still turns 20° in 0.60 s on +its mains alone, and does it while gaining 83 m/s if forward thrust was also asked for against 20 m/s if it was +not — regime (a) against regime (b) on one fixture. + +- **Per-file changes** (against `cd846916`): + - `Ship.cs:251-292`: `Update` calls `ShipControl.Step(dt)` inside the existing `:253` gate. The drag and position + code below stays. + - `Ship.cs:92-119`: `Activate` collects actuators into `ShipControl` (all `Thruster` and `AetherDrive` + behaviours). No classification. + - `Ship.cs:121-125`: the constructor drops both `ItemDestroyed` subscriptions. + - `Thruster.cs:40`: implement the actuator interface, drop `IAnalogBehavior`. `:45-49`: public getter, internal + command. `:61-71`: `Torque` stays as the geometry factor. + - `AetherDrive.cs:81-85`: same pattern. Add the nominal-authority read next to its stats. + - `tests/Aetheria.Shared.Tests/BrownoutTests.cs:111-131` and `ConditionRatioTests.cs:94-95`: equip the consumer + thruster on the hull's centre column, using `TryEquip(item, int2(2, y))` with a free y, so `Torque == 0`. These + tests pin brownout and condition, not flight. Their asserted values (for example `.1f` at + `BrownoutTests.cs:162`) must not change. Fix the fixture, never the assertion. `PowerCurveTests.cs:52-62`'s + comment about the `Axis` being clobbered becomes true for a different reason; update the wording only. +- **Authority map:** + - Owner: `ShipControl` decides every actuator throttle and the feasible turn direction. + - Inputs: `MovementDirection`, `LookDirection.xz`, current `Direction`, `Mass`, `dt`, each actuator's live + column, `TorqueMultiplier`/`AetherTorqueMultiplier`. + - Outputs: per-actuator throttle (`Thruster._input`, `AetherDrive._axis`); the achievable extremes, the + movement-command mapping, `TurnTime(heading)`, and the step itself, which Cut 4 uses as its prediction model. + - Derived state: columns, extremes and the demand are per-tick derived, never stored across ticks except the + warm start and the committed turn direction, which are command state and not truth. `Thruster.Axis` and + `AetherDrive.Axis` are display and command readbacks. + - Forbidden writers: `Ship.Update`'s mixer (gone); any assignment to `Thruster.Axis`/`AetherDrive.Axis` outside + `ShipControl`; `Ship.cs:283`'s direct `Direction` write; bucket pruning on `ItemDestroyed`; any Unity-side + write of throttles. Also forbidden inside `ShipControl`: any test of whether an actuator is "for" rotation or + translation, any per-direction special case, and any second solve that re-decides part of the wrench. + - Shared paths: player writes (`ActionGameManager.cs:1254,1263`) and AI writes (`Agent`, `MoveTo`, `Combat`) both + reach actuators only through `ShipControl.Step`. Wormhole exit leaves the gate closed until the animation + ends, as today. + - Deletion line: the delete commits above land before `ShipControl` gains behaviour. +- **Verification:** + - Builds: `dotnet build Aetheria.Shared`; `dotnet test tests/Aetheria.Shared.Tests -m:1 -p:CultLibRoot=…`. + Unity batchmode compile (operator, or Self when the editor is closed). + - Tests (new file `LocomotionControlTests.cs`, fixtures exactly as in "Findings": off-origin, off-axis nose with + `|fx| > |fy|`, nonzero dt, asymmetric damage): + - `IntactLonginusMatchesBase` and `IntactDjinniMatchesBase`: every row of the base table within tolerance + (2% of that ship's full-scale acceleration or turn rate), except the four accepted divergences below, which + are pinned at their own probed values. Pins the allocator against base. + - **Accepted divergences** (operator, 2026-09-22, "The divergence is fine"). Soul reads these as pinned + behaviour, not as regressions, and anything outside them is a regression: + 1. Longinus turning in place spends its mains for torque, peaking near 0.73 throttle at w = 3. The + manoeuvre costs 16.9 m/s of drift against base's 13.9 and finishes in 0.30 s against base's 0.32. + 2. Longinus strafing gains a weak strafe it never had: about 10.7 m/s in a held second, heading holding + within about 1°, where base produced exactly nothing. + 3. Djinni strafe reaches 37.0 m/s² against base's 34.0 (+9%), by balancing torque with Stern 3 and Bow. + 4. Djinni turning in place may run a stern thruster and the bow thruster against each other at low throttle; + net force stays zero, but their exhaust is visible. + Plus two intended fixes, which are not divergences to tolerate but behaviour this cut is buying: turns past + 90° run at full rate instead of decaying to zero at 180°, and `|Direction|` no longer shrinks when the look + is directly aft. + - `LonginusXDriveAxisEqualsBaseFormula`: a drive-only hull; for every probe input, `AetherDrive.Axis` equals + `(my, mx, sqrt(|sin e|)·sign)` to 1e-3. Pins the live player ship's feel exactly. + - `CentreMainHullTakesTheLongWayRound`: the long-way fixture, built so the long way is the *only* way. One + centred main (torque 0, so no differential authority) and one clockwise attitude thruster, the + counter-clockwise one destroyed through the real `ItemDamage` path. Write `LookDirection` directly, as the + player would, to 90° left. Heading changes clockwise every tick (never counter-clockwise), and the ship + arrives within 2° in under 1.2 s (model: 0.86 s). Base comparison: a ship of that shape never turns at all. + Pins the shared feasible-direction choice. + - `LonginusMissingCcwPrefersTheLongWayAtNinetyDegrees`: the two-main Longinus with `Th.CCW` destroyed, looking + 90° left. It sweeps clockwise through about +270° and arrives (model: 0.74 s). Pins that the comparison is by + time, not by a rule about which actuators are "for" turning. + - `LonginusMissingCcwLurchesRoundTheShortWayAtTwentyDegrees`: the same ship looking 20° left. It sweeps + counter-clockwise through about −20° on differential main thrust, arrives within 2°, and its speed rises + while it does (model: 1.18 s, 19.7 m/s). Pins that the lurching turn is available and is chosen when it is + faster. + - `IntactShipTurningInPlaceDoesNotBurnForward`: the intact Djinni, looking 90° right with + `MovementDirection` zero. Its speed at the end of the turn is under 1 m/s and no stern or bow thruster + exceeds a low throttle. Pins regime (b) on a hull that has the actuators to keep translation clean. The + intact Longinus, which does not, is covered by accepted divergence 1 instead, with its own bound: drift no + worse than 1.3× base's 13.9 m/s and turn time no worse than base's 0.32 s. + - `NoAttitudeAuthorityStillTurns`: a Longinus with both nose thrusters destroyed, looking 20° right. It + arrives, on differential main thrust, and it gains speed doing so (model: 0.60 s). Pins regime (c). + - `TurningIsCheaperWhenForwardThrustIsAlsoWanted`: the same mains-only ship, 20° right, run twice with + `MovementDirection` zero and at full forward. Both arrive in the same time; the second gains much more + speed because the burn was wanted anyway (model: 20 m/s against 83 m/s). Pins regime (a) as a consequence of + one solve rather than a special case. + - `StrafeHoldsHeading`: the intact Longinus, full strafe held for 1 s with `LookDirection` fixed. Heading + deviation stays under 2° (model at w = 3: 1.1°). Pins the weight's other half, which is what stops the nose + swinging when a ship strafes with a thruster that also yaws. + - `LonginusMissingCcwTurnsRightAsBefore`: looking 90° right reaches within 2° in 0.32 s ± one tick (base + value). Pins that damage on one side leaves the other side alone. + - `LookDirectlyBehindTurns`: intact, look exactly aft. The ship turns, `|Direction|` stays 1 ± 1e-4, and there + is no NaN. Pins the snap's deletion and the >90° profile. + - `ZeroLookHoldsHeading`: default `LookDirection` gives no NaN in `Direction` or any throttle. + - `DestroyedThrusterNeverReceivesThrottle`: for each Djinni thruster in turn, destroy it, then drive 50 + ticks of mixed inputs (forward, strafe, turn both ways, diagonal). Its `Axis` is 0 on every tick. Repeat for + `Enabled = false` and for a zero power grant. Pins invariant 7. + - `RepairedThrusterRejoins`: destroy, restore durability, `UpdatePerformance`. The next tick's strafe uses it. + Pins the live read (invariant 3) against the old permanent pruning. + - `PlayerAndAiInputsGiveIdenticalThrottles`: two identical Djinni hulls with the same damage. One gets inputs + the way `ActionGameManager` writes them (`LookDirection` with nonzero pitch from yaw/pitch Euler, + `MovementDirection` from a stick value). The other gets them through an `Agent` state writing the same yaw, + flat. After `Update`, every actuator throttle is identical. Pins R2. + - `LonginusMissingMainHoldsHeadingUnderThrust`: `Th.L` destroyed, forward input. Heading drift stays under 1° + over 2 s, and forward acceleration is at least 70 m/s² (probe: 78 with 12 lateral slip). Pins torque-coupling + compensation in the shared layer. + - Negative greps, verified against `cd846916` to hit only the lines this cut deletes: + - `\.Axis\s*[-+]?=` in `Assets/Scripts` should match nothing outside `ShipControl.cs`. At base it matches + exactly `Ship.cs:256,264,273,275,276,287,288,291`. `FieldDriver.cs`'s `RectTransform.Axis` does not match; + checked. + - `_forwardThrusters|_clockwiseThrusters|StrafeTorqueThrusters|RecalculateThrust|TurnTime\(` should match only + `ShipControl.cs`'s new `TurnTime`. + - `TorqueFloor` should match nothing in `Assets/Scripts`, and nothing in `Assets/Resources/Settings.asset` + once Unity rewrites it. + - `IAnalogBehavior` should match nothing. + - `lerp\(Direction` in `Ship.cs` should match nothing. + - No least-squares code outside CultMath: `Cholesky|pseudo.?inverse|ActiveSet` in `Assets/Scripts` should + match nothing. + - Stryker: `dotnet stryker --since:` from `tests/Aetheria.Shared.Tests`. Every non-equivalent + survivor in `ShipControl.cs`, `Thruster.cs` and `AetherDrive.cs` is killed or triaged by name. Float boundary + flips are equivalent, per the fire-control Cut 11 ruling. + - Operator: fly LonginusX and confirm it feels unchanged (strafe, turn, look behind now turns). Then fly a + restored Longinus or Djinni from Cut 1, shoot a thruster off it, and confirm the damaged handling and the + lurching turn. Without Cut 1 this half of the check cannot be done at all. +- **Operator questions:** Q6, below, which does not block the cut. Q1, Q2, Q3 are ruled and Q5 is dissolved. +- **Ledger estimate:** −200 lines in `Ship.cs`, −12 elsewhere (`IAnalogBehavior`, `TorqueFloor` and its authored + value). `ShipControl.cs` adds about 250–350 lines, tests about 500. Net source roughly +100. That buys + actuator-loss capability, which is the explicit ask, and removes seven parallel role sets, the snap, permanent + pruning, one authored setting and four capacity solves a tick. + +## Cut 4. Heading planner in the AI + +- **Repo/branch:** Aetheria, after Cut 3. +- **First:** pin base AI results as constants. On base, a Djinni with all stern thrusters destroyed never reaches + the target (speed 0 at 120 s). Intact, it reaches it in 4.34 s. Both come from the probe (Agent plus MoveTo, dt + 0.02, target 300 m to port from (37,−12), nose `normalize(0.8,0.6)`). +- **Deletes first:** `Agent.cs:16-17` (the two thresholds) and `Agent.cs:60-78` (the body of `Accelerate`, 19 + lines). `MoveTo.cs:26` (the duplicate `LookDirection` write). The signature `Accelerate(float2, bool noTurn)` + stays, since `Combat.cs:127` and `MoveTo.cs:27` call it. +- **Adds:** in `Agent` (above the inputs, AI only). **The planner chooses by predicted outcome, never by a + rule.** It simulates each candidate and compares where the ship actually ends up. + - **Candidate manoeuvre:** a pair (heading command, movement command) held for the horizon. The set is the + headings the ship might fly with the translation that points the demand along Δv at each: 16 evenly spaced + headings (Hands default; 8 to 32 is the sensible range), plus the Δv direction and the current heading. + - **Prediction:** roll each candidate forward over a horizon of 1.0 s (Hands default; 0.5 to 2.0 s is the + sensible range) in 5 substeps (3 to 10), stepping the *same* `ShipControl` solve and the same kinematics the + ship will actually fly. There is no second physics model and no closed-form estimate: one owner for what the + ship does, which is invariant 8 applied to prediction. + - **Score:** the predicted outcome at the horizon — remaining distance to the objective, and the closing speed + that ends the horizon (so a manoeuvre that arrives pointing the wrong way with no closing speed loses to one + that is simply nearer and still closing). Infinite where the candidate ends further away than it started. + - **This is what answers the operator's case.** A ship whose rotation has to be bought with main thrust turns in + a great circle: the prediction shows the heading improving while the range opens, so a lateral burn that keeps + the bearing and closes wins on its own numbers. Nothing in the planner tests whether the ship is a one-way + turner, whether a thruster is missing, or which actuator would be used. + - Pick the argmin with hysteresis: replan when a candidate beats the current plan by a margin, on a cadence + (0.25 s default) or when the actuator set changes. The cadence exists for cost: 18 candidates × 5 substeps is + about 90 solves per replan, roughly 1 ms per replan per ship at the scratch timings, so a 0.25 s cadence over + 20 AI ships is a few percent of a core. Hands measures and reports; if it is too slow the answer is fewer + candidates or a shorter horizon, both of which are stated ranges, not a cache. + - Write `LookDirection = θ*` unless `noTurn`. Write `MovementDirection` as the per-half-axis fractions that point + the translation demand along Δv, inverting `ShipControl`'s own mapping (a `ShipControl` helper, so the AI never + re-derives it), with a deadband near zero Δv. + - `noTurn` (combat) skips the heading half of the candidate but still predicts the translation. A Djinni with no + stern facing its target strafes or backs off on its own. +- **Authority map:** + - Owner: `Agent` decides what the AI writes to the two inputs. + - Inputs: target velocity and objective position, the `ShipControl` step used as the prediction model, current + velocity and heading. + - Outputs: `LookDirection` (unless `noTurn`) and `MovementDirection`. + - Derived: the plan (heading, time of last replan) is command state only. + - Forbidden writers: `MoveToState` writing `LookDirection`; any AI code writing throttles or `Direction`; any + second model of what the ship will do, whether a closed-form estimate, a capability table of its own, or a + branch on which actuators are missing. + - Shared paths: `MoveToOrbitState` (patrol) and `CombatState` both go through `Accelerate`. +- **Verification:** + - `DjinniWithoutSternReachesTargetByCrabbing`: stern cluster destroyed, target 300 m at a bearing 30° right of + the nose, driven through `Agent` plus a fixed-target `MoveToState`. The ship arrives within 10 m in under 8 s. + Base never arrives (speed 0 at 120 s). Pins R1(2) and the stuck-forever cheese. + - `CrabBeatsTheBigCircle`: **the operator's case, and the reason Cut 4 predicts instead of deciding.** The + fixture is a hull that can strafe cleanly but can only rotate by spending a main: two off-centre mains aft and + one amidships lateral pair whose torque cancels, no attitude thrusters, with an objective abeam to port at + 300 m. A turn-to-face policy run in the same harness spends the manoeuvre sweeping a wide arc that barely + improves the bearing and opens the range; the planner's choice closes it. Pinned as time to reach the + objective, and as range at the horizon, never as "the planner chose to strafe". + - `CrabBeatsPointAndBurnWhenMainIsWeak`: Djinni with Stern 1 and Stern 2 destroyed (asymmetric; Stern 3's torque + is balanced by the laterals), target 300 m at 60° left. The planner arrives strictly sooner than a naive + point-and-burn agent run in the same harness through the same Cut 3 controller. A relation, not a captured + number. + - `IntactShipsStillPointAndBurn`: an intact Djinni arrives no later than base's 4.34 s + 10%, with the restored + hull's own `VelocityLimit` in force. Pins no regression. + - `LonginusWithoutReverseFlipsToBrake`: target velocity opposite the current velocity; the predicted-outcome + score puts the ship round, because a Longinus has no reverse thrust. + - `PlannerTakesTheLurchWhenItPays`: the Longinus with `Th.CCW` destroyed and a Δv 20° to port, where the + lurching turn is genuinely faster. The ship gets there sooner than a fixed-heading strafe would in the same + harness. Pins that the planner inherits the controller's comparison rather than avoiding the lurch on + principle. + - `PlannerPredictionMatchesFlight`: run a candidate's prediction, then fly the same commands for the same + horizon, and compare the end states. They agree within a small tolerance. Pins that the prediction uses the + real controller and that nobody has grown a second model. + - Negative grep: `LookDirection` in `Agents/States/MoveTo.cs` should match nothing. + - Stryker `--since:` over `Agent.cs`, `MoveTo.cs` and `ShipControl.cs` queries. + - Operator: watch a patrol Djinni with a stern thruster shot out crab to its next orbit. +- **Ledger estimate:** −22 lines, +140 lines of source (the prediction loop replaces the closed-form score), + +300 lines of test. No new type beyond the plan and the candidate. + +## Cut 5. Combat facing and tactics (target shape only) + +This cut gets mapped after fire-control Cut 12 lands, because that cut decides what presenting a side is worth. + +- **Owner:** `CombatState` (`Combat.cs:88-127`), above the inputs. +- **Shape:** extend Cut 4's predicted-outcome scoring with what the weapons want — arcs, ranges and the aim the + fire-control path will take — so the facing a ship holds is the one its predicted damage favours, not a rule + about its turn asymmetry. A ship that turns one way fast and the other way slowly will, under that scoring, tend + to keep its target drifting toward its fast side, because that is what predicts better. A ship with good turret arcs and poor rotation holds a heading and lets the + turrets work. Weapons with fixed arcs pull the heading onto the target. It uses the Cut 4 planner for thrust. +- **Fork:** Q4 (heading versus aim), which must be ruled before mapping. + +## Operator questions + +Each question gives options and a recommendation. Self paces these one at a time. Q1, Q2 and Q3 were ruled on +2026-09-22; their text is kept as history, and the live design is in the cuts above. + +- **Q6: the yaw weight in Cut 3's solve (does not block Hands).** The solve has exactly one knob, the weight on + the yaw residual against the translation residuals, after each axis is normalized by that ship's achievable + extreme. On a Longinus both the strafe and the turn come from the same nose thrusters, so the knob trades + heading-holding against how hard the mains are spent for torque (closed-loop model, table in Cut 3): + + | w | strafe gained in a held second | heading deviation while strafing | 90° turn in place | main peak | + |---|---|---|---|---| + | 1 | 46.4 m/s | 59° | 0.60 s | 0.37 | + | 2 | 13.7 m/s | 3.8° | 0.36 s | 0.63 | + | 3 | 10.7 m/s | 1.1° | 0.30 s | 0.73 | + | 5 | 9.9 m/s | 0.4° | 0.28 s | 0.79 | + + The Djinni is unaffected at every value: it has the actuators to do both cleanly. Base, for reference, does not + strafe at all and turns 90° in 0.32 s. + - **A: w = 3.** Heading holds while strafing, turns match base's speed, and the mains carry a real share of the + torque in a turn. + - **B: w ≈ 1.** The mains stay nearly out of turns, but a Longinus asked to strafe swings its nose most of the + way round, because strafing and turning are the same thrusters. + - **C: w = 5.** Heading holding is near-exact and the mains are spent hardest. + + **Recommended: A.** Hands starts there and the Cut 3 pins are written against it. This is a feel question, so + the honest place to settle it is the Cut 1 play check; changing it afterwards moves numbers in pins, not + structure. +- **Q4: heading versus aim (blocks Cut 5 only).** `LookDirection` is both the heading command and the aim, read by + `LockWeapon.cs:94`'s lock cone, guided projectiles, reticle targeting and the tractor beam. Capability-aware + facing wants the AI to hold a heading that is not its aim, and under R2 any split applies to the player too. + - **A.** Add a heading input beside the aim on the shared surface. The player's heading follows the view unless a + player control is added later. + - **B.** Keep one input, and the AI accepts degraded lock cones and missile aim while it manoeuvres. + + **Recommended: A**, decided when Cut 5 is mapped. + +### Ruled or dissolved (history) + +- **Q1: intact-ship divergence from base.** **Ruled A, 2026-09-22: "The divergence is fine."** The four accepted + divergences and their magnitudes are listed in Cut 3's verification. The rejected option was to require exact + base behaviour, which would have meant reintroducing per-thruster role rules into the objective. +- **Q2: rotation from translation thrusters.** **Ruled B, 2026-09-22: "that's absolutely viable and can be a + better decision than turning around the long way, I wouldn't introduce a thruster exclusion for this."** The + option this map recommended (A: rotation authority only from thrusters above `TorqueFloor`, so a lost attitude + thruster means that direction is gone) was rejected. Cut 3 and Cut 4 are written to B. +- **Q3: Longinus fixture layout.** **Ruled, 2026-09-22: "Good catch on the Longinus main thrusters, I was + misremembering."** Fixtures follow the data: two aft 2×2 mains. +- **Q5: how the rotation demand is sized.** **Dissolved, 2026-09-22.** The question assumed a separate rotation + demand that something had to size. The operator refused the premise — "it should only do that if the ship's + controller also *wants* to accelerate forwards that much" — and the answer is one solve over the whole wrench + with both residuals in the same currency, which makes the three regimes consequences rather than rules. Neither + of the options this map offered (size from attitude authority with a fall-back; size from everything) survives, + and `TorqueFloor` loses its last consumer. What is left of the question is Q6, the single weight. + +## Subtraction ledger (estimates) + +| Cut | Removed | Added | Dependencies / targets | +|---|---|---|---| +| 1 | 0 source; the one-shot command is deleted again in this campaign | 3–7 catalog records + products; ~150 lines of throwaway tool + ~120 test | No new package, target, asset or authored field. Existing prefabs, schematics and factions are reused | +| 2 | 0 | ~200 src + ~150 test (CultLib) | CultMath gains one file; cultmath-unity 0.2.4; Aetheria pin bump | +| 3 | ~212 (`Ship.cs` ~200, `Behaviors.cs` 4, `Entity.cs` 1, setters, `TorqueFloor` and its authored value) | ~300 src + ~500 test | `IAnalogBehavior` and one authored setting removed; four capacity solves a tick removed; no new package, target or authored field | +| 4 | ~22 | ~140 src + ~300 test | none | +| 5 | not mapped | not mapped | not mapped | diff --git a/docs/merge-to-master-checklist.md b/docs/merge-to-master-checklist.md new file mode 100644 index 000000000..4feec9edb --- /dev/null +++ b/docs/merge-to-master-checklist.md @@ -0,0 +1,172 @@ +# Aetheria merge checklist: `codex/fire-control-12` into `master` + +Compiled 2026-09-29 by Eyes from the maps on the branch. Pointers are `doc:line` in `docs/` at the branch tip. "Unrecorded" means the maps do not say the check was done. + +**What merging does.** `origin/master..codex/fire-control-12` is 257 commits. It carries the item-provenance work after 2026-09-17, stats-and-power (resolver, power bus, input capacitors, priority tiers, power-supply term and brownout, nominal requests, condition ratio, shield reserve, authored roles), fire-control Cuts 1-12.4(b), locomotion Cut 1 (restored hulls), the shield-panel interceptor, and the Addressables cuts. Self performs the git merge after you sign off. Nothing here is for the merge itself; it is what only you can check first. + +**Correction to the brief.** `codex/cultcache-cutover`, `codex/aetheria-state-rebuild` and `codex/item-provenance` are all ancestors of the branch tip, and the first two are already on `origin/master`. Master's merge commit `9b85211f` (2026-09-17) took cultcache-cutover and item-provenance at their 09-17 tips, after your play smoke passed (`cultcache-migration-cut.md:148`). Master has two commits the branch lacks (`9b85211f`, `263ac421`, which deletes dead narrative fixtures). `git merge-tree` of the two showed no conflicts. No `aetheria-state-rebuild` doc exists. + +## 1. Before you start + +- Open branch `codex/fire-control-12` at its tip, `4b594e116c4063ef5ee1dcb937bab0288008e63e` (2026-09-26, in sync with `origin/codex/fire-control-12`). +- Unity `6000.3.24f1` (`ProjectSettings/ProjectVersion.txt`). +- Batchmode: `tasklist` on 2026-09-29 showed Unity Hub and the licensing client but no `Unity.exe`, so none was running. Batchmode refuses an open editor and the maps say to close it and not kill anything (`item-provenance-cut.md:485,519`). Recheck when you start. +- The Unity manifest pins `cultmath-unity-v0.2.4` (`Packages/manifest.json`); the tag exists in CultLib. Unity needs network access to resolve it on first open (`fire-control-cut.md:2205-2212`). +- **Delete or move `GameData/run.cc` and `GameData/player.cc` before the first launch.** Both are git-ignored and stale (2026-09-22, 91,580 and 1,964 bytes). The maps' fresh-launch checks assume neither exists (`cultcache-migration-cut.md:2745`), and an old run store throws on the first lot lookup (`item-provenance-cut.md:595-596`). The persisted shape has changed since (power tier slot, fuse slot 32). + +**Your uncommitted work.** The merge happens in a separate worktree and does not touch it. `git status` lists 22 modified tracked files and 3 untracked. `git diff --stat` shows content changes in only 9; I infer the other 13 (materials, render textures) differ by line endings, from the LF/CRLF warnings. +- Content changes: `Asset Sources/Adrasteia1.blend`, `Car Paint White.mat`, `Steel.mat`, `Map Gravity Lines.mat`, `Minimap Gravity Lines.mat`, `LiberationSans SDF - Fallback.asset`, `Assets/Scenes/ARPG.unity` (+374), `Assets/Scenes/FieldShieldTest.unity` (+263), `Assets/Scripts/AetheriaInput.cs` (+40). +- Line-ending-only (inferred): the other Map/Sector `.mat` files, 8 `.renderTexture` files, `Stardust.mat`. +- Untracked: `Asset Sources/Adrasteia1_ContourRoute.blend`, `Ships.blend`, `adra1.png`. +- **Decide about three that belong to merged campaigns (my reading of the diffs):** + - `AetheriaInput.cs` (+40) adds the `Cycle Target Item` action (key J). The branch already has that action in `Assets/Resources/Aetheria.inputactions:72,326` and reads it in `ActionGameManager.cs:412`, but not in the generated wrapper. It is the wrapper catching up to committed data. Recommend including it. + - `FieldShieldTest.unity` (+263) adds `ShieldInterceptor`, `ShieldEnvelope`, a `Panel` with `Prototype` and `ShieldPanel`. This is the shield-panel Cut 4 rig you clicked together (`shield-panel-cut.md:840-846`). It is not on the branch. Without it the interceptor code merges with no rig. + - `ARPG.unity` (+374) adds "Debug Info Panel" and "Debug Info Text". `ActionGameManager.DebugInfoText` (`ActionGameManager.cs:151,1287,1333`) is the fire-control debug HUD and it returns early when null. Without the scene change the HUD "hull" factor in step 10 is invisible. + +## 2. One play session, in order + +### 2a. Fixtures to author first + +1. **Scratch catalog for 12.4.** Copy the catalog; do not edit `GameData/Aetheria.cc`. Author one weapon with `Fuse = Proximity` and a `BlastRadius`, and one with `Fuse = Delayed`, a `BlastRadius` and a penetration of about 2 cells (`fire-control-cut.md:3094,2656-2658`). Shipped penetration is at most 0.25 and enclosed cockpits need about 2 cells, so a penetrator authored from shipped values cannot reach the interior (`:3224-3225`). Fields are `aetheria.weaponitemdata` slot 29 (`BlastRadius`) and slot 32 (`Fuse`). Unrecorded: how to point the game at the scratch catalog, and which tool you author with (the map says only "Studio or `AetherDb`", `:2665`). +2. **Studio open, no game running.** You need it at 2c and 2d. Batchmode-based checks are the agent's, not yours. + +### 2b. Fresh launch (no `run.cc`, no `player.cc`) + +3. **Launch to main menu.** Expect no Addressables errors, and the Groups window shows `Assets/Content` under `Default Local Group` (`addressables-cut.md:301`). `player.cc` appears and Continue is disabled (`cultcache-migration-cut.md:2746`). Proves: Addressables Cut 1, CultCache cutover. The 09-17 smoke covered the last two on the old tree; this is a cheap recheck on the new schema. +4. **New Game (tutorial).** Expect the galaxy generates and the start ship is LonginusX (`settings-globals-cut.md:498`, an expectation, not a recorded check for this stack). Expect a Zenith station with a docking bay in the entrance zone every time (`locomotion-cut.md:375`). Proves: locomotion Cut 1 station rule. +5. **Addressables, "Use Asset Database".** Ships, turret and Zenith spawn; thruster and aether-drive particles show; fire an instant weapon (Autocannon) and a charged one (ChargeBlast SG); action bar icons show (`addressables-cut.md:362-366`). Proves: Addressables Cut 2. + +### 2c. Flight, power and economy + +6. **Provenance in play.** Starting-ship items show tier colour; the properties panel shows Manufacturer, product name and flavour for branded gear. Dock and buy: the brand still shows in cargo. `give Lamp` (any design name): no Manufacturer row. Kill an NPC and pick up loot: brand shown (`item-provenance-cut.md:528-539`). Proves: item-provenance Cut C. +7. **Fly a restored hull (Longinus or Djinni).** Thrusters move and turn it (`locomotion-cut.md:501`). Watch the speed cap: `VelocityLimit` is gated by the hull's `Active`, and a hull taken offline with thrusters firing loses the cap (`:377`). It did not happen in 300 s on shipped settings; report if you see runaway speed. Unrecorded: how to spawn a restored hull in play (the map names no path). +8. **Weak reactor (O2).** Fly with a reactor too small for the loadout. Expect visible, legible starvation rather than a cooked reactor (`stats-and-power-cut.md:705-706`). Proves: power bus (map Cut 3). +9. **Brownout and refill (O6, O4).** Brownout reads as degradation, not breakage (`:774`, `:990`). A weapon refilling under brownout stutters instead of firing full rate and cooking the ship (`:729-730`). Proves: power-supply term, brownout, input capacitors. +10. **Memory across zones (O1).** Play across several zones with kills, open the Profiler or Task Manager, and confirm memory does not climb (`:672-673`, `:980-982`). Proves: stat resolver, the entity-leak fix (map Cut 2). Combine with steps 11 and 12. + +### 2d. Combat (against a LonginusX-class AI), then save and Studio + +11. **Fire-control Cut 1 arcs.** Side-mounted weapons still fire at targets abeam; a turret tracks all the way round; nothing fires through the hull (`fire-control-cut.md:397-398`). +12. **Fire-control Cut 3 and shots.** Shots show rolled impacts and misses; a miss reads as a near-miss, not a bug; damage matches the HUD; aiming at a revealed subsystem concentrates damage; kill time is in the same order as before (`:659-661`). Reveal and selection work, kills drop loot, pickup stores it, the tractor beam still pulls (`headless-playground-cut.md:789-794`). No crash or frozen hot ship when the target vanishes (the 2026-09-20 failure, `fire-control-cut.md:1464-1470`). Recheck after the fixes: unrecorded. +13. **12.2, bow then beam.** The HUD `hull` factor reads higher from the beam. Turn your armoured face into a slow missile and watch where it lands (`:2380-2383`). +14. **12.3, launcher into a bow, then a flank.** Use a GT 3K or pswarm launcher. The schematic display pulses the facing edge only (`:2519-2520`). +15. **12.4, scratch catalog.** Watch a proximity airburst beside a LonginusX and a delayed penetrator into its nose. Damage must land where the model shows the blast; this confirms the centre-of-mass anchor. Bow is already confirmed +y (`:3095-3097`, `:2028-2031`). Proves: 12.4(b) `Detonate`. +16. **Wormhole, save, Continue.** Take a wormhole, quit, Continue: same items, same tier and brand (`item-provenance-cut.md:536-537`). +17. **Die.** Continue is disabled and `run.cc` holds no ledger (`:540-541`). +18. **Shield panel, `FieldShieldTest`.** Follow the numbered look in `shield-panel-cut.md:683-700`. Nothing visible on load. Click the nose: a patch grows and the field ripple runs too. Click the flank: the panel lies flat, not tilted. Click the same spot twice: it strikes the same panel. Spam far-apart clicks: at most 12 panels, oldest replaced. Let them fade: none left. Then the Cut 5 payoff: strong hits dice the patch, and repeated hits on one spot break through on a later hit (`:737-739`). Proves: shield-panel Cuts 3, 4, 5. +19. **Studio click-through.** + - Open `Aetheria.cc`. The fire-control behaviour union renders (`fire-control-cut.md:503-506`). + - `LonginusX` schematic still underlays the shape drawer (`addressables-cut.md:370-371`). + - The restored hull records open (`locomotion-cut.md:501`; the map gives no checklist beyond "the Studio click-through"). + - The scratch catalog's new `Fuse` and `BlastRadius` fields edit (from 2a; no map line). + - Open `GameData/run.cc` after step 16. `aetheria.provenanceledger` renders `Lots` with `Attributed` origins and per-role lists. If Studio cannot draw an int-keyed dictionary of unions, that is a Studio defect to raise, not a schema change (`item-provenance-cut.md:541-544`). +20. **Addressables, "Use Existing Build".** Run *Build > New Build > Default Build Script*, then repeat step 5. This proves the player catalog carries GUID keys. Then *Analyze*, "Check Resources to Addressable Duplicate Dependencies", and record the result; 5 known leftovers are expected (`addressables-cut.md:367-370`). + +### 2e. Desk review (not play) + +21. **Role content (O7).** Review `docs/stats-power-cut7-roles.md`. Rows marked "generic, needs review" are arbitrary-seed, not brand identity, and no design yet has a second product with a raised role (`:156`, `:194-202`; `stats-and-power-cut.md:797-799`, `:991`). + +## 3. Feel calls during the session + +Only what the maps name. +- **Balance numbers.** `CommitHorizon` (first value 0.3 s), `SchematicCellSize` (0.35) and the targeting stat ranges are first guesses; the smoke is the arbiter (`fire-control-cut.md:664-665`, `:855-859`). `UnaidedAccuracy` is ruled "really, really bad" (`:253-259`); judge whether it reads as a last resort. +- **Arc rule (Q2).** Arcs apply to the player as well as the AI. The map says it is a real difficulty change to be felt in the smoke (`:836-841`). +- **AI holds fire more** once `pOnHull` prices shots. The map calls that tuning, not broken AI (`:1396-1400`). +- **Penetration retune (F12-2)** is a ruled follow-up, "Retuning penetration is a follow up" (`:3223-3229`). The map does not tie it to a smoke. The only link is 2a step 1: shipped penetrators cannot reach interiors, so step 15 needs the scratch value. +- **Locomotion Q6, yaw weight (w = 3 recommended).** The map says settle it in the Cut 1 play check (`locomotion-cut.md:372-373`, `:903-904`), but the weight belongs to Cut 3's solver, which has not landed. There is nothing to feel today. Ambiguity, not a merge gate. + +## 4. Already done: do not redo + +- Play smoke for cultcache-cutover plus item-provenance, 2026-09-17: new game, combat, loot, save and Continue with brand and quality intact, death clears the run, stance toggle, `FieldShieldTest` (`cultcache-migration-cut.md:148-153`). It ran on the old tree, so steps 3, 6, 16 and 17 are rechecks, not repeats of a gap. +- Studio click-through for the CultCache port, 2026-09-17: AmmoType refs repointed at `515859cf`, `dangling` at 0, schematic underlay restored and checked (`cultcache-migration-cut.md:161-165`). +- Bow is +y on the schematic, confirmed 2026-09-22 from the schematic images (`fire-control-cut.md:2028-2029`). The blast-anchor half is still open (step 15). +- Shield-panel play evidence, 2026-09-18, rigged scene: a panel spawns, is reused and spiderwebs; it shatters only near the rim (`shield-panel-cut.md:119-124`). Partial: the Cut 5 fixes landed after, so step 18 is still open. +- A play smoke on 2026-09-20 found the frozen-ship, no-hits and `LockWeapon` crash (`fire-control-cut.md:1464-1470`). That was a failure report, not a pass. Cuts 8 and 9 answered it and no recheck is recorded. +- Stats-and-power Cuts 0-1 landed with Soul verification and your re-author ruling (`stats-and-power-cut.md:79-99` onward). No operator check remains from them. + +## 5. Not part of this merge + +- Stats-and-power tier control in the schematic UI (O5) and the deletion of `IOrderedBehavior`/`SortPosition` (O3): no `PowerTier` under `Assets/Scripts/UI` at the tip, and `SortPosition`/`IOrderedBehavior` still exist in `Reactor.cs`, `Entity.cs` (my grep). `stats-and-power-cut.md:752-753`, `:985-989`. +- Stats-and-power UI reconciliation (O8: properties panel, trade menu, schematic HUD, Studio over a stat with terms): the map's Cut 8 has not landed; the branch's "Cut 8" commits are the condition ratio (`:819`, `:991-992`). +- Settings-into-globals: only Cut 0 (delete 5 dead assets, `ea8b420e`) landed. Its play smoke and Studio click-through wait for Cut 1 (`settings-globals-cut.md:498-511`). The map header still says nothing landed. +- Build delivery: the two commits are the map and a ruling (`build-delivery-cut.md:5`, operator checks `:252,324,360,404`). +- Locomotion Cuts 2-5, including Q6 and Q4 (`locomotion-cut.md:360,905`). +- `content-batch-one.md` is a spec that writes no catalog data (`:5-8`). +- Fire-control follow-ups F12-2 to F12-8 (`fire-control-cut.md:3223-3250`). + +## 6. Open questions still yours + +- **Q12-5 (not ruled):** should the HUD show which target items are exposed from the current bearing? Recommended as a follow-up after 12.3, on the target-item cycling UI (`fire-control-cut.md:1959`, `:3215-3218`). +- **Locomotion Q4:** heading versus aim; blocks Cut 5 only (`locomotion-cut.md:905-913`, `:372`). +- **Locomotion Q6:** the yaw weight; open, does not block (`:372-373`). +- **Defaults Self took in 12.4, which you may overrule** (`fire-control-cut.md:3208-3213`): a blast ignores `DamageSpread` and P sits on the centre lane; `IncomingHit` fires for the host of a contact or delayed hit only; the delayed fuse point clamps to the last reached cell's exit; `Detonate` runs a flat armour, pool, hull pass. +- **Stale headers, not open questions:** the fire-control 12.3 "pending ruling" at `:2581` was ruled at `:2588`; Q12-9 is ruled A (`:3171`); the shield-panel Q1-Q7 block (`shield-panel-cut.md:61`, `:800-`) was not updated after Cuts 1-5 landed, Q5 being ruled C (`:839-846`). + +## 7. What happens after you sign off + +- **Self merges `codex/fire-control-12` to `master` with `--no-ff`**, in a separate worktree. That single merge carries item-provenance, the stats-and-power work, fire control and locomotion Cut 1. The cultcache-cutover and item-provenance merge into master already happened on 2026-09-17 (`9b85211f`, `cultcache-migration-cut.md:148`), so the maps' "merge the two together" instruction (`item-provenance-cut.md:82`) is spent. The tree merges clean today. +- Any of section 1's three uncommitted files you want included need to be committed to the branch first, or they stay out. +- **Soul gates outstanding:** + - Fire-control 12.4 Soul passes: running tonight. The 12.4 section carries no "closed" status yet (`fire-control-cut.md:2602`). 12.0-12.3 are closed (`:2168-2210`, `:2253`, `:2385`, `:2522`). + - CultMath bounded least squares is locomotion Cut 2, not in this stack (`locomotion-cut.md:508`, `:360`). It gates nothing here. + - Unrecorded: a Unity batchmode compile at the tip `4b594e11`. 12.2 and 12.3 record clean compiles at their own heads. +- **Soul status at merge time: Self fills in.** + +## 8. Results: play smoke, 2026-09-30 (operator plays, agent records) + +Tree: `codex/fire-control-12` at `113164fa` plus the fixes listed below. `run.cc` and `player.cc` moved to +`GameData/stale-2026-09-22/` before launch. + +**Compile.** The Unity 6000.3.24f1 batchmode compile at `113164fa` was clean (exit 0, no `error CS`). The four +never-compiled files needed no fix. One warning, `EntityInstance._destroyed` assigned but never read, is legacy +(`729ab0e8`). + +**Changes made during the smoke.** +- `e0a80208`: `AetheriaInput.cs` regenerated; Cycle Target Item bound through `Input.Player.CycleTargetItem`, + retiring the by-name `FindAction` stopgap. Batchmode recompile clean. +- `97edf48c`: `ARPG.unity` Debug Info Panel and `FieldShieldTest.unity` shield-panel Cut 4 rig committed. +- Console: control characters from text input are no longer appended, and Backspace deletes (see step 6 finding). +- `give` (operator ruling 2026-09-30): an unknown name and a full cargo bay now say so instead of doing nothing. A + ship hull is refused unless docked; when docked it goes through `ActionGameManager.CommissionShip`, the same + primitive `TradeMenu.Buy` now uses, and moors a bare player ship at the docked entity with no docking bay + ("mothballed ships do not require docking bays"). Non-ship hulls are refused. This is the step 7 path to a + restored hull. +- Scratch weapons authored in place in `GameData/Aetheria.cc` (not committed; restore with + `git checkout -- GameData/Aetheria.cc`): `Smoke Proximity` (Proximity, blast 8), `Smoke Delayed` (Delayed, + blast 4, penetration 2 cells), `Smoke Refused` (Proximity, blast 30, range 20). Each is a Spectra clone, energy, + 1 column by 2 rows, to fit the test ship's only energy hardpoint. + +| Step | Result | Evidence | +|---|---|---| +| 3 Launch to main menu | pass | Operator reached play through step 6 with no reported Addressables errors. | +| 4 New Game | pass | As above. | +| 5 Addressables, Use Asset Database | partial | Charged weapon (ChargeBlast SG) unprovable: the test ship's only energy hardpoint is 1x2 and ChargeBlast SG is 2x2. Instant weapon not separately reported. | +| 6 Provenance in play | partial | Properties panel correct for starting gear and for a purchased item. **Tier colours missing everywhere**: known defect, `settings-globals-cut.md:150` (Unity-serialised `RarityTier.Color` loads as zero); fixed by settings-globals Cut 1, not part of this merge. `give Lamp` was first refused (console finding), then silently did nothing: `Lamp` is a test-fixture name, absent from the live catalog, and `give` reported no miss (now it does). No-Manufacturer check still open with a real unbranded design. Loot brand not yet reported. | + +**Finding, console (not fire control).** Every console command was refused with "commands take only letters, +digits, spaces and hyphens", and Backspace inserted a glyph. Cause: `Keyboard.onTextInput` delivers control +characters (`\b`, `\r`), and `ConsoleView` appended them to the input line. Enter's `\r` rode into every command; +`d503f076` (2026-09-14, on master) correctly changed the parser from stripping disallowed characters to refusing +them, which exposed it. Backspace had never been handled (only Delete), which is legacy (`7006a6b0`). + +**Feel notes.** Engaging an AI Djinni: the player was destroyed decisively. "Game is hard." + +**Finding, ship purchase and docking bays (design gap, not a merge gate).** The intended UX is that a purchased +ship is assigned its own docking bay. The code does not do that: +- `TradeMenu.Buy` (`TradeMenu.cs:373-381`) creates a bare `Ship` parented to the docked station and assigns it no + bay. It also charges `data.Price` rather than the lot's quality price that `GetPrice` computes for other items. +- `LoadoutGenerator` equips exactly one docking bay per station (`LoadoutGenerator.cs:84-92`), so Zenith has one. +- The inventory panel's Current button (`InventoryPanel.cs:110-120`) sets the new ship as current and overwrites + the single bay's `DockedShip`; the previous ship stays a station child with no bay. + +**Smoke stopped after step 6 (operator, 2026-09-30).** The operator rejected the script as a monolithic play order +that does not respect how the game works: step 7 alone meant spawning and fitting a whole Djinni by catalog name. +Direction: operator checks become a list of behaviours verified over time, each set up by an authored, retained +scenario selectable from the main menu (a Eureka cut). Steps 7-20 and handoff checks A-F are unplayed. The operator +prefers to merge now, with those as open entries to verify through scenarios; Self rules on the merge. + +**Stale expectations found in this checklist.** Step 6 transcribes `item-provenance-cut.md:529-537` (written +2026-09-17 03:38, `7425ff1b`). "Starting-ship items show tier colour" was falsified by that day's smoke and recorded +at `settings-globals-cut.md:150` (`db420981`, 17:36); the provenance map was never corrected and this checklist +copied it despite its own section 5. "`give Lamp`" names a test fixture (`LoadoutTests.cs:289-312`), never a live +catalog design, and `give` could not have run at all between `d503f076` (09-14) and `f935c29b`. diff --git a/docs/negent-weapons-concept.md b/docs/negent-weapons-concept.md new file mode 100644 index 000000000..9a51df32a --- /dev/null +++ b/docs/negent-weapons-concept.md @@ -0,0 +1,62 @@ +# Adrasteian Negent Weapons: Item Concept + +Date: 2026-09-18 + +Status: concept, for the content pass to author against. No cut, no schedule. +The mechanism it rests on is ruled in `docs/stats-and-power-target.md` +("Negentropy items"); the fiction is `AetheriaLore` → +`Worldbuilding/Post-Elysium/Technology/Running Cold` and +`Concepts/Negative Entropy`. + +## The inversion + +Negent converts heat into high-energy photons. So an Adrasteian weapon is not a +gun that tolerates cold: **its ammunition is the ship's thermal load**. A crew +works up a fever and spends it as light. + +Every other weapon in the game is limited by how hot firing makes you. This one +is limited by how cold firing makes you. Operator, 2026-09-18: "You're +desperately pumping heat into it just to get it warm enough to fire again. Fun +inversion." + +## What falls out of it + +- **Firing makes a ship quieter.** The universal tell — the heat a weapon dumps, + which is what a sensor operator is watching for — reverses. An Adrasteian + gunner becomes harder to see by shooting. +- **Reloading is deliberate overheating**, and that is the loud, fragile, + visible part of the cycle. The ship is most detectable when the gun is empty + and safest while it is firing. No other weapon has that rhythm. +- **The crew is the leak.** Heat leaves the ship somewhere, and sustained fire + walks a ship toward the cold end of the band, where hypothermia is waiting. +- **A long fight starves it.** Everything already spent downrange, no star + nearby, and the best weapon aboard is inert until the crew makes heat again — + which means running drives, which means being seen. +- **Wear punishes the swing**, and this weapon is nothing but swings: + `Entity.cs:1386` derives wear from distance off optimum *and* from `deltaTemp`. + A negent gun eats itself faster than a conventional one, which is Adrasteia's + commercial relationship with its customers in miniature. + +## What it needs from the Body + +Almost nothing new, which is the point. + +- `ItemData.MinimumTemperature` / `MaximumTemperature` and `HeatPerformanceCurve` + (`ItemData.cs:371-435`) already express a weapon whose optimum sits well above + ambient and whose performance collapses when it cools. +- The firing behaviour must **remove** heat rather than add it. That is the one + behaviour-level piece, and it is a sign, not a mechanism. +- The Adrasteian charge (the consumable that gates negent generally, per the + ruling) is what stops this being free cooling. **Without the charge cost this + item solves heat management**, which would flatten the system the whole design + rests on. It is the balance, not a flavour item. +- Roles for the content pass (`docs/item-provenance-target.md`): the charge + assembly, the conversion array, the emitter. Quality per role is what makes + two Adrasteian guns different guns rather than two numbers. + +## Why it is worth authoring early + +The heat system reads as three systems until a player meets something that +inverts it. A weapon that wants you hot, goes quiet when it fires, and freezes +its own crew teaches the whole system in one encounter, without a tutorial +saying any of it. diff --git a/docs/settings-globals-cut.md b/docs/settings-globals-cut.md index 9fa4704fd..b031ba4bf 100644 --- a/docs/settings-globals-cut.md +++ b/docs/settings-globals-cut.md @@ -18,7 +18,16 @@ move). Claims marked **(probe)** were run, not read off names: Operator ruling, 2026-09-17: "We don't care for Unity's serializer, we have CultCache sitting right there. All settings should be in globals." -Rulings: none yet. Open: forks K, I, B, D, P (these block Cut 1), then M and X. +Rulings (2026-09-17): +- **Engine asset references: Addressables, stored as asset GUIDs** (operator: "you got it", + to that proposal). This supersedes forks I and B: nothing moves under `Resources`, and the + existing `[CultInspectorAssetPath]` fields (`ItemData.Icon`/`ActionBarIcon`, `Weapon` effect, + `Thruster`/`AetherDrive` particles, `Corporations` logo) convert too, so there is one rule. + That conversion is its own cut, mapped in `docs/addressables-cut.md`, and lands before Cut 1; + Cut 1's icon and body-preset fields then use it. +- **K (a), D (a), P (a), X (a), M (a): Self's defaults**, stated to the operator, who did not + object. Reopen any of them on request. +- Open: none blocking Cut 0. Doc sweep for Self: this map supersedes the mechanism in `docs/headless-playground-cut.md` fork S ("a text edit of `Settings.asset`", `:1151-1159`, `:404-410`, `:693-694`). It also changes the `Run.New(CultCache, GameplaySettings, ZoneGenerationSettings, PlanetSettings, ...)` @@ -182,6 +191,14 @@ Fork M decides whether any of this counts as "settings". ## 2. Global shape +> **Superseded in part (2026-09-17):** wherever this map stores an engine asset as an +> `Assets/Resources/...` path or loads it with `UnityHelpers.LoadAsset`/`Resources.Load`, read +> instead: the asset's GUID under `[CultInspectorAssetGuid]`, loaded with `EngineAssets.Load`, +> the asset living under `Assets/Content` (`docs/addressables-cut.md`, which lands first). That +> covers `ItemIcons`/`WeaponTypeIcons`/... (2.1), the 3.2 import's `{fileID, guid}` resolution (store +> the guid directly, no path), the body presets (the 7 `CelestialBodySettings` assets move to +> `Assets/Content`, not `Resources`), and Cut 1's picker check. Forks I and B are history. + ### 2.1 Document This is fork D, recommended (a). One catalog global in a new file, @@ -502,6 +519,14 @@ landed type differs from it by the one attribute. ## 6. Operator forks (ordered by how much they block) +> **Superseded in part (2026-09-17):** wherever this map stores an engine asset as an +> `Assets/Resources/...` path or loads it with `UnityHelpers.LoadAsset`/`Resources.Load`, read +> instead: the asset's GUID under `[CultInspectorAssetGuid]`, loaded with `EngineAssets.Load`, +> the asset living under `Assets/Content` (`docs/addressables-cut.md`, which lands first). That +> covers `ItemIcons`/`WeaponTypeIcons`/... (2.1), the 3.2 import's `{fileID, guid}` resolution (store +> the guid directly, no path), the body presets (the 7 `CelestialBodySettings` assets move to +> `Assets/Content`, not `Resources`), and Cut 1's picker check. Forks I and B are history. + **K. How the first catalog global gets written.** Blocks Cut 1. - (a) A scratch-only authoring build without `[CultGlobal]` writes the record under the explicit `global:` key, and the landed type adds the attribute (probed, 3.1). No landed diff --git a/docs/shield-panel-cut.md b/docs/shield-panel-cut.md new file mode 100644 index 000000000..ee9373f46 --- /dev/null +++ b/docs/shield-panel-cut.md @@ -0,0 +1,910 @@ +# Shield Panel: Hard-Light Interceptor Cut Map + +Date: 2026-09-18 (revised; supersedes the flat-panel map committed at `1fdf8151`) + +Status: Imagination pass, cut map. Nothing here has landed. Repo anchors are against +`codex/item-provenance` HEAD `482229d1` — the Addressables cuts (`67cbf34b`, `482229d1`) +landed after the first draft, so every `file:line` below was re-checked against the current +tree. The six incoming sources are still not in the repo; they are anchored as +`files/:` against the operator's drop at `/files/`. + +Claims marked **(probe)** were measured in Unity 6000.3.24f1, Direct3D12, `srp=builtin`, +batchmode with graphics, in a throwaway project holding only the six files and a ported +shader. Nothing was built in the Aetheria tree and no Unity session was opened against it. +The probe project is scratch and goes with the session; §1 and §2 record what it proved so +nobody has to re-run it. + +## 0. Operator rulings, 2026-09-18 + +These close four of the previous pass's forks and change the shape of the thing. The old +design — one permanent flat disc standing in front of the hull — is history; see §9. + +**R1 (was Q2) — alongside, not instead.** "It's a different way of doing shields." The panel +effect runs next to `FieldDriver`'s absorb visual, not in place of it. Both subscribe to the +same `AbsorbEvent` stream; neither owns the other. The previous recommendation ("A for the +first landing, then B as a deliberate deletion") is withdrawn — B is not coming. + +**R2 — the shield is an interceptor.** "It's a shield that intercepts hits by spawning those +hard-light panels. I'd set the normal of the panels to the surface of the collision +ellipsoid." So a panel is **short-lived and per-interception**: it does not exist until +something is intercepted, it is posed at the intercept point with its normal along the +ellipsoid surface normal there, and it dies. Not a permanent panel. Not a wrapped shell. Its +**size comes from the shield item, not the hull** — the hull decides where the panel appears, +the item decides how big it is. + +**R3 — every shield effect is a drop-in behavioural replacement.** A shield presentation takes +absorb events, presents them, and decides nothing. The contract is behavioural: coverage is +expressed **through the envelope**, not through literal geometry. A presentation is allowed to +be a wrapped shell, a flat plate, or a swarm of spawned patches, as long as it answers the +same events over the same envelope. + +**R4 — the ellipsoid envelope is standardised and authored on the hull prefab.** "The field +shield already has the ellipsoid, we'd just be standardizing that. Ship prefabs would need to +define their ellipsoid envelope." + +**R5 (was Q3) — fix Penrose in this cut.** Operator: "definitely fix penrose". D18 +(`Penrose` ignores `panelRadius`) is a Cut 1 defect, not a follow-up: R2 makes panel size the +item's to set, so a tiling that cannot be sized cannot ship. Hex remains the fallback while +the fix is verified, and D19 (TriHex yields no dual edges) and D7 (Voronoi is 15 s at r10) +stay recorded, not fixed. + +**R6 (was Q2) — the envelope is derived from the existing transform**, not authored as +centre and radii. Operator: "derived; the simulation doesn't even use this collision, the +hits we end up showing will be 'fake'". Two consequences worth holding on to: +- The envelope has no second copy to drift from the `Shield` collider, which is the whole + point of collapsing the two implicit ellipsoids. +- **Panel placement is cosmetic.** The simulation does not resolve hits against this + geometry; fire control rolls decide what happens (`docs/three-gates-scope.md`). The + intercept point is where the presentation says the shot arrived, and nothing downstream + may read it back as truth. + +Q1 (does the first landing have to fracture) is **still open** and still first; see §8. + +> **Cut 1 landed 2026-09-18** (`a935b965` sources+port+defects, `ff8b2ff7` probe and its +> mutation harness). Batchmode: 0 C#, shader and compute errors; only D15's uninitialised- +> variable warning survives. D1, D3, D4, D6, D12, D18 fixed; D2/D5 collapsed the draw to one +> call; D7 and D19 recorded; D9/D10 to Cut 5, D17/D20 to Cut 3. The fix for D18 retired +> `penroseIterations` and `subdivisions`. A repo-specific trap the map missed: +> `UI/ContextMenu.cs` shadows `UnityEngine.ContextMenu`, so the attribute needs qualifying. +> +> **Open, found in Cut 1's own numbers (D21):** Penrose scales with radius now, but not with +> `cellSize` the way Hex does — at r1.5/cellSize 0.35 it yields 460 cells where Hex yields 19, +> and 8050 at r10 against Hex's 979. Iteration count is quantised (each substitution divides +> edge length by phi), so a panel can only land near the requested density, and it currently +> lands an order of magnitude denser. Cut 3 sizes panels from the item (R2) and pools them +> (§2.4), so settle this before pooling: either the count is wrong, or `cellSize` means +> something different for a quasiperiodic tiling and the item's knob must say which. + +> **Cut 2 landed 2026-09-18** (`a454a5a7`, fallback fix `7848a8a8`). One owner, +> `Gameplay/ShieldEnvelope.cs`; the hand-rolled projections in `FieldDriver` and +> `ShieldManager` are gone. Radii are measured from the carrier's collider mesh, not assumed: +> the map's `lossyScale * 0.5` guess was wrong, and Shield.prefab's icosphere collider is not +> even isotropic. `SurfaceNormal` scales the gradient by the inverse of `lossyScale` (a normal +> is a covector); the first implementation used `TransformDirection` and a finite-difference +> probe caught it 5-20% out. It diverges from `normalize(p)` by dot 0.674 on the Longinus +> flank, which is why the panel needs it and the two old consumers did not. +> +> Carriers without the component behave exactly as before Cut 2, warning once per object. +> Self sent the first attempt back: its fallback degraded shield hits to a fixed direction +> until the operator edited prefabs, which is a regression on working behaviour. +> +> **Open (D22):** `FieldDriver`'s two branches are not interchangeable in general — +> `ProjectToSurface` returns a world point, the legacy fallback stays local because that is +> what the shader eats. They agree only for a carrier at the origin with identity rotation, +> which is the only `FieldDriver` carrier in the tree. Settle before a second one exists. + +> **Cut 3 landed 2026-09-18** (`0ef88419`, `0e2fe6dc`, `5ae5c518`). `ShieldInterceptor` +> poses a pooled panel per absorb event on the envelope, reusing `Prototype`. D17/D20 fixed +> (a panel absorbs the hit that spawned it, and the gate stays open after), D21 fixed +> (`Round` not `Ceil+1` on the substitution count): Penrose is now 2.1-4.7x Hex's cell count +> at the same radius and cell size, down from ~24x, and that residual is the quantisation +> floor of "nearest edge length", not a knob. Reuse strikes a live panel within +> `PanelRadius * ReuseFraction` without resetting it, so temper erosion survives; at the cap +> (12) the oldest panel is recycled in place. +> +> Found beyond the map: `OnEnable`/`OnDisable` would have rebuilt the tiling and released +> every buffer on each pooled activation, which would have made pooling cost more than +> rebuilding and quietly voided §2.4's whole argument. +> +> Two yields with a background run still going cost a round trip each; the wait rule now +> names the mechanism (`~/.claude/skills/eureka/references/briefs.md`). + +> **Ruling R7 (operator, 2026-09-18): a panel's rim is held, not free.** "held is probably +> nicer even if free is more accurate". The emitters projecting the panel hold its edge, so the +> rim stops being a tension source (`ShieldSim.compute`'s `openW` ghost term) and the only +> spall left comes from the shot itself through the plate. Cracks radiate from the impact and +> shards leave where the hit landed, and panel size stops being a fragility multiplier. +> **A broken cell's faces stay free**: that reflection is the dicing cascade and it survives. +> +> Operator play evidence, 2026-09-18, on the rigged scene: a panel spawns, is reused, and +> spiderwebs, but only shatters when hits are spammed near the rim — which is the free edge +> reflecting compression back as tension exactly as designed, and confirms the wave, the +> reflection and the temper erosion all work. The interior spall source (the through-thickness +> echo) is too weak or mistimed to compete. That, D9 (velocity unclamped, so hard hits saturate +> in compression) and D10 (fracture evaluated once per frame while the wave integrates several +> times) are Cut 5's targets. + +## 1. What the repo actually has today + +The re-probe found the thing the first pass missed: **the ellipsoid envelope already exists +twice, in two unconnected places, and neither is typed.** + +**Authority A — `FieldDriver`'s transform.** The ellipsoid is implicit in the field object's +scale. `Assets/Scripts/Gameplay/FieldDriver.cs:125` projects a hit onto it with +`normalize(transform.InverseTransformPoint(position)) * transform.localScale`; `:201` feeds +`_InverseScale` to the shader; `:297-299` scales the tendril vectors by it. The only carrier +in the whole tree is the `cubesphere Variant` prefab instance in +`Assets/Scenes/FieldShieldTest.unity`, whose localScale is **(4, 3, 12)** at +`Assets/Scenes/FieldShieldTest.unity:948-959`. `FieldDriver` is on **no ship prefab at all** +— its script guid appears in exactly one asset file, that scene, at `:1042`. + +> Correction to the previous pass: the old map said this object was scaled `(10, 10, 20)` +> (old §"Cut 3"). It is `(4, 3, 12)`. Every geometry number in the old Cut 3 was wrong. + +**Authority B — the `Shield` prefab instance on each ship.** `Assets/Content/Prefabs/Shield.prefab` +has base localScale `(100,100,100)` at `:212` and a convex `MeshCollider` at `:218-231`. Each +ship overrides it non-uniformly, and *that override is the ship's envelope today*: + +| Ship | localScale override | localPosition | effective radii | +| --- | --- | --- | --- | +| Longinus (`Assets/Content/Prefabs/Ships/Longinus.prefab:1832-1843`, pos `:1847-1858`) | (395.35, 235.47, 990.38) | (0, 0.73, 1.32) | ≈ (3.95, 2.35, 9.90) | +| Djinni (`Assets/Content/Prefabs/Ships/Djinni.prefab:7378-7389`) | (857.93, 424.00, 1036.26) | (0, 1.75, -1.06) | ≈ (8.58, 4.24, 10.36), pitched 10° | + +`ShieldManager.ShowHit` (`Assets/Scripts/Gameplay/ShieldManager.cs:73-78`) already does the +**same normalise-and-project** as `FieldDriver.cs:125`, against this transform instead: +`normalize(shield.transform.InverseTransformPoint(point))`. Two implementations of one idea, +neither aware of the other. The test scene's (4,3,12) is visibly an eyeball of Longinus's +(3.95, 2.35, 9.90) — which is the tell that this was always one concept. + +So the standardisation R4 asks for is **not a new invention. It is a collapse of two existing +duplicate authorities**, and the panel is its third consumer rather than its reason. + +**No data-side size exists.** `HullData` (`Assets/Scripts/ServerShared/ItemData.cs:493-533`) +carries `Hardpoints`, `Prefab`, `HullType`, `GridOffset`, `Armor`, `Drag`, `CanTow`, `Shape` +— no radius, no extents. Nothing in `Assets/Scripts` ever assigns a shield or field object's +`localScale`; `ZoneRenderer.cs:295` instantiates the hull prefab and applies no scale at all. +The prefab is the only place the envelope can live today. **For the record, and as a +follow-up rather than a fork:** this belongs in the catalog next to `HullData`, for the same +reason hardpoints do — `HardpointData.Transform` +(`Assets/Scripts/ServerShared/ItemData.cs:541`) is a *GameObject name string* matched against +prefab child names at runtime (`EntityInstance.cs:242,257,268`, `ShipInstance.cs:69-70`), +which the operator has called unfortunate. The envelope would inherit the same unfortunateness +by living on the prefab. It should move when hardpoints move, in that cut, not this one. + +**The real absorb publisher does not exist, but its hook does.** `PublishAbsorb` has exactly +one caller and it is test-only: `Assets/Scripts/UI/FieldTester.cs:76`, from a +`ClickableCollider.OnClick`. The real damage path is entirely separate — `Entity.IncomingHit` +/ `HullDamage` (`Assets/Scripts/ServerShared/Entity.cs:126,131`), published from +`EntityInstance.cs:312,333`. But the real *shield-hit visual* path already converges on one +method: `ShieldManager.ShowHit`, called from `ShieldManager.cs:65` (collision) and six +weapons — `ConstantLaser.cs:83`, `ConstantLightning.cs:62`, `GuidedProjectile.cs:162`, +`HitscanEffect.cs:40`, `Laser.cs:49`, `Lightning.cs:44`. **That is where a real +`PublishAbsorb` belongs**, and it is a later cut, not this one. Naming it here so nobody +invents a second bridge. + +**The pool already exists.** `Assets/Scripts/Prototype.cs` is the repo's pooling helper: +`Instantiate()` at `:41`, `ReturnToPool()` at `:80`, an `OnReturnToPool` event at `:14`. +`ShieldManager.ShowHit` uses it (`ShieldManager.cs:75`) for exactly the short-lived +per-hit visual this design needs. **Do not write a pool.** Use this one. + +**The materialisation wavefront exists and is dead.** `CellStatic.spawnDelay` +(`files/ShieldCommon.hlsl:20`) is baked per cell at build time from a `wavefrontOrigin` and +`wavefrontSpeed` (`files/TilingBuilder.cs:225,230`), and `KUpdate` reads it as +`s.growth = saturate((_Time - _PanelSpawnTime - c.spawnDelay) / _GrowDuration)` +(`files/ShieldSim.compute:346`). But `files/ShieldPanel.cs:360` hardcodes +`sim.SetFloat("_PanelSpawnTime", 0f)`, so the wavefront runs once at sim time zero and never +again. For the interceptor it becomes the panel's arm time — **this is the mechanism R2 +already wanted and it needs one line changed, not a new system.** + +The bake is also, by luck, already correct: an interceptor panel is *centred on* its intercept +point, and the tiling is built with `wavefrontOrigin: Vector2.zero` +(`files/ShieldPanel.cs:136`), which is the panel centre. So **one tiling serves every panel** +and the growth wavefront still radiates from the strike. Nothing about the bake needs to +become per-interception. + +## 2. Probe record + +All measured, not reasoned. + +### 2.1 Port and drawing (carried forward from the first pass, unchanged) + +1. **The shader ports to built-in with three substitutions**: drop + `"RenderPipeline"="UniversalPipeline"` from the SubShader tags, replace the URP `Core.hlsl` + include with `UnityCG.cginc`, rename three `TransformWorldToHClip(` to + `UnityWorldToClipPos(`. Result `shader supported=True, messages=0`. No other URP symbol is + used. **(probe)** +2. **Indirect drawing works on built-in.** `Graphics.RenderPrimitivesIndirect` with + `RenderParams`, a `MaterialPropertyBlock` carrying `GraphicsBuffer`s, and an append-buffer + counter copied in by `GraphicsBuffer.CopyCount` rendered the panel. **(probe)** +3. **`RenderParams` has no `shaderPass`** in 6000.3.24f1. `files/ShieldPanel.cs:410,415,420` + do not compile. **(probe)** +4. **A multi-pass material renders every pass from one call**, and pass selection is not + available at this API. **(probe)** +5. **One args buffer sized to the widest pass is equivalent** — pixel-identical output to the + three-call version (`litPixels=43929` both ways). **(probe)** +6. **`centroid` is reserved in the compute compiler.** `CellStatic.centroid` + (`files/ShieldCommon.hlsl:18`) must become `center`. **(probe)** +7. One surviving shader warning: `Warning 172: potentially uninitialized variable (BuildLine)`, + Conduit vertex program. Warning only. **(probe)** +8. **The supplied tuning never fractures anything.** Hex, `panelRadius 5`, `cellSize 0.35`, + 253 cells, head-on Radial hit, 60 frames: `broken 0/253` at every energy from 3.5 to 3500. + Peak tension is non-monotonic and collapses above ~60. Causes are D9/D10. **(probe)** + +### 2.2 Tiling build cost — new, and it settles prebuild-vs-build-on-demand + +`Tilings.Generate` + `TilingBuilder.Build`, `cellSize 0.35`, single-threaded, warm JIT. **(probe)** + +| kind | radius | cells | edges | gen ms | build ms | **total ms** | +| --- | --- | --- | --- | --- | --- | --- | +| Hex | 3 | 85 | 444 | 0.07 | 0.84 | **0.91** | +| Hex | 5 | 253 | 1404 | 0.20 | 2.10 | **2.30** | +| Hex | 10 | 979 | 5640 | 0.72 | 20.26 | **20.98** | +| PenroseP3+merge | 1.5 / 3 / 5 / 10 | 460 (all) | 1750 | 1.7–5.8 | 1.9–4.3 | **3.6–10.1** | +| PenroseP3 | 1.5 / 3 / 5 / 10 | 890 (all) | 2610 | 0.3–1.7 | 2.8–3.6 | **3.1–5.3** | +| TriHex | 3 | 211 | **0** | 0.30 | 1.44 | **1.74** | +| TriHex | 5 | 577 | **0** | 0.84 | 8.57 | **9.41** | +| TriHex | 10 | 2275 | **0** | 3.48 | 105.84 | **109.32** | +| Voronoi | 3 | 251 | 1390 | 131.31 | 2.58 | **133.89** | +| Voronoi | 5 | 733 | 4200 | 1014.35 | 11.65 | **1026.01** | +| Voronoi | 10 | 2940 | 17246 | 14941.02 | 85.64 | **15026.67** | + +Three findings fall straight out of this table and are written up as D17–D19: + +- **Penrose ignores `panelRadius` entirely** — identical cell count at radius 1.5 and radius 10. +- **TriHex produces zero dual edges at every radius.** No adjacency means no wave. It is not + "probably fine", it is inert. +- **Voronoi is 15 seconds at radius 10.** D7 quantified. It is not shippable at any radius + where you would want it. + +Build cost is not the argument either way on its own — 0.91 ms for a small Hex patch is about +5% of a 60 Hz frame, which is survivable but spiky. §2.4 is what actually settles it. + +### 2.3 Per-frame cost of N live panels + +N panels each stepped once per frame. `cpuIssueMs` is dispatch issuance only; `syncedMs` +forces a GPU drain each frame via a small readback, so it is an upper bound that a real frame +does not pay. Hex, `cellSize 0.35`. **(probe)** + +| cells/panel | N | cpu issue ms/frame | synced ms/frame | synced ms **per panel** | +| --- | --- | --- | --- | --- | +| 85 | 1 | 0.101 | 0.756 | 0.756 | +| 85 | 4 | 0.584 | 1.752 | 0.438 | +| 85 | 16 | 2.139 | 6.146 | 0.384 | +| 85 | 32 | 5.262 | 10.120 | 0.316 | +| 253 | 1 | 0.106 | 0.509 | 0.509 | +| 253 | 4 | 0.451 | 1.449 | 0.362 | +| 253 | 16 | 1.958 | 4.716 | 0.295 | +| 253 | 32 | 4.438 | 11.605 | 0.363 | + +**The load-bearing result: per-frame cost is flat in cell count and linear in panel count.** +85 cells and 253 cells cost the same per panel. That is because a panel's frame is ~17 compute +dispatches, one `SetData`, one `CopyCount` and one indirect draw *regardless of how many cells +it holds* — the cell count rides inside the dispatches, where it is nearly free at these sizes. + +So the budget is a **panel-count budget, not a cell budget**, and the constant is roughly +**0.12 ms of CPU issuance per live panel** at either size. Sixteen live panels is ~2.0 ms of +CPU every frame, before the GPU does anything — about 12% of a 60 Hz frame for one ship's +shield. That is the number the pool cap has to respect. + +### 2.4 Recycle vs rebuild — this settles the pool + +Re-arming an existing panel (`ResetSim`, two dispatches) against building one from scratch +(`Rebuild`: tiling + 12 `GraphicsBuffer`s + a `Material`). Hex, 20 iterations each. **(probe)** + +| cells | Rebuild ms | ResetSim ms | ratio | +| --- | --- | --- | --- | +| 85 | 1.32 | 0.011 | **123×** | +| 253 | 3.33 | 0.008 | **428×** | +| 979 | 20.91 | 0.008 | **2686×** | + +Recycling is **free and flat** — 8 microseconds, independent of cell count, because it is two +GPU dispatches and nothing else. Building is 1.3–21 ms and scales with cells. + +**Decision, from the number and not from taste: prebuild.** One tiling per shield item, built +once at load; a preallocated pool of armed `ShieldPanel` instances; interception costs a +`ResetSim` and a transform write. Building a tiling at interception time would drop a 1–21 ms +spike into the exact frame the player is being shot in, which is the worst possible frame to +spend it, and buys nothing — every panel of one shield item is the *same tiling*. + +## 3. Target + +A shield item's presentation is an interceptor. On each absorbed hit it spawns a hard-light +panel at the intercept point on the ship's ellipsoid envelope, normal along the envelope's +surface normal there, sized by the item. The panel materialises outward from the strike, takes +that hit and any subsequent hit that lands inside it, and dies. + +Invariants: + +- **One owner of the envelope.** `ShieldEnvelope` is the only thing that answers "where is the + surface, and which way does it face". `FieldDriver`, `ShieldManager` and the panel presenter + all read it and none of them recompute it. +- **One owner of the hit.** `ShieldPanel.Hit` is the only entry into a panel's simulation, and + its presenter is the only caller. +- **One event, one strike.** An `AbsorbEvent` produces exactly one strike — on a new panel or + on a living one, never both, never neither. +- **Presentation owns only presentation.** The presenter converts an event into a pose and an + energy. It decides nothing about the rule. (`CapabilityPresenters.cs:8`, and the fork-L + contract at `CapabilityEvents.cs:9-20`.) +- **Presentations do not see each other.** `FieldDriver` and the panel presenter both subscribe + and neither reads the other (R1). The binder already supports this without change: + `CapabilityPresentationBinder.cs:32-36` subscribes every `IAbsorbPresenter` it finds. +- **No second render pipeline, no `Unity.Mathematics`.** Unchanged from the first pass; + `ProjectSettings/GraphicsSettings.asset:41` is still `m_CustomRenderPipeline: {fileID: 0}`. +- **No allocation at interception.** The pool is preallocated and the tiling is prebuilt. An + interception costs a `ResetSim` and a transform write (§2.4). + +## Cut 1. Land the sources, compiling, on built-in + +Unchanged in substance from the committed pass, minus the parts R2 made pointless. Depends on +nothing; touches no file the Addressables cuts moved (nothing here goes near +`Assets/Content` or `Assets/Resources`). + +**Location.** `Assets/Shaders/Compute/ShieldField/`, matching +`Assets/Shaders/Compute/Lightning/` and `Slime/`, which each hold their `.compute`, +`.shader`, `.cginc` and driver `.cs` together. Colocation is load-bearing: +`#include "ShieldCommon.hlsl"` resolves relative to the including file **(probe)**. No asmdef +— the files land in `Assembly-CSharp` with everything else in `Assets/Shaders/Compute/*.cs`. +The `ShieldField` namespace collides with nothing. + +**Deletes first:** + +- `files/ShieldPanel.cs:450-487` — `ShieldPanelTester`. The standalone click harness the + operator ruled against, and it cannot run anyway: `ProjectSettings/ProjectSettings.asset:797` + is `activeInputHandler: 1` (new Input System only), so `Input.GetMouseButtonDown` throws. +- `files/ShieldPanel.cs:90` — `argsOutline`, `argsConduit` and their `MakeArgs` calls at + `:172-173`; the `drawFill`/`drawOutline`/`drawConduits` bools at `:68-70` and their branches + at `:408-422`. Replaced by one args buffer and one draw (§2.1.5). +- `files/ShieldPanel.cs:9` — `[ExecuteAlways]`. With it, `Update` runs the whole simulation and + every dispatch in every open scene view, and `OnEnable` rebuilds the tiling on every domain + reload. Keep `Rebuild` on the context menu. + +**Per-file changes:** + +- `ShieldCommon.hlsl:18` — `float2 centroid;` → `center`. Every `.centroid` in + `ShieldSim.compute` and `ShieldPanel.shader` follows. Required to compile (§2.1.6). +- `ShieldCommon.hlsl:31` — add `float pad2;`, taking `CellStatic` to 16 floats. +- `TilingBuilder.cs:27` — add `public float pad2;`, initialise at `:242`. Today the struct is + 15 floats (60 bytes) while `Stride` at `:28` claims `4 * 16`, and `SetData` throws on the + mismatch **(probe)**. Fix both sides or neither. +- `TilingBuilder.cs:214,221` — rename the inner `dist` to `nbrDist`. CS0136 against the `dist` + at `:225`; the file does not compile as delivered **(probe)**. +- `ShieldPanel.shader:27` — drop the URP tag, add `"IgnoreProjector"="True"`. +- `ShieldPanel.shader:33` — `#include "UnityCG.cginc"`. +- `ShieldPanel.shader:162,163,244` — `TransformWorldToHClip(` → `UnityWorldToClipPos(`. +- `ShieldPanel.cs:171-173` — one `argsFill = MakeArgs((uint)(MaxV * 2 * 6));`. +- `ShieldPanel.cs:369-371` — one `CopyCount(bVisible, argsFill, sizeof(uint))`. +- `ShieldPanel.cs:408-422` — one `RenderPrimitivesIndirect`, no `rp.shaderPass`. +- `ShieldPanel.cs:296-298` — hoist the per-frame `new uint[] { 0u, cap }` to a cached field. +- Defect fixes D3–D8 from §7, each named in the commit. + +**Authority map:** + +- Owner: `ShieldPanel` owns one panel's simulation and its GPU buffers. `TilingBuilder` owns + adjacency, areas and ghost weights. `Tilings` owns geometry generation. +- Inputs: authored fields; `Hit(worldPos, worldDir, energy, pattern, radius)` from outside. +- Outputs: one indirect draw's worth of pixels; GPU state readable for verification. +- Derived: `worldBounds`, `groups`, `echoSlot`, `simTime`. +- Forbidden writers: nothing outside `ShieldPanel` touches its buffers. `ShieldPanelTester` is + deleted rather than kept as a second hit source. +- Deletion line: `ShieldPanelTester`, two args buffers, three draw bools, the `shaderPass` + writes, and `[ExecuteAlways]` are gone before Cut 2 is written. + +**Verification:** batchmode compile of the Aetheria project, zero errors. +`ShaderUtil.GetShaderMessages` / `GetComputeShaderMessages` report zero errors (the §2.1.7 +warning may remain). Negative: +`rg "UniversalPipeline|TransformWorldToHClip" Assets/Shaders/Compute/ShieldField` is empty; +`rg "shaderPass" Assets` is empty. No operator look yet. + +## Cut 2. `ShieldEnvelope` — collapse the two implicit ellipsoids into one + +This is the R4 cut, and it is a **teardown, not an addition**. Two authorities (§1 A and B) +become one reader, and both existing consumers are converted to it before the panel exists. +Depends on nothing; can land in parallel with Cut 1. + +**Adds:** `Assets/Scripts/Gameplay/ShieldEnvelope.cs`, one component. + +**Shape, and why this shape.** The component is a typed **reader of its own transform**, not a +new store of numbers: + +```csharp +public class ShieldEnvelope : MonoBehaviour +{ + // Radii and centre come from this transform. They are not authored twice. + public Vector3 Center => transform.position; + public Vector3 Radii => transform.lossyScale * 0.5f; // see note on the 100x base + + public Vector3 ProjectToSurface(Vector3 worldPoint); // local, normalise, scale, back + public Vector3 SurfaceNormal(Vector3 worldSurfacePoint); // gradient, see below +} +``` + +The operator asked for "a `ShieldEnvelope` component with centre + three radii, **or whatever +the Body argues for**". The Body argues for **derived, not authored**, and the reason is +ownership: the two ships already have their envelope authored, as the `Shield` prefab +instance's transform (`Longinus.prefab:1832-1858`, `Djinni.prefab:7378-7389`), and the convex +`MeshCollider` on that same object (`Shield.prefab:218-231`) is *already* driven by it. +Authoring centre and radii as separate serialized fields next to a transform that also decides +the collider would create a second truth about the same surface and a silent way for them to +disagree — the split this map exists to avoid. One authority, three readers. + +The cost of that choice, stated plainly: the envelope is only as expressive as a transform, so +it is an ellipsoid with a rotation and no more. That is exactly what R2 asked for, and if a +hull ever needs a non-ellipsoid envelope this component is the place that changes. + +`Radii` must account for `Shield.prefab`'s `(100,100,100)` base (`Shield.prefab:212`) and for +whether the mesh at `Shield.prefab:218-231` is a unit-diameter or unit-radius sphere. **Hands +must read that mesh's bounds and derive the factor rather than assuming `0.5`** — get it wrong +and every panel is posed at twice or half the right distance, which looks like a bug in the +panel and is not. + +**`SurfaceNormal` is not `normalize(p)`.** For an ellipsoid with radii `(a,b,c)` centred at +the origin, the outward normal at a surface point `p` is +`normalize(p.x/a², p.y/b², p.z/c²)`, then back to world through the transform's rotation. +`normalize(p)` is only correct for a sphere, and Longinus's envelope is 4:1 in aspect, so the +error is large and visible — panels would visibly tilt wrong along the flanks. Both existing +consumers get away with `normalize` today because they use it to pick a *direction on the +surface*, not a *normal to it*. The panel needs the normal (R2) and this is where the +distinction is paid for. + +**Deletes / demotions — the part the model will want to skip:** + +- `FieldDriver.cs:125` stops computing its own projection. It reads + `envelope.ProjectToSurface(position)`. **`FieldDriver.transform.localScale` is no longer an + owner of the envelope; it is display geometry for the field mesh only.** +- `FieldDriver.cs:201` and `:297-299` read `envelope.Radii` rather than + `transform.localScale`. If a `ShieldEnvelope` is absent, `FieldDriver` must log once and + fall back to its own transform — explicitly a compatibility path, and it delegates rather + than keeping its own opinion. +- `ShieldManager.cs:76` stops computing `normalize(InverseTransformPoint(point))` and reads + the envelope. `ShieldAnimation.Direction` keeps its existing meaning. + +**Scene/prefab changes:** add `ShieldEnvelope` to the `Shield` object on `Longinus.prefab` and +`Djinni.prefab`, and to the `cubesphere Variant` instance in `FieldShieldTest.unity`. No +transform value changes — that is the point; the numbers already there become the typed +authority instead of being re-entered somewhere else. + +**Authority map:** + +- Owner: `ShieldEnvelope` owns the answer to "where is the surface and which way does it + face". The transform it sits on owns the numbers. +- Inputs: its own transform. Nothing else. +- Outputs: `Center`, `Radii`, `ProjectToSurface`, `SurfaceNormal`. +- Derived state: all of it. The component stores nothing. +- Forbidden writers: nothing writes a `ShieldEnvelope`. After this cut, **no other code may + compute a projection onto the shield surface** — `FieldDriver.cs:125` and + `ShieldManager.cs:76` are the two that must stop, and the negative check below pins it. +- Shared paths: collision hits (`ShieldManager.cs:65`), the six weapon call sites, the test + scene's clicks, and the panel presenter all resolve the surface through this one method. +- Deletion line: the two hand-rolled projections go before the panel presenter is written. + +**Verification:** + +- test: `ShieldEnvelope` is pure math on a transform, so this one *can* be unit-tested — + but `Tests.asmdef` cannot reference `Assembly-CSharp` (see Q7), so it is a batchmode probe: + for each of Longinus's and Djinni's envelopes, assert `ProjectToSurface` of a point outside + lands on the ellipsoid (`Σ(pᵢ/rᵢ)² == 1` within epsilon) and that `SurfaceNormal` at that + point is within 1e-3 of the finite-difference gradient of the implicit surface. **The + sphere-vs-ellipsoid distinction above is exactly what this test pins**, so it must run on + Longinus's 4:1 envelope and not on a sphere. +- negative: `rg "InverseTransformPoint" Assets/Scripts/Gameplay` returns no shield-surface + projection outside `ShieldEnvelope.cs` — specifically that `FieldDriver.cs:125` and + `ShieldManager.cs:76` no longer contain one. +- operator: `FieldShieldTest` behaves exactly as it does today. This cut is a no-op to look at, + and if anything changes visually, something is wrong. + +## Cut 3. The interceptor presenter and the panel pool + +Depends on Cuts 1 and 2. This is where R2 lives. + +**Adds:** `Assets/Scripts/Gameplay/ShieldInterceptor.cs`, one component, +`MonoBehaviour, IAbsorbPresenter`. **Adds no pool** — `Assets/Scripts/Prototype.cs` is the +pool (`Instantiate()` at `:41`, `ReturnToPool()` at `:80`). + +**Shape:** + +```csharp +public class ShieldInterceptor : MonoBehaviour, IAbsorbPresenter +{ + public ShieldEnvelope Envelope; + public Prototype PanelPrototype; // a prefab carrying one ShieldPanel + public int MaxLivePanels = 12; // see the budget in §2.3 + public float PanelRadius = 2f; // the SHIELD ITEM's size, not the hull's (R2) + public float EnergyScale = 1f; + public FracturePattern[] PatternByDamageType; + + public void Absorb(AbsorbEvent e) { /* project, reuse-or-spawn, strike */ } +} +``` + +**The event is enough, and here is exactly what it carries.** +`AbsorbEvent` (`Assets/Scripts/ServerShared/CapabilityEvents.cs:21-35`) has `float3 Position`, +`float3 Direction`, `float Magnitude`, `DamageType DamageType`. The publisher +(`FieldTester.cs:76`) passes `hit.point` and `ray.direction` — both **world space**, position +being the raycast's contact point on the field's `MeshCollider`. So: + +- **Intercept point** = `Envelope.ProjectToSurface(e.Position.ToUnity())`. The projection is + needed and is not optional: `hit.point` lands on whatever collider was struck, which is the + field mesh in the test scene and will be the hull or the `Shield` collider in the real path + — none of which is guaranteed to be on the envelope. +- **Which projection**: normalise-and-scale, matching `FieldDriver.cs:125` and + `ShieldManager.cs:76`, not ray-ellipsoid. Two reasons. It is what the two existing consumers + already do, so the panel appears where the existing ripple appears — and the two visuals run + together under R1, so a disagreement between them would read as a bug. And ray-ellipsoid + needs a ray origin the event does not carry; `Direction` alone does not give one. **If the + operator later wants true ray-ellipsoid, `AbsorbEvent` has to grow an origin**, which is a + capability-contract change and not a presentation change. Noted, not forked. +- **Panel normal** = `Envelope.SurfaceNormal(interceptPoint)` (R2). +- **Panel pose** = `position = interceptPoint`, `rotation = Quaternion.LookRotation(normal)`. + `ShieldPanel` builds its disc in local XY with its normal on `+Z` and draws through + `mpb.SetMatrix("_PanelToWorld", transform.localToWorldMatrix)` + (`files/ShieldPanel.cs:394`), so **posing the transform is the entire pose plumbing and no + code changes for it.** +- **Scale must be 1 and the panel must not be parented to the envelope object.** The + `Shield` instance's scale is non-uniform (395, 235, 990) — parenting under it would ride + that shear into `_PanelToWorld` and skew every shard. Parent to the ship root, or to the + interceptor's own object if that carries unit scale. +- **Size** = `PanelRadius`, authored on the interceptor, which stands in for the shield item + until items own presentation data. Explicitly *not* derived from the envelope (R2). +- **Energy** = `e.Magnitude * EnergyScale`; **pattern** from `PatternByDamageType` indexed by + `(int)e.DamageType`, falling back to `Radial`. + +**Reuse — the rule that makes temper erosion visible.** + +A second hit landing inside a living panel must strike *that* panel, not spawn a new one. +Without this the temper mechanism (`files/ShieldSim.compute:286-298`) is invisible: every hit +would land on a fresh, fully-tempered panel and the "survive two, die on the third" behaviour +that the whole material model exists for could never happen. + +Rule: keep the live panels in a list. On a strike, find the live panel whose centre is nearest +the intercept point; if that distance is `< PanelRadius * ReuseFraction` **and** the panel is +grown, call `Hit` on it and spawn nothing. Otherwise spawn. + +`ReuseFraction` starts at 1.0 (anywhere inside the disc) and is a tuning field, not a fork. +The comparison is in world space against the panel's transform position; no need to go into +panel space, and going into panel space would be wrong for a hit near the rim of a strongly +curved envelope. + +**The test that pins it** (batchmode, and it is the one test in this map that must not be +skipped): + +1. Build an envelope, an interceptor with `MaxLivePanels >= 2`, and a pool. +2. Publish an `AbsorbEvent` at point P. Assert exactly one panel is live. +3. Step frames until that panel is grown (`growth >= 0.6` on the cells near the centre — + read back `bState`). +4. Publish a second `AbsorbEvent` at P + a small offset well inside `PanelRadius`. + **Assert the live-panel count is still one**, and assert the struck panel's minimum + `temper` has fallen below what one hit produced — i.e. the second strike reached the same + material, which is the thing that matters and is stronger than counting panels alone. +5. Publish a third at P + `PanelRadius * 3`. Assert the live count is now two. + +Step 4's temper assertion is what separates "did not spawn" from "actually struck". A panel +count alone would pass if the second event were silently dropped. + +**Lifecycle — spawn, grow, take hits, die, release.** + +- **Spawn:** `PanelPrototype.Instantiate()`, pose it, `ResetSim()`, set + `_PanelSpawnTime` to the panel's own arm time (D21), `Hit(...)`. Cost: 8 µs plus a transform + write (§2.4). +- **Grow:** the existing per-cell wavefront, now actually driven — `spawnDelay` is baked from + the panel centre (`files/ShieldPanel.cs:136`, `TilingBuilder.cs:225`) and the panel centre is + the intercept point, so it radiates outward from the strike for free. +- **Die:** when every cell is either broken-and-past-`shardLife` or has decayed. `KUpdate` + already declines to append dead cells (`files/ShieldSim.compute:352`), so the panel's + visible count reaching zero is the signal. Reading that back every frame would stall the + GPU, so **use a timer**: `growDuration + shardLife + a margin`, started at arm time. It is a + presentation, and a fixed lifetime is honest here; the readback would cost more than it buys. +- **Release:** `ReturnToPool()`, remove from the live list. +- **Pool exhausted** (`MaxLivePanels` reached): **recycle the oldest live panel** — return it + and immediately re-arm it at the new intercept point. Do not drop the strike (that breaks + "one event, one strike") and do not grow the pool (that breaks the §2.3 budget). Oldest-first + is right because the oldest is the one furthest through its own fade. + +**Authority map:** + +- Owner: `ShieldInterceptor` owns the live-panel set, the spawn/reuse decision, and each + panel's pose. It owns nothing about the material. +- Inputs: one `AbsorbEvent`; one `ShieldEnvelope`; its authored fields. +- Outputs: `Prototype.Instantiate` / `ReturnToPool` calls, transform writes, `ShieldPanel.Hit` + calls. +- Derived state: the live list and each panel's arm time. No per-hit state beyond that — + `ShieldPanel` already queues hits (`files/ShieldPanel.cs:82`). +- Forbidden writers: **nothing else may spawn, pose, or return a `ShieldPanel`.** The + interceptor never touches `CapabilityEvents` (the binder owns subscription, + `CapabilityPresentationBinder.cs:32-36`), never reads `FieldDriver` (R1), never writes the + envelope, and never computes a surface projection itself (Cut 2). +- Shared paths: the test scene's clicks and the eventual real publisher (`ShieldManager.ShowHit`'s + six weapon call sites, §1) reach this through the same `Absorb` method, because both go + through `CapabilityEvents.Absorb`. **A panel spawned by a click and a panel spawned by a + laser must be the same code path, posed by the same method** — if a later cut adds a separate + "real hit" entry point, this invariant is gone. +- Deletion line: none in this cut, but it is gated on Cut 2 having already deleted the two + hand-rolled projections. Writing this against the un-collapsed envelope would create a third. + +**Verification:** + +- test: the five-step reuse probe above. Plus: a strike with `Envelope == null` or + `PanelPrototype == null` does not throw. Plus: publishing `MaxLivePanels + 3` widely-separated + events leaves exactly `MaxLivePanels` live and issues no allocation after the first + `MaxLivePanels` spawns. +- negative: `rg "ShieldPanel" Assets/Scripts --glob '!ShieldInterceptor.cs'` finds no second + caller of `Hit`, `Instantiate` or `ReturnToPool` on a panel. `rg "localScale" ` over the + interceptor is empty — it never scales a panel. +- operator: nothing to look at yet; Cut 4 rigs it. + +## Cut 4. The scene rig + +Depends on Cuts 1–3. Touches `Assets/Scenes/FieldShieldTest.unity` and one new prefab. + +**Geometry, re-anchored.** The field is the `cubesphere Variant` instance at scene root, +localScale **(4, 3, 12)** (`FieldShieldTest.unity:948-959`), localPosition zero, camera at +`(0, 0, -50)`. A real `Longinus` hull already sits inside it at `:1189`. So the rig is: + +- `ShieldEnvelope` on the field object (Cut 2 already adds it). +- `ShieldInterceptor` on the **field object**, next to `FieldDriver`, `ClickableCollider` + (`:1077-1088`) and `CapabilityPresentationBinder` (`:1089-1100`), because the binder finds + presenters with `GetComponentsInChildren` on its own object + (`CapabilityPresentationBinder.cs:32`). Both `FieldDriver` and `ShieldInterceptor` are then + subscribed — which is R1, achieved with no code change. +- A new `ShieldPanel` prefab carrying a `Prototype` and a `ShieldPanel`, with `sim` and + `panelShader` assigned. `PanelRadius` on the interceptor, not on the prefab — the interceptor + pushes it, because it stands in for the item. +- `PanelRadius` starting value: the envelope's smallest radius is 1.5 (y = 3/2), so a panel + radius around **1.5–2** reads as a patch rather than a wall. At `cellSize 0.35` that is + roughly 19–50 Hex cells, well inside budget. + +**Note for the record, not a cut:** the flat-disc panel the first pass designed is still a +perfectly good shape for a *different* shield item — a "panel array" that presents a fixed +plate rather than spawning patches. R3 says a presentation is a drop-in behavioural +replacement, so that item is a later `IAbsorbPresenter` beside this one, reusing the same +`ShieldPanel` and the same envelope, with no pool and no reuse rule. Nothing in this map +should be shaped to prevent it, and nothing here does. + +**How the scene is changed.** A throwaway editor script under `Assets/Scripts/Editor/`, run +once by `-batchmode -executeMethod`, that opens the scene, adds the components, builds the +prefab, assigns references by `AssetDatabase.LoadAssetAtPath`, and saves. **The script is +deleted in the same commit; the scene diff is the artifact.** Hand-editing scene YAML next to +a prefab instance with an override list is the exact shape that produced `1428ada3` ("Repair +the Addressables map after a bad sed prefixed every line"). Q4 keeps the alternatives open. + +**Authority map:** + +- Owner: the scene owns the rig. The editor script is a one-shot actuator with no standing + authority and no life after the commit. +- Forbidden writers: nothing creates a `ShieldInterceptor` or an envelope at runtime. + `FieldTester` is **not** modified — it already publishes the events, and giving it knowledge + of the panel would re-split the fork-L authority. +- Deletion line: the editor script, before the commit closes. + +**Verification:** + +- test: batchmode headless open of `FieldShieldTest.unity` asserting exactly one + `ShieldInterceptor`, one `ShieldEnvelope`, a non-null prototype, and that the binder resolves + both `FieldDriver` and the interceptor as `IAbsorbPresenter`. +- **operator — this is the first look, and here is exactly what to look at:** + 1. Enter play mode. Nothing is visible until you click. **If a panel is visible on load, the + pool is arming itself and Cut 3's lifecycle is wrong.** + 2. Click the field near the **nose** (far +Z). A lattice patch materialises at the click, + growing outward from it, lying flat against the surface. The existing field ripple runs at + the same time (R1) — both, not one. + 3. Click near the **flank** (far +X, where the envelope is 4:1). **The panel must lie flat + against the flank, not tilt toward the ship's centre.** This is the single thing that + falsifies the `SurfaceNormal` gradient — a `normalize(p)` normal is visibly wrong here and + correct at the nose, so checking only the nose proves nothing. + 4. Click the **same spot twice**, about a second apart. The second click must **not** produce + a second panel; it must strike the one already there. + 5. Click **many times, far apart, quickly**. Count never exceeds `MaxLivePanels`; the oldest + patch is replaced rather than the newest being dropped. + 6. Let them all fade. Every panel disappears; nothing is left behind. + 7. Whether anything **shatters** is Cut 5, and at this point the honest answer is no. + +## Cut 5. Make a hit actually break the panel + +Depends on Cut 4 and on ruling Q1. A tuning and mechanism pass, not a wiring pass. §2.1.8 +shows no energy fractures the panel at the supplied defaults; do not tune by eye first, +because two mechanism defects bound what tuning can reach. + +- `ShieldSim.compute:226-232` (D9) — `u` is clamped to `±_StressClamp`, `v` is not. A hard hit + drives `v` past what the clamp permits, `u` pins in compression, and the field never swings + back into tension. That is the measured collapse above energy 60. Clamp `v` too, or normalise + the injection so energy maps to a bounded velocity. +- `ShieldPanel.cs:301,359` (D10) — `_Dt` is set to `fixedSubstepDt` before the injection loop + and not restored until after `KFracture` is dispatched, so `KFracture` integrates damage and + temper erosion with a substep `dt` while running **once per frame**. Damage accrues ~6× slow. + Either dispatch `KFracture` inside the substep loop (physically honest, five more dispatches + per frame — and §2.3 says dispatch count is what costs, so this is not free at 16 live panels) + or hand it the frame `dt` (one line). Say which in the commit; this is "which clock owns + fracture", not a constant. +- `ShieldSim.compute:160,191,276` (D17) — **the interceptor's own worst defect.** `KInject`, + `KWaveStep` and `KFracture` all skip cells with `growth < 0.6`. A panel spawned by an + interception is at `growth = 0` in the frame it is struck, so **the hit that created the + panel injects nothing at all.** Every panel would materialise inert and only respond to the + *second* hit. Fix by one of: arm the panel with `_PanelSpawnTime` back-dated so the centre is + already grown; hold the strike in `pending` until growth passes the gate (the queue at + `files/ShieldPanel.cs:82` already exists for this); or drop the gate for the injecting cell. + **This must be settled before the operator look in Cut 4 step 2, or that step shows a + growing-but-dead patch and reads as a tuning problem when it is a gating problem.** It is + listed here rather than in Cut 3 only because the fix lives in the sim. +- Then the tuning itself: `ShieldPanel.cs:34,53,56` and the interceptor's `EnergyScale`. + +**Verification:** + +- test: batchmode energy sweep, readback of `bState`, asserting weak energy leaves + `broken == 0` while strong energy breaks a bounded non-zero fraction — the same readback + shape as §2.1.8, already written once. +- negative: no energy in the authored range leaves `maxPeakTension == 0` with + `minTemper == temperInit` (the saturation signature). And: a panel struck in its **spawn + frame** shows non-zero `|v|` in the cells near the centre — the D17 negative. +- operator: strong hits dice the patch. **Repeated hits on the same spot break through on a + later hit than the first** — this is the payoff of the Cut 3 reuse rule and the temper + mechanism together, and it is the whole reason the reuse rule exists. + +## 6. Cost and budget + +**Build budget.** One tiling per shield item, prebuilt at load. Hex at the interceptor's +working size costs **0.91 ms (85 cells, r3)** to **2.30 ms (253 cells, r5)**, once, ever +(§2.2). The pool's `ShieldPanel` instances are preallocated: 32 instances at 253 cells cost +111 ms to construct (§2.3), which is a load-time cost and must not happen during play. + +**Frame budget.** ~0.12 ms CPU dispatch issuance per live panel, flat in cell count (§2.3). +`MaxLivePanels = 12` is ~1.4 ms/frame; 16 is ~2.0 ms. **Cap at 12 for one ship.** The cap is +the real budget knob; cell count is not, and raising `cellSize` to "optimise" would only cost +visual density for nothing. + +**Interception cost.** `ResetSim` + transform write = **8–11 µs** (§2.4), flat. Free. + +**Static-buffer sharing, named and deliberately deferred.** `bCells`, `bVerts` and `bEdges` +are byte-identical across every panel of one shield item, so the pool currently holds N copies +of the same data — at 253 cells that is ~40 KB × N of pure duplication and N redundant +uploads at load. Hoisting them into one shared owner is the obvious subtraction. It is **not** +in this map because it changes `ShieldPanel`'s buffer ownership, which §2.3 says buys no frame +time (the cost is dispatch count, not data), and Cut 1's authority map says `ShieldPanel` owns +its buffers. Do it as its own cut when the pool is real and the duplication is measurable, +not speculatively. + +## 7. Defects in the supplied code + +Confirmed by compiling and running, not by reading. D1–D4 are fatal to compilation; D17 is +fatal to the interceptor design specifically. + +| # | Where | Finding | +| --- | --- | --- | +| D1 | `files/TilingBuilder.cs:214` | CS0136: `dist` shadows the `dist` at `:225`. Does not compile. **(probe)** | +| D2 | `files/ShieldPanel.cs:410,415,420` | `RenderParams.shaderPass` does not exist in 6000.3.24f1. **(probe)** | +| D3 | `files/TilingBuilder.cs:28` vs `:12-27` | `Stride = 4 * 16` but the struct is 15 floats; `SetData` throws. **(probe)** | +| D4 | `files/ShieldCommon.hlsl:18` | `centroid` is reserved in the compute compiler. **(probe)** | +| D5 | `files/ShieldPanel.cs:411-421` | Pass selection does not exist at this API; the three per-pass args buffers cannot work as designed, independent of D2. **(probe)** | +| D6 | `files/ShieldSim.compute:38` | Comment says `_BreakBudget` is a "single element"; the C# allocates two and reads index 1. **A lying comment, not a bug.** | +| D7 | `files/Tilings.cs:302-333` | `VoronoiCells` is O(n² log n). **Quantified in §2.2: 134 ms at r3, 1.03 s at r5, 15.0 s at r10.** Unusable. **(probe)** | +| D8 | `files/Tilings.cs:286-288` | `Vector2.zero` is both the invalid-cell sentinel and a legal centroid. `idx` at `:292,297` is dead. | +| D9 | `files/ShieldSim.compute:226-232` | `u` clamped, `v` not; hard hits pin the field in compression and no tension reflects. The measured collapse in §2.1.8. **(probe)** | +| D10 | `files/ShieldPanel.cs:301,356,359` | `KFracture` runs once per frame but integrates with the substep `dt`; damage accrues ~6× slow. | +| D11 | `files/ShieldSim.compute:326-330` | `_V[e.nbr] +=` is a non-atomic read-modify-write across threads; the cascade is nondeterministic. Fine for an effect; fatal to any test pinning an exact shard count. | +| D12 | `files/ShieldPanel.cs:296-298` | A two-element `uint[]` allocated every frame. **Worse under the pool: this is now N allocations per frame, not one.** Cache it. | +| D13 | `files/ShieldPanel.cs:9` | `[ExecuteAlways]` runs the full sim in every editor scene view and rebuilds on every domain reload. **Incompatible with a pool** — `OnEnable → Rebuild` would run a 1–21 ms tiling build every time `Prototype.Instantiate` wakes an instance (§2.4). Must go; arming becomes explicit. | +| D14 | `files/ShieldPanel.cs:426-440` | `ReleaseAll` does not clear `pending`, so hits queued before a rebuild are injected into the new tiling. **Now a real bug, not a principle:** a recycled pool panel would fire the previous victim's queued hit. | +| D15 | `files/ShieldPanel.shader` Conduit pass | `Warning 172: potentially uninitialized variable (BuildLine)`. Warning only. **(probe)** | +| D16 | `files/Tilings.cs:91-116` | TriHex interstitial triangles are placed by a separate polar construction rather than from the hex corners they share, so welding is not guaranteed. **Superseded by D19 — it is worse than suspected.** | +| **D17** | `files/ShieldSim.compute:160,191,276` | **`growth < 0.6` gates injection, wave and fracture. A panel spawned at the intercept point is at `growth = 0` when struck, so the hit that created it does nothing.** Fatal to the interceptor design. See Cut 5. | +| **D18** | `files/Tilings.cs` Penrose path | **Penrose ignores `panelRadius`** — 460 cells (merged) / 890 (unmerged) at radius 1.5 *and* radius 10, identically. R2 says panel size comes from the shield item, so Penrose cannot express size at all. Hex can. **(probe)** | +| **D19** | `files/Tilings.cs:91-116` | **TriHex produces zero dual edges at every radius** (0 edges at r1.5, 3, 5, 10). No adjacency means no wave, no fracture, no anything. It is inert, not merely suspect. **(probe)** | +| **D20** | `files/ShieldPanel.cs:360` | `_PanelSpawnTime` is hardcoded `0f`, so the materialisation wavefront fires once at sim time zero and never again. Must become the panel's arm time — **this is the one line that turns the dead wavefront into the interceptor's spawn animation.** | +| D21 | `files/TilingBuilder.cs:9,119` | `Limits.MaxVerts = 8`, and polygons with more vertices are **silently dropped** at `:119`. A tiling that quietly loses cells looks like a tiling bug. Log it. | + +Buffer lifetime, `SetCounterValue` and `CopyCount` were checked specifically and are +**correct**: `bVisible` is `Append | Structured` (`files/ShieldPanel.cs:164`), reset at `:366` +before the appending dispatch, copied at `:369` into offset `sizeof(uint)` (= `instanceCount` +in `IndirectDrawArgs`), and every buffer is released by `ReleaseAll` on both `OnDisable` and +the head of `Rebuild`. A probe readback of `argsFill instanceCount=253` against `cells=253` +confirms the counter path end to end. **(probe)** + +## 8. Operator questions + +Most blocking first. + +**Q1 — Does the first landing have to fracture?** (Carried over; still first, now with a +second reason.) §2.1.8 says no energy breaks a cell at the supplied defaults, and D9/D10 bound +what tuning alone can reach. D17 adds that the spawning hit currently injects nothing at all. +**A:** land Cuts 1–4, ship an interceptor that spawns, poses and materialises panels correctly +with a visible stress wave and no fracture, and take Cut 5 as its own pass. **B:** hold +everything until Cut 5 lands too. **Recommended: A**, with one amendment to the previous +pass's reasoning — **D17 must be fixed in A, not deferred to Cut 5**, because without it the +Cut 4 operator look shows an inert patch and proves nothing about the wiring it exists to +prove. Fracture can wait; injection cannot. Wiring is the thing ruled on and it is verifiable +on its own. + +**Q2 — Is the envelope derived from the transform, or authored as explicit fields?** Cut 2 +recommends **derived**, because the `Shield` prefab instance transform already owns the +numbers and also drives the collider, and a second authored copy would be a second truth. +The cost is that the envelope can only ever be a rotated ellipsoid. **A: derived (recommended). +B: explicit centre + radii fields, transform ignored.** B is the operator's literal phrasing +and would be right if the envelope is meant to diverge from the collider — if it is, say so, +because that is a different machine. + +**Q3 — Which tiling?** D18 and D19 narrow this hard. **Penrose cannot express panel size** +(identical cell count at every radius) and R2 requires size to come from the item. **TriHex is +inert** (zero adjacency). **Voronoi is 15 s at r10 and 1 s at r5.** That leaves **Hex** as the +only kind that is both correct and sized. **Recommended: Hex, and treat D18 as a real defect +to fix later if Penrose's look is wanted** — Penrose is the better-looking tiling and losing it +is a genuine cost, but it cannot ship as the interceptor's tiling until it respects +`panelRadius`. + +**Q4 — Which fracture pattern per damage type, and where authored?** Six of each, no natural +ordering. Proposed: Kinetic→Radial, Optical→Lance, Electric→Star, Thermal→Crystal, +Corrosive→Fizzle, Ionizing→Spiral. **A:** a serialized `FracturePattern[]` on the interceptor +indexed by `(int)DamageType`. **B:** a switch in code. **C:** item data. **Recommended: A** — +presentation choice belongs with the presentation and retunes without a compile. C is wrong +today: the absorb capability owns no data yet (`CapabilityEvents.cs:17-20` parks it explicitly). + +**Q5 — How is the scene edited?** **A:** one-shot editor script run in batchmode, deleted in +the same commit. **B:** hand-edited YAML. **C:** the operator clicks it together. +**Ruled C (operator, 2026-09-18): "screw it, I'll do any editor shenanigans we need for +now."** Cut 4 therefore ships no editor script and no YAML edit: it ships an exact rig recipe +for the operator (object names, parents, components, transform values, which asset goes in +which field) plus a batchmode check that reads the saved scene back and refuses a rig that is +wrong. Agent-driven editor commands through Brokkr are parked +(`F:\Projects\Brokkr\docsgent-access-cut.md`). + +Superseded reasoning: **Recommended: A.** B is the failure mode that produced `1428ada3`. If the operator would +rather keep the script as a menu item, say so — that makes it a standing second authority over +the rig and should be deliberate, not leftover. + +**Q6 — Per-pass draw toggles?** `drawFill`/`drawOutline`/`drawConduits` die with the +single-call draw (§2.1.5). **A: gone. B: back as shader uniforms zeroing each pass's alpha.** +**Recommended: A**, until someone wants them. No consumer today. + +**Q7 — Buy NUnit coverage with an asmdef?** `Tests.asmdef` references only +`UnityEngine.TestRunner`, `UnityEditor.TestRunner` and `Aetheria.Shared.Unity`, with +`autoReferenced: false`, so it cannot see `Assembly-CSharp`. **A:** no asmdef; pin the rules +with batchmode probes. **B:** add `Aetheria.ShieldField.asmdef`. **Recommended: A**, but +weaker than last pass: `ShieldEnvelope` (Cut 2) is pure testable math and is exactly the kind +of thing an asmdef would buy real coverage for. If the operator wants B, **scope it to +`ShieldEnvelope` alone**, not to the six sources — the envelope is the piece whose arithmetic +has a wrong answer that looks plausible (sphere vs ellipsoid normal), and it is three lines of +reference edge rather than a compile boundary across 5.4k lines. + +## 9. Superseded: the flat-panel design + +Kept because it explains why several defects are recorded the way they are, and because the +shape itself is still wanted for a different item (Cut 4's note). + +The committed pass at `1fdf8151` designed **one permanent flat disc** parented near the hull: +a `ShieldPanel` living in `FieldShieldTest` from scene load, materialising once at sim time +zero, with a `ShieldPanelPresenter` converting each `AbsorbEvent` into one `Hit` and doing +nothing else. It had no pool, no lifecycle, no envelope, and no pose problem — the panel's +transform was authored in the scene and never moved. + +R2 retired it. What changed and why it matters: + +- **No pool → a pool.** Panels are per-interception, so `Prototype` enters the map and + `[ExecuteAlways]` / `OnEnable → Rebuild` (D13) goes from "wasteful" to "incompatible", and + D12/D14 go from "principle" to "real bug under recycling". +- **Fixed pose → derived pose.** The panel's normal now comes from the envelope, which is what + forced the discovery that two implicit ellipsoids already exist (§1) and that + `normalize(p)` is the wrong normal for a 4:1 ellipsoid (Cut 2). +- **Materialise once → materialise per interception.** `_PanelSpawnTime` goes from an ignored + constant to the mechanism (D20), and the wavefront bake turns out to already be correct. +- **No reuse rule → a reuse rule.** Without it the temper model is unobservable (Cut 3). +- **Q2 "replace or alongside" is answered alongside (R1)**, so `FieldDriver.Absorb` + (`FieldDriver.cs:158`) and its hit-buffer machinery (`:76,105-132,204-217`) are **not** + deleted. The old map's recommendation to delete them later is withdrawn. + +Its geometry numbers were also simply wrong: it placed the panel against a field scaled +`(10, 10, 20)`, and the scene says `(4, 3, 12)` (`FieldShieldTest.unity:948-959`). + +## 10. Subtraction ledger + +| Cut | Removed | Added | Deps/targets | +| --- | --- | --- | --- | +| 1 | `ShieldPanelTester` (~38 lines), 2 args buffers, 3 draw bools, 2 draw calls, `[ExecuteAlways]` | 6 files (~2.1k lines) | none; no package, no pipeline, no asmdef | +| 2 | 2 hand-rolled ellipsoid projections (`FieldDriver.cs:125`, `ShieldManager.cs:76`); `FieldDriver.transform.localScale` demoted from envelope owner to mesh geometry | 1 file (~40 lines), 3 component instances | none | +| 3 | — (**no pool written**: `Assets/Scripts/Prototype.cs` is reused) | 1 file (~120 lines) | none | +| 4 | the one-shot editor script, same commit | 1 prefab, 2 component instances | none | +| 5 | — | ~10 lines changed | none | + +Net: additive, and it should be. The capability bought is a simulation that exists nowhere in +the tree. The honest subtractions are Cut 2's — two duplicate projections collapsed into one +owner, which is a real reduction in authority count and the only part of this map that makes +the *existing* machine simpler — and Cut 3's refusal to write a pool that already exists. +Cut 1's line count is the liability; it is bought with an explicitly requested capability and +nothing currently in the repo can be deleted in exchange for it. The `FieldDriver` absorb +visual is **not** available as a subtraction: R1 rules it stays. diff --git a/docs/shield-presentation-contract.md b/docs/shield-presentation-contract.md new file mode 100644 index 000000000..7a784659c --- /dev/null +++ b/docs/shield-presentation-contract.md @@ -0,0 +1,83 @@ +# Shield and Pickup Presentation Contract + +Date: 2026-09-18 + +Status: target, operator direction only. Nothing here is built or scheduled. The +panel interceptor's means live in `docs/shield-panel-cut.md`; the Lariat has no +map. This document owns the contract both answer to. + +## Why + +Aetheria is growing more than one presentation of the same capabilities: the +existing field shield (`FieldDriver`), the hard-light panel interceptor, and the +Lariat. They must be interchangeable without the simulation knowing which one is +equipped. + +## Rulings (operator, 2026-09-18) + +- **Every shield effect is a drop-in behavioural replacement.** A presentation + takes capability events, presents them, and decides nothing. +- **Coverage is expressed through the ship's envelope**, not through an + effect's own geometry. The envelope is derived from the existing per-ship + shield transform, not authored twice (`docs/shield-panel-cut.md` R6). +- **Presentation may anticipate.** Operator: "anticipatory is great, and we'll + want it for other one too". A presentation may begin reacting before a shot + lands, performing an outcome the simulation has already committed (below). +- **One presentation may serve several capabilities.** Operator: "the Lariat + would be a presentation for both pickup and shielding capabilities." +- **The hits a presentation shows are cosmetic.** Fire control decides what + happened; the intercept point is where the presentation says the shot arrived + (`docs/shield-panel-cut.md` R6). + +## The commit window, which is where anticipation comes from + +Operator, 2026-09-18: "we get anticipation for free when we move to roll-based combat, where +we fudge the presentation to show whatever the simulation rolled", and, on how that squares +with resolution on arrival, "That hybrid is the way". + +So a shot still **resolves on arrival**, and evasion still matters during flight +(`docs/three-gates-scope.md`), but its outcome is **committed a short fixed time before +impact**. That commit is the anticipation feed, and it changes the contract: + +- **A committed outcome is authoritative, not speculative.** A presentation that acts on it + is performing a decision the simulation has already made. The earlier framing of + anticipation as a guess that may be wrong is superseded: presentations do not predict. +- **Evasion counts until the commit.** Deviation from the predicted intercept feeds the roll + right up to the commit horizon; after it, the result stands and the remaining flight is + choreography. +- **The horizon is one authored number**, long enough for a whip to snap or a panel to + materialise, short enough that late evasion still covers most of the flight. It belongs + with the other fire-control settings, not in an effect. +- **A commit carries what a presentation must perform:** what happens (hit, absorbed, miss), + where, when it arrives, and which capability answers it. Not who fired, not the damage + numbers. +- **A miss is choreography too.** The presentation may show a near-miss, or a whip lunging + and failing, because the roll said miss — a deliberate performance, not an error. +- **Nothing outside presentation reads a commit to change state.** Damage, pickups and + everything else still happen through the capability events on arrival. + +## A presentation serving several capabilities + +The Lariat answers absorb and grab with one object, so it owns an arbitration +the single-capability presenters never needed: + +- **It decides what it is currently doing** — idle, snapping, coiling, reeling + — and what interrupts what. That decision is presentation-local. +- **It may not delay or deny the simulation.** The pickup completed and the + damage was absorbed when the simulation says so, whether or not the whip has + finished reeling. A presentation that is still animating is a presentation + that is behind, never a gameplay state. +- **It may refuse to show something** (a second absorb mid-coil), and that + refusal is cosmetic. + +## Open + +- Where the commit window lives (fire control's owner), its horizon value, and what + presentations read before fire control exists: there is no commit to read yet, so the + panel's first landing reacts on arrival and looks a beat late by construction. +- Whether the Lariat is one item carrying both capabilities, or a presentation + shared by two items. The item data has to say which. +- The Lariat's strand: a node chain with distance and bending constraints, drawn + as a tube, with the snap as a travelling kink. `FieldDriver`'s bezier tendril + (extend, envelop, pull, damped sliding base) is the existing half, and it + cannot leave the field surface. diff --git a/docs/ship-collection-authoring.md b/docs/ship-collection-authoring.md new file mode 100644 index 000000000..b4e85f7a1 --- /dev/null +++ b/docs/ship-collection-authoring.md @@ -0,0 +1,83 @@ +# Ship collection authoring + +Status: authoring contract for new ships. Longinus and Djinni predate this contract and keep their current prefabs. + +## Objective and ownership + +One Blender collection exports one FBX model. Its hierarchy names the ship's presentation anchors. Unity builds +`Assets/Content/Prefabs/Ships/.prefab` from that model and assigns the components and references that the +runtime currently requires on a hand-built ship prefab. `GameData/Aetheria.cc` remains the owner of `HullData`, +hardpoint cells and types, gameplay stats, products, and the prefab's Addressables key. The model never decides a +hit, thruster force, weapon arc, or shield capacity. + +Today `ShipInstance` reads manually filled arrays on each ship prefab. It matches their transform names against +`HullData.Hardpoints[].Transform`. Its shared ping, invisible material, explosion, shield, and tractor references +also live on each prefab. The builder derives the arrays from the FBX hierarchy and supplies the shared Unity +assets once, from their existing project paths. The old prefabs are left alone. + +## Blender collection and export + +- Name the collection `Ship.` and configure its collection exporter as FBX. Export to + `Assets/Models/Ships/.fbx`. The filename is the generated prefab name. Blender's collection exporter can + export a collection directly and repeat that export after edits. +- Export meshes and empties, with animation disabled. Use Blender's `-Z Forward`, `Y Up` axis conversion for Unity. + Keep the collection origin at the ship's simulation centre, with the nose pointing toward the exported +Z axis. + Apply object scales on visual and collision meshes; the `Shield` empty's scale is the deliberate exception. +- The importer owns every prefab under `Assets/Content/Prefabs/Ships/` whose matching model is in + `Assets/Models/Ships/`. Editing generated components by hand is temporary; the next export rebuilds them. + Put appearance in the FBX and gameplay values in the catalog. + +The exported hierarchy uses **exact, case-sensitive names**. Names beginning `HP.`, `Pivot.`, or `SHIP.` are +reserved. IDs after a role prefix must be unique across the model. Other visual objects may have any names. + +```text + FBX root + Body visible meshes, any nesting + SHIP.MapIcon one mesh; assigned the shared map material and Minimap layer + SHIP.HullCollider one low-poly closed convex mesh; rendered invisible, given a MeshCollider + SHIP.Shield one empty; its position and scale set the shield envelope + SHIP.TractorBeam one empty; its pose sets the tractor effect origin + HP.Thruster.PortAft empty; exact name copied to HullData.Hardpoints[].Transform + Emitter one mesh; thruster particle emission shape + HP.Weapon.PortGun empty; exact name copied to the catalog + Muzzle.0 empty; +Z is firing direction + Muzzle.1 optional additional barrel + HP.Radiator.Port empty; exact name copied to the catalog + RadiatorMesh one mesh; temperature emission surface + HP.Equipment.Reactor empty; exact name copied to the catalog + Pivot.0.-90.90.-80.80.60 optional empty parent of articulated mounts +``` + +`HP.Equipment` covers sensors, reactor, shield, cargo, and other mounts without a special visual component. +`HP.Weapon` covers ballistic, energy, and launcher mounts; the catalog chooses which. Every `HP.*` becomes an +`EquipmentHardpoints` entry. Thruster, weapon, and radiator roles also get their matching components and arrays. +`Muzzle.` children are sorted numerically; their transforms become `FiringPoint`. A weapon +mount requires at least one. A `Pivot` name carries `Group.YawMin.YawMax.PitchMin.PitchMax.Speed` as signed +integers in degrees and degrees per second. It gets `ArticulationPoint`; child mounts follow its transform. Pivots are +presentation only: the simulation's catalog firing arc remains authoritative. + +`SHIP.Shield` receives the existing Shield prefab as a child. That prefab's effective unscaled radius is one +Unity unit; the marker's three scale components are the intended envelope radii in world units. The builder adds +`ShieldEnvelope` to the Shield prefab root if absent. `SHIP.TractorBeam` receives the existing Tractor Beam +prefab. The four `SHIP.*` markers are required exactly once. Ship-specific mesh materials remain authored in +Blender/Unity's material import mapping; the builder only assigns the shared map-icon material. + +## Build, validation, and failure + +Re-exporting an FBX in `Assets/Models/Ships/` schedules a prefab rebuild after Unity finishes importing it. +`Aetheria/Build Ship Prefab From Selected Model` performs the same build on demand. The builder validates all +reserved names, required anchors, component meshes, muzzle numbering, and shared asset references **before** +replacing a prefab. A bad export reports an error and leaves the last valid prefab in place. The output is a +Prefab Variant of the FBX model, so mesh and transform identities remain tied to the source export. + +After adding a ship, author its `HullData` and product in the catalog, set `HullData.Prefab` to the generated +prefab's GUID, and set every `HardpointData.Transform` to an exact `HP.*` name. Run the catalog asset check and +the Unity compile. A visual play check still owns size, materials, shield fit, barrel orientation, and thrust +appearance. The builder cannot infer the 2D schematic from a 3D mesh. + +## Cut and build budget + +This adds an Editor-only builder, its focused smoke, and this contract. It adds no runtime component, persistent field, package, +service, or build target. It replaces per-ship array and shared-reference authoring for newly exported ships. +The focused verification target is Unity Editor script compilation plus a synthetic named-hierarchy check; a +real Blender collection export is required before claiming end-to-end import fidelity. diff --git a/docs/stats-and-power-cut.md b/docs/stats-and-power-cut.md new file mode 100644 index 000000000..9cd57c085 --- /dev/null +++ b/docs/stats-and-power-cut.md @@ -0,0 +1,1047 @@ +# Stats and Power: Cut Map + +Date: 2026-09-18 + +Status: Imagination pass, cut map. Nothing here has landed. Ends are owned by +`docs/stats-and-power-target.md`; this document owns the means. + +Anchors are against `codex/item-provenance` HEAD `acb2fef9`. Claims marked +**(probe)** were measured headless against the real catalog `GameData/Aetheria.cc` +(read-only) with a scratch console referencing `Aetheria.Shared`, CultLib a temporary +detached worktree at `45c2f40`, built with `--artifacts-path` in scratch so this tree +got no `bin`/`obj` writes. Both are removed. The probe source is not kept; §1.6 records +what it did. + +**Scope: stats and power only.** Fire control is the next campaign and gets its own map. +§8 names the seam and what fire control will need from this authority. + +--- + +## Rulings (operator, 2026-09-18) + +- **Q1: stats and power land before fire control.** "Stats before". Fire control is the next + campaign and maps against the authority this builds (§8). +- **New, R-heat: the heat performance curve is replaced by a range with falloffs.** Operator: + "Can we reuse most of the calculation and simultaneously simplify heat to remove the curve + calculation? I never ended up authoring those curves much even before the breach, we'd be + fine with a range and falloffs, which reduces to range checks and a lerp." + - `ItemData.HeatPerformanceCurve` (`ItemData.cs:384`, a `BezierCurve`) goes. **The authored + shape is minimum, maximum, optimum and plateau width** (operator, 2026-09-18: "Same shape, + more intuitive controls"): where the part likes to be, how forgiving it is, and where it + dies. Performance is 1 across the plateau and falls linearly to 0 at each bound, so the + two sides are asymmetric whenever the optimum is off-centre — which is how most gear + behaves, and how negent gear inverts. + - **`OptimalTemperature` becomes authored, not derived.** `:409-430` scans 100 samples of a + bezier to find it and caches the result; wear reads that inferred number + (`Entity.cs:1386`). The scan, the cache and `_optimum` all die, and the value wear reads + is one somebody typed. + - Validation (load and Studio save, with the campaign's other checks): the optimum lies + between the bounds, the width is not negative, and the plateau clamps to the bounds + rather than poking past them. Fails loudly naming the item. + - `Performance` becomes comparisons and a lerp. + - `BezierCurve` itself stays: `Sensor.SensitivityCurve` still uses it. + - **Migration is a fit, not a guess.** Each authored curve is sampled: the optimum is where + it peaks, the width is how far it holds near that peak, and the bounds are already + authored. The rewrite + reports a per-item table (old optimum and width against new plateau and bounds) for the + operator to review, and it rides with Cut 1's catalog rewrite rather than being a second + migration. Pre-breach history is not consulted for curves: the operator has ruled the + shape is going regardless. + - **The balance change is intentional** (operator, 2026-09-18): "currently it's basically + impossible to run many items without damaging them due to low thermal performance. + Controlled wear is good, but we need a better lever for operational lifespan when an item + is managed well." Under R-heat the plateau means full performance, so the thermal term of + `Wear` (`Entity.cs:1386`) goes to zero inside it and only `deltaTemp` still costs + anything. **Plateau width is therefore the operational-lifespan lever**: it is the band + where careful operation is free, authored per item instead of emerging from a curve + nobody drew. A negent weapon still pays, because it is nothing but swings. + - Verification must show it: an item held inside its plateau takes no thermal wear, an item + outside it does, and a fast swing across the plateau still wears. Each under its own + mutation. + - Wear reads `OptimalTemperature` (`Entity.cs:1386`), so the fit changes wear too. The + verification must show wear unchanged for an item at its optimum and a sane curve + elsewhere. +- **Q4: the input capacitor is the charging one**, and it is derived from `Energy` and + `Cooldown` with an authored override. Operator: "Do you mean the charging capacitor that + we're putting in to convert discrete power draw to continuous? Derived from energy and + cooldown sounds convenient." Burst capacity and sustained rate stay separate levers. +- **Q5: four or five tiers, defaulted per behaviour kind.** +- **Q2, Q6, Q7: Self's defaults**, stated and not objected to — modifiers keep the name pair + resolved at load, the trade menu shows the evaluated value and sorts by the ceiling, and + negent needs nothing beyond `ConsumableProgress`. +- **Q3 stands as recommended:** accept per-tick recomputation; do not quantise heat. It is + the recommendation most likely to be wrong (§9), on a three-hull sample. +- **Products author quality, designs author roles.** `ProductRole` already carries a mean and + a standard deviation per role (`FactionProduct.cs:37-58`) — the manufacturer's technology + and their quality control — so a market segment is a second product with one role raised. + Zero designs declare roles today, so Cut 7 is what makes any of that reachable. + +> **Cut 0 landed 2026-09-18** (`e9b688de`, tests `67217ed9`). One evaluation path +> (`PerformanceStat.Evaluate(IStatContext)`) with three contexts; the three arithmetic bodies +> are gone. Equivalence over the real catalog: 3123/3123 unequipped values identical, max +> delta 0. 64 tests, batchmode clean, the three shield probes still pass. +> +> Corrections to this map, from the Body: +> - The Cut 0 negative grep contradicted the map's own keeps. Four `Evaluate(PerformanceStat` +> sites are legitimate wrappers, and `AetherDrive.cs:105` calls `Item.Evaluate` directly, +> which the map did not mention. +> - **12 of 30 heat-bearing catalog stats disagree between the unequipped and at-optimum +> paths, by up to ~100 units**, because a real `HeatPerformanceCurve` rarely reaches 1.0 at +> its own reported optimum. That is R-heat's problem: the fit must decide whether the +> plateau means full performance (it does) and therefore that these items get slightly +> stronger at their optimum. Report that per-item in the migration table. +> - `ItemManager.CreateInstance` has no `ConsumableItemData` branch, so it falls through to +> `CompoundCommodity` and a `ConsumableItem` is apparently never minted by the normal path. +> Look before Cut 1 touches consumables. +> - The cut is net +55, not the ledger's ~+1: the interface and three contexts cost more than +> the 44 deleted lines of arithmetic. Accepted, recorded rather than argued away. + +> **Cut 1 landed 2026-09-18** (`05b5a209` schema+code, `38aec5dd` catalog+migration table). +> Soul verified the rewrite lost nothing: 1,181,195 common leaf paths, 0 changed values; term +> migration exact against the census; tombstoned keys absent from the bytes and the old reader +> fails loudly; keys 30/31 collide with no subclass. 71 tests. +> +> Soul's findings, and what they cost: +> - **S1 `Tractor Beam` is permanently dead** (`ItemData.cs:422`): min == max == optimum, width +> 0, so performance is 0 at every temperature and every heat-dependent stat collapses to +> `Min`. Validation accepted it. +> - **S2, 14 designs went from immune to fatal.** With no authored curve the old code returned +> 1 at any temperature, below the minimum included; the new code returns 0 outside the +> bounds, so a cold `Autocannon` is offline and wearing. Nobody decided this and the +> migration doc claims the opposite. +> - **S3 the migration table's old-value columns are wrong for 15 of 51 rows** (the old getter +> returned `MinimumTemperature` for curve-less designs; one design's old code threw), and +> **S4 the at-optimum slice understates the change ~80x** — at-optimum is where a trapezoid +> and its curve agree. Real band: 36 of 37 fitted designs up to +0.19, `Earp` down. No +> qualitative inversion. +> - **S5/S6 validation is weaker than the ruling and than its own comment**: no plateau-clamp +> check, zero-span and NaN accepted, and `AetheriaStores.Open` validates on read, so the +> tools' writable path writes invalid catalogs (the migration relied on that). +> - **S7 four of Soul's five mutations survived**, including a `Quality` term ignoring its own +> exponent — the migration's central equivalence claim is unpinned. +> - **S8 the shield Cut 3 probe passes at HEAD**, twice. Hands' `KInit` failure did not +> reproduce; Cut 1 touched no shader. +> +> **Ruling (operator, 2026-09-18): re-author those items.** "reauthor those items". Bounds +> mean the part is dead outside them, which is the point of the change; the 14 are unauthored +> placeholders, not deliberate immunity. Zero-span ranges become an authoring error validation +> refuses. Correction to R-heat: **wear does not read `OptimalTemperature`** — `UpdatePerformance` +> reads `Data.Performance(temp)`, and the property had no reader at all before Cut 1. S3 is a +> reporting defect, not a gameplay one. +> +> **Findings closed 2026-09-18 (Hands, this pass):** +> - **S1/S2 re-authored.** `Tractor Beam` (newly authored bounds 200-400, matching the small +> Sensors/gear family already using that exact range) and the 13 other curve-less designs got +> real optimum/plateau values, drawn from the catalog's own house style: every already-fitted +> design shares one Bezier template (peak at 25.5% of the range, plateau 12% wide), so each +> unauthored item gets that fraction over its own bounds, or mirrors the exact-bounds sibling +> that already carries it. Table, full reasoning and per-item "why": +> `docs/stats-power-cut1-migration.md`. **Beyond the named 15**: enforcing S5/S6's plateau-clamp +> rule broke the catalog for two more already-*fitted* designs whose plateau poked past a bound +> — `Earp` (a real curve peaking exactly at its own Minimum, the same "dead at every +> temperature" bug as S1) got the same re-authoring, and `The Bat` (a genuinely distinct fitted +> optimum, kept) had only its `PlateauWidth` shrunk to fit. Real fork, not silently patched +> around: the catalog cannot open at all without a decision here. Recorded, not asked, because +> the fix was mechanical once the rule was enforced — flag if that call should have gone to +> the operator instead. +> - **S5/S6 closed.** `StatValidation.ValidateHeatResponse` now refuses NaN in any of the four +> heat-response fields, a zero-span range (`MinimumTemperature == MaximumTemperature`), and a +> plateau whose low or high edge pokes past its bounds — the exact rule the ruling names and the +> Performance() comment already claimed. The writable-path hole is closed for every path this +> repository owns: `CultRecordRefs.Upsert` (`AetheriaStores.cs`), the one function every tool, +> test and migration script writes catalog documents through, now validates before the write +> reaches disk. The one hole left, named in the code: CultCache Studio's generic document editor +> bypasses `Upsert` and exposes no per-document validation hook to attach to yet — that gap is +> CultLib's, not this repo's, and stays named rather than silently assumed closed. Giving +> `EquippableItemData`'s four heat fields non-degenerate default values (0/100/50/20, the same +> shape `HeatResponseTests` already hand-authored) was necessary alongside this: dozens of +> existing test fixtures across the suite construct designs that never cared about heat and +> defaulted to the same zero-span trap Tractor Beam shipped with; without a sane default, closing +> S5/S6 correctly would have required touching every one of those fixtures instead of the root +> cause. +> - **S7 pinned.** Four new mutations, each with its own dedicated test (not a coincidentally- +> sensitive existing one): out-of-bounds semantics (`return 1f` instead of `0f` outside bounds, +> `HeatResponseTests.PerformanceIsZeroAtAndBeyondEachBound`); the plateau-clamp validation's two +> sides, low and high (`HeatResponseTests.UpsertRefusesAPlateauThatPokesPastItsBounds`); and a +> `Quality` term ignoring its own exponent (`LoadoutTests.QualityTermAppliesItsOwnExponent`, +> `Exponent = 2` where the prior suite's `Exponent = 1` everywhere could not distinguish "reads +> the term's exponent" from "always uses 1"). **A fifth, separate finding**: `Performance()`'s +> own runtime plateau clamp (`max`/`min` around `OptimalTemperature ± halfPlateau`) is provably +> dead code, not merely uncovered — exhaustively verified (a dense numeric sweep well outside +> both bounds, 0 mismatches) that the outer `<= Minimum || >= Maximum` guard makes the inner +> clamp unobservable at every input, validated or not. Recorded as unreachable, not worked +> around with a forced test; the comment at `ItemData.cs` already says so ("redundant, not +> load-bearing"). +> - **S8 corrected.** No comment or note in this repository claims the shield Cut 3 probe is a +> pre-existing environmental regression; the doc entry above already states, correctly, that it +> did not reproduce. Re-verified clean at HEAD (batchmode, this pass). +> - **Zero `ConsumableItemData` records** in the catalog (Cut 0's question): nothing to touch. +> +> 9 new tests (80 total), 17 mutation cases in `tests/mutation_tests_stats_power_cut1.py` (all +> behave as declared), catalog re-authored in place (`GameData/Aetheria.cc`, 16 records changed, +> byte length unchanged), `AetherDb` census/factions/dangling/station-fit/hardpoint-fit/loadout-1 +> byte-identical before/after re-authoring. + +> **Ruling (operator, 2026-09-19), shield reserve.** "Shield capacitor energy should be an +> authored number, and its charge rate should be two numbers, one duration for refilling from +> partial and another for restoring a broken shield (games do this often, I guess for the punish +> window?)" — yes, that is the reason: a shield that drops has to cost time or breaking it means +> nothing. Supersedes Cut 4's derived sizing (Hands sized the reserve at `EnergyUsage`, refilling +> every second, and flagged it as a guess). +> - `ShieldData` authors a capacity (it has only `Efficiency` and `EnergyUsage` today), a refill +> duration and a restore duration. +> - **A broken state, which the shield does not have.** Today it refuses a hit it cannot fully +> cover and keeps its remaining charge. Under this ruling that hit breaks the shield: it +> absorbs nothing while down and comes back on the restore duration, not the refill one. +> - The two durations are the lever: refill is how fast it tops up while holding, restore is how +> long you are naked. + +> **Ruling (operator, 2026-09-19): power supply multiplies, it does not interpolate.** +> "You're right, it really should be a multiplier". Every other declared term (heat, durability, +> quality) interpolates between a stat's `Min` and `Max`, which is right for them: a worn laser is +> weaker, not absent. Power is the exception — no power means no output — so `StatSource.PowerSupply` +> applies as a multiplier on the resolved value, after interpolation, with its authored exponent. +> Consequences: zero supply resolves to zero, and a stat whose `Min` equals its `Max` still responds +> (three of Cut 7's eight authored terms are inert today for exactly that reason). +> +> Found by Soul over the power cuts, 2026-09-19, with seven other confirmed defects: the bus bills +> inactive consumers and is funded by inactive reactors (F2), an empty tier reports fully supplied +> and sub-epsilon demand is fed from nothing (F3), a shield broken by a hit that also cooks it never +> unbreaks because unbreaking sits behind `Active` (F4), `CanTakeHit` mutates and six weapon files +> call it twice per shot (F5), request-independence validates only each consumer's top-level field +> so six shipped requests now depend on power (F6), the migration tools write around the validating +> path (F7), and the Cut 3 and Cut 4 mutation harnesses no longer run because later cuts moved their +> anchors (F8). + +## 0. What is there now + +### 0.1 The stat, as it exists + +`PerformanceStat` (`ItemData.cs:575-622`) is a `[MessagePackObject]` **value**, not a +document. It has no `[CultDocument]`; every instance on disk lives nested two levels +inside a catalog record (`ItemData` → `BehaviorData` union member → the stat field). +Keys: `0 Min`, `1 Max`, `2 HeatExponentMultiplier`, `3 DurabilityExponentMultiplier`, +`4 QualityExponent`, `5 FromRole`. + +**(probe)** The catalog holds **348 reachable `PerformanceStat` instances** across 180 +documents: + +| | count | +|---|---| +| `HeatExponentMultiplier != 0` | 275 | +| `DurabilityExponentMultiplier != 0` | 145 | +| `QualityExponent != 0` | 193 | +| `FromRole` set | **0** | +| `Min != Max` (actually authored, not a stub) | 188 | + +By owner: `AutoWeaponData` 91, `ChargedWeaponData` 64, `GuidedWeaponData` 45, +`LauncherData` 40, `InstantWeaponData` 26, `RadiatorData` 25, `ReactorData` 20, +`SensorData` 12, `ThrusterData` 8, `AetherDriveData` 6, `EnergyDrawData` 3, +`HeatData`/`CapacitorData`/`ReflectorData` 2 each, `StatModifierData` 1, `GearData` 1 +(an `AudioStat`). + +**(probe)** Roles: 102 `CraftedItemData` designs, 51 equippable, 37 products. **0 +designs author roles; 0 products author role spreads.** `FromRole` therefore does +nothing today in the shipped catalog — `Lot.QualityForRole` (`Provenance.cs:73-79`) +falls back to `Lot.Quality` for every read. + +### 0.2 Three evaluation bodies that disagree + +| Site | Terms applied | Modifiers | +|---|---|---| +| `ItemManager.cs:75-90` `Evaluate(stat, item)` — **unequipped** | quality × durability | no | +| `Entity.cs:1177-1186` `ConsumableItemEffect.Evaluate` | quality × **consumable effectiveness** | no | +| `Entity.cs:1354-1371` `EquippedItem.Evaluate` | quality × durability × **heat** | yes | + +Three authorities for one number. The unequipped path in the trade menu and the +equipped path in the properties panel show different values for the same item on +purpose in no design document; it falls out of which function the caller reached for. + +### 0.3 The catalog object holds per-entity runtime state, and it leaks + +`PerformanceStat` carries two `[IgnoreMember]` dictionaries (`ItemData.cs:596-622`): + +``` +Dictionary> _scaleModifiers; +Dictionary> _constantModifiers; +``` + +`EquippedItem.Evaluate` calls `stat.GetScaleModifiers(Entity)` and +`GetConstantModifiers(Entity)` on **every evaluation** (`Entity.cs:1361,1365`), and both +accessors insert an entry for the entity when missing (`ItemData.cs:607-621`). +`StatModifier.RemoveModifier` (`StatModifier.cs:100-103`) removes only the inner +`[this]` key. **Nothing ever removes the outer `Entity` key.** + +So a shared catalog document holds a strong reference to every `Entity` that has ever +evaluated any stat, for the life of the process. Every destroyed ship, every zone left +behind, stays reachable from `Aetheria.cc`'s in-memory records. This is the target +doc's "authored catalog data that also stores runtime modifier state per entity" stated +as a defect rather than a smell. It is the strongest reason for the resolver, stronger +than the performance argument. + +Not measured: whether the leak is visible in a real session's memory profile. That is +an operator check (§7, O1), not a claim this map makes. + +### 0.4 Evaluation cost, measured, and why it is not the motive + +`Weapon.Execute` calls `UpdateStats()` unconditionally every tick (`Weapon.cs:127-131`), +so an **idle** weapon still evaluates 13 stats per tick (10 in `Weapon.UpdateStats` +`:113-125`, 3 in `InstantWeapon.UpdateStats` `:107-117`); a `ChargedWeapon` 16 +(`ChargedWeapon.cs:111-123`). + +**(probe)** Real generated loadouts (`LoadoutGenerator`, seed 1, the three hulls that +generate at HEAD): + +| hull | items | behaviors | evaluations/tick | +|---|---|---|---| +| `LonginusX` (Ship) | 14 | 24 | **78** | +| `Turret` | 9 | 15 | 20 | +| `Zenith` (Station) | 8 | 10 | 17 | + +78/tick is 4,680/s at 60 fps for one ship; a zone of 7 entities at the median is +~8,400/s. Each evaluation is three `pow` calls plus two nested dictionary lookups and +two dictionary iterations. That is around a millisecond per second per ship. **Caching +buys almost nothing here.** Say so plainly rather than selling the resolver on speed: +the resolver is bought by ownership, by the leak, and by making a power-supply term +cheap enough that adding it is not a regression. The operator's "we don't wanna pay +that cost for every stat evaluation" is satisfied because the power-supply source +changes at most once per tick, not because 78 became 12. + +### 0.5 The power path, end to end + +**Draw.** Nine call sites, all inside `ServerShared`, none outside it: + +| site | shape | +|---|---| +| `EnergyDraw.cs:46` | continuous or per-activation, `PerSecond` flag | +| `Thruster.cs:77` | continuous, scaled by input axis | +| `AetherDrive.cs:138` | continuous, three axes summed | +| `Radiator.cs:74` | continuous, scaled by temperature ratio | +| `ConstantWeapon.cs:87` | continuous while firing | +| `InstantWeapon.cs:98` | **instant**, per burst | +| `InstantWeapon.cs:178` | **instant**, per shot | +| `Sensor.cs:85` | **instant**, per ping | +| `Shield.cs:59` (+ `:54` `CanConsumeEnergy`) | **instant**, per hit taken | + +**Supply.** `Entity.TryConsumeEnergy` (`Entity.cs:858-888`): drain every charged +capacitor evenly in a `do/while`, then hand any remainder to every online reactor as +`Reactor.ConsumeEnergy` → `Draw +=`. It returns `onlineReactors > 0`. **Every draw +succeeds while one reactor is online**, which is the target doc's first complaint. + +`Reactor.Execute` (`Reactor.cs:85-147`), running last because `Reactor` is the only +`IOrderedBehavior` (`Order => 100`, `Reactor.cs:48`), subtracts its own generation from +the accumulated `Draw`, and: +- deficit → overload heat at `OverloadEfficiency`, then `Draw = 0`. Unmet demand is + never refused, only taxed as heat. +- surplus → fill capacitors evenly, then throttle. + +**The hidden priority the target names.** `EquippedItem.SortPosition` is set from the +**last** `IOrderedBehavior` found on the item (`Entity.cs:1347-1348`) and +`_orderedEquipment = Equipment.OrderBy(SortPosition)` (`Entity.cs:586,810`). Only +`Reactor` sets it, so reactor items sort last and **every other item sorts equal, in +equip order**. Whoever was equipped first drains the capacitors first. That is the +whole existing priority system: the order a player happened to fit their ship. + +Within an item, `BehaviorGroups` run in `Group` key order and the chain breaks on the +first `Execute` returning false (`Entity.cs:1404-1414`), so a failed draw silently +stops everything authored after it in the group. + +**Capacitors.** `Capacitor` (`Capacitor.cs:31-61`) is a bus capacitor: one shared pool, +charge added by the reactor's surplus and drained by anyone. There is no input-only +capacitor today. + +### 0.6 What persists, and where + +- **Catalog `Aetheria.cc`, read-only at runtime:** every `PerformanceStat`, every + `BehaviorData`, `CraftedItemData.Roles`, `FactionProductData.Roles`. A schema change + here is a migration with a census, like provenance Cut C. +- **Run store `run.cc`, deleted on death:** `EquippableItem.Durability` + (`ItemInstance.cs:53`), `OverrideShutdown` (`:54`), `Lot` and its `RoleFill`s + (`Provenance.cs:63-88`), `EntityPack.Temperature`/`Armor`/`Settings`/`WeaponGroups` + (`EntitySerializer.cs:179-188`). +- **Runtime-only, never saved:** `Capacitor.Charge`, `Reactor.Draw`/`CurrentLoadRatio`, + every `EquippedItem` derived performance value, the modifier dictionaries. +- **No migration mechanism exists.** `[CultDocument(name, "1")]` is identity only; + CultCache resolves by name and refuses ambiguity (`CultCache.cs:346-356`). The only + compatibility is slot-driven: an extra persisted `[Key(n)]` is ignored (reported in + `LastSchemaMigrationReports.IgnoredExtraSlots`), a missing one is defaulted, and any + type change is a hard refusal. So the only safe moves are **append a new key** or + **tombstone a key by comment and rewrite the catalog in one writable `Commit`**. +- `EquippableItem`'s next free MessagePack key is **12** (0,1 base; 2,9,10 tombstoned; + 7,8,11 live). + +### 0.7 Consumers outside `ServerShared` + +- `UI/Properties Panel/PropertiesPanel.cs:407-419` (equipped, evaluated), + `:553-569` (design, shows `Min`–`Max`), and `UI/Menu/TradeMenu.cs:273-284` (shows and + **sorts on `.Max` only**, no evaluation). Three near-duplicate + `RuntimeInspectable` → `float`/`int`/`PerformanceStat` switches that disagree. +- `PropertiesPanel.cs:400-402` and `:545-550` are the only places + `StatReference.Target`/`.Stat` are shown, as `SplitCamelCase()` strings. +- `UI/HUD/SchematicDisplay.cs:91-92,176-184` is the power HUD: `_reactor.Draw`, + `_capacitors.Sum(Charge)/Sum(Capacity)`. +- `Editor/CultCacheDrawers.cs:17-29` is the `InspectableType` dropdown that writes a + **bare type-name string** and silently resolves an unknown one to "None". It drives + `StatModifier.cs:23` `RequireBehavior` and `StatModifier.cs:136` `StatReference.Target`. +- `Gameplay/ActionGameManager.cs:899` is the only gameplay gate on power: + `GetBehavior() == null` refuses undock. +- Tests: `LoadoutTests.cs:491-533` `StatsReadTheLot`, `:554-571` + `EvaluateDurabilityExponentReadsLotQuality`, `:575-598` + `EquippedItemThermalExponentReadsLotQuality`; `IffAndCombatTests.cs:286-321` builds + `LockWeaponData` stats. `tests/mutation_tests.py:134-196` mutates the stat path. +- `tools/AetherDb` touches **no** stat or power type. Its `census` + (`Program.cs:37-108`) is the before/after invariant a catalog rewrite is verified + against, and `:85-90` is the role-authoring audit. +- There is **no** `CustomPropertyDrawer` for `PerformanceStat`; in CultCache Studio it + falls through to `inspector.DrawDefault`. +- There is **no** headless stat or power probe harness in the tree. + +--- + +## 0b. Identity, lifecycle, authority + +One row per persistent or runtime kind this campaign touches. No cut below is mapped +while a cell here is empty. + +| Kind | What names it | What happens to it over time | Who decides it | +|---|---|---|---| +| **A stat** (`PerformanceStat`) | Its field on a `BehaviorData`/`EquippableItemData`, reached by reflection; on disk, the `[Key(n)]` slot inside its owning catalog record | Authored in the catalog; **immutable at runtime**; one instance shared by every entity equipping that design | The catalog author, through CultCache Studio or a console over `Aetheria.Shared` | +| **A declared term** (`StatTerm`) | Its index in its stat's `Terms` list, plus its `Source` kind (and `Role` name for a quality term) | Authored with the stat; migrated once from the four fixed exponent fields; immutable at runtime | The catalog author. Validation refuses a role the design lacks, and refuses a power-supply term on a stat that decides a power request | +| **A modifier term** | The pair (modifier `Behavior` instance, target stat entry) in the owning entity's resolver | Attaches when the modifier's item is equipped **and** its behaviour executes; detaches on `Dispose` or a tick without execution (`StatModifier.cs:117-124`); never outlives the entity | The `StatModifier` behaviour, writing into the entity's resolver. **Not** the catalog object | +| **A resolver entry** | `(EquippedItem or ConsumableItemEffect, PerformanceStat reference)` | Created at equip / consumable start; invalidated when one of its term sources changes; destroyed at unequip / entity teardown | `StatResolver`, one per `Entity`, the sole owner of every resolved stat value | +| **A bus capacitor** (`Capacitor`) | Its `Behavior` instance on an equipped item | Runtime-only; charge starts at 0 each session and is never persisted; charged only by the bus, drained only by the bus | The `PowerBus`. No behaviour drains it directly | +| **An input capacitor** | Its `Behavior` instance on the instant-activation item that owns it | Runtime-only; filled continuously by the bus up to its rate; spent whole by one activation; never persisted | The bus fills; the owning behaviour spends. Two writers, disjoint signs, named | +| **A power tier** | `EquippableItem.PowerTier` (new run-store key 12) on the *unit*, not the design | A player choice; persists with the run; survives save/Continue; resets only with the run | The player, through the schematic/inventory UI. Nothing else writes it | +| **A power grant** | `(Entity, EquippedItem)` for one tick | Computed once per tick before equipment updates; overwritten next tick; never persisted | The `PowerBus`. Read-only everywhere else, including the HUD | +| **A lot's per-role quality** (`RoleFill`) | `(LotId, role name)` | Minted with the lot and **immutable**; an upgrade mints a *new* lot and repoints the item, which the resolver sees as a quality-source change | `ItemManager.CreateLot` (`ItemManager.cs:110-133`), rolling the product's `ProductRole` spread | +| **A design role** (`ItemRole`) | Its `Name` string on `CraftedItemData.Roles` | Catalog-authored; adding one to a shipped design changes what existing lots fall back on (`QualityForRole` returns `Lot.Quality` for an unfilled role) | The catalog author. Agents generate, the operator reviews | + +Two rows deserve their consequence spelled out. + +- **A stat is shared; a resolved value is not.** That single sentence is the whole + ownership fix. Everything per-entity moves off the catalog object into the resolver. +- **A lot is immutable, so quality is a rare invalidation.** An upgrade is a lot swap, + not a mutation, so the resolver never needs to watch a quality number — only the item's + `Lot` field. + +--- + +## 1. Authority maps + +### 1.1 Stat values + +- **Owner:** `StatResolver`, one per `Entity`, constructed with it. +- **Inputs:** the stat's `Min`/`Max` and declared `Terms`; the current value of each + term's source (heat, durability, quality-for-role, power supply, consumable + progress); the modifier terms attached to that entry. +- **Outputs:** one `float` per `(item, stat)`, read by every behaviour and by the UI. +- **Derived state:** every `Weapon.Damage`-style cached field on a behaviour becomes a + read of the resolver, not an independent computation. +- **Forbidden writers:** `PerformanceStat` may hold no per-entity state at all — the + two dictionaries and their four accessors are deleted, not hidden. `ItemManager` and + `ConsumableItemEffect` stop computing stat values. No behaviour writes into another + behaviour's stat. +- **Shared paths:** equipped items, consumable effects, the properties panel, the trade + menu and any future headless balance harness resolve through the same entry point. The + unequipped case (trade menu, an item on the floor) is the one path with no entity; + it resolves against a null-entity context that has no heat, no power and no modifiers, + and it says so in its own name rather than being a third quiet dialect. +- **Deletion line:** `ItemData.cs:580-594` (the four fixed exponent fields), + `ItemData.cs:596-622` (the dictionaries), `ItemManager.cs:75-90`, `Entity.cs:1177-1186`, + `Entity.cs:1354-1371`, `StatModifier.cs:61-103` (the reflection target search and the + writes into the catalog object) all go before the resolver's first read lands. + +### 1.2 Power + +- **Owner:** `PowerBus`, one per `Entity`, stepped once per tick from `Entity.Update` + **before** `_orderedEquipment` updates. +- **Inputs:** each reactor's resolved generation for the tick; each bus capacitor's + stored charge; each consumer's request, which is a resolved stat multiplied by a + behaviour-supplied scalar in [0,1]; each item's `PowerTier`. +- **Outputs:** a grant ratio per equipped item in [0,1]; the reactor load ratio; the + overload and throttling heat the reactor adds. +- **Derived state:** `EquippedItem.PowerSupply` (the grant ratio) is display-only and + stat-source-only. `Capacitor.Charge` is written only by the bus. `Reactor.Draw` + becomes a **reported** total, not a sink for unmet demand. +- **Forbidden writers:** `Entity.TryConsumeEnergy` and `Entity.CanConsumeEnergy` are + deleted, so no behaviour can spend energy on its own. `Reactor.ConsumeEnergy` is + deleted. `Capacitor.AddCharge` becomes internal to the bus and the owning behaviour. + Behaviour execution order is no longer allowed to decide who gets power — it decides + nothing about power at all. +- **Shared paths:** player firing, AI firing, thrust, radiators, shields and sensor + pings all request through the same path. There is no "the player's weapon is special" + branch; fire control will inherit this (§8). +- **Deletion line:** `Entity.cs:851-888` and `Reactor.cs:80-83,90-141` are cut before + the bus's first grant is computed. + +### 1.3 Priority tiers + +- **Owner:** `PowerBus`'s allocation pass. +- **Inputs:** the per-item tier and the per-item request. +- **Outputs:** the grants. +- **Derived state:** nothing. A tier is a stored player choice, not a derivation. +- **Forbidden writers:** `EquippedItem.SortPosition` must no longer influence who is fed + — and once the bus exists, `IOrderedBehavior` has exactly one remaining job (running + the reactor's own heat accounting last) which the bus subsumes. `IOrderedBehavior`, + `Reactor.Order` and the `OrderBy(SortPosition)` sorts are candidates for deletion in + the same cut; the map assumes they go, and §7 O3 is where the operator confirms + nothing else wanted that ordering. +- **Shared paths:** the tier is set from the schematic UI and read by the bus. The AI + sets tiers through the same field or not at all. +- **Deletion line:** `Entity.cs:1347-1348`, `Entity.cs:586`, `Entity.cs:810`, + `Reactor.cs:48`, `Behaviors.cs:127-130`. + +--- + +## 2. The target shape, named + +Types this campaign creates. Bodies are not transcribed; what matters is what each owns +and what must die under its own mutation. + +**`StatSource`** — an enum: `Heat`, `Durability`, `Quality`, `PowerSupply`, +`ConsumableProgress`. Closed. Adding a member is a schema change with a census. + +**`StatTerm`** — `[MessagePackObject]`: `Key(0) StatSource Source`, `Key(1) float +Exponent`, `Key(2) string Role`. `Role` is meaningful only for `Quality`; validation +refuses it elsewhere rather than ignoring it. + +**`PerformanceStat`** — keeps `Key(0) Min`, `Key(1) Max`; gains `Key(6) List +Terms`; **tombstones keys 2, 3, 4, 5**. The name stays: renaming it would churn 98 call +sites and buy no invariant. + +**`StatResolver`** — per entity. Owns `(item, stat) → value`. Exposes resolve, +invalidate-by-source, attach-modifier, detach-modifier, and dispose. The only type that +may hold a resolved value. + +**`StatContext`** — what a resolver entry reads: the item's heat performance, durability +ratio, lot, power supply and consumable progress. Implemented by `EquippedItem`, by +`ConsumableItemEffect`, and by a bare unequipped context whose heat, power and modifier +sets are empty. This is the seam that makes the three dialects one function. + +**`PowerBus`** — per entity. Owns supply, demand, tiers, grants, and every capacitor +write. + +**`IPowerConsumer`** — implemented by a behaviour that draws: it names *which* +`PerformanceStat` is its request and supplies a scalar in [0,1] for this tick. It does +**not** hand the bus a number it computed itself. That is deliberate: it gives the +validator an exact, static set of request stats, so "a stat deciding a power request may +not depend on power supply" is checkable at catalog load rather than by discipline. A +consumer whose draw is genuinely multi-term (`AetherDrive`, three axes) expresses it as +one stat times a computed scalar. + +**`InputCapacitor`** — a behaviour on an instant-activation item. The bus fills it at a +rate; one activation spends a whole shot's worth or does not fire. Its capacity and rate +default from the item's existing `Energy` and `Cooldown` stats so that no catalog +authoring is required for the first cut (§7 Q4). + +**`StatValidation`** — the loud refusal. Runs at catalog load, at Studio save, and at +equip. Names the items and the stat. + +--- + +## 3. Cuts + +Deletes come first inside each cut, and subtraction cuts are kept separate from +behaviour cuts. + +### Cut 0. One evaluation path + +- **Repo/branch:** `Aetheria` `codex/item-provenance` from `acb2fef9`. Depends on + nothing. +- **First:** capture `tools/AetherDb` `census`, `factions`, `dangling`, `station-fit`, + `loadout 1`, `hardpoint-fit` into scratch, byte-for-byte, same method both sides. + Record the `dotnet test` baseline at HEAD (the headless map recorded 32 passed / 2 + failed at `1e647953`; re-measure, do not assume). +- **Deletes first:** + - `ItemManager.cs:75-90` (16 lines) — the unequipped body. + - `Entity.cs:1177-1186` (10) — the consumable body. + - `Entity.cs:1354-1371` (18) — the equipped body. +- **Adds:** one evaluation function taking a `StatContext`, plus the three context + implementations. `EquippedItem`, `ConsumableItemEffect` and the unequipped case + implement it; each supplies only the sources it has. +- **Per-file changes:** + - `Behaviors.cs:60` `Evaluate(stat) => Item?.Evaluate(stat) ?? Consumable.Evaluate(stat)` + becomes a resolve against the owning context. **The method name `Evaluate` and its + signature stay**, so all 81 behaviour call sites are untouched. This is the one place + in the campaign where preserving a name is the right call: it is a genuine facade, + not a compatibility shim, and it delegates rather than deciding. + - `PropertiesPanel.cs:529` and `:490` both route through the new contexts. + - `TradeMenu.cs:273-284` keeps showing `.Max` **in this cut** — changing what the trade + menu shows is a design change, and it belongs in Cut 8 with the operator's ruling + (§7 Q6). +- **Authority map:** §1.1, minus the resolver and minus the modifier move. This cut + makes one function own the arithmetic; it does not yet move where modifier state + lives. +- **Verification:** + - builds: `dotnet build Aetheria.Shared/Aetheria.Shared.csproj`, + `dotnet build tools/AetherDb`, each with `-p:CultLibRoot=`. + - tests: `LoadoutTests.StatsReadTheLot` pins that a role-named stat reads the role and + an unnamed one reads workmanship. + `EvaluateDurabilityExponentReadsLotQuality` pins the durability path. + **New:** a test pinning that the *unequipped* and the *equipped-at-full-health-and- + optimal-temperature* paths now agree, which they do not at HEAD. That test is the + cut. + - negative: `rg "public float Evaluate\(PerformanceStat" Assets/` returns exactly one + hit. Verified against legitimate names: `BezierCurve.Evaluate(float)`, + `ExponentialCurve.Evaluate(float)`, `Environment.Evaluate(float2,…)` and + `ZoneData.Evaluate(float)` all differ in parameter type, so the pattern does not + collide. + - mutation: restore heat to the unequipped path; the new agreement test must go red + naming it. + - operator: none. No visible change is intended, and if one appears it is a defect. + +### Cut 1. Terms replace the four fixed exponents + +- **Depends on:** Cut 0. +- **First:** a read-only probe against a **copy** of `Aetheria.cc` answering: does the + catalog still open read-only with keys 2-5 gone, and how many records report + `IgnoredExtraSlots`? Record the file's sha1 and byte length. This is provenance Cut C's + method (`docs/item-provenance-cut.md:179-196`) and it is not optional. +- **Deletes first:** `ItemData.cs:580-594` — `HeatExponentMultiplier`, + `DurabilityExponentMultiplier`, `QualityExponent`, `FromRole` (15 lines), replaced by a + tombstone comment in the style of `ItemData.cs:281-282`: *keys 2, 3, 4 and 5 belonged + to fixed exponent fields now expressed as terms; do not reuse them.* +- **Adds:** `StatSource`, `StatTerm`, `PerformanceStat.Terms` at `Key(6)`. +- **Per-file changes:** the single evaluation function from Cut 0 walks `Terms` instead + of the four fields. `ConsumableItemEffect`'s `effectiveness` becomes a + `ConsumableProgress` term rather than a hard-coded factor, which removes the last + reason for that path to be different. +- **The migration.** A scratch console, **deliberately not committed**, opening + `AetheriaStores.Open(..., catalogWritable: true)` and re-upserting every stored + `ItemData` under its own key in **one `Commit`**. Precondition: Unity and CultCache + Studio closed. The committed binary is the artifact; the code is not kept. Per stat, + the rewrite is mechanical: + - `HeatExponentMultiplier != 0` → a `Heat` term with that exponent (275 stats). + - `DurabilityExponentMultiplier != 0` → a `Durability` term (145). + - `QualityExponent != 0` → a `Quality` term with `Role = FromRole` (193; `FromRole` is + unset on all 348, so every migrated quality term is roleless). + - A zero multiplier becomes **no term**, not a term with exponent 0. The difference is + the point: a stat declares only the terms it uses. +- **Authority map:** unchanged owner (the catalog author); what changes is that the + stat's dependency set becomes data the validator can read instead of four fields that + are always present and usually zero. +- **Verification:** + - Reopen shows **zero** reports with `IgnoredExtraSlots`, and the expected byte delta. + - `AetherDb` `census`, `factions`, `dangling`, `station-fit`, `loadout 1`, + `hardpoint-fit` captured before and after are **byte-identical**. None of them reads a + stat value, so any difference is a defect. + - A reflection dump of all 180 records before and after, comparing every *evaluated* + stat at quality 0.5, durability 1.0 and optimal temperature: identical to float + tolerance. This is the real proof; `census` only proves nothing unrelated moved. + - tests: `StatsReadTheLot` and `EvaluateDurabilityExponentReadsLotQuality` rewritten to + build `Terms`, still pinning the same rules. + - negative: `rg "HeatExponentMultiplier|DurabilityExponentMultiplier|QualityExponent|FromRole" Assets/ tools/ tests/` + returns only the tombstone comment. `FromRole` is checked separately because + `ProductRole.Role` and `RoleFill.Role` are legitimate and must not be swept. + - Unity batchmode, editor closed, no `error CS` (§6). + - Two commits, as Cut C landed: code, then the catalog rewrite. + +### Cut 2. The resolver owns every value + +- **Depends on:** Cut 1. +- **Deletes first:** + - `ItemData.cs:596-622` (27 lines) — both dictionaries and all four accessors. **The + leak in §0.3 dies here, structurally, not by cleanup.** + - `StatModifier.cs:61-86` (26) — the reflection target search that matches by type + name and grabs `PerformanceStat` references out of other designs. + - `StatModifier.cs:88-103` — the writes into the catalog object. +- **Adds:** `StatResolver`; `StatValidation` for self-dependency and modifier-chain + cycles; `StatModifier` rewritten to attach and detach a term in the entity's resolver. +- **Per-file changes:** + - `Entity.cs` constructs a resolver with the entity and disposes it with it. + - `EquippedItem` signals `Durability` and `Heat` invalidation from `UpdatePerformance` + (`Entity.cs:1377-1393`), and `Quality` invalidation when its `Lot` changes. + - `StatReference` (`StatModifier.cs:132-140`) keeps its `(type name, field name)` shape + but is **resolved once at catalog load into a concrete field, and fails loudly when + it does not resolve**. Today an unresolvable reference is a silent no-op + (`StatModifier.cs:63-65` leaves `_stats` null and `ApplyModifier` would throw on + first use). §7 Q2 is whether the operator wants a stronger identity than a name pair. +- **Authority map:** §1.1 in full. +- **Verification:** + - tests: a test equipping the same design on **two** entities, attaching a modifier on + one, and asserting the other's resolved value is unchanged. At HEAD this passes by + accident because the dictionary is keyed by entity; after the cut it passes because + there is nothing shared to get wrong. Mutation: make the resolver static; it goes red. + - tests: a test that unequipping and destroying an entity leaves no reference to it + reachable from the catalog record — a `WeakReference` to the entity, a forced + collection, and an assertion that it is dead. **This test fails at HEAD.** It is the + cut's headline and the only test here that proves the leak is gone. + - tests: a stat whose term chain reaches itself through a modifier is refused at equip, + naming both items and the stat. + - negative: `rg "GetScaleModifiers|GetConstantModifiers"` returns nothing. + - operator: O1 — play a session, open a few zones, kill some ships, and confirm memory + does not climb the way it did. + +### Cut 3. The power bus replaces `TryConsumeEnergy` + +- **Depends on:** Cut 2 (the bus's grant is a resolver source). +- **Deletes first:** + - `Entity.cs:851-856` `CanConsumeEnergy` (6 lines, one caller: `Shield.cs:54`). + - `Entity.cs:858-888` `TryConsumeEnergy` (31). + - `Reactor.cs:80-83` `ConsumeEnergy` (4). + - `Reactor.cs:90-141` — the deficit and capacitor-fill branches as a *demand* + mechanism (~52 lines; the overload-heat and throttling arithmetic survives, moved + under the bus's numbers). +- **Adds:** `PowerBus`; `IPowerConsumer` on the nine draw sites; `EquippedItem.PowerSupply`. +- **Per-file changes:** each of the nine sites in §0.5 stops calling a spend and starts + declaring a request. The five continuous ones (`EnergyDraw`, `Thruster`, `AetherDrive`, + `Radiator`, `ConstantWeapon`) convert directly. The four instant ones + (`InstantWeapon.cs:98,178`, `Sensor.cs:85`, `Shield.cs:59`) are **out of scope for this + cut** — they keep a direct capacitor spend, marked with a comment naming Cut 4 as their + owner, and they are the one temporary split this campaign accepts. That split is named, + bounded to one cut, and Cut 4's deletion line closes it. +- **Authority map:** §1.2, with the instant sites listed under forbidden writers as + *not yet*, which is the honest word. +- **Verification:** + - tests: a ship whose requests exceed generation gets a total grant equal to + generation plus available capacitor charge, and **not** more. At HEAD the equivalent + assertion is unwritable, because every draw succeeds. + - tests: a refused request produces no effect at all — not a partial one, and not an + overload-heat receipt. Mutation: make the bus grant the full request and heat the + reactor; it goes red. + - tests: two ships with identical loadouts fitted in **opposite equip order** get + identical grants. That is the death of the hidden priority, and it fails at HEAD. + - negative: `rg "TryConsumeEnergy|CanConsumeEnergy|ConsumeEnergy"` returns nothing. + - operator: O2 — fly with a weak reactor and confirm that things starve visibly and + legibly rather than cooking the reactor. + +### Cut 4. All draw is continuous + +- **Depends on:** Cut 3. +- **Deletes first:** the four direct capacitor spends left standing by Cut 3 + (`InstantWeapon.cs:98,178`, `Sensor.cs:85`, `Shield.cs:59`), and with them the last + path by which anything but the bus touches stored charge. +- **Adds:** `InputCapacitor`, with capacity and rate defaulting from the owning item's + `Energy` and `Cooldown` stats (§7 Q4). +- **Per-file changes:** firing becomes a check against the input capacitor's charge. + `ChargedWeapon` (`ChargedWeapon.cs:125-152`) is the interesting case: its charge cycle + already is a buffer, and `ChargeEnergy` should become the fill rate rather than a + separate concept. `Shield.cs:57-61` is the odd one out: a shield hit is not an + activation the player chose, so its "instant" draw is really a continuous reserve. Name + it as such rather than giving it an activation buffer. +- **Verification:** + - tests: a weapon whose input capacitor is partly filled does **not** fire a partial + shot. The operator's ruling — "no item receives a fraction of a shot" — is the rule + this test pins. + - tests: halving the grant halves the refill rate and therefore the sustained rate of + fire, without changing damage per shot. + - negative: `rg "AddCharge" Assets/` hits only the bus and `InputCapacitor`. + - operator: O4 — the feel of a weapon that stutters under brownout instead of firing + at full rate and cooking the ship. + +### Cut 5. Priority tiers + +- **Depends on:** Cut 3. +- **Deletes first:** `Entity.cs:1347-1348` (the `SortPosition` assignment), + `Entity.cs:586` and `:810` (the `OrderBy(SortPosition)` sorts), `Reactor.cs:48` + (`Order => 100`), `Behaviors.cs:127-130` (`IOrderedBehavior`) — **subject to O3**. +- **Adds:** `EquippableItem.PowerTier` at `Key(12)`; the tiered allocation pass; + a tier control in the schematic UI. +- **Per-file changes:** `EntitySerializer` needs nothing — the field rides inside + `EquippableItem`, which is already packed. Old run stores default the field, and death + clears the run anyway. +- **Authority map:** §1.3. +- **Verification:** + - tests: with generation short of total demand, tier 0 is satisfied in full before tier + 1 receives anything, and tier 1 divides what is left. + - tests: within a tier, two consumers with equal requests get equal grants; with + unequal requests, proportional ones. + - tests: a tier change on an equipped item takes effect on the next tick with no + re-equip. + - negative: `rg "SortPosition|IOrderedBehavior"` returns nothing. + - operator: O3 — confirm nothing depended on reactors executing last for a reason this + map did not find. O5 — the tier UI is playable. + +### Cut 6. The power supply term, brownout, and the request-independence rule + +- **Depends on:** Cuts 2, 3, 5. +- **Deletes first:** nothing. This is the one purely additive cut, and it buys the + operator's named capability: a continuous consumer that degrades instead of stopping. +- **Adds:** `StatSource.PowerSupply` handling in the resolver; the validation rule that a + request stat may not depend on power supply, directly or through a modifier chain. +- **Per-file changes:** the resolver invalidates `PowerSupply` entries once per tick from + the bus. The validator runs at catalog load, at Studio save, and at equip; because + `IPowerConsumer` names its request stat rather than computing a number, the set of + request stats is exactly enumerable from the catalog, and the check is static. +- **Verification:** + - tests: a thruster at half grant produces less thrust, by the authored exponent. + - tests: a catalog holding a thruster whose `EnergyUsage` carries a `PowerSupply` term + is **refused at load**, naming the item and the stat. Mutation: drop the rule; the + test goes red. + - tests: the same refusal through a modifier chain, at equip time, naming both items. + - tests: the resolver recomputes a power-termed stat at most once per tick. This is the + operator's "don't pay that cost for every stat evaluation" stated as an assertion. + - operator: O6 — brownout reads as degradation, not as breakage. + +### Cut 7. Roles authored + +- **Depends on:** Cut 1 (terms carry the role). +- **Deletes first:** nothing. This is a content cut, and it is where the campaign stops + being code. +- **Adds:** roles on designs, per-role means and deviations on products, and + `Role`-carrying quality terms on the stats that should read a part rather than + workmanship. Agents generate; the operator reviews. Written through the catalog's own + types — AetherDb or a scratch console over `Aetheria.Shared` — never a hand-copied + schema. +- **Note the sharp edge:** adding a role to a shipped design changes nothing for existing + lots, because `QualityForRole` falls back to `Lot.Quality` for an unfilled role. But a + *newly minted* lot from a product with no spread for that role rolls + `new ProductRole()` — mean .5, and `ProductRole`'s default deviation + (`FactionProduct.cs:33-48`). Authoring a role on a design without authoring the + matching product spreads silently pulls every maker toward average. The census + (`AetherDb Program.cs:85-90`) is where that is caught, and the two must land together. +- **Verification:** + - `AetherDb census` reports every design's roles filled by every product that sells it; + zero designs with roles and no matching product spread. + - `AetherDb loadout 1` before and after: the failure count must not rise. + - operator: O7 — the review pass on generated role content. Only the operator can do + this. + +### Cut 8. UI reconciliation + +- **Depends on:** Cuts 2, 3, 5. +- **Deletes first:** `PropertiesPanel.cs:553-569` and `TradeMenu.cs:273-284`, the two + divergent copies of the `RuntimeInspectable` switch (~35 lines together), collapsed + into the survivor at `PropertiesPanel.cs:407-419`. +- **Per-file changes:** + - `PropertiesPanel.cs:400-402` and `:545-550` stop building a label from + `SplitCamelCase()`d type and field names and use the identity the resolver already + has. + - `SchematicDisplay.cs:176-184` reads the bus's supply and grants instead of + `_reactor.Draw` and a hand-summed capacitor pool, and gains the per-item tier and + grant readout — which is the whole point of tiers being a player choice. + - `Editor/CultCacheDrawers.cs:17-29`: the type-name dropdown keeps writing a name, but + Cut 2's load-time resolution means an unresolvable one now fails loudly instead of + reading as "None". +- **Verification:** + - operator only, and it is the largest operator surface in the campaign: the properties + panel, the trade menu, the schematic HUD, the tier control, and a CultCache Studio + click-through over a stat with terms. §7 O8. + +--- + +## 4. Subtraction ledger, estimated + +Line counts for deletes are exact against `acb2fef9`; adds are estimates and should be +compared with what lands, in the postmortem. + +| Cut | Removed | Added (est.) | Net | Structure | +|---|---|---|---|---| +| 0 | 44 | ~45 | ~+1 | 3 evaluation authorities → 1 | +| 1 | 15 | ~40 | ~+25 | 4 fixed fields → a declared term list; 4 catalog keys retired; **catalog rewritten** | +| 2 | 53 | ~180 | ~+127 | per-entity state leaves the catalog object; a leak dies; validation appears | +| 3 | 93 | ~200 | ~+107 | 9 ad-hoc spends → 1 bus; 2 public entity methods deleted | +| 4 | ~30 | ~90 | ~+60 | the last direct capacitor writers die | +| 5 | ~12 | ~110 | ~+98 | `IOrderedBehavior` deleted; 1 new run-store key | +| 6 | 0 | ~120 | ~+120 | the only purely additive cut; buys brownout and the request rule | +| 7 | 0 | ~0 code | 0 | content; catalog data only | +| 8 | ~35 | ~20 | ~-15 | 3 stat renderers → 1 | +| **Total** | **~282** | **~805** | **~+523** | | + +This campaign is net additive by roughly 500 lines and it should be. What it buys, named +against the "every new abstraction must name its owner" rule: + +- `StatResolver` — owner of every resolved value. Replaces per-entity state living on a + shared catalog document, and the entity leak that shape produced. +- `PowerBus` — owner of allocation. Replaces nine independent spend paths and an implicit + priority nobody authored. +- `StatTerm` — replaces four fixed fields with a declared set, which is what makes + validation possible at all. +- `InputCapacitor` — buys the operator's "all power draw is continuous" ruling. + +Targets, packages, dependencies, formats: **none added, none removed**. One MessagePack +key added to `EquippableItem` (run store), one added and four retired on +`PerformanceStat` (catalog). + +## 5. Build budget + +- Packages: `Aetheria.Shared` (netstandard2.1), `tools/AetherDb` and + `tests/Aetheria.Shared.Tests` (net10.0), Debug only. No new project, target, package or + store. +- CultLib at `45c2f40`, unmodified, as a detached worktree passed with + `-p:CultLibRoot=`, removed after. +- Headless commands use `--artifacts-path ` while another agent reads the tree. +- **Build host is this Windows workstation, and it is also the target** for every + headless check. `Assembly-CSharp` is proven only by Unity batchmode, editor closed + (§6), which is also this host. +- The catalog rewrite in Cut 1 is the one irreversible artifact. It runs once, with Unity + and CultCache Studio closed, against a backed-up `Aetheria.cc`. + +## 6. Unity batchmode recipe + +Per cut that touches `Assembly-CSharp` (Cut 8, and any cut whose signature change reaches +`Assets/Scripts/UI` or `Assets/Scripts/Gameplay` — Cuts 0, 2, 3, 5 all do): + +1. Confirm no editor: `Get-Process Unity`, and `Test-Path F:\Projects\Aetheria\Temp\UnityLockfile`. + If either says yes, stop and ask. Never two batchmode runs at once. +2. `Start-Process "C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe" -ArgumentList '-batchmode -nographics -quit -projectPath F:\Projects\Aetheria -logFile \unity-cut-N.log' -PassThru` +3. `Select-String -Path -Pattern "error CS|Exiting batchmode"`. + +There is no stat or power probe harness in the tree and this map does not add one. The +NUnit suite reaches everything in `ServerShared`, which is where all of this lives; +batchmode is only the compile check for the UI edits. + +## 7. Operator forks + +Most blocking first. + +**Q1 — Does the campaign land before or after fire control?** +`docs/three-gates-scope.md` puts fire control at item 4 of the shipping list and +`docs/headless-playground-cut.md` maps it as Cut 2. Fire control will roll hits over +weapon and targeting stats, and those rolls want a resolver that can hand out a value +without re-deriving it, and a power model where a weapon can be starved. +**A:** stats and power first, fire control leans on the finished authority. +**B:** fire control first, and it builds its rolls on `PerformanceStat` as it is today, +then migrates. +**Recommended: A**, because B means fire control is written twice — once against three +disagreeing evaluation bodies and a stat that leaks entities, then again. But A delays +the shipping list's item 4, and that is the operator's call, not this map's. + +**Q2 — How does a modifier name the stat it targets?** +Today it is `(type name string, field name string)`, matched by reflection, and an +unresolvable pair is a silent no-op that would throw on first use. Exactly **one** +modifier is authored in the whole catalog (**probe**: `CapacitorData.Capacity`, +Multiplier, and it does resolve). +**A:** keep the name pair, resolve it once at catalog load, fail loudly. Studio's +existing `InspectableType` dropdown (`CultCacheDrawers.cs:17-29`) gains a second popup +for the field so a name is never typed. +**B:** give every stat a stable authored id and reference that. +**Recommended: A.** One authored instance does not justify a new identity scheme, and B +is a registry by another name — the thing doctrine says not to reach for. A converts the +silent failure into a loud one, which is what the target doc actually asked for. If role +authoring (Cut 7) multiplies modifiers into the dozens, revisit. + +**Q3 — Heat changes every tick. What does "recompute only when a source changes" mean +for it?** +275 of 348 stats carry a heat term, and `ThermalPerformance` is recomputed every tick in +`UpdatePerformance` (`Entity.cs:1377`). A literal dirty-flag on heat means recomputing +almost everything every tick anyway, which is exactly what happens now. +**A:** accept it. Heat-termed stats recompute per tick; the resolver's win is ownership +and the fact that power supply, durability and quality — the sources that would otherwise +multiply the cost — are cheap. Measured cost at HEAD is ~4,680 evaluations/s for the +heaviest generated ship (**probe**), around a millisecond per second. +**B:** quantize `ThermalPerformance` to a step (1/256, say) and invalidate only when it +crosses one. Turns a per-tick recompute into a rare one at the price of a visible +quantization in stat values and HUD bars. +**Recommended: A.** The measurement says the cost is not real, and B pays gameplay +visibility for a performance win nobody needs. B stays available if a future zone is +much denser than 7 entities. This is the fork most likely to be wrong, because the probe +measured three hulls and a real fight may be heavier. + +**Q4 — Where does an input capacitor's size come from?** +Every instant-activation item needs one, and **0 are authored**. +**A:** derive it — capacity from the item's `Energy` stat, rate from `Energy / Cooldown` — +so Cut 4 needs no catalog authoring and a ship at rest behaves as it does today. +**B:** author `InputCapacitorData` on every instant item, which is a catalog content pass +across 15 weapon-bearing designs before Cut 4 can land. +**C:** A now, with an authored override that B can fill in per design later. +**Recommended: C.** A alone makes burst capacity and sustained rate the same authored +number, which flattens a real design lever — a weapon that holds three shots and refills +slowly is a different weapon from one that holds one and refills fast. C keeps the cut +landable and keeps the lever. + +**Q5 — How many priority tiers, and who authors the default?** +The target says critical first, each lower tier dividing what is left. It does not say +how many, or what an item defaults to. +**A:** three (critical / normal / auxiliary), defaulted per hardpoint type. +**B:** four or five, defaulted per behaviour kind (life support and reactor critical, +thrust normal, weapons normal, sensors auxiliary). +**C:** a free integer, everything defaulting to the same tier, and the player sorts it. +**Recommended: B.** A default that is already sensible is what makes the tier UI an +option rather than a chore, and per-behaviour is where the knowledge is. C makes every +new ship a configuration task before it flies. + +**Q6 — What should the trade menu show for a stat?** +It shows and sorts on `.Max` alone (`TradeMenu.cs:273-284`) — the value at perfect +quality, perfect durability and optimal temperature, which no item ever has. The +properties panel shows the evaluated value for an equipped item and `Min`–`Max` for a +design. +**A:** show the evaluated value for the unit on offer, which is what the player is buying. +**B:** keep `.Max` as a comparable ceiling and add the evaluated value beside it. +**Recommended: A** for what the player reads, **B** for what the column sorts on — a +ceiling is the honest sort key when comparing designs, and the evaluated number is the +honest display when comparing units. This is a feel question and the operator should see +it before it lands. + +**Q7 — Does the heat/negent work want anything from this campaign?** +`docs/stats-and-power-target.md`'s negentropy section and +`docs/negent-weapons-concept.md` both rest on "cooling is a change to a condition, not an +effect on a stat", which this campaign's `StatSource.Heat` term delivers unchanged. The +one thing negent needs that is not here is a **consumable charge gating the cooling**, and +`StatSource.ConsumableProgress` (Cut 1) is the term for it. +**A:** nothing more. Negent is content against this authority. +**B:** add a negent-shaped source now. +**Recommended: A.** The concept doc's own claim is "almost nothing new, which is the +point"; adding a source ahead of a single authored item would be inventing a mechanism to +match a document. + +### Operator-only checks + +- **O1 (Cut 2)** — play a session across several zones with kills, and confirm memory does + not climb the way the leak in §0.3 predicts. The `WeakReference` test proves the + reference is gone; only a played session proves it mattered. +- **O2 (Cut 3)** — fly with a reactor too small for the loadout. Starvation should be + legible. +- **O3 (Cut 5)** — confirm nothing wanted reactors to execute last for a reason this map + did not find, before `IOrderedBehavior` is deleted. +- **O4 (Cut 4)** — the feel of a weapon refilling under brownout. +- **O5 (Cut 5)** — the tier control is playable from the schematic screen. +- **O6 (Cut 6)** — brownout reads as degradation. +- **O7 (Cut 7)** — review the agent-generated role content. Nobody else can. +- **O8 (Cut 8)** — properties panel, trade menu, schematic HUD, tier control, and a + CultCache Studio click-through over a stat with terms. + +--- + +## 8. The seam with fire control + +Fire control is the next campaign and owns its own map. Where the two touch: + +**What fire control will find already built.** +- One resolved value per `(item, stat)`, from one owner, with no per-entity state on the + catalog object. A roll over weapon and targeting stats reads the resolver; it does not + evaluate. +- Weapon and targeting stats that can carry a `PowerSupply` term, so a starved targeting + system rolls worse rather than switching off. That is the brownout ruling applied to + accuracy, and it is available the moment Cut 6 lands. +- An input capacitor per instant weapon, which means "can this weapon fire right now" is + a question with a cheap, local, authoritative answer — no bus round trip inside the + roll. +- A power request path shared by player and AI, so an AI ship cannot draw in a way a + player ship cannot. + +**What fire control must own, and this map does not touch.** +- The hit roll itself, the target-data thresholds, subsystem selection, and + `DamageSchematic` moving out of `Gameplay/EntityInstance.cs` + (`docs/three-gates-scope.md:80-99`). +- **The pre-impact commit window.** `docs/three-gates-scope.md:92-97` rules that the + outcome commits a short fixed time before impact and that fire control owns the horizon + as one authored setting; `docs/shield-presentation-contract.md` binds presentations to + it. Nothing in this campaign reads or writes that horizon. The one thing to be careful + of: a weapon's resolved stats at the moment of the roll and at the moment of impact may + differ, because heat and power move in between. **Fire control must snapshot what it + rolled over at commit time.** The resolver hands out a value; it does not promise that + value is still current a second later. That is the seam, stated as the rule fire + control has to keep. +- Whether a starved weapon that fires anyway should roll worse, or simply not fire. This + campaign makes it not fire (Cut 4: no fraction of a shot). If fire control wants a + degraded shot instead, that is a ruling against this map's Q4/Cut 4, and it should be + raised there rather than worked around downstream. + +## 9. Known gaps in this map + +Stated so Soul does not have to find them. + +- Only **three** hulls generate a loadout at HEAD, so §0.4's per-ship measurement rests + on a small sample. A real fight may be much heavier than 7 entities at 20 + evaluations/tick. +- The entity leak in §0.3 is proven from the code, not from a memory profile. O1 is where + it becomes a measured fact. +- The per-tick evaluation table in §0.4 was counted from behaviour bodies by hand and + applied to real loadouts by the probe. A behaviour whose `Execute` returns early most + ticks is overcounted; `Thruster` and `Radiator` are the likely ones. +- Cut 3 leaves four instant draw sites spending directly for exactly one cut. That is a + named, bounded split of authority, and if Cut 4 does not follow closely it becomes the + thing this doctrine forbids. +- The add estimates in §4 are guesses for types that do not exist yet. They are there to + be compared against reality in the postmortem, not to be trusted now. diff --git a/docs/stats-and-power-target.md b/docs/stats-and-power-target.md index da75daf65..b3378746a 100644 --- a/docs/stats-and-power-target.md +++ b/docs/stats-and-power-target.md @@ -81,3 +81,28 @@ Two current mechanisms need a better shape: new lot whose provenance names the original lot, the station and the inputs, and the upgraded item points at it. The resolver treats a change of lot as a change to that item's quality terms. + +## Negentropy items (operator direction, 2026-09-18) + +Adrasteian **negent** gear cools below ambient. Untended it is an infinite-DPS exploit: +heat is what limits sustained fire, so a free heat sink removes the limit. + +- **It runs on a consumable, and that is the balance.** Operator: "I like the consumable + option... That would make it expensive to run". The charge is a lot like any other, with a + maker, a process and a price (`docs/item-provenance-target.md`), so a damage ceiling + becomes a supply line, priced by the economy rather than by a cap. +- **Embrittlement needs no new mechanic.** Operator: "embrittlement isn't a joke, either, + running gear that cold already causes rapid wear". `Entity.cs:1386` already derives `Wear` + from distance off `OptimalTemperature` (`ItemData.cs:409`) and from `deltaTemp`, so negent + wears gear twice: off-optimum, and by the speed of the swing. Weapons already spend that + wear as durability damage (`Behaviors.cs:77`, `InstantWeapon.cs:192`, + `ConstantWeapon.cs:135`). Do not author a negent-specific wear rule. +- **Cooling is a change to a condition, not an effect on a stat.** It lowers temperature and + every stat that names heat responds on its own, the pilot included. That is the rule this + document already states for conditions; negent is its sharpest case, not an exception. +- **The pilot stays exposed.** Dying hot is the default failure and the cultural nightmare; + negent moves a ship toward the other end, where hypothermia is waiting. The safe band + narrows from both sides and the thing saving the ship is what kills the crew. + +- **A weapon is the sharpest case.** An Adrasteian gun whose ammunition is the + ship's thermal load inverts the whole heat loop: `docs/negent-weapons-concept.md`. diff --git a/docs/stats-power-cut1-migration.md b/docs/stats-power-cut1-migration.md new file mode 100644 index 000000000..ae18a4675 --- /dev/null +++ b/docs/stats-power-cut1-migration.md @@ -0,0 +1,238 @@ +# Cut 1 migration table: R-heat fit and term counts + +Date: 2026-09-18. Cut 1 of `docs/stats-and-power-cut.md`. Generated by a scratch migration console (two +phases, neither kept) against `GameData/Aetheria.cc` at pre-migration sha1 `d65c2aede2ee637fdb77fb83e6d7acd4d6c37cb3` +(12,682,424 bytes). The committed file's sha1 is `13a13a675064f971dfff3a3d83998220da42dd49` (12,682,737 bytes, ++313 for 51 authored `OptimalTemperature`/`PlateauWidth` float pairs). + +**The pre-migration catalog does not open under the post-Cut-1 code.** Opening the unmigrated bytes with the +new `AetheriaStores.Open` throws immediately: `ClearPath: OptimalTemperature 0 lies outside its bounds +[223.15, 473.15]` -- `StatValidation` refusing the unauthored-default 0 for the very first design it checks. +This is why the schema change and the catalog rewrite are one commit, not two: there is no working +intermediate state. Keys 2-5 (the retired `HeatExponentMultiplier`/`DurabilityExponentMultiplier`/ +`QualityExponent`/`FromRole`) are simply absent from the new `PerformanceStat` type, so CultCache's normal +slot-compatibility path (an unrecognized stored key is skipped, not an error) is what makes the *stat* fields +safe to drop silently -- it is `OptimalTemperature`/`PlateauWidth` on `EquippableItemData`, freshly required by +the new validation, that make a bare code swap without the catalog rewrite unusable. + +**AetherDb invariants (`census`, `factions`, `dangling`, `station-fit`, `hardpoint-fit`, `loadout 1`) are +byte-identical before and after** -- captured with the pre-Cut-1 binary against the unmigrated copy and the +post-Cut-1 binary against the migrated copy, since neither binary can open the other's catalog shape. None of +these commands reads a stat value, so identical output is the expected proof that nothing but the stat/heat +shape moved. + +- EquippableItemData records before: 51, after: 51 (must match -- the migration adds no records) +- Stats migrated into declared terms: 348 of 348 dumped +- Items fitted from an authored curve: 37; items with no curve (full-range plateau): 14 +- Stat values at quality .5 / full durability / the item's own optimum: 281 of 348 unchanged, 67 diverge -- every divergence moves toward Max (the plateau's full-performance value), which is a move away from Min for an inverted stat (Max < Min) and reads as a negative delta below; max |delta| 72.8125 + +## Stats whose at-optimum value changed (the intended R-heat balance change) + +| Item key | Path | Min-Max | Old value | New value | Delta | +|---|---|---|---|---|---| +| 42d1ffaa-f764-4a65-ba03-43cc4e6fd2bc | Behaviors[0].Torque | 750000-1750000 | 999927.2000 | 1000000.0000 | +72.8125 | +| 04255796-db68-48d2-81e5-8cfdab150706 | Behaviors[0].Thrust | 250000-1000000 | 437472.7000 | 437500.0000 | +27.3125 | +| 480d6cc4-cd8a-49b6-9c89-211033e7c1d5 | Behaviors[0].PingVisibility | 5000-1000 | 4001.1640 | 4000.0000 | -1.1643 | +| 0d499d62-3b92-454b-9498-37d12616167f | Behaviors[0].Visibility | 5000-1000 | 4000.5820 | 4000.0000 | -0.5823 | +| 20e9f41a-6bd2-4f6c-9740-7b707614509c | Behaviors[1].PumpedHeat | 1000-5000 | 1999.4180 | 2000.0000 | +0.5823 | +| 499f7891-c5df-4cbe-8431-9db7108102ec | Behaviors[0].Visibility | 5000-1000 | 4000.5820 | 4000.0000 | -0.5823 | +| 03a842b2-295e-4cf7-9faa-ecc4bc54eb76 | Behaviors[0].Range | 1000-3000 | 1499.4180 | 1500.0000 | +0.5822 | +| 2935b4d4-2ad5-4b90-8416-a85bbb38171b | Behaviors[1].PumpedHeat | 750-4000 | 1562.0270 | 1562.5000 | +0.4731 | +| 542169f2-363c-41f7-8d6d-60a5bd7578bd | Behaviors[1].PumpedHeat | 1000-4000 | 1749.5630 | 1750.0000 | +0.4368 | +| 2933c4ce-bf41-459c-b0d6-9583466b393d | Behaviors[0].Range | 750-2250 | 1124.5630 | 1125.0000 | +0.4366 | +| 8ac84e31-f0cf-43b2-8611-3316adfa4010 | Behaviors[0].Visibility | 1000-250 | 812.9364 | 812.5000 | -0.4364 | +| 679fb6a1-cefb-4adc-a49c-71942602196b | Behaviors[0].Range | 1000-2000 | 1353.1420 | 1353.5530 | +0.4116 | +| 679fb6a1-cefb-4adc-a49c-71942602196b | Behaviors[0].Visibility | 1000-500 | 703.0444 | 702.6982 | -0.3462 | +| 5ee2ff03-0ac4-4cdd-89bc-81e29223900b | Behaviors[0].MinRange | 500-50 | 366.5256 | 366.2142 | -0.3114 | +| 480d6cc4-cd8a-49b6-9c89-211033e7c1d5 | Behaviors[0].PingRange | 1000-5000 | 1999.7090 | 2000.0000 | +0.2913 | +| 2e5193aa-3703-4ecc-911d-4d8394ce4c2f | Behaviors[0].Visibility | 1250-250 | 896.6525 | 896.4467 | -0.2058 | +| 0271bb97-4ce8-4d64-97c3-8bfd3f76b2c6 | Behaviors[0].Visibility | 500-100 | 358.7433 | 358.5786 | -0.1647 | +| 457ac901-aa54-4ba7-9df6-fbacdfb40b3f | Behaviors[0].Visibility | 500-100 | 358.7433 | 358.5786 | -0.1647 | +| 457ac901-aa54-4ba7-9df6-fbacdfb40b3f | Behaviors[0].Velocity | 400-900 | 524.8544 | 525.0000 | +0.1456 | +| 03a842b2-295e-4cf7-9faa-ecc4bc54eb76 | Behaviors[0].Damage | 100-600 | 224.8545 | 225.0000 | +0.1455 | +| 0d499d62-3b92-454b-9498-37d12616167f | Behaviors[0].ChargeHeat | 2500-2000 | 2250.1460 | 2250.0000 | -0.1455 | +| 499f7891-c5df-4cbe-8431-9db7108102ec | Behaviors[0].ChargeHeat | 2500-2000 | 2250.1460 | 2250.0000 | -0.1455 | +| 4a41dec8-6b65-46ee-b578-ba805f112c0c | Behaviors[0].Range | 800-1750 | 1037.3620 | 1037.5000 | +0.1383 | +| 5ee2ff03-0ac4-4cdd-89bc-81e29223900b | Behaviors[0].Visibility | 1000-250 | 777.1535 | 777.0237 | -0.1298 | +| 542169f2-363c-41f7-8d6d-60a5bd7578bd | Behaviors[1].WasteHeat | 500-100 | 358.6610 | 358.5786 | -0.0824 | +| 499f7891-c5df-4cbe-8431-9db7108102ec | Behaviors[0].Range | 800-1900 | 1074.9200 | 1075.0000 | +0.0801 | +| 3aa1ac05-6d55-4569-8a4b-e1c3c6e07c61 | Behaviors[0].Visibility | 1750-600 | 1508.3130 | 1508.2420 | -0.0704 | +| 04255796-db68-48d2-81e5-8cfdab150706 | Behaviors[0].Visibility | 100-400 | 206.0042 | 206.0660 | +0.0618 | +| 4a41dec8-6b65-46ee-b578-ba805f112c0c | Behaviors[0].Visibility | 550-150 | 450.0582 | 450.0000 | -0.0582 | +| 3aa1ac05-6d55-4569-8a4b-e1c3c6e07c61 | Behaviors[0].Range | 500-1250 | 687.4454 | 687.5000 | +0.0546 | +| 457ac901-aa54-4ba7-9df6-fbacdfb40b3f | Behaviors[0].Range | 500-1250 | 687.4454 | 687.5000 | +0.0546 | +| 679fb6a1-cefb-4adc-a49c-71942602196b | Behaviors[0].Velocity | 500-1250 | 687.4454 | 687.5000 | +0.0546 | +| 8ac84e31-f0cf-43b2-8611-3316adfa4010 | Behaviors[0].Velocity | 750-1500 | 937.4454 | 937.5000 | +0.0546 | +| 2933c4ce-bf41-459c-b0d6-9583466b393d | Behaviors[0].Heat | 500-0 | 323.2748 | 323.2233 | -0.0515 | +| 3aa1ac05-6d55-4569-8a4b-e1c3c6e07c61 | Behaviors[0].Heat | 500-1000 | 676.7252 | 676.7767 | +0.0515 | +| 0271bb97-4ce8-4d64-97c3-8bfd3f76b2c6 | Behaviors[0].Velocity | 700-1250 | 863.4684 | 863.5160 | +0.0476 | +| 20e9f41a-6bd2-4f6c-9740-7b707614509c | Behaviors[1].WasteHeat | 100-300 | 170.6695 | 170.7107 | +0.0412 | +| 5ee2ff03-0ac4-4cdd-89bc-81e29223900b | Behaviors[0].Range | 1650-3500 | 2199.9680 | 2200.0080 | +0.0400 | +| 0d499d62-3b92-454b-9498-37d12616167f | Behaviors[0].Velocity | 250-750 | 374.9636 | 375.0000 | +0.0364 | +| 499f7891-c5df-4cbe-8431-9db7108102ec | Behaviors[0].Velocity | 250-750 | 374.9636 | 375.0000 | +0.0364 | +| 0d499d62-3b92-454b-9498-37d12616167f | Behaviors[0].Range | 450-950 | 574.9636 | 575.0000 | +0.0364 | +| 8ac84e31-f0cf-43b2-8611-3316adfa4010 | Behaviors[0].Range | 500-1000 | 624.9636 | 625.0000 | +0.0364 | +| 0271bb97-4ce8-4d64-97c3-8bfd3f76b2c6 | Behaviors[0].Range | 250-600 | 337.4745 | 337.5000 | +0.0255 | +| 2933c4ce-bf41-459c-b0d6-9583466b393d | Behaviors[0].Visibility | 300-0 | 225.0219 | 225.0000 | -0.0219 | +| 5ee2ff03-0ac4-4cdd-89bc-81e29223900b | Behaviors[0].Velocity | 100-175 | 137.4782 | 137.5000 | +0.0218 | +| 8ac84e31-f0cf-43b2-8611-3316adfa4010 | Behaviors[0].Damage | 100-250 | 137.4782 | 137.5000 | +0.0218 | +| 72859489-677a-47a4-ae04-550e582ba29e | Behaviors[0].PingBoost | 10000-20000 | 12500.0200 | 12500.0000 | -0.0205 | +| 205efba0-16af-477b-80e9-c9597cead5b4 | Behaviors[0].Charge | 50-200 | 103.0176 | 103.0330 | +0.0154 | +| 372d23cc-5ef0-42b5-9d96-807f2e412b0d | Behaviors[0].Charge | 25-125 | 60.3450 | 60.3553 | +0.0103 | +| 4a41dec8-6b65-46ee-b578-ba805f112c0c | Behaviors[0].Damage | 20-85 | 36.2405 | 36.2500 | +0.0095 | +| 0d499d62-3b92-454b-9498-37d12616167f | Behaviors[0].Damage | 50-175 | 81.2409 | 81.2500 | +0.0091 | +| 2933c4ce-bf41-459c-b0d6-9583466b393d | Behaviors[0].Damage | 40-160 | 69.9913 | 70.0000 | +0.0087 | +| 34b15c33-c01f-4712-ac75-5fcf779f0685 | Behaviors[0].Capacity | 50-150 | 74.9927 | 75.0000 | +0.0073 | +| 480d6cc4-cd8a-49b6-9c89-211033e7c1d5 | Behaviors[0].PingEnergy | 150-100 | 137.5073 | 137.5000 | -0.0073 | +| 5ee2ff03-0ac4-4cdd-89bc-81e29223900b | Behaviors[0].Thrust | 135-325 | 182.4931 | 182.5000 | +0.0069 | +| 499f7891-c5df-4cbe-8431-9db7108102ec | Behaviors[0].Damage | 25-110 | 46.2438 | 46.2500 | +0.0062 | +| 2935b4d4-2ad5-4b90-8416-a85bbb38171b | Behaviors[1].WasteHeat | 75-50 | 66.1663 | 66.1612 | -0.0051 | +| 457ac901-aa54-4ba7-9df6-fbacdfb40b3f | Behaviors[0].Damage | 20-80 | 34.9956 | 35.0000 | +0.0044 | +| 3aa1ac05-6d55-4569-8a4b-e1c3c6e07c61 | Behaviors[0].Energy | 25-50 | 33.8363 | 33.8388 | +0.0026 | +| 72859489-677a-47a4-ae04-550e582ba29e | Behaviors[0].PingRange | 1000-2000 | 1250.0020 | 1250.0000 | -0.0021 | +| 5ee2ff03-0ac4-4cdd-89bc-81e29223900b | Behaviors[0].LockAngle | 15-35 | 19.9985 | 20.0000 | +0.0015 | +| 5ee2ff03-0ac4-4cdd-89bc-81e29223900b | Behaviors[0].Cooldown | 15-8 | 12.5266 | 12.5251 | -0.0014 | +| 480d6cc4-cd8a-49b6-9c89-211033e7c1d5 | Behaviors[0].PingCooldown | 20-5 | 16.2511 | 16.2500 | -0.0011 | +| 5ee2ff03-0ac4-4cdd-89bc-81e29223900b | Behaviors[0].Energy | 5-1 | 3.5866 | 3.5858 | -0.0008 | +| 542169f2-363c-41f7-8d6d-60a5bd7578bd | Behaviors[1].Emissivity | 4-8 | 5.1885 | 5.1892 | +0.0007 | +| 5ee2ff03-0ac4-4cdd-89bc-81e29223900b | Behaviors[0].Damage | 14-48 | 22.4994 | 22.5000 | +0.0006 | +| 542169f2-363c-41f7-8d6d-60a5bd7578bd | Behaviors[1].EnergyUsage | 6-3 | 5.1086 | 5.1081 | -0.0005 | + +## Corrections to this table (Soul S3/S4; Hands, 2026-09-18) + +- **S3, wrong old-value columns.** Soul found the `Old optimum`/`Old value @ optimum` columns below wrong for 15 + of 51 rows. The pre-Cut-1 `Performance()` was `saturate(HeatPerformanceCurve?.Evaluate(...) ?? 1)`: a design + with **no authored curve** evaluated to exactly 1 at every temperature, so its correct old optimum is simply + its `MinimumTemperature` (the whole range was equally "optimal") and its correct old value is `1.0000` -- not + whatever this table originally printed. That is 14 rows: Assembly Line, Autocannon, Deep Ore Extractor, + Industrial Thermostatic Heater, LRMM72, Large Drive, Refinery, SRMM72, Shipyard, Surface Ore Extractor, + Tractor Beam, plight, pswarm, and Earp (Earp's real reclassification is below). The 15th wrong row is **Core + Power**: its curve was non-null but authored with zero keys, so its old getter threw + `IndexOutOfRangeException` -- there is no old baseline for it, and the table says so instead of printing a + number. +- **S4, at-optimum understates the change ~80x.** At-optimum is the one point a trapezoid and the curve it + replaces are made to agree (both are fitted to the same peak), so it is the smallest possible reading of the + shape change, not a representative one. The real size is the largest deviation anywhere inside the item's own + band -- added below as **Max |Δ| in band**, sampled at 2001 points across `[MinimumTemperature, + MaximumTemperature]`, old code (pre-Cut-1 catalog and curve-reading `Performance()`) against new (current + code, current catalog). 35 of the 37 originally-fitted designs share the exact same `+0.1923` -- one Bezier + template reused catalog-wide, evaluated identically regardless of hardpoint or behaviour -- and **The Bat** + moves by a different amount for a reason of its own (below). **Earp** does not belong in the "fitted, small + delta" group at all. + +### Earp and The Bat: found while closing S5/S6, not part of the operator's 15 + +Enforcing the plateau-clamp rule this cut's validation now carries (`StatValidation.ValidateHeatResponse`, +`ItemData.cs`; enforced on every catalog write through `CultRecordRefs.Upsert`, `AetheriaStores.cs`) breaks the +catalog for exactly two already-fitted designs outside the operator's named 15: their authored plateau, built +from the real curve's peak and near-peak width, pokes past a bound. + +- **Earp**'s real 2-key curve peaks at parameter 0 -- exactly at its own `MinimumTemperature` (175). That is the + same failure class as S1/S2: `Performance()` returns 0 *at* `MinimumTemperature`, not just below it, so a peak + sitting exactly on the boundary is dead at every temperature the design will ever reach. Soul's original "Earp + down" framing (`+0.0023 -> ~0` at-optimum) undersold this badly: the at-optimum sample landed exactly on the + one point where the old fit and the new trapezoid both happen to read close to 1, by construction of the old + curve peaking there too. Earp is re-authored the same way as the 14 (house-style fraction over its own + bounds -- see the table below); its Max |Δ| in band is now `-1.0000`, the same "went from immune to fatal" + shape as the unauthored designs, which is what it always was underneath the misleading at-optimum reading. +- **The Bat** is different: its optimum (209) is a genuinely distinct fitted value, not a placeholder or an + edge artifact, and stays untouched. Only its `PlateauWidth` moves, from 33 (whose low edge, 192.5, poked 7.5 + past `MinimumTemperature` 200) to **18**, the largest width whose plateau still clamps to the bounds with the + optimum held fixed. This moves The Bat's own Max |Δ| in band from Soul's originally-measured `+0.876` to + **`+0.968`** -- both computed the same way against the pre-Cut-1 curve; the difference is entirely the + narrower plateau, which now reaches full performance immediately above `MinimumTemperature` instead of only + within the old, poking width. + +| Item | Type | Bounds | Old optimum | New optimum | New plateau width | Old value @ old optimum | Max |Δ| in band @ temp (old→new) | +|---|---|---|---|---|---|---|---| +| 6k Shooter | WeaponItemData | 173.15-473.15 | 249.5 | 249.65 | 36 | 0.9977 | +0.1923 @ 356.15 (0.3770->0.5693) | +| Arctica | GearData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| Assembly Line | GearData | 273.15-373.15 | 273.15 | 298.65 | 12 | 1.0000 | -1.0000 @ 273.15 (1.0000->0.0000) | +| Autocannon | WeaponItemData | 173.15-473.15 | 173.15 | 249.65 | 36 | 1.0000 | -1.0000 @ 173.15 (1.0000->0.0000) | +| CShot RainbowLite Lazer | WeaponItemData | 250-400 | 288.175 | 288.25 | 18 | 0.9977 | +0.1923 @ 341.5 (0.3770->0.5693) | +| ChargeBlast SG | WeaponItemData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| ChargeBlast+- | WeaponItemData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| ClearPath | WeaponItemData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| Cockpit 2x2 | GearData | 223.15-398.15 | 267.688 | 267.775 | 21 | 0.9977 | +0.1923 @ 329.9 (0.3770->0.5693) | +| ColdFire | WeaponItemData | 263.15-398.15 | 297.508 | 297.575 | 16.2 | 0.9977 | +0.1923 @ 345.5 (0.3770->0.5693) | +| Core Power | GearData | 200-450 | N/A | 263.75 | 30 | N/A (threw IndexOutOfRangeException) | N/A | +| DeathCluster | WeaponItemData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| Deep Ore Extractor | GearData | 273.15-373.15 | 273.15 | 298.65 | 12 | 1.0000 | -1.0000 @ 273.15 (1.0000->0.0000) | +| Earp | WeaponItemData | 175-475 | 175 | 251.5 | 36 | 1.0000 | -1.0000 @ 175 (1.0000->0.0000) | +| FastBlast+- | WeaponItemData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| GT 3K | WeaponItemData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| Iapyx | GearData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| Industrial Thermostatic Heater | GearData | 150-400 | 150 | 213.75 | 30 | 1.0000 | -1.0000 @ 150 (1.0000->0.0000) | +| LRMM72 | WeaponItemData | 223.15-473.15 | 223.15 | 286.9 | 30 | 1.0000 | -1.0000 @ 223.15 (1.0000->0.0000) | +| Large Drive | GearData | 200-400 | 200 | 251 | 24 | 1.0000 | -1.0000 @ 200 (1.0000->0.0000) | +| LonginusX | HullData | 173.15-573.15 | 274.95 | 275.15 | 48 | 0.9977 | +0.1923 @ 417.15 (0.3770->0.5693) | +| Manhattan | GearData | 200-450 | 263.625 | 263.75 | 30 | 0.9977 | +0.1923 @ 352.5 (0.3770->0.5693) | +| Medium Docking Bay | DockingBayData | -0-673.15 | 171.317 | 171.653 | 80.778 | 0.9977 | +0.1923 @ 410.622 (0.3770->0.5693) | +| MoveOnPro | GearData | 200-500 | 276.35 | 276.5 | 36 | 0.9977 | +0.1923 @ 383 (0.3770->0.5693) | +| Notorious | GearData | 250-450 | 300.9 | 301 | 24 | 0.9977 | +0.1923 @ 372 (0.3770->0.5693) | +| OK Disperser | GearData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| PotaT+- | GearData | 200-500 | 276.35 | 276.5 | 36 | 0.9977 | +0.1923 @ 383 (0.3770->0.5693) | +| Refinery | GearData | 273.15-373.15 | 273.15 | 298.65 | 12 | 1.0000 | -1.0000 @ 273.15 (1.0000->0.0000) | +| SRMM72 | WeaponItemData | 223.15-473.15 | 223.15 | 286.9 | 30 | 1.0000 | -1.0000 @ 223.15 (1.0000->0.0000) | +| SafeStuff II | CargoBayData | 175-600 | 283.163 | 283.375 | 51 | 0.9977 | +0.1923 @ 434.25 (0.3770->0.5693) | +| Shipyard | GearData | 273.15-373.15 | 273.15 | 298.65 | 12 | 1.0000 | -1.0000 @ 273.15 (1.0000->0.0000) | +| Skiron | GearData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| Spectra | WeaponItemData | 263.15-398.15 | 297.508 | 297.575 | 16.2 | 0.9977 | +0.1923 @ 345.5 (0.3770->0.5693) | +| Store-All Enterprise Edition | CargoBayData | 175-600 | 283.163 | 283.375 | 51 | 0.9977 | +0.1923 @ 434.25 (0.3770->0.5693) | +| Store-All Plus | CargoBayData | 175-600 | 283.163 | 283.375 | 51 | 0.9977 | +0.1923 @ 434.25 (0.3770->0.5693) | +| Store-All Plus Mini | CargoBayData | 175-600 | 283.163 | 283.375 | 51 | 0.9977 | +0.1923 @ 434.25 (0.3770->0.5693) | +| Surface Ore Extractor | GearData | 273.15-373.15 | 273.15 | 298.65 | 12 | 1.0000 | -1.0000 @ 273.15 (1.0000->0.0000) | +| The Bat | GearData | 200-400 | 208.8 | 209 | 18 | 1.0000 | +0.9680 @ 200.1 (0.0320->1.0000) | +| Traction | GearData | 200-450 | 263.625 | 263.75 | 30 | 0.9977 | +0.1923 @ 352.5 (0.3770->0.5693) | +| Tractor Beam | GearData | -0--0 -> **200-400** | -0 | 251 | 24 | 1.0000 | -1.0000 @ -0 (1.0000->0.0000) | +| Turret | HullData | 123.15-773.15 | 288.575 | 288.9 | 78 | 0.9977 | +0.1923 @ 519.65 (0.3770->0.5693) | +| Turret Control Module | GearData | 173.15-523.15 | 262.225 | 262.4 | 42 | 0.9977 | +0.1923 @ 386.65 (0.3770->0.5693) | +| Vulcan | GearData | 200-500 | 276.35 | 276.5 | 36 | 0.9977 | +0.1923 @ 383 (0.3770->0.5693) | +| Zenith | HullData | 123.15-773.15 | 288.575 | 288.9 | 78 | 0.9977 | +0.1923 @ 519.65 (0.3770->0.5693) | +| cold like my heart | GearData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| deep space burnout | GearData | 200-400 | 250.9 | 251 | 24 | 0.9977 | +0.1923 @ 322 (0.3770->0.5693) | +| not if i see you first | GearData | 200-400 | 250.9 | 251 | 24 | 0.9977 | +0.1923 @ 322 (0.3770->0.5693) | +| plight | WeaponItemData | 263.15-398.15 | 263.15 | 297.575 | 16.2 | 1.0000 | -1.0000 @ 263.15 (1.0000->0.0000) | +| pretty pretty bang bang | WeaponItemData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | +| pswarm | WeaponItemData | 223.15-473.15 | 223.15 | 286.9 | 30 | 1.0000 | -1.0000 @ 223.15 (1.0000->0.0000) | +| scorched void policy | WeaponItemData | 223.15-473.15 | 286.775 | 286.9 | 30 | 0.9977 | +0.1923 @ 375.65 (0.3770->0.5693) | + +## Re-authoring: Tractor Beam, 13 curve-less designs, and Earp/The Bat (operator ruling: "reauthor those items") + +Written through the catalog's own types, in a scratch console over `Aetheria.Shared` (not committed; the +committed `GameData/Aetheria.cc` diff is the artifact). Every already-fitted design in the catalog shares the +exact same normalized curve shape -- optimum at 25.5% of the range above `MinimumTemperature`, plateau 12% of +the range wide -- regardless of hardpoint or behaviour, matching five distinct bound ranges to 5+ significant +figures. That is not rounding coincidence; it is one Bezier template authored once and reused catalog-wide, and +the 14 unauthored designs are simply the ones nobody assigned it to. Each row below applies that same fraction +to the design's own (unchanged, except Tractor Beam's) bounds, or points at the specific sibling design that +carries the identical bounds and therefore the identical fitted curve. All 16 records were validated +(`StatValidation.ValidateHeatResponse`) before any write reached disk; a failing record would have written +nothing. + +| Item | What it is | Old bounds | Proposed (landed) | Why | +|---|---|---|---|---| +| Assembly Line | Tool-hardpoint facility gear, no active Behavior (a station production module) | 273.15-373.15, degenerate full-range plateau | Opt 298.65, PW 12 (bounds unchanged) | House-style fraction (25.5%/12%) over its own band; shares its exact bounds and prior placeholder shape with 4 sibling facility items below | +| Deep Ore Extractor | Same Tool/no-Behavior facility family | 273.15-373.15, degenerate | Opt 298.65, PW 12 | Same as Assembly Line -- identical bounds, identical fraction | +| Refinery | Same Tool/no-Behavior facility family | 273.15-373.15, degenerate | Opt 298.65, PW 12 | Same as Assembly Line | +| Shipyard | Same Tool/no-Behavior facility family | 273.15-373.15, degenerate | Opt 298.65, PW 12 | Same as Assembly Line | +| Surface Ore Extractor | Same Tool/no-Behavior facility family | 273.15-373.15, degenerate | Opt 298.65, PW 12 | Same as Assembly Line | +| Autocannon | Ballistic weapon, `AutoWeaponData` | 173.15-473.15, degenerate | Opt 249.65, PW 36 | Matches 6k Shooter, the only other design sharing this exact 173.15-473.15 band | +| Core Power | Reactor gear, `ReactorData`+`WearData` | 200-450, degenerate (old curve authored with 0 keys, old getter threw) | Opt 263.75, PW 30 | Matches Manhattan, the only other design sharing this exact 200-450 band | +| Industrial Thermostatic Heater | Tool-hardpoint heater, `ThermotoggleData`+`EnergyDrawData`+`HeatData` (it generates heat) | 150-400, degenerate | Opt 213.75, PW 30 | House-style fraction skewed toward the cold end, matching a device meant to run best where it is needed (cold) and degrade once its own output overheats it | +| LRMM72 | Launcher weapon, `LauncherData` | 223.15-473.15, degenerate | Opt 286.9, PW 30 | Matches GT 3K (`LauncherData`) and scorched void policy (`GuidedWeaponData`), both sharing this exact band with identical fitted curves | +| SRMM72 | Launcher weapon, `GuidedWeaponData` | 223.15-473.15, degenerate | Opt 286.9, PW 30 | Matches scorched void policy, same hardpoint/behaviour/band | +| pswarm | Launcher weapon, `GuidedWeaponData` | 223.15-473.15, degenerate | Opt 286.9, PW 30 | Matches scorched void policy, same hardpoint/behaviour/band | +| Large Drive | Thruster gear, `ThrusterData`+`WearData` | 200-400, degenerate | Opt 251, PW 24 | Matches deep space burnout, the only other design sharing this exact 200-400 Thruster band | +| plight | Energy weapon, `ChargedWeaponData` | 263.15-398.15, degenerate | Opt 297.575, PW 16.2 | Matches ColdFire and Spectra, both sharing this exact band with identical fitted curves (`AutoWeaponData`, but bounds -- not behaviour -- is what the shared template keys on) | +| Tractor Beam | Sensors-hardpoint gear, no active Behavior | -0 to -0, min==max==optimum, width 0 (S1: dead at every temperature) | Bounds 200-400 (newly authored), Opt 251, PW 24 | Never had real bounds authored at all; 200-400 matches the other small Sensors/gear family already using that exact range (not if i see you first, The Bat, Large Drive, deep space burnout), and the fraction gives it not-if-i-see-you-first's exact curve | +| Earp | Ballistic weapon, `InstantWeaponData` | 175-475, real 2-key curve peaking exactly at Minimum (dead at every temperature -- same bug class as S1, found while enforcing S5/S6's plateau-clamp rule) | Opt 251.5, PW 36 | House-style fraction over its own unique 175-475 band (no exact-bounds sibling exists); moves its peak off the lethal boundary the same way the 14 do | +| The Bat | Sensors-hardpoint gear, `SensorData` | 200-400, real fitted optimum 209, PlateauWidth 33 (pokes 7.5 past Minimum -- found while enforcing S5/S6) | PlateauWidth 18 only; optimum 209 unchanged | Minimal fix: shrink only the plateau to the largest width that clamps to the bounds with the authored optimum held fixed, preserving the one genuinely distinct fitted curve in this list | + +## Zero consumables in the catalog + +Cut 0 flagged that `ItemManager.CreateInstance` has no `ConsumableItemData` branch, so a `ConsumableItem` is +apparently never minted by the normal path, and asked whether Cut 1 should look before touching consumables. +**(probe)** The catalog holds **zero** `ConsumableItemData` records (`cache.GetAll().Count() +== 0` against `GameData/Aetheria.cc`). There is nothing to touch: no consumable design exists to migrate, to +break, or to validate. diff --git a/docs/stats-power-cut7-roles.md b/docs/stats-power-cut7-roles.md new file mode 100644 index 000000000..d40a58b5c --- /dev/null +++ b/docs/stats-power-cut7-roles.md @@ -0,0 +1,202 @@ +# Cut 7 (Roles Authored): Operator Review + +Generated by `dotnet run --project tools/AetherDb -- roles-migrate` (dry run captures this exact table; `apply` +landed it in `GameData/Aetheria.cc`). Regenerate with `roles-migrate` any time the catalog changes — this file is +a snapshot for review, not a second source of truth. + +Role vocabulary is small and shared within a kind: the same handful of part names cover every design of that +kind, not one bespoke name per item. A stat is only pointed at a role when it both carries a `Quality` term and +actually varies with quality (`Min != Max`) — Cut 1's flat placeholder stats (`Min == Max`, a harmless leftover +`Heat^1` term) have no observable role-vs-workmanship difference, so pointing a role at one would be authoring +fiction, not content. + +Product role means below are almost all **generic placeholders** (deterministic per product+role, not hand-tuned) +flagged "needs operator review" — the one exception is Arctica/Alakrita, where `docs/item-provenance-target.md` +already states a brand identity ("optimizing for heat efficiency"). Every other product is waiting on the +operator to decide who is actually good at what; right now they only differ from each other by an arbitrary seed, +not by authored intent. + +## Designs and their roles, by item kind + +### AetherDrive + +| Design | Roles | Stat → role | +|---|---|---| +| Traction | coupling, rotor | `AetherDriveData.MaximumRpm` → rotor · `AetherDriveData.Torque` → rotor · `AetherDriveData.CouplingEfficiency` → coupling · `AetherDriveData.EnergyDraw` → coupling · `AetherDriveData.PassiveCoupling` → coupling | + +**Role meanings:** *rotor* — the spinning core that produces RPM and torque. *coupling* — how efficiently that +output couples into drive and how much bleeds off passively. + +### CargoBay, ControlModule, DockingBay, Hull/Station, Hull/Turret — no roles + +SafeStuff II, Store-All Enterprise Edition, Store-All Plus, Store-All Plus Mini (CargoBay); Cockpit 2x2, Turret +Control Module (ControlModule); Medium Docking Bay (DockingBay); Zenith (Hull/Station); Turret (Hull/Turret). + +**Why:** none of these kinds' behaviours read `StatSource.Quality` at all today (cargo capacity, docking, cockpit +draw and hull cross-section on these particular designs are flat or non-quality stats) — there is no per-part +distinction for a role to express yet. Generic quality stays. Turret's own `ReflectorData.CrossSection` is present +but authored flat (`Heat^1`, no `Quality` term), unlike LonginusX's. + +### Hull/Ship + +| Design | Roles | Stat → role | +|---|---|---| +| LonginusX | plating | `ReflectorData.CrossSection` → plating | + +**Role meaning:** *plating* — the hull's armor/stealth coating, which is what actually governs radar cross-section. + +### Radiator + +| Design | Roles | Stat → role | +|---|---|---| +| Arctica | fins, pump, reservoir | `Emissivity`→fins · `WasteHeat`→fins · `PumpedHeat`→pump · `EnergyUsage`→pump · `ThermalMass`→reservoir | +| cold like my heart | fins, pump, reservoir | same five fields, same roles | +| OK Disperser | fins, pump, reservoir | same five fields, same roles | +| Iapyx | reservoir | `ThermalMass`→reservoir only (Emissivity/PumpedHeat/WasteHeat/EnergyUsage are authored flat, `Heat^1`, no Quality term) | +| Skiron | reservoir | same as Iapyx | + +**Role meanings:** *fins* — the radiating surface (emissivity, waste heat). *pump* — the coolant pump (heat moved, +its own energy cost). *reservoir* — the thermal mass of the coolant loop itself. + +### Reactor + +| Design | Roles | Stat → role | +|---|---|---| +| Core Power | core, regulator | `Efficiency`→core · `OverloadEfficiency`→regulator · `ThrottlingFactor`→regulator (Charge authored flat here) | +| Manhattan | core, regulator | `Charge`→core · `Efficiency`→core · `OverloadEfficiency`→regulator · `ThrottlingFactor`→regulator | +| MoveOnPro | core, regulator | same four | +| Notorious | core, regulator | same four | +| Vulcan | core, regulator | same four | + +**Role meanings:** *core* — the primary reaction chamber (raw charge and efficiency). *regulator* — the safety and +throttling electronics (overload behaviour, throttling). + +MoveOnPro also carries a `StatModifierData.Modifier` (Quality-termed) — left at generic quality: it modifies an +external stat rather than the reactor's own part, so no reactor role fits it. + +### Sensors + +| Design | Roles | Stat → role | +|---|---|---| +| not if i see you first | emitter, receiver | `Sensitivity`→receiver · `PingBoost`,`PingEnergy`,`PingVisibility`,`PingRange`,`PingCooldown`→emitter | +| The Bat | emitter, receiver | `Sensitivity`→receiver · `PingBoost`,`PingVisibility`,`PingRange`,`PingCooldown`→emitter (`PingEnergy` authored flat here) | +| Tractor Beam | *(none)* | dead design (S1, Cut 1 findings): every field is flat, nothing to point | + +**Role meanings:** *receiver* — passive sensitivity. *emitter* — the active ping subsystem (boost, cost, +visibility, range, cooldown). + +### Thruster + +| Design | Roles | Stat → role | +|---|---|---| +| deep space burnout | injector, nozzle | `Thrust`→nozzle · `Visibility`→nozzle · `Heat`→injector (`EnergyUsage` authored flat 0/0, no terms) | +| Large Drive | *(none)* | every field authored flat (`Heat^1`/`Quality^1` but `Min == Max` throughout) | + +**Role meanings:** *nozzle* — exhaust output and plume (thrust, visibility). *injector* — fuel/power feed, whose +byproduct is heat. + +### Tool + +Tool is not a shared kind the way a laser or radiator is — each design is its own bespoke gadget, so roles are +authored per design rather than from one shared field map. + +| Design | Roles | Stat → role | +|---|---|---| +| Industrial Thermostatic Heater | heating element | `EnergyDrawData.EnergyDraw`→heating element · `HeatData.Heat`→heating element | +| PotaT+- | converter, storage cell | `CapacitorData.Capacity`→storage cell · `CapacitorData.Efficiency`→converter | +| Assembly Line, Deep Ore Extractor, Refinery, Shipyard, Surface Ore Extractor | *(none)* | carry no `PerformanceStat` behaviour at all in the current catalog | + +### Weapon/Ballistic + +| Design | Roles | Stat → role | +|---|---|---| +| 6k Shooter | barrel, feed mechanism | `Damage`,`Range`,`Velocity`→barrel · `Heat`,`Visibility`,`Spread`→feed mechanism | +| Earp | barrel, feed mechanism | same shape as 6k Shooter | +| ClearPath | barrel, feed mechanism | `Damage`,`Range`,`Velocity`→barrel · `Cooldown`,`Energy`,`Heat`,`Visibility`,`Spread`→feed mechanism | +| DeathCluster | barrel, feed mechanism | same shape as ClearPath | +| pretty pretty bang bang | barrel, feed mechanism | same shape as ClearPath (charged weapon, same field names) | +| Autocannon | *(none)* | dead design (S1, Cut 1 findings): every field flat | + +**Role meanings:** *barrel* — the part that decides damage, range and muzzle velocity. *feed mechanism* — cycling: +rate of fire, spread, heat and signature it generates, and (where authored) its energy cost. + +### Weapon/Energy + +| Design | Roles | Stat → role | +|---|---|---| +| CShot RainbowLite Lazer | focusing array, power coupling | `Damage`,`Range`→focusing array · `Cooldown`,`Energy`,`Heat`,`Visibility`,`Spread`→power coupling | +| ColdFire | focusing array, power coupling | same shape | +| Spectra | focusing array, power coupling | same shape | +| FastBlast+- | focusing array, power coupling | `Damage`,`Range`,`Velocity`→focusing array · `Cooldown`,`Heat`,`Visibility`,`Spread`→power coupling | +| ChargeBlast SG | focusing array, power coupling | `Damage`,`Range`,`Velocity`→focusing array · `ChargeTime`,`ChargeHeat`,`Cooldown`,`Visibility`,`Spread`→power coupling | +| ChargeBlast+- | focusing array, power coupling | same shape as ChargeBlast SG | +| plight | focusing array, power coupling | `Damage`,`Range`→focusing array · `ChargeTime`,`ChargeEnergy`,`ChargeHeat`,`Cooldown`,`Heat`,`Visibility`→power coupling | + +**Role meanings:** *focusing array* — the beam optics that decide damage, range and velocity. *power coupling* — +everything about how the weapon draws, stores and discharges energy (including charge-up on charged lasers) and +the heat/signature that produces. + +### Weapon/Launcher + +| Design | Roles | Stat → role | +|---|---|---| +| GT 3K | guidance system, thruster, warhead | `Damage`,`DamageSpread`→warhead · `LockSpeed`,`SensorImpact`,`LockAngle`,`DirectionImpact`,`Decay`,`Range`,`Cooldown`,`Energy`,`Heat`,`Visibility`→guidance system · `Thrust`,`MissileVelocity`,`Velocity`→thruster | +| pswarm | guidance system, thruster, warhead | `Damage`,`DamageSpread`→warhead · `Cooldown`,`Range`,`Energy`,`Heat`,`Visibility`→guidance system · `Thrust`,`MissileVelocity`,`Velocity`→thruster | +| scorched void policy | guidance system, thruster, warhead | same shape as pswarm | +| LRMM72, SRMM72 | *(none)* | dumbfire volleys authored fully flat: every field `Heat^1`, nothing varies | + +**Role meanings:** *warhead* — payload (damage, damage spread, penetration). *guidance system* — the seeker +(lock speed/angle, sensor and direction sensitivity, decay, targeting range, cooldown, its own energy/heat/ +signature). *thruster* — the missile's own motor (thrust, flight/missile velocity). + +## Product role quality authored + +37 products exist; 28 got role-quality entries this pass (the rest sell a design that authored no role, or are +one of the two-missile launchers left flat). All means/deviations are **generic placeholders** except Arctica's +`fins`, and every row below needs an operator pass to become real brand identity rather than an arbitrary seed. + +| Product (= design; content pass to split brand vs. design names is separate scope) | Role | Mean | Dev | Basis | +|---|---|---|---|---| +| Arctica | fins | 0.78 | 0.07 | **lore**: Alakrita brands on heat efficiency (`item-provenance-target.md`) | +| Arctica | pump | 0.47 | 0.11 | generic — needs review | +| Arctica | reservoir | 0.53 | 0.14 | generic — needs review | +| cold like my heart | fins, pump, reservoir | 0.54 / 0.74 / 0.73 | 0.17 / 0.17 / 0.09 | generic — needs review | +| OK Disperser | fins, pump, reservoir | 0.61 / 0.57 / 0.59 | 0.14 / 0.10 / 0.18 | generic — needs review | +| Iapyx | reservoir | 0.61 | 0.17 | generic — needs review | +| Skiron | reservoir | 0.64 | 0.16 | generic — needs review | +| Manhattan | core, regulator | 0.48 / 0.63 | 0.08 / 0.12 | generic — needs review | +| MoveOnPro | core, regulator | 0.62 / 0.46 | 0.15 / 0.15 | generic — needs review | +| Notorious | core, regulator | 0.65 / 0.46 | 0.15 / 0.17 | generic — needs review | +| Vulcan | core, regulator | 0.75 / 0.58 | 0.12 / 0.16 | generic — needs review | +| not if i see you first | emitter, receiver | 0.71 / 0.75 | 0.09 / 0.18 | generic — needs review | +| The Bat | emitter, receiver | 0.45 / 0.57 | 0.09 / 0.16 | generic — needs review | +| deep space burnout | injector, nozzle | 0.64 / 0.67 | 0.12 / 0.14 | generic — needs review | +| Traction | coupling, rotor | 0.69 / 0.65 | 0.10 / 0.10 | generic — needs review | +| LonginusX | plating | 0.48 | 0.13 | generic — needs review | +| PotaT+- | converter, storage cell | 0.67 / 0.53 | 0.17 / 0.11 | generic — needs review | +| 6k Shooter | barrel, feed mechanism | 0.59 / 0.46 | 0.14 / 0.14 | generic — needs review | +| ClearPath | barrel, feed mechanism | 0.71 / 0.57 | 0.10 / 0.10 | generic — needs review | +| DeathCluster | barrel, feed mechanism | 0.65 / 0.68 | 0.17 / 0.11 | generic — needs review | +| Earp | barrel, feed mechanism | 0.73 / 0.68 | 0.13 / 0.10 | generic — needs review | +| pretty pretty bang bang | barrel, feed mechanism | 0.51 / 0.63 | 0.10 / 0.09 | generic — needs review | +| CShot RainbowLite Lazer | focusing array, power coupling | 0.71 / 0.54 | 0.16 / 0.11 | generic — needs review | +| ChargeBlast SG | focusing array, power coupling | 0.51 / 0.54 | 0.09 / 0.10 | generic — needs review | +| ChargeBlast+- | focusing array, power coupling | 0.45 / 0.51 | 0.17 / 0.18 | generic — needs review | +| ColdFire | focusing array, power coupling | 0.68 / 0.58 | 0.10 / 0.13 | generic — needs review | +| FastBlast+- | focusing array, power coupling | 0.74 / 0.61 | 0.11 / 0.13 | generic — needs review | +| Spectra | focusing array, power coupling | 0.75 / 0.63 | 0.09 / 0.13 | generic — needs review | +| GT 3K | guidance system, thruster, warhead | 0.69 / 0.63 / 0.58 | 0.15 / 0.17 / 0.16 | generic — needs review | +| scorched void policy | guidance system, thruster, warhead | 0.61 / 0.61 / 0.68 | 0.08 / 0.12 / 0.15 | generic — needs review | + +**Not authored (no product sells the design, so nothing to author):** Core Power, Industrial Thermostatic Heater, +pswarm, plight (all previously flagged "no product sells" in the Cut 1 census; still true here). + +## O7 — what the operator should actually do with this + +Everything above the "generic — needs review" line is mechanically correct (verified: `census`'s new +"(product, role) pairs where a sold, role-bearing design authors no matching spread" check reports 0, and two +products of one design with different means resolve a role-termed stat to different, deterministic values — +`RoleAuthoringTests.TwoProductsOfOneDesignWithDifferentRoleMeansResolveDifferentValues`). What it is *not* is +real market segmentation: every design still has exactly one product, so nothing yet demonstrates "a second +product with a role pumped up." That is next scope, and it needs the operator's judgment on which manufacturer +is good at what — this table is the jumping-off point, not the destination. diff --git a/docs/three-gates-scope.md b/docs/three-gates-scope.md index b87f1e64e..85912b862 100644 --- a/docs/three-gates-scope.md +++ b/docs/three-gates-scope.md @@ -88,6 +88,13 @@ gathered enough target data to reveal those subsystems". `Physics.*` queries no longer decide damage. - **Projectiles still fly** toward the predicted intercept; the hit is resolved on arrival against how far the target deviated, so maneuvering is evasion. +- **The outcome commits a short fixed time before impact** (operator, + 2026-09-18: "That hybrid is the way"). Deviation feeds the roll up to that + horizon; after it the result stands and the rest of the flight is + choreography. The commit is what lets a presentation act before the hit — + a panel materialising, a whip snapping — and it is authoritative, not a + guess. Fire control owns the horizon, one authored setting. The rules that + bind presentations to it are `docs/shield-presentation-contract.md`. - **Deferred Unity-physics surfaces:** ship collision (`HullCollider`), loot pickup (`ShieldManager`), `TractorBeam`, `Mine`. Not blockers. diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 000000000..bd8661400 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-stryker": { + "version": "5.0.0", + "commands": [ + "dotnet-stryker" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj b/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj index dc0384bc1..8e36142f1 100644 --- a/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj +++ b/tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj @@ -8,6 +8,9 @@ + + diff --git a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs index 1542950f0..e0fec193d 100644 --- a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs +++ b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs @@ -149,6 +149,57 @@ public sealed class AetheriaStoresTests : IDisposable Assert.Contains("aetheria.tests.catalogglobal", error.Message); } + // --- F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "there are at least four [write bypasses]." + // --- tools/AetherDb/Program.cs's Dangling, ShieldMigrate and BrownoutMigrate commands repair an existing + // --- record in place through `cache.Commit(batch => batch.Upsert(document.GetType(), document, key))` -- + // --- CultCache's own batch API, keyed explicitly to preserve identity -- because CultRecordRefs.Upsert's own + // --- cache.UpsertAsync(document) cannot take a caller-supplied key. That is real, not an excuse to skip + // --- validation: all three now call CultRecordRefs.Validate(document) directly, immediately before their own + // --- commit. This pins that the extracted method actually refuses what Upsert would have, proving the tools' + // --- direct call is wired to the same rule rather than a hollow copy. --- + [Fact] + public void ValidateRefusesAnEquippableItemWithAZeroSpanHeatRangeTheSameWayUpsertWould() + { + var badDesign = new GearData + { + Name = "BadDangling", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 100, MaximumTemperature = 100, OptimalTemperature = 100, PlateauWidth = 0 + }; + var error = Assert.Throws(() => CultRecordRefs.Validate(badDesign)); + Assert.Contains("BadDangling", error.Message); + } + + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19), superseding F6: this + // direct-call path used to also catch F6's static rule (a power request stat may not carry a PowerSupply + // term at all -- StatValidation.ValidateNoPowerSupplyOnRequest, since deleted). PowerRequest now reads a + // request field through EquippedItem.EvaluateNominalPower, which pins that stat's own PowerSupplyFactor to 1 + // regardless of its Terms, so the term can no longer make the request depend on its own answer -- a repair + // tool landing a curved ThrusterData.EnergyUsage is no longer any more dangerous than R-heat's own check, + // which this still runs (see the sibling test above). + [Fact] + public void ValidateAcceptsAPowerRequestCarryingAPowerSupplyTermTheSameWayUpsertWould() + { + var curvedEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + var goodDesign = new GearData + { + Name = "CurvedMigratedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ThrusterData { EnergyUsage = curvedEnergy } } + }; + var record = Record.Exception(() => CultRecordRefs.Validate(goodDesign)); + Assert.Null(record); + } + + // A document type Validate has no opinion about (neither EquippableItemData nor ConsumableItemData) must be + // a pure no-op, not a crash -- the three tools' change lists carry `object`, and not every migration touches + // an equippable design (e.g. a future repair over FactionProductData). + [Fact] + public void ValidateIsANoOpForADocumentTypeItDoesNotGovern() + { + var record = Record.Exception(() => CultRecordRefs.Validate(new Faction { Name = "Unrelated" })); + Assert.Null(record); + } + private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); private static string[] SchemaNames(string path) diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs new file mode 100644 index 000000000..e9a2dbf11 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -0,0 +1,461 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; + +// Cut 7 (docs/stats-and-power-target.md): "Continuous consumers brown out through a power supply curve on +// their performance stats. An exponent is enough." Cut 6 wired the curve itself (PowerCurveTests.cs) and the +// bus already writes a real fractional grant (PowerBusTests.cs, PowerTierTests.cs), but every continuous +// consumer still gated its own effect on Item.PowerSupply >= 1f (Thruster.Execute, AetherDrive.Execute, +// Radiator.Execute, ConstantWeapon.Execute, EnergyDraw.Execute) -- so a partial grant read exactly like none, +// the flicking-off bug the ruling names. This file pins each behaviour's own gate removal: a partial grant now +// produces a reduced effect, a full grant is unchanged, a zero grant still produces nothing, the degradation +// follows the authored exponent (not accidentally linear), and an instant item (InputCapacitor's all-or-nothing +// rule, Cut 4) is untouched by any of it. Each rule has a matching mutation in +// tests/mutation_tests_stats_power_cut7.py. +// +// F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) surfaced a real conflict this file's Radiator and +// AetherDrive fixtures happened to sit on top of, unnoticed because the pre-F6 registry only named each +// consumer's own top-level request field. Radiator.PowerRequest's own early-out reads PumpedHeat AND WasteHeat +// (to decide whether the pump can keep up this tick); AetherDrive.PowerRequest's spin-up arithmetic reads +// Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling. Cut 7 asks for the brownout curve to live on exactly +// the stats these two behaviours actually curve (PumpedHeat, Torque), but F6 forbade a PowerSupply term on any +// stat a request reads at all, on pain of the exact oscillation Soul measured against the shipped catalog's own +// "OK Disperser" radiator (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates, docs/stats-and-power-cut.md). +// The six tests below that authored one were marked Skip rather than deleted or silently reworked to hide the +// gap, naming the conflict as real, scoped work for a future pass rather than a fix a registry correction should +// smuggle in. +// +// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19) is that future pass: a power +// request is evaluated nominally -- what the item wants at full supply, not what it is currently managing +// (EquippedItem.EvaluateNominalPower, Entity.cs). Radiator.PowerRequest and AetherDrive.PowerRequest now read +// PumpedHeat/Torque (and every other field in StatValidation.PowerRequestFields) through that nominal read, so +// the same stat can carry a PowerSupply term (curved for Execute's real effect) AND be a request field, without +// the request depending on its own answer -- there is no more oscillation to reproduce, because the request no +// longer reads the live grant at all. StatValidation.ValidateNoPowerSupplyOnRequest (the static check F6 added +// to forbid this) is deleted; only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) +// still refuses anything, and it was never what these six tests tripped. Un-skipped below. Thruster and +// ConstantWeapon were never affected by F6 -- their own PowerRequest reads only EnergyUsage/Energy, never the +// separate Thrust/Damage field their tests curve -- but now also read that field nominally, for the same reason +// every other IPowerConsumer does. +public sealed class BrownoutTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-brownout-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public BrownoutTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // Min=0 (not Min=Max like Constant) is the point: a term can only ever be observed to degrade something + // when the stat actually has room to move between its floor and its authored ceiling. + private static PerformanceStat Curved(float max, float exponent) => + new PerformanceStat { Min = 0, Max = max, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = exponent } } }; + + // A flat Bezier (two keys, equal value, zero tangents): AetherDrive's TorqueProfile multiplies potential + // torque by this curve's value at the current rpm ratio -- flat at 1 keeps that multiplier out of the way of + // the PowerSupply curve under test. + private static BezierCurve FlatCurve(float value) => new BezierCurve + { + Keys = new[] { new float4(0, value, 0, 0), new float4(1, value, 0, 0) } + }; + + private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + // PowerBusTests/PowerCurveTests' own fixture: a 5x5 hull, a Reactor with no capacitor on the ship (so this + // tick's grant ratio is a clean min(1, generation/demand), nothing smoothed by stored charge), and one named + // consumer behaviour under test. + private CultCache OpenCatalog(BehaviorData consumer) + { + var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ReactorData + { + Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) + } } + }); + cache.Upsert(new GearData + { + Name = "Consumer", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { consumer } + }); + cache.FlushAsync().Wait(); + return cache; + } + + private Ship BuildShip(CultCache cache, float reactorCharge) + { + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hull = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("Reactor"); + ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); + Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")))); + + zone.Entities.Add(ship); + // A ship already "looking" the way Direction already points keeps Ship.Update's own steering pass a + // no-op (deltaRot == 0): no NaN from an unset LookDirection propagating into AetherDrive's Axis.z, and + // no incidental torque thrust from the ship trying to turn toward a look direction none of these tests + // care about. + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + return ship; + } + + // --- Thruster: a reverse thruster (default EquippableItem.Rotation == None) driven by MovementDirection.y + // --- (Ship.cs's own axis pass), producing a measurable, unidirectional velocity change with no rotation + // --- component to disentangle (Velocity is updated from the pre-rotation Direction within the same tick). --- + private (float velocityDelta, Thruster thruster) RunThruster(float reactorCharge, float exponent = 1) + { + using var cache = OpenCatalog(new ThrusterData + { + Thrust = Curved(100, exponent), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) + }); + var ship = BuildShip(cache, reactorCharge); + ship.MovementDirection = float2(0, -1); // reverse-thruster axis += -MovementDirection.y == 1 + ship.Update(1f); + return (-ship.Velocity.y, ship.GetBehavior()); + } + + [Fact] + public void ThrusterAtHalfGrantProducesReducedNotZeroNotFullThrust() + { + var (full, _) = RunThruster(reactorCharge: 1000); // demand 100, generation far exceeds it -> ratio 1 + var (half, _) = RunThruster(reactorCharge: 50); // demand 100, generation 50 -> ratio .5 + + Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}"); + } + + [Fact] + public void ThrusterAtFullGrantIsUnchangedFromToday() + { + var (full, thruster) = RunThruster(reactorCharge: 1000); + Assert.Equal(1f, thruster.Item.PowerSupply, 3); + Assert.Equal(.1f, full, 3); // 100 thrust / 1000 mass * dt 1, exactly as it read before this cut + } + + [Fact] + public void ThrusterAtZeroGrantProducesNothing() + { + var (zero, thruster) = RunThruster(reactorCharge: 0); + Assert.Equal(0f, thruster.Item.PowerSupply, 3); + Assert.Equal(0f, zero, 3); + } + + // --- The degradation must follow the authored exponent, not the linear response a careless "just multiply + // --- by PowerSupply" implementation (or a mutation dropping the exponent) would produce. Mirrors + // --- PowerCurveTests' own exponent-2 bullet, but through a real gated behaviour instead of an inert stat. --- + [Fact] + public void ThrusterAtHalfGrantDegradesByItsAuthoredExponentNotLinearly() + { + var (half, _) = RunThruster(reactorCharge: 50, exponent: 2); + // Thrust = lerp(0, 100, pow(.5, 2)) = 25; velocity delta = 25 / 1000 mass * dt 1 = .025, not the + // linear-response .05 a dropped exponent would produce. + Assert.Equal(.025f, half, 3); + } + + // --- AetherDrive: PassiveCoupling = 1 engages the rotor regardless of axis input, so the drive can be + // --- exercised without threading Ship's own movement/torque classification -- Axis stays at Ship.Update's + // --- own default (0,0,deltaRot), and deltaRot is 0 by BuildShip's own LookDirection setup. Rpm starts at 0, + // --- so decay(0, ...) contributes nothing and the whole tick's Rpm gain is exactly this behaviour's own + // --- spin-up under test. --- + private (float rpmGain, AetherDrive drive) RunAetherDrive(float reactorCharge, float exponent = 1) + { + using var cache = OpenCatalog(new AetherDriveData + { + RotorDiameter = float3(1, 1, 1), RotorMass = float3(1, 1, 1), + MaximumRpm = Constant(100), CouplingLambda = float3(1, 1, 1), LambdaMultiplier = Constant(1), + CouplingEfficiency = Constant(1), Torque = Curved(30, exponent), TorqueProfile = FlatCurve(1), + EnergyDraw = Constant(50), PassiveCoupling = Constant(1) + }); + var ship = BuildShip(cache, reactorCharge); + ship.Update(1f); + var drive = ship.GetBehavior(); + return (drive.Rpm.x, drive); + } + + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, see file header): Torque + // is a registered AetherDrive request field (PowerRequest's spin-up arithmetic reads it), but PowerRequest now + // reads it through EvaluateNominalPower (full-supply, undegraded), so Curved(...) on Torque is a legal + // catalog shape again -- Execute's own real, curved Evaluate is what actually degrades the spin-up below. + [Fact] + public void AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp() + { + var (full, _) = RunAetherDrive(reactorCharge: 1000); // demand ~50, generation far exceeds it -> ratio 1 + var (half, _) = RunAetherDrive(reactorCharge: 25); // demand ~50, generation 25 -> ratio .5 + + Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}"); + } + + [Fact] + public void AetherDriveAtFullGrantIsUnchangedFromToday() + { + var (full, drive) = RunAetherDrive(reactorCharge: 1000); + Assert.Equal(1f, drive.Item.PowerSupply, 3); + Assert.True(full > 0f); + } + + [Fact] + public void AetherDriveAtZeroGrantProducesNothing() + { + var (zero, drive) = RunAetherDrive(reactorCharge: 0); + Assert.Equal(0f, drive.Item.PowerSupply, 3); + Assert.Equal(0f, zero, 4); + } + + // --- Radiator: no explicit power gate survives Cut 7 at all -- PumpedHeat above is a plain Evaluate() read, + // --- so a curved PumpedHeat already pumps less under a partial grant, and waste heat is unaffected, so a + // --- starved radiator falls behind (the reduced-performance failure the ruling asks for) instead of the + // --- pump simply refusing to run. Measured via RadiatorTemperature's own rise, which is exactly + // --- pumpedHeat / ThermalMass * dt. --- + private (float temperatureDelta, Radiator radiator) RunRadiator(float reactorCharge, float exponent = 1) + { + using var cache = OpenCatalog(new RadiatorData + { + Emissivity = Constant(0), PumpedHeat = Curved(10, exponent), TemperatureFloor = 0, + WasteHeat = Constant(.1f), EnergyUsage = Constant(50), ThermalMass = Constant(1000) + }); + var ship = BuildShip(cache, reactorCharge); + var radiator = ship.GetBehavior(); + var before = radiator.RadiatorTemperature; + ship.Update(1f); + return (radiator.RadiatorTemperature - before, radiator); + } + + // Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19, see file header): + // PumpedHeat and WasteHeat are registered Radiator request fields (PowerRequest's own early-out reads both), + // but PowerRequest now reads them through EvaluateNominalPower, so Curved(...) on PumpedHeat is a legal + // catalog shape again -- Execute's own real, curved Evaluate is what actually degrades the pumping below. + [Fact] + public void RadiatorAtHalfGrantPumpsReducedNotZeroNotFullHeat() + { + var (full, _) = RunRadiator(reactorCharge: 1000); // demand 50, generation far exceeds it -> ratio 1 + var (half, _) = RunRadiator(reactorCharge: 25); // demand 50, generation 25 -> ratio .5 + + Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}"); + } + + [Fact] + public void RadiatorAtFullGrantIsUnchangedFromToday() + { + var (full, radiator) = RunRadiator(reactorCharge: 1000); + Assert.Equal(1f, radiator.Item.PowerSupply, 3); + Assert.True(full > 0f); + } + + [Fact] + public void RadiatorAtZeroGrantProducesNothing() + { + var (zero, radiator) = RunRadiator(reactorCharge: 0); + Assert.Equal(0f, radiator.Item.PowerSupply, 3); + Assert.Equal(0f, zero, 4); + } + + // --- ConstantWeapon: the exact bug the ruling names -- a partial grant used to safe the weapon off + // --- entirely (_firing = false), not merely fire it for less. base.Execute(dt) re-reads Damage through + // --- Evaluate() every tick regardless of _firing, so a curved Damage stat already degrades; this proves the + // --- weapon also keeps firing (Firing stays true) at a partial grant, and only a true-zero grant stops it. --- + private (float damage, bool firing) RunConstantWeapon(float reactorCharge, float exponent = 1) + { + using var cache = OpenCatalog(new ConstantWeaponData + { + DamageType = DamageType.Kinetic, Damage = Curved(100, exponent), Penetration = Constant(0), + DamageSpread = Constant(0), MinRange = Constant(0), Range = Constant(0), Energy = Constant(50), + Heat = Constant(0), Visibility = Constant(0), MagazineSize = 0 + }); + var ship = BuildShip(cache, reactorCharge); + var weapon = ship.GetBehavior(); + weapon.Activate(); + ship.Update(1f); + return (weapon.Damage, weapon.Firing); + } + + [Fact] + public void ConstantWeaponAtHalfGrantKeepsFiringWithReducedDamage() + { + var (full, fullFiring) = RunConstantWeapon(reactorCharge: 1000); // demand 50, ratio 1 + var (half, halfFiring) = RunConstantWeapon(reactorCharge: 25); // demand 50, ratio .5 + + Assert.True(fullFiring); + Assert.True(halfFiring); // the flicking-off bug: this used to be false + Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}"); + } + + [Fact] + public void ConstantWeaponAtFullGrantIsUnchangedFromToday() + { + var (full, firing) = RunConstantWeapon(reactorCharge: 1000); + Assert.True(firing); + Assert.Equal(100f, full, 3); + } + + [Fact] + public void ConstantWeaponAtZeroGrantStopsFiringAndProducesNothing() + { + var (zero, firing) = RunConstantWeapon(reactorCharge: 0); + Assert.False(firing); + Assert.Equal(0f, zero, 3); + } + + [Fact] + public void ConstantWeaponDamageDegradesByItsAuthoredExponentNotLinearly() + { + var (half, firing) = RunConstantWeapon(reactorCharge: 25, exponent: 2); + Assert.True(firing); + Assert.Equal(25f, half, 3); // lerp(0, 100, pow(.5, 2)) == 25, not the linear-response 50 + } + + // --- EnergyDraw: named explicitly (docs/stats-and-power-target.md) as the one continuous consumer whose + // --- effect is not a performance stat at all -- its Execute return value IS the effect (Entity.cs's + // --- per-BehaviorGroup Execute chain reads it to decide whether later behaviours in the group run). There + // --- is nothing here for a curve to degrade continuously, so a partial grant still passes and only a true + // --- zero grant closes the gate. Execute is public and side-effect-free (a plain read of Item.PowerSupply), + // --- so it is safe to call directly for inspection after the same tick the group loop already ran it in. --- + private bool RunEnergyDraw(float reactorCharge) + { + using var cache = OpenCatalog(new EnergyDrawData { EnergyDraw = Constant(50), PerSecond = true }); + var ship = BuildShip(cache, reactorCharge); + ship.Update(1f); + return ship.GetBehavior().Execute(1f); + } + + [Fact] + public void EnergyDrawAtHalfGrantStillPasses() + { + Assert.True(RunEnergyDraw(reactorCharge: 25)); // demand 50, ratio .5 -- used to fail this gate entirely + } + + [Fact] + public void EnergyDrawAtFullGrantPasses() + { + Assert.True(RunEnergyDraw(reactorCharge: 1000)); + } + + [Fact] + public void EnergyDrawAtZeroGrantFails() + { + Assert.False(RunEnergyDraw(reactorCharge: 0)); + } + + // --- Regression: Cut 4's InputCapacitor all-or-nothing rule for instant items must survive Cut 7 completely + // --- untouched -- an input capacitor half full still refuses a whole-shot spend, because InstantWeapon, + // --- Sensor and Shield are unaffected by anything in this cut. Unit-level (no Ship/catalog needed at all): + // --- InputCapacitor is a plain class. --- + [Fact] + public void AnInputCapacitorStillRefusesToSpendOnAPartialCharge() + { + var capacitor = new InputCapacitor(); + capacitor.UpdateStats(energy: 10, cooldown: 1); // Capacity = 10, Rate = 10 + capacitor.AddCharge(5); // half charge -- real, but not a whole shot + + Assert.True(capacitor.CanSpend(5)); + Assert.False(capacitor.TrySpend(10)); // the whole-shot cost must still be refused outright + + capacitor.AddCharge(5); // now full + Assert.True(capacitor.TrySpend(10)); // and only now does it fire, for the whole cost + } + + // --- Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19): a direct probe of + // --- EquippedItem.EvaluateNominalPower itself, decoupled from any behaviour's own physical state (Radiator's + // --- temperature, AetherDrive's Rpm) so the only thing that can make it diverge from the ordinary Evaluate + // --- is the item's own PowerSupply grant. Thruster's EnergyUsage carries the term here purely as a probe -- + // --- not a shape BrownoutTests' other fixtures author -- because Thruster has no other tick-to-tick state + // --- that could confound the comparison the way Radiator's RadiatorTemperature or AetherDrive's Rpm would. + // --- Backs tests/mutation_tests_stats_power_nominal_request.py's own mutation of this exact method. --- + [Fact] + public void EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant() + { + var energyUsage = Curved(100, exponent: 1); + using var cache = OpenCatalog(new ThrusterData + { + Thrust = Constant(0), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = energyUsage + }); + var ship = BuildShip(cache, reactorCharge: 25); // demand 100 (nominal), generation 25 -> ratio .25 + ship.MovementDirection = float2(0, -1); + ship.Update(1f); + var thruster = ship.GetBehavior(); + + // The fixture must actually land a partial grant, or this probe proves nothing. + Assert.True(thruster.Item.PowerSupply > 0f && thruster.Item.PowerSupply < 1f, + $"expected a partial grant, got {thruster.Item.PowerSupply}"); + + Assert.Equal(100f, thruster.Item.EvaluateNominalPower(energyUsage), 3); // full-supply value, always + Assert.True(thruster.Item.Evaluate(energyUsage) < 100f); // the real, curved value the grant actually shrank + } + + // --- Wiring regression for Radiator.PowerRequest specifically: EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant + // --- above proves the underlying mechanism, but a single ship.Update never actually exercises it here -- + // --- PumpedHeat/WasteHeat's own ratio (100/5, nominally) so outstrips tempRatio (pinned at 1 on a radiator + // --- that started at ambient) that a single tick's PowerRequest returns the same gate-open answer whether or + // --- not PowerRequest reads PumpedHeat nominally. Two ticks exposes it: tick 1 is identical either way (the + // --- item's PowerSupply has not been written yet, so Evaluate and EvaluateNominalPower agree), landing the + // --- same partial grant (ratio1 = 20/50 = .4) and so the same RadiatorTemperature going into tick 2 in both + // --- cases. Only starting at tick 2's own PowerRequest can the two implementations diverge: nominally, + // --- PumpedHeat/WasteHeat is still 10/5 = 2, comfortably above tempRatio, so tick 2 still requests (demand + // --- unchanged from tick 1); read through the ordinary curved Evaluate instead, PumpedHeat is now + // --- pow(.4, 1)*10 = 4, so PumpedHeat/WasteHeat drops to 4/5 = .8, BELOW tempRatio -- PowerRequest's own + // --- early-out fires and demand collapses to 0. A wiring regression (PowerRequest reverted to the ordinary + // --- Evaluate) is exactly what tests/mutation_tests_stats_power_nominal_request.py mutates and this catches. + [Fact] + public void RadiatorSecondTickDemandIsUnaffectedByTheFirstTicksPartialGrant() + { + using var cache = OpenCatalog(new RadiatorData + { + Emissivity = Constant(0), PumpedHeat = Curved(10, exponent: 1), TemperatureFloor = 0, + WasteHeat = Constant(5), EnergyUsage = Constant(50), ThermalMass = Constant(1000) + }); + var ship = BuildShip(cache, reactorCharge: 20); // demand 50 (nominal, both ticks) -> ratio1 = .4 + ship.Update(1f); + var firstDemand = ship.PowerBus.TotalDemand; + Assert.Equal(50f, firstDemand, 3); // tick 1: PowerSupply defaults to 1, so both readings agree + + ship.Update(1f); + var secondDemand = ship.PowerBus.TotalDemand; + // Still the gate-open, EnergyUsage*tempRatio figure (tempRatio has crept up slightly from the radiator's + // own tick-1 heating, which is real and expected -- not the gate-closed 0 a reverted, non-nominal + // PowerRequest would produce once Item.PowerSupply < 1 curves PumpedHeat/WasteHeat below tempRatio). + Assert.True(secondDemand > 0f, + $"expected tick 2's gate to stay open on the nominal PumpedHeat/WasteHeat ratio, got demand {secondDemand}"); + } + + // --- The same wiring check attempted on AetherDrive.PowerRequest's own Torque read does not carry over + // --- cleanly: torqueRatio (actualRpmDelta/potentialRpmDelta) self-cancels Torque's own magnitude whenever + // --- the rotor is not yet rpm-capped (both numerator and denominator scale with Torque, so the ratio -- and + // --- so the final draw -- comes out identical whether Torque is read nominally or through the curved + // --- Evaluate; verified empirically while building this fixture: reverting AetherDrive.PowerRequest to + // --- Evaluate produced byte-identical TotalDemand across two ticks, including once the rotor was pushed + // --- into its rpm cap). A black-box demand comparison cannot discriminate this call site the way it can + // --- Radiator's binary gate above. Its correctness rests instead on the identical pattern to Radiator's + // --- (same EvaluateNominalPower call, same EquippedItem mechanism EvaluateNominalPowerIgnoresTheItemsCurrent + // --- PowerSupplyGrant already pins) and on AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp/ + // --- AtFullGrantIsUnchangedFromToday/AtZeroGrantProducesNothing above, which exercise the real end-to-end + // --- Execute path this ruling exists to keep legal. Not part of the mutation harness for that reason. +} diff --git a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs new file mode 100644 index 000000000..2ff03d18f --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; + +// Operator ask (2026-09-19, docs/... none yet -- this is Cut 8 on top of Cut 7's brownout work): "you can't +// currently tell when [a thruster is] busted -- the particle system it spawns responds only to thrust intent. +// Should multiply that by actual performance." EquippedItem.ConditionRatio (Entity.cs) is the one number that +// answers "how healthy does this item actually look": the item's resolved value for a stat against what the +// same item, same lot, same modifiers would produce with Heat/Durability/PowerSupply pinned to their identity. +// Thruster.Condition and AetherDrive.Condition (Thruster.cs/AetherDrive.cs) are that ratio read against each +// behaviour's own governing stat (Thrust, Torque); ShipInstance.cs (Unity-side, not covered here) multiplies +// presentation intent by it instead of recomputing any durability/heat/power arithmetic of its own. +// Each rule here has a matching mutation in tests/mutation_tests_condition_ratio_cut8.py. +public sealed class ConditionRatioTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-conditionratio-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public ConditionRatioTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // Optimal temperature 280, plateau 400 wide (matches BrownoutTests' own consumer shape): the ambient + // temperature this fixture starts at (280, set explicitly per test rather than relied on as a default) sits + // dead center of the plateau, so ThermalPerformance reads exactly 1 there. + private const float OptimalTemperature = 280f; + + private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + private static string NewRoot() + { + var root = Path.Combine(Path.GetTempPath(), "aetheria-conditionratio-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + // Hull, a Reactor (BrownoutTests' own shape: no capacitor, so a tick's grant ratio is a clean + // min(1, generation/demand)), and one Consumer carrying the given ThrusterData. Defaults to this instance's + // own catalog path; a test that needs two independent ships (the power-grant comparison below) passes a + // second, freshly rooted path rather than reopening the same file twice in one process. + private CultCache OpenCatalog(ThrusterData thruster, string catalogPath = null) + { + var cache = AetheriaStores.Open(catalogPath ?? Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = OptimalTemperature, PlateauWidth = 400, + Behaviors = { new ReactorData + { + Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) + } } + }); + cache.Upsert(new GearData + { + Name = "Consumer", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = OptimalTemperature, PlateauWidth = 400, + Behaviors = { thruster } + }); + cache.FlushAsync().Wait(); + return cache; + } + + private Ship BuildShip(CultCache cache, float reactorCharge, out EquippedItem consumer) + { + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hull = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("Reactor"); + ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); + Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")))); + + zone.Entities.Add(ship); + ship.LookDirection = float3(0, 0, 1); // Ship.Update's own steering pass stays a no-op, as BrownoutTests + ship.Activate(); + consumer = ship.Equipment.Single(e => e.Data.Name == "Consumer"); + + // Settle at the plateau's center: two ticks so UpdatePerformance's deltaTemp term reads 0 before a test + // drives its own change, matching HeatResponseTests' own settle pattern. + foreach (var cell in consumer.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = OptimalTemperature; + consumer.UpdatePerformance(); + consumer.UpdatePerformance(); + return ship; + } + + // --- 1: a perfect item -- full durability, a grant far exceeding demand -- reads exactly 1, on a stat that + // --- declares Durability and PowerSupply together (both at their identity). The Heat term's own identity + // --- case is pinned by ConditionFallsWithTemperatureAwayFromThePlateauAlone's "atPlateau" reading below; + // --- combining it here too would make this test depend on UpdateTemperature's own ambient drift over the + // --- Update tick PowerSupply needs, which is a real dependency this test has no reason to take on. --- + [Fact] + public void APerfectItemsConditionIsOne() + { + var thrust = new PerformanceStat + { + Min = 0, Max = 100, + Terms = + { + new StatTerm { Source = StatSource.Durability, Exponent = 1 }, + new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } + } + }; + using var cache = OpenCatalog(new ThrusterData { Thrust = thrust, Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) }); + var ship = BuildShip(cache, reactorCharge: 1000, out var consumer); + ship.Update(1f); + + Assert.Equal(1f, ship.GetBehavior().Condition, 4); + } + + // --- 2: durability alone. Full temperature/power, half durability, a stat with only a Durability term. --- + [Fact] + public void ConditionFallsWithDurabilityAlone() + { + var thrust = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.Durability, Exponent = 1 } } }; + using var cache = OpenCatalog(new ThrusterData { Thrust = thrust, Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) }); + var ship = BuildShip(cache, reactorCharge: 1000, out var consumer); + + consumer.EquippableItem.Durability = consumer.Data.Durability; // full + consumer.UpdatePerformance(); // direct call, not ship.Update -- no reason to also churn UpdateTemperature + var full = ship.GetBehavior().Condition; + + consumer.EquippableItem.Durability = consumer.Data.Durability * .5f; // half + consumer.UpdatePerformance(); + var half = ship.GetBehavior().Condition; + + Assert.Equal(1f, full, 4); + Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}"); + } + + // --- 3: temperature alone, away from the plateau. Full durability/power, a stat with only a Heat term. --- + [Fact] + public void ConditionFallsWithTemperatureAwayFromThePlateauAlone() + { + var thrust = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 } } }; + using var cache = OpenCatalog(new ThrusterData { Thrust = thrust, Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) }); + var ship = BuildShip(cache, reactorCharge: 1000, out var consumer); + var atPlateau = ship.GetBehavior().Condition; // BuildShip already settled at OptimalTemperature + + // Push the item's own temperature far outside the plateau (center 280, half-width 200 -> outside past + // 480), then read performance directly (not through ship.Update, whose UpdateTemperature would pull the + // value we just set back toward ambient equilibrium within the same tick, before UpdatePerformance ever + // sees it -- exactly HeatResponseTests' own SetTemperature-then-UpdatePerformance pattern). + foreach (var cell in consumer.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = 900f; + consumer.UpdatePerformance(); + var offPlateau = ship.GetBehavior().Condition; + + Assert.Equal(1f, atPlateau, 4); + Assert.True(offPlateau > 0f && offPlateau < atPlateau, $"offPlateau={offPlateau} should sit strictly between 0 and atPlateau={atPlateau}"); + } + + // --- 4: power alone. Full durability/temperature, a partial grant, a stat with only a PowerSupply term + // --- (mirrors BrownoutTests' own Curved fixture). --- + [Fact] + public void ConditionFallsWithAPartialPowerGrantAlone() + { + var thrust = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + + using var cache = OpenCatalog(new ThrusterData { Thrust = thrust, Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) }); + var fullShip = BuildShip(cache, reactorCharge: 1000, out _); // demand 100, generation far exceeds it -> ratio 1 + fullShip.MovementDirection = float2(0, -1); // real thrust demand -- PowerRequest is 0 with no input at all + fullShip.Update(1f); + var full = fullShip.GetBehavior().Condition; + + var secondRoot = NewRoot(); + try + { + using var cache2 = OpenCatalog(new ThrusterData { Thrust = thrust, Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) }, catalogPath: Path.Combine(secondRoot, "Aetheria.cc")); + var halfShip = BuildShip(cache2, reactorCharge: 50, out _); // demand 100, generation 50 -> ratio .5 + halfShip.MovementDirection = float2(0, -1); + halfShip.Update(1f); + var half = halfShip.GetBehavior().Condition; + + Assert.Equal(1f, full, 4); + Assert.True(half > 0f && half < full, $"half={half} should sit strictly between 0 and full={full}"); + } + finally { Directory.Delete(secondRoot, true); } + } + + // --- 5: dead ends at 0, not some small positive residue -- a fully destroyed item on a stat with Min=0 + // --- produces nothing, so the ratio itself is 0/0-guarded down to 0 rather than a divide-by-zero or NaN. --- + [Fact] + public void ConditionIsZeroWhenTheItemProducesNothing() + { + var thrust = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.Durability, Exponent = 1 } } }; + using var cache = OpenCatalog(new ThrusterData { Thrust = thrust, Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) }); + var ship = BuildShip(cache, reactorCharge: 1000, out var consumer); + + consumer.EquippableItem.Durability = 0f; + ship.Update(1f); + + Assert.Equal(0f, ship.GetBehavior().Condition, 4); + } + + // --- 5b: the guard's real reason to exist -- a stat authored with Max=0 (no terms at all) makes the nominal + // --- side degenerate to exactly 0 too, so actual/nominal is 0/0. Without the guard that is NaN, which is not + // --- equal to 0f (the ConditionIsZeroWhenTheItemProducesNothing case above never hits this: its nominal + // --- stays 100 -- only the numerator goes to 0 there). --- + [Fact] + public void ConditionIsZeroNotNaNWhenTheNominalValueItselfDegeneratesToZero() + { + var thrust = Constant(0); // Min == Max == 0, no Terms -- both actual and nominal evaluate to exactly 0 + using var cache = OpenCatalog(new ThrusterData { Thrust = thrust, Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) }); + var ship = BuildShip(cache, reactorCharge: 1000, out _); + ship.Update(1f); + + Assert.Equal(0f, ship.GetBehavior().Condition, 4); + } + + // --- 6: presentation reading Condition must not have moved what the thruster actually does. Exact numbers + // --- from BrownoutTests.ThrusterAtFullGrantIsUnchangedFromToday and ...DegradesByItsAuthoredExponentNotLinearly, + // --- reproduced here against a ship that also reads Condition every tick, to prove Execute()'s own force + // --- math is untouched by this cut. --- + [Fact] + public void ThrustForceForAGivenInputIsUnchangedByThisCut() + { + var thrust = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using var cache = OpenCatalog(new ThrusterData { Thrust = thrust, Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) }); + var ship = BuildShip(cache, reactorCharge: 1000, out _); // demand 100, generation far exceeds it -> ratio 1 + ship.MovementDirection = float2(0, -1); // reverse-thruster axis += -MovementDirection.y == 1 + ship.Update(1f); + + // Reading Condition after Update (as presentation would every frame) must not perturb the result. + _ = ship.GetBehavior().Condition; + + Assert.Equal(.1f, -ship.Velocity.y, 3); // 100 thrust / 1000 mass * dt 1, exactly as BrownoutTests pins it + } + + [Fact] + public void ThrustForceDegradesByItsAuthoredExponentUnchangedByThisCut() + { + var thrust = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 2 } } }; + using var cache = OpenCatalog(new ThrusterData { Thrust = thrust, Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) }); + var ship = BuildShip(cache, reactorCharge: 50, out _); // demand 100, generation 50 -> ratio .5 + ship.MovementDirection = float2(0, -1); + ship.Update(1f); + + _ = ship.GetBehavior().Condition; + + // Thrust = lerp(0, 100, pow(.5, 2)) = 25; velocity delta = 25 / 1000 mass * dt 1 = .025. + Assert.Equal(.025f, -ship.Velocity.y, 3); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs new file mode 100644 index 000000000..921961b15 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -0,0 +1,543 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 3 (docs/fire-control-cut.md): pins hit authority itself -- the roll's inputs, the commit horizon, frozen +// payloads (Q6), seeding (Q5), the arc gate on player fire (Q2), and the damage rule moved verbatim onto +// Entity. Builds its own shooter/target fixture rather than reusing Cut 1's or Cut 2's private helpers, the +// same convention those files established. Each test's own comment names the mutation that must kill it. +public sealed class FireAuthorityTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-fireauthority-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireAuthorityTests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + // Cut 6d (docs/fire-control-cut.md): high enough that pOnHull saturates to 1 at this fixture's 5x5 + // hull's own centre of mass -- these tests pin the Accuracy cap and hit/miss timing, not the dart- + // throw kernel, so the unaided floor here is authored to stay out of their way, the same reason + // Spread is authored 0 in most of these fixtures to keep pSpread pinned at 1. + UnaidedPrecision = 2f, + AgentMinHitProbability = 0f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + } + + // A shooter (one Sensors-hardpoint weapon + a Tool-slot targeting system) and a target ship, both + // activated in the same zone, the target `targetRange` metres dead ahead (well within the wide default + // arc), fully detected and marked hostile so StanceAllowsFire and the reveal/detection gates never + // interfere with a test that isn't about them. + private Engagement Build( + GameplaySettings settings, + float damage = 10, float range = 1000, float minRange = 0, float velocity = 0, + float spread = 0, float damageSpread = 0, float penetration = 0, + // Cut 6d (docs/fire-control-cut.md): Precision now feeds HitProbability's pOnHull for every shot, aimed + // or not (the dart-throw kernel's sigma), where it used to matter only for the coin-flip aimed-item + // branch this cut deletes. The old default of 0 -- harmless when Precision only gated that branch -- + // would now flatten pOnHull to nearly nothing against this fixture's 5x5 hull (sigma = 1/Precision + // balloons), starving every test in this file that doesn't care about aim placement. 1 keeps pOnHull + // close to its ceiling for an unaimed shot at this hull's centre of mass without pinning it exactly. + float accuracy = 1, float resolution = 1, float precision = 1, float tracking = 1000, + float targetRange = 100, bool equipTargeting = true, float hullDurability = 1000, float armor = 0, + Action beforeActivate = null) + { + // 5x5 so InteriorCells (Shape.Shrink -- every cell with all 8 neighbours present) is the inner 3x3, + // room enough for more than one Tool item (ShieldTakesHit needs a Reactor and a Shield both). + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = hullDurability, Mass = 1000, Armor = armor, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(damage), Range = Constant(range), MinRange = Constant(minRange), + Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(damageSpread), + Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(accuracy), Resolution = Constant(resolution), Precision = Constant(precision), Tracking = Constant(tracking) + } } + }); + // Authored for every engagement (harmless unless equipped): a shield/reactor pair strong and fast + // enough to fill from cold in a handful of ticks, for ShieldTakesHit. Catalog entries are looked up by + // name through this same already-open CultCache handle, which does not see writes made to it after + // this point without a reopen (GetByName is a build-time index) -- so these are authored up front + // rather than upserted later from inside a test's beforeActivate. + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1000), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + if (equipTargeting) + { + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + } + + var targetHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + // Entity._orderedEquipment is populated only by TryEquip -- a ship that never equips anything leaves + // it null and Entity.Update throws regardless of this cut (FireControlTests' own fixture notes the + // same rake). Equip a harmless gun so the target is an ordinarily-constructed ship. + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + // Entity.TryEquip refuses once an entity is active ("don't allow equipping while deployed") -- any + // extra gear a test needs (a second aimable item, a shield, a reactor) must go on before Activate(). + beforeActivate?.Invoke(items, shooter, target); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + // A zero-dt warm-up tick populates the resolved weapon/targeting stats (UpdateStats runs inside + // Execute) before FireControl.Fire reads them, the same warm-up InputCapacitorTests uses. + zone.Update(0f); + + return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon }; + } + + private static InstantWeaponData DataOf(EquippedItem weaponItem) => + (InstantWeaponData) weaponItem.Data.Behaviors.Single(b => b is InstantWeaponData); + + // R1/Q5: two zones built identically, seeded identically, fire the same 50 shots (instant, so each + // commits and resolves in the tick it is stepped) and get byte-identical Hit/Cell/Shielded sequences. + // Mutation: FireControl draws from `new Random()` instead of the shooter's ItemManager.Random. + [Fact] + public void RollsAreSeeded() + { + List<(bool Hit, bool Shielded, int2 Cell)> Run(uint seed) + { + var e = Build(TestSettings(), accuracy: .6f, precision: .5f); + e.Items.Random = new Random(seed); + var results = new List<(bool, bool, int2)>(); + e.Zone.ShotResolved.Subscribe(outcome => results.Add((outcome.Hit, outcome.Shielded, outcome.Cell))); + for (var i = 0; i < 50; i++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + FireControl.Step(e.Zone, 0f); + } + return results; + } + + var a = Run(777u); + var b = Run(777u); + Assert.Equal(a, b); + } + + // FireControl.HitProbability, R1: zero with no draw when undetected or out of range; rises with info + // toward Resolution; falls with Spread; caps at UnaidedAccuracy with no targeting system equipped. + // Mutations: drop pSensor (probability stops depending on info); drop the range gate (probability nonzero + // outside [MinRange,Range]); drop the UnaidedAccuracy cap (an unaided shooter rolls as well as Accuracy). + [Fact] + public void ProbabilityFollowsInputs() + { + var e = Build(TestSettings(), accuracy: .8f, resolution: 1f, spread: 0f, targetRange: 100, range: 200); + + // Undetected: below TargetDetectionInfoThreshold (.1) -- zero regardless of everything else. + e.Shooter.EntityInfoGathered[e.Target] = 0f; + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target)); + + // Rising with info toward Resolution (1): more info, more probability, saturating at Resolution. + e.Shooter.EntityInfoGathered[e.Target] = .2f; + var low = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + e.Shooter.EntityInfoGathered[e.Target] = .6f; + var mid = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + e.Shooter.EntityInfoGathered[e.Target] = 1f; + var full = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + Assert.True(low < mid); + Assert.True(mid <= full); + Assert.True(full > 0f); + + // Out of range: zero. + e.Target.Position = float3(0, 0, 500); + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target)); + e.Target.Position = float3(0, 0, 100); + + // Falling with Spread: a wide-spread weapon on a small target scores lower than a zero-spread one. + DataOf(e.WeaponItem).Spread = Constant(0f); + e.Weapon.Execute(0f); + var noSpread = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + DataOf(e.WeaponItem).Spread = Constant(60f); + e.Weapon.Execute(0f); + var wideSpread = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + Assert.True(wideSpread < noSpread); + + // Capped at UnaidedAccuracy with no targeting system: a second engagement, otherwise identical, but + // never equips one. + var unaided = Build(TestSettings(), accuracy: .8f, resolution: 1f, spread: 0f, targetRange: 100, range: 200, equipTargeting: false); + unaided.Shooter.EntityInfoGathered[unaided.Target] = 1f; + Assert.Equal(unaided.Items.GameplaySettings.UnaidedAccuracy, FireControl.HitProbability(unaided.Weapon, unaided.Shooter, unaided.Target), 3); + } + + // Cut 7 (docs/fire-control-cut.md): this was OutOfArcConsumesNoDraw, which asserted that a shot gated to + // zero left ItemManager.Random untouched. Cut 6b (6.1) moved the roll onto a per-shot local generator, so + // Commit stopped touching that stream on EVERY path and the old test passed for a reason that had nothing + // to do with arcs -- it would have passed just as well with the target dead ahead, and no mutation of the + // short-circuit it named could ever turn it red. Rewritten to pin the rule that is actually live: no + // combat path draws from the shared stream, in arc or out. Mutation: restore the shared-stream read in + // Commit; the in-arc case then goes red. + [Theory] + [InlineData(100f, 0f)] // directly abeam: gated to zero, outside the default arc + [InlineData(0f, 100f)] // dead ahead: a real firing solution that actually rolls + public void CombatNeverDrawsFromTheSharedStream(float x, float z) + { + var e = Build(TestSettings(), accuracy: 1, resolution: 1, spread: 0); + e.Target.Position = float3(x, 0, z); + + const uint seed = 4242u; + var untouched = new Random(seed); + var expected = untouched.NextFloat(); + + e.Items.Random = new Random(seed); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + FireControl.Step(e.Zone, 0f); + var actual = e.Items.Random.NextFloat(); + + Assert.Equal(expected, actual); + } + + // R3: no durability change before the shot arrives (range/Velocity after this cut's fire), and a target + // removed mid-flight takes none. Mutation: apply the outcome at fire/commit time instead of arrival. + [Fact] + public void ShotResolvesOnArrival() + { + var e = Build(TestSettings(), damage: 100, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50); + // range 50, velocity 10 -> flight time 5s, commit at 4.5s (.5s horizon), arrival at 5s. + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var before = e.Target.Hull.Durability; + + e.Zone.Update(4f); // still short of commit (4.5s) + Assert.Equal(before, e.Target.Hull.Durability); + + // Cut 7 (docs/fire-control-cut.md, 7.2): an intermediate check between commit and arrival -- without + // it, jumping straight from before-commit to past-arrival lets "apply as soon as committed, not only + // once also arrived" (Step's arrival gate collapsed to just `if (shot.Committed)`) go unnoticed, since + // a single wide Zone.Update spanning both CommitTime and ArrivalTime crosses both thresholds in one + // call either way. + e.Zone.Update(.55f); // t=4.55: past commit (4.5s), still short of arrival (5s) + Assert.Equal(before, e.Target.Hull.Durability); + + e.Zone.Update(1f); // now past arrival + Assert.True(e.Target.Hull.Durability < before); + } + + // 0b table: a shot whose target left the zone resolves as a miss and is removed, taking nothing. + [Fact] + public void DeadEntityStopsTakingShots() + { + var e = Build(TestSettings(), damage: 100, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var before = e.Target.Hull.Durability; + + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(10f); // well past arrival + + Assert.Equal(before, e.Target.Hull.Durability); + SafeAssert.NoShots(e.Zone); + } + + // R4: ShotCommitted fires exactly CommitHorizon before ShotResolved, once per shot. Mutation: publish only + // at arrival, or publish twice. + [Fact] + public void OutcomeCommitsBeforeImpact() + { + var e = Build(TestSettings(), velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50); + var commits = new List(); + var resolves = new List(); + e.Zone.ShotCommitted.Subscribe(_ => commits.Add(e.Zone.Time)); + e.Zone.ShotResolved.Subscribe(_ => resolves.Add(e.Zone.Time)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + for (var i = 0; i < 100; i++) e.Zone.Update(.1f); // 10s of ticks, well past a 5s flight + + Assert.Single(commits); + Assert.Single(resolves); + Assert.Equal(e.Items.GameplaySettings.CommitHorizon, resolves[0] - commits[0], 2); + } + + // R4's second half: deviation counts up to the commit horizon and not after. A target that jinks off the + // predicted straight-line path before commit is judged on that jink; the identical jink after commit + // changes nothing, because the outcome is already frozen. Mutation: measure deviation at arrival instead + // of commit (both jinks would then matter), or measure it at fire (neither would). + [Fact] + public void EvasionCountsUntilCommitAndNotAfter() + { + // Tracking small enough that a real jink defeats it, Precision moot (no aim), full accuracy/detection. + GameplaySettings Settings() => TestSettings(); + + bool RunWithJink(float jinkTime, float commitHorizon) + { + var settings = Settings(); + settings.CommitHorizon = commitHorizon; + var e = Build(settings, damage: 10, velocity: 10, accuracy: 1, resolution: 1, spread: 0, tracking: 1f, targetRange: 50); + // Cut 5 (Soul's own named survivor): a stationary target left the map's declared mutation + // (elapsed = shot.FlightTime in place of now - shot.FireTime) invisible, because + // FireTargetVelocity * elapsed is identically zero either way. A real, nonzero velocity -- Ship. + // Update already integrates Position.xz += Velocity * delta every tick on its own, no manual + // tracking needed -- makes the mutation observable even with no jink at all: the mutant + // mispredicts the intercept by Velocity * CommitHorizon, which the post-commit-jink assertion + // below (expected to still hit) catches as a spurious miss. + // Cut 11: advance the zone clock before firing. Build leaves it at 0, so every shot fired with + // FireTime = 0 -- and `now - FireTime` equals `now + FireTime` when FireTime is 0. That is why the + // moving target Cut 5 added still left this test's own declared mutation (measure elapsed wrongly) + // alive: Stryker found `now + shot.FireTime` surviving. + e.Zone.Update(2.5f); + e.Target.Velocity = float2(4, 0); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var t = 0f; + const float dt = .05f; + var jinked = false; + var before = e.Target.Hull.Durability; + while (t < 6f && e.Zone.PendingShots.Count > 0) + { + if (!jinked && t >= jinkTime) + { + e.Target.Position += float3(50, 0, 0); // far outside Tracking's forgiveness + jinked = true; + } + e.Zone.Update(dt); + t += dt; + } + return e.Target.Hull.Durability < before; // true if the shot still landed + } + + // Flight time 5s, horizon 0.5s -> commit at t=4.5s. A jink well before commit (t=1s) is judged. + Assert.False(RunWithJink(jinkTime: 1f, commitHorizon: .5f)); + // The identical jink AFTER commit (t=4.6s, horizon .5s means commit at 4.5s) must not matter. + Assert.True(RunWithJink(jinkTime: 4.6f, commitHorizon: .5f)); + } + + // R4's graceful degradation: a velocity-0 (or otherwise sub-horizon) weapon commits and resolves within + // the same tick it fires, publishing both events, in order. Mutation: skip the commit for short flights + // (only ShotResolved fires, or damage applies with no ShotCommitted at all). + [Fact] + public void ShortFlightCommitsAtFire() + { + var e = Build(TestSettings(), velocity: 0, accuracy: 1, resolution: 1, spread: 0, damage: 10); + var order = new List(); + e.Zone.ShotCommitted.Subscribe(_ => order.Add("committed")); + e.Zone.ShotResolved.Subscribe(_ => order.Add("resolved")); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.016f); // one ordinary tick + + Assert.Equal(new[] { "committed", "resolved" }, order); + SafeAssert.NoShots(e.Zone); + } + + // R10/Q6: a shot's parameters freeze when the trigger is pulled. Changing the weapon's authored Damage + // stat (and refreshing it into the live behaviour before arrival) must not change what an already-fired + // shot deals. Mutation: re-evaluate Damage at arrival instead of using the frozen snapshot. + [Fact] + public void OutcomeIsSnapshotNotReread() + { + var e = Build(TestSettings(), damage: 10, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50, armor: 0); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var before = e.Target.Hull.Durability; + + // Mid-flight (before arrival at 5s): the gun's Damage stat collapses, and a fresh Execute re-reads it + // into the live Weapon.Damage -- simulating the "power cut, heat spike" scenario R10 names. + e.Zone.Update(2f); + DataOf(e.WeaponItem).Damage = Constant(99999f); + e.Weapon.Execute(0f); + Assert.Equal(99999f, e.Weapon.Damage, 1); + + e.Zone.Update(4f); // past arrival + var dealt = before - e.Target.Hull.Durability; + Assert.True(dealt > 0f); + Assert.True(dealt < 100f); // nowhere near the inflated 99999 value -- the original 10 landed + } + + // R5's payoff: with Precision authored extremely tight (Cut 6d) and p 1, the aimed item's durability + // falls. Sigma floors at half a cell (SigmaFloor) regardless of how tight Precision is authored, so the + // lateral draw is not actually deterministic onto the aimed item's own one-cell-wide lane -- it is + // Phi(.5)-Phi(-.5) ~= .68 per shot, not 1. This one Fire() call is a single draw, not a repeated + // statistical check; it passes reliably only because this file's fixture uses one fixed GalaxyZone name + // ("Test", :146), so CombatSeed and this exact draw are the same on every run (F9, Soul's fix batch, + // 2026-09-24: an earlier comment overclaimed "same-lane-every-time" as if this were architecturally + // guaranteed). Mutation: the draw ignores Aimed and always picks a uniform random hull lateral offset. + // Cut 12.2 (docs/fire-control-cut.md): aiming now only prices and draws the LATERAL offset (the item's + // own aim point still centres the scatter, same as before); depth along the shot's own travel direction is + // resolved by Lane to whichever occupied cell is nearest the entry surface, which for a solid hull is + // always a border cell, never a Tool item's own interior cell (Shape.Shrink). A direct, unpenetrating hit + // (penetration 0, the fixture's old default) can therefore only ever land on the border cell in the aimed + // item's own lane -- reaching the item itself now requires the same penetration march + // AimingAtAnItemCentresTheScatterOnItsLane and HitsLandOnTheFacingEdge exercise, so this fixture is given + // enough penetration (5, well past this 5x5 hull's own width) to march from the entry edge through to the + // interior cell the aimed item occupies. + [Fact] + public void AimedHitLandsOnSelectedItem() + { + EquippableItem aimedGear = null; + var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, precision: 1000, armor: 0, penetration: 5, + beforeActivate: (items, shooter, target) => + { + // A second, distinct interior item on the target to aim at, equipped before Activate() (Entity. + // TryEquip refuses once an entity is active). + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + aimedGear = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 5 }; + Assert.True(target.TryFindSpace(aimedGear, out var pos)); + Assert.True(target.TryEquip(aimedGear, pos)); + }); + var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear); + + e.Shooter.EntityInfoGathered[e.Target] = 1f; // fully revealed + Assert.True(e.Shooter.TrySelectTargetItem(aimedItem)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.1f); + + Assert.True(aimedItem.EquippableItem.Durability < 1f); + } + + // HardpointHitDamagesItemThenHull and PenetrationMarchIsPlanar (Entity.ApplyHit/DamageSchematic, deleted by + // Cut 12.3 -- docs/fire-control-cut.md) pinned two rules that no longer have that function to call: + // - "damage above armor plus item durability zeroes the item and the remainder hits the hull" is now + // FireControlCut123Tests.DamageIsAbsorbedInOrderAlongTheRay, through the real Fire/Commit/Apply path + // rather than a direct ApplyHit call. + // - "the penetration march is planar, in the target's own schematic frame" is subsumed by 12.2's own + // FireControlCut12Tests.HitsLandOnTheFacingEdge and TurningArmourIntoTheShotTakesItOnTheArmour (the + // bearing FireControl.Apply's lane walk reads is already the committed, schematic-frame bearing Commit + // computed, the same one those tests pin end-to-end) and by FireControlCut123Tests.ArmourFacesTheShot, + // which pins that the lane's own facing cell is planar across several non-axis-aligned facings. + + // The absorb rule now exists once, in FireControl: an active shield that CanTakeHit absorbs, and the + // schematic is untouched. Mutation: remove the shield branch (the hit always reaches the hull). + // + // Cut 6d (docs/fire-control-cut.md): damage raised from 10 to 50, well above the Reactor/Shield gear's own + // Durability of 10. Unaimed fire now lands via the dart-throw kernel instead of a uniform pick over the + // whole hull, and Build's own default Precision groups shots tightly enough around this fixture's small + // hull's centre of mass that the deterministic draw for this exact seed lands on one of the two equipped + // Tool items. At damage 10 that coincidentally consumed the item's own durability with zero remainder, + // which left the hull's durability unchanged with or without the shield mutation applied -- the shield + // branch could be deleted outright and this test would not have noticed. Damage well past what any single + // item on this hull could fully absorb makes the assertion mean what it says regardless of which cell the + // kernel happens to land on. + [Fact] + public void ShieldTakesHit() + { + var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0, + beforeActivate: (items, shooter, target) => + { + // The Reactor/Shield catalog entries are authored in Build's own upsert batch (see its + // comment). Equipped before Activate() (Entity.TryEquip refuses once an entity is active). + var reactorRef = items.ItemData.RefOf(items.ItemData.GetByName("Reactor")); + var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 7 }; + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + + var shieldRef = items.ItemData.RefOf(items.ItemData.GetByName("Shield")); + var shield = new EquippableItem { Data = shieldRef, Durability = 10, Lot = 8 }; + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + }); + e.Target.Shield.Item.Enabled.Value = true; + + for (var i = 0; i < 20; i++) e.Zone.Update(.1f); // charge the reserve + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 50)); + + var beforeHull = e.Target.Hull.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.1f); + + Assert.Equal(beforeHull, e.Target.Hull.Durability); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs new file mode 100644 index 000000000..ab2966025 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -0,0 +1,425 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 11 (docs/fire-control-cut.md): behavioural tests for what a Stryker run against FireControl.cs found +// undefended. Every test here states a rule as behaviour, observed through the public surface, and is the +// test whose absence let a real mutant survive. Builds its own fixtures, the convention every cut's test +// file follows. +public sealed class FireControlCut11Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut11-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut11Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings(float schematicCellSize = 1f) => 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 = schematicCellSize, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 2f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Engagement + { + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public EquippedItem PortMarker; + public EquippedItem StarboardMarker; + } + + // A shooter and a target. The shooter sits OFF the origin on purpose: a shooter at float3.zero makes + // `target - source` and `target + source` the same vector, which is exactly how four position mutants in + // FireControl.cs survived every earlier suite. + private Engagement Build( + string zoneName = "Cut11", + float damage = 5, float velocity = 0, float accuracy = 1, + bool equipShield = false, float shieldCapacity = 30, + bool markers = false, float spread = 0, float schematicCellSize = 1f) + { + // 5 wide x 3 tall: schematic x runs port (0) to starboard (4). Tool gear may only occupy interior + // cells, which on this hull is the middle row x = 1..3, so the markers sit at (1, 1) and (3, 1) -- + // one cell either side of the centre of mass (2, 1). + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 3), Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 2), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0), + Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(accuracy), Resolution = Constant(1000), Precision = Constant(1), Tracking = Constant(100000) + } } + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 100, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 16; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, TestSettings(schematicCellSize), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = 1 }, new EntitySettings()); + var gun = Make("Gun", 2, 1); + Assert.True(shooter.TryEquip(gun, new int2(2, 2))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var targeting = Make("Targeting", 3, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = 4 }, new EntitySettings()); + Assert.True(target.TryEquip(Make("Gun", 5, 1), new int2(2, 2))); + + EquippedItem port = null, starboard = null; + if (markers) + { + var p = Make("Marker", 6, 100); + var s = Make("Marker", 7, 100); + Assert.True(target.TryEquip(p, new int2(1, 1)), "port marker must fit an interior cell"); + Assert.True(target.TryEquip(s, new int2(3, 1)), "starboard marker must fit an interior cell"); + port = target.Equipment.Single(x => x.EquippableItem == p); + starboard = target.Equipment.Single(x => x.EquippableItem == s); + } + + if (equipShield) + { + var reactor = Make("Reactor", 8, 10); + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + var shield = Make("Shield", 9, 10); + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3(37, 0, -11); + target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + if (equipShield) target.Shield.Item.Enabled.Value = true; + + zone.Update(0f); + + return new Engagement + { + Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, + Weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon), + PortMarker = port, StarboardMarker = starboard + }; + } + + private static void Charge(Engagement e) + { + for (var i = 0; i < 20; i++) e.Zone.Update(.1f); + } + + // ---- The shield pays for what it absorbs. ---- + + // Deleting Shield.TakeHit from FireControl.Apply survived every earlier suite, because they asserted only + // that an absorbed hit left the hull untouched -- never that the shield paid for it. A shield that absorbs + // must eventually run dry. Stepped with dt = 0 so the (near-instant) refill cannot top the reserve back up + // between shots. Mutation: delete TakeHit from the discrete path; the reserve never drains. + [Fact] + public void DiscreteShieldAbsorptionDrainsTheReserve() + { + var e = Build(damage: 5, equipShield: true, shieldCapacity: 30); + Charge(e); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); + var hull = e.Target.Hull.Durability; + + var shots = 0; + while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && shots < 200) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(0f); + shots++; + } + + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); // the reserve ran dry + Assert.False(e.Target.Shield.Broken); // by paying, not by breaking + Assert.Equal(hull, e.Target.Hull.Durability); // and every hit was absorbed + } + + // The same rule on a blast's path (12.4(b) rewrites this to Detonate, docs/fire-control-cut.md + // "ShieldPaysForWhatReachesIt": "the reserve drops by the entity's covered share, not by the whole + // blast"), whose absorb branch had no test coverage at all. The radius sits well inside the hull's own + // solid interior (the 5x3 shape's centre of mass, at least 1 cell from every edge) so the disc never spills + // off the hull -- the covered share equals the whole damage exactly, the same amount Splash always charged, + // so this expectation is not loosened. Mutation: delete TakeHit from Detonate's absorb branch. + [Fact] + public void ABlastShieldAbsorptionDrainsTheReserve() + { + var e = Build(equipShield: true, shieldCapacity: 30); + Charge(e); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); + var hull = e.Target.Hull.Durability; + + var blasts = 0; + while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && blasts < 200) + { + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 5, damageType: DamageType.Kinetic); + blasts++; + } + + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); + Assert.False(e.Target.Shield.Broken); + Assert.Equal(hull, e.Target.Hull.Durability); + } + + // ---- A shot whose target is gone resolves as a miss (Soul C4). ---- + + // The 0b table says a shot whose target has left the zone resolves as a miss "whichever stage it is at." + // Step only rewrote the outcome on the uncommitted branch, so a hit committed inside the horizon and then + // orphaned by the target dying was republished as a hit on ShotResolved -- a hit marker on a corpse. The + // committed outcome is not mutated (R4 keeps it immutable); the resolution is published as its own miss. + [Fact] + public void ShotAtTargetThatLeavesResolvesAsMiss() + { + var e = Build(velocity: 20); // 100 units at 20 u/s: flight 5 s, commits at 4.5 s + ShotOutcome committed = null, resolved = null; + var commits = 0; + using var c = e.Zone.ShotCommitted.Subscribe(o => { committed = o; commits++; }); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(4.7f); // past the commit, short of arrival + Assert.NotNull(committed); + Assert.True(committed.Hit, "precondition: this fixture's shot must commit as a hit"); + Assert.Null(resolved); + // What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s. + Assert.Equal(.3f, committed.ArrivalIn, 3); + + e.Zone.Entities.Remove(e.Target); // the target is gone before the shot arrives + e.Zone.Update(1f); + + Assert.NotNull(resolved); + Assert.Equal(committed.ShotId, resolved.ShotId); + Assert.False(resolved.Hit, "a shot whose target left the zone must resolve as a miss, not publish its committed hit"); + Assert.True(committed.Hit, "the committed outcome itself is not rewritten (R4)"); + Assert.Equal(1, commits); // a shot commits once; its orphaned resolution is not a second commit + } + + // The other stage of the same rule: a target gone before the shot even commits. The shot never got its + // commit, so the miss is published as the commit too -- a listener waiting on ShotCommitted for this + // ShotId must not wait forever -- and it is a plain miss, with no shield hit or shield break attached. + [Fact] + public void ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss() + { + var e = Build(velocity: 20, equipShield: true); // commits at 4.5 s + Charge(e); + var committed = new List(); + var resolved = new List(); + using var c = e.Zone.ShotCommitted.Subscribe(committed.Add); + using var r = e.Zone.ShotResolved.Subscribe(resolved.Add); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(1f); + SafeAssert.Empty(committed); + + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(1f); + + 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"); + } + SafeAssert.NoShots(e.Zone); + } + + // ---- A targeting system's Tracking is what forgives a target that moves off its predicted line. ---- + + // Tracking fell back to GameplaySettings.UnaidedTracking under a mutant that ignored the fitted system, + // and nothing noticed: every fixture's target sat still, so deviation was 0 and Tracking divided nothing. + // Here the target jinks 5 units sideways mid-flight. The fitted system (Tracking 100000) shrugs it off; + // unaided Tracking (10) would halve the shot's chance. + [Fact] + public void AFittedTargetingSystemForgivesAJink() + { + var e = Build(velocity: 20); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(1f); + e.Target.Position += float3(5, 0, 0); + + var shot = e.Zone.PendingShots.Single(); + var p = FireControl.DeviationProbability(shot, e.Zone.Time, out var deviation); + Assert.Equal(5f, deviation, 3); + Assert.True(p > .99f, $"a 5-unit jink against Tracking 100000 must barely matter, got {p}"); + } + + // ---- A beam's shot carries the interval's damage, not the weapon's. ---- + + // ConstantWeapon fires one shot per BeamResolveInterval for Damage * interval, through Fire's + // damageOverride. Dropping the override froze the weapon's full Damage into every one of those shots -- + // a beam doing ten times its damage at the default interval -- and survived, because the beam test + // counted its rolls and multiplied the count by the expected damage itself. + [Fact] + public void ADamageOverrideIsWhatTheShotCarries() + { + var e = Build(damage: 5, velocity: 20); + var full = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var partial = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride: .5f); + + Assert.Equal(5f, e.Zone.PendingShots.Single(s => s.ShotId == full).Damage); + Assert.Equal(.5f, e.Zone.PendingShots.Single(s => s.ShotId == partial).Damage); + } + + // ---- Splash damages the side the blast came from. ---- + // + // Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in + // FireControlCut124Tests.cs succeeds this test and Cut4Tests.SplashIsDirectional -- a blast is now an area + // (12.4(b)), not a directional half-hull split, so "the side the blast came from" is pinned by which cells + // the disc actually covers, not by a facing-rotated half-shape. + + // ---- pSpread is the share of the spread cone the target's silhouette covers. ---- + + // Every earlier fixture used zero spread (pSpread is then 1 regardless) and a cell size of exactly 1 (so + // multiplying and dividing by it agree), which left the whole spread term untested. This pins what the + // number MEANS rather than how it is computed: a silhouette that exactly fills the cone scores 1, one half + // the cone's angular size scores .5, and one bigger than the cone saturates at 1. Here the 5-wide hull at a + // cell size of 2 has a half-extent of 5 world units, against a 10-degree cone (5-degree half-angle). + [Fact] + public void PSpreadIsTheSilhouettesShareOfTheSpreadCone() + { + const float halfExtent = 5f, halfCone = 5f; + float At(float range) + { + var e = Build(spread: 2 * halfCone, schematicCellSize: 2f); + e.Target.Position = e.Shooter.Position + float3(0, 0, range); + return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PSpread; + } + + var fills = halfExtent / (float) Math.Tan(halfCone * Math.PI / 180); // silhouette == cone + var half = halfExtent / (float) Math.Tan(halfCone / 2 * Math.PI / 180); // silhouette == half the cone + Assert.Equal(1f, At(fills), 2); + Assert.Equal(.5f, At(half), 2); + Assert.Equal(1f, At(fills / 3), 2); // bigger than the cone: saturates, never exceeds 1 + Assert.True(At(half * 2) < At(half)); // and falls away with range + } + + // ---- Zones roll differently. ---- + + // The roll is a function of (zone, shot id). Replacing `CombatSeed *` with `/` in Commit's seed drops the + // zone out of it -- integer division by a large constant is zero for almost every seed -- so every zone + // rolls the same sequence. That survived, because the die test only checks uniformity within one zone. + // Same shot ids in two zones must not produce the same hit sequence. + [Fact] + public void DifferentZonesRollDifferently() + { + bool[] Sequence(string zoneName) + { + var e = Build(zoneName: zoneName, accuracy: .5f); + var p = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); // accuracy times the other factors + var hits = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => hits.Add(o.Hit)); + for (var i = 0; i < 400; i++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(0f); + } + Assert.Equal(400, hits.Count); + var fraction = hits.Count(h => h) / 400f; + Assert.True(Math.Abs(fraction - p) < .08f, $"zone {zoneName}: hit fraction {fraction:F3} is not near p = {p:F3}"); + return hits.ToArray(); + } + + var a = Sequence("Rhea"); + var b = Sequence("Mimas"); + var differ = a.Zip(b, (x, y) => x != y).Count(d => d) / 400f; + // Two independent rolls at p ~ .4 disagree 2p(1-p) ~ 48% of the time; identical seeds disagree never. + Assert.True(differ > .3f, $"only {differ:P1} of shots differ between zones -- the zone is not reaching the roll"); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs new file mode 100644 index 000000000..b01d04b76 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -0,0 +1,1935 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): a resolved hit's damage now travels down +// its own lane -- armour, then the occupying item, then whatever is left to the hull, in order -- replacing +// Entity.DamageSchematic's even split and Entity.ApplyHit's 0.5-step march (both deleted by this cut). Builds +// its own fixtures, the convention every cut's test file follows. Every armour value this file cares about is +// set directly on Entity.Armor after Build, overriding the fixture's own flat HullData.Armor -- the only way +// to get a non-uniform plate without a second hull-authoring path. +public sealed class FireControlCut123Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut123-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut123Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = .3f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public HullData HullData; + public EquippedItem[] Markers; + public EquippedItem Cockpit; + public Dictionary Custom = new Dictionary(); + } + + // A shooter off the origin (Cut 11.3), a target over a caller-supplied hull shape, straight ahead so the + // default facing (0,1) makes the shot travel exactly (0,1) in world XZ (velocity 0 -- PredictedIntercept + // returns target.Position exactly, and the shooter/target share the same world X) -- the same clean + // geometry FireControlCut12Tests' own SigmaFloor fixture relies on. A full-strength targeting system + // (Accuracy 1, Resolution huge) so only the fixture's own geometry decides the numbers. + private static Shape LShape() { var s = new Shape(2, 2); s[new int2(0, 0)] = true; s[new int2(1, 0)] = true; s[new int2(0, 1)] = true; return s; } + + private Engagement Build( + GameplaySettings settings, Shape hullShape, float precision, + float penetration = 0, float damageSpread = 0, float2? targetFacing = null, + (int2 Cell, float Durability)[] markers = null, int2? cockpitCell = null, float cockpitDurability = 40, + (int2 Cell, float Durability)[] bars = null, (int2 Cell, float Durability)[] lBars = null, + (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000, Armor = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(5), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(damageSpread), + Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f) + } } + }); + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new GearData + { + Name = "Cockpit", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new CockpitData() } + }); + // A multi-cell item (2x1), for the proportional-absorption fix batch's own fixtures -- the only shape + // that can ever be shared between two lanes (cells are disjoint between lanes; only an item spanning + // several cells can straddle two of them). + cache.Upsert(new GearData + { + Name = "Bar", Hardpoint = HardpointType.Tool, Shape = SolidShape(2, 1), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + // A non-convex multi-cell item (an L: three cells of a 2x2 box), for F8's model sweep -- a straight + // lane can cross an L's two arms non-contiguously, which a plain 2x1 bar can never do. + cache.Upsert(new GearData + { + Name = "LBar", Hardpoint = HardpointType.Tool, Shape = LShape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + if (custom != null) + foreach (var cu in custom) + cache.Upsert(new GearData { Name = cu.Name, Hardpoint = HardpointType.Tool, Shape = cu.Shape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; // F8: shared-item fixtures equip more items (bars) than earlier cuts needed + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut123", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var lot = 1; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var gun = Make("Gun", lot++, 1); + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + + var targetGun = Make("Gun", lot++, 1); + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + var markerItems = markers?.Select(m => + { + var mi = Make("Marker", lot++, m.Durability); + Assert.True(target.TryEquip(mi, m.Cell), $"marker must fit at {m.Cell}"); + return target.Equipment.Single(x => x.EquippableItem == mi); + }).ToArray(); + + if (bars != null) + foreach (var bar in bars) + { + var bi = Make("Bar", lot++, bar.Durability); + Assert.True(target.TryEquip(bi, bar.Cell), $"bar must fit at {bar.Cell}"); + } + if (lBars != null) + foreach (var bar in lBars) + { + var bi = Make("LBar", lot++, bar.Durability); + Assert.True(target.TryEquip(bi, bar.Cell), $"L-bar must fit at {bar.Cell}"); + } + + var customItems = new Dictionary(); + if (custom != null) + foreach (var cu in custom) + { + var ci0 = Make(cu.Name, lot++, cu.Durability); + Assert.True(target.TryEquip(ci0, cu.Cell), $"custom {cu.Name} must fit at {cu.Cell}"); + customItems[cu.Name] = target.Equipment.Single(x => x.EquippableItem == ci0); + } + + EquippedItem cockpit = null; + if (cockpitCell != null) + { + var ci = Make("Cockpit", lot++, cockpitDurability); + Assert.True(target.TryEquip(ci, cockpitCell.Value), $"cockpit must fit at {cockpitCell.Value}"); + cockpit = target.Equipment.Single(x => x.EquippableItem == ci); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + // Cut 11.3: the shooter sits off the origin. + shooter.Position = float3(37, 0, -11); + target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter -- travel direction is exactly (0,1) + if (targetFacing != null) target.Direction = targetFacing.Value; + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(1f); // fire times nonzero (Cut 11.3) + + return new Engagement + { + Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, + HullData = hullData, Markers = markerItems, Cockpit = cockpit, Custom = customItems + }; + } + + // Fires until a hit lands, retrying with a fresh shot each time (a miss already resolved and left the + // pending queue empty) -- the roll can still miss outright, and none of these tests care which attempt + // actually connects. + private static ShotOutcome FireUntilHit(Engagement e, float? damageOverride = null, int attempts = 60) + { + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + for (var i = 0; i < attempts && (outcome == null || !outcome.Hit); i++) + { + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride); + e.Zone.Update(.01f); // velocity 0 -> zero flight time, commits and resolves in this tick + } + return outcome; + } + + // DamageIsAbsorbedInOrderAlongTheRay replaces FireAuthorityTests.HardpointHitDamagesItemThenHull and + // PenetrationMarchIsPlanar (both called the deleted Entity.ApplyHit directly). A 3x3 solid hull's only + // interior cell (1,1) -- Shape.Shrink needs all 8 neighbours present -- carries a Tool item, so the marker + // must sit on the centre column; every cell of that column shares the SAME lateral shadow (ell has only an + // x component at this bearing, and every column-1 cell has x=1), so once a shot's committed Lateral falls + // in that column's own interval, its lane is the full 3-cell column in near-to-far order -- (1,0) is always + // the impact cell there, regardless of precision. Retries until a hit lands on that column (the other two + // columns' own shadows are adjacent, not overlapping, so this is just "which column the draw picked", not a + // low-probability corner). Damage 30, penetration 3: a 10-armour front cell, then the marker, then a + // 50-armour soft cell the remainder never reaches. + // Kills: an even split (would put damage/3 = 10 on every cell, exactly canceling the front armour by + // coincidence but consuming only 10 of the marker's durability and 10 off the soft cell's -- distinguishing + // both); items absorbing before armour (irrelevant here since the front cell carries no item, but the + // marker cell's own Absorb call still checks armour(0) before the item); no carry-forward (would re-deal + // the full 30 fresh to the marker instead of the 20 that survived the front cell, and would put 30 on the + // soft cell instead of 0). + [Fact] + public void DamageIsAbsorbedInOrderAlongTheRay() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: 3f, + markers: new[] { (new int2(1, 1), 1000000f) }); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[1, 0] = 10f; e.Target.MaxArmor[1, 0] = 10f; // front: a plate + e.Target.Armor[1, 2] = 50f; e.Target.MaxArmor[1, 2] = 50f; // soft cell: armoured but never reached + + var marker = e.Markers[0]; + var markerDurabilityBefore = marker.EquippableItem.Durability; + var hullBefore = e.Target.Hull.Durability; + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 1); attempt++) + outcome = FireUntilHit(e, damageOverride: 30f, attempts: 1); + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit on the centre column within the attempt budget"); + Assert.Equal(new int2(1, 0), outcome.Cell); // fixture precondition: (1,0) is always this column's impact cell + + Assert.Equal(0f, e.Target.Armor[1, 0]); // 10 armour fully consumed + Assert.Equal(markerDurabilityBefore - 20f, marker.EquippableItem.Durability, 2); // the carried-forward 20 + Assert.Equal(50f, e.Target.Armor[1, 2]); // untouched -- the remainder never reached it + Assert.Equal(hullBefore, e.Target.Hull.Durability); // nothing left over for the hull + } + + // ArmourFacesTheShot: only the bow half (y >= height/2) of a 1x9 hull carries a plate, penetration 0 so only + // the impact cell can ever record a hit. For each of three facings, including one where |fx| > |fy|, the + // shooter is repositioned so the shot's own TravelDirection is EXACTLY that facing -- the same relationship + // FireControlCut12Tests.TurningArmourIntoTheShotTakesItOnTheArmour uses (Direction = travelDirection exposes + // the stern; Direction = -travelDirection exposes the bow), just repeated at three different WORLD + // directions instead of one, so the bearing this produces is exactly (0,1) or (0,-1) in schematic space + // every time (R7: the schematic frame is planar for any world facing, not only the default one) -- with no + // diagonal bearing in play, there is no lane-selection ambiguity to fight, only Entity.ToSchematic's own + // rotation to check. + [Fact] + public void ArmourFacesTheShot() + { + var shape = SolidShape(1, 9); + var e = Build(TestSettings(), shape, precision: .4f, penetration: 0f); + for (var y = 0; y < shape.Height; y++) + { + e.Target.Armor[0, y] = y >= 5 ? 1f : 0f; + e.Target.MaxArmor[0, y] = e.Target.Armor[0, y]; + } + + void CheckOneDirection(float2 travelDirection, bool bowFacesShot) + { + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + e.Target.Direction = bowFacesShot ? -travelDirection : travelDirection; + + var actualTravel = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + Assert.Equal(travelDirection.x, actualTravel.x, 3); // fixture precondition + Assert.Equal(travelDirection.y, actualTravel.y, 3); + + var hitCells = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hitCells.Add(x.pos)); + for (var i = 0; i < 60; i++) { FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } + + Assert.True(hitCells.Count > 5, $"expected several hits for travel direction {travelDirection}, bow facing {bowFacesShot}, got {hitCells.Count}"); + foreach (var c in hitCells) + if (bowFacesShot) Assert.True(c.y >= 5, $"bow should face the shot, but {c} is on the stern half"); + else Assert.True(c.y < 5, $"stern should face the shot, but {c} is on the bow half"); + } + + void Check(float2 travelDirection) + { + travelDirection = normalize(travelDirection); + CheckOneDirection(travelDirection, bowFacesShot: true); + CheckOneDirection(travelDirection, bowFacesShot: false); + } + + Check(float2(0, 1)); + Check(float2(.8f, .6f)); + Check(float2(.9f, .3f)); // |fx| > |fy| + } + + // BuriedCockpitNeedsPenetration: a 3x3 solid hull's only interior cell (1,1) -- Shape.Shrink needs all 8 + // neighbours present, which only the centre of a 3x3 has -- carries a Cockpit item, ringed by the hull's + // own 8 border cells. Firing straight down any of the 4 axis-aligned bearings crosses exactly one ring + // cell before the cockpit (and one more after, unread since nothing needs to go further). + [Fact] + public void BuriedCockpitNeedsPenetration() + { + float RunOnce(float2 facing, float penetration, float damage, float ringArmor) + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: penetration, + targetFacing: facing, cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + foreach (var c in e.HullData.Shape.Coordinates) + { + var isRing = c.x != 1 || c.y != 1; + e.Target.Armor[c.x, c.y] = isRing ? ringArmor : 0f; + e.Target.MaxArmor[c.x, c.y] = e.Target.Armor[c.x, c.y]; + } + var before = e.Cockpit.EquippableItem.Durability; + var outcome = FireUntilHit(e, damageOverride: damage); + Assert.NotNull(outcome); + Assert.True(outcome.Hit, $"fixture: facing {facing} must eventually hit"); + return before - e.Cockpit.EquippableItem.Durability; + } + + // Penetration 0: only the ring's own facing cell is ever reached -- the cockpit, one cell further in, + // must be untouched from all four axis-aligned bearings. + foreach (var facing in new[] { float2(0, 1), float2(0, -1), float2(1, 0), float2(-1, 0) }) + Assert.Equal(0f, RunOnce(facing, penetration: 0f, damage: 40f, ringArmor: 5f)); + + // A penetration that clears the ring (one cell, entry difference ~1): the cockpit takes exactly the + // damage minus what the ring absorbed. + var lost = RunOnce(float2(0, 1), penetration: 2f, damage: 40f, ringArmor: 5f); + Assert.Equal(35f, lost, 2); + + // Enough damage, with the ring cleared, destroys the cockpit and fires Death with CockpitDestroyed. + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: 2f, + targetFacing: float2(0, 1), cockpitCell: new int2(1, 1), cockpitDurability: 20f); + foreach (var c in e.HullData.Shape.Coordinates) + { + var isRing = c.x != 1 || c.y != 1; + e.Target.Armor[c.x, c.y] = isRing ? 0f : 0f; + e.Target.MaxArmor[c.x, c.y] = 0f; + } + CauseOfDeath? death = null; + using var d = e.Target.Death.Subscribe(cod => death = cod); + var outcome = FireUntilHit(e, damageOverride: 100f); + Assert.NotNull(outcome); + Assert.Equal(CauseOfDeath.CockpitDestroyed, death); + } + } + + // SpreadWidensTheImpactAcrossLanes: spread 1 (n=1, three parallel lanes one cell apart) at penetration 0 -- + // a 3-wide, 2-tall hull, stern (y=0) facing the shot (default facing == travel direction, Cut 12's own + // TurningArmourIntoTheShotTakesItOnTheArmour convention). Retries until the committed impact lands on the + // centre column (x=1), so the three lanes are exactly this hull's three columns, each with its own + // facing-edge cell at y=0. + [Fact] + public void SpreadWidensTheImpactAcrossLanes() + { + var e = Build(TestSettings(), SolidShape(3, 2), precision: .6f, penetration: 0f, damageSpread: 1f); + for (var x = 0; x < 3; x++) + for (var y = 0; y < 2; y++) + { + e.Target.Armor[x, y] = 0f; + e.Target.MaxArmor[x, y] = 0f; + } + + ShotOutcome outcome = null; + var hitCells = new List<(int2 pos, float damage)>(); + using (e.Target.ArmorDamage.Subscribe(x => hitCells.Add(x))) + { + for (var attempt = 0; attempt < 200; attempt++) + { + hitCells.Clear(); + outcome = FireUntilHit(e, attempts: 1); + if (outcome != null && outcome.Hit && outcome.Cell.x == 1) break; + } + } + + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit landing on the centre column within the attempt budget"); + + // Exactly the three facing-edge cells (y=0), damage/3 each; nothing behind them (y=1). + var byCell = hitCells.GroupBy(h => h.pos).ToDictionary(g => g.Key, g => g.Sum(h => h.damage)); + Assert.Equal(3, byCell.Count); + foreach (var x in new[] { 0, 1, 2 }) + { + Assert.True(byCell.TryGetValue(new int2(x, 0), out var d), $"expected a hit on facing cell ({x},0)"); + Assert.Equal(5f / 3f, d, 3); // weapon Damage is 5 (this fixture's Gun, no override) + Assert.False(byCell.ContainsKey(new int2(x, 1)), $"cell ({x},1) is behind the facing edge and must be untouched"); + } + } + + // ARayThroughACornerCrossesTheCornerCell: the diagonal case the old 0.5-step march (Entity.ApplyHit, + // deleted by this cut) could skip. A bearing angled off both axes and a lateral offset comfortably inside + // one cell's own shadow (not on a shared boundary) crosses several cells; the exact slab walk must not + // drop one of them the way a fixed-step Euclidean march could stumble at a shared corner. + [Fact] + public void ARayThroughACornerCrossesTheCornerCell() + { + var shape = SolidShape(3, 3); + var hull = new HullData { Name = "Diagonal", HullType = HullType.Ship, Shape = shape }; + var b = normalize(float2(1, 1)); + var buffer = new LaneCell[shape.Coordinates.Length]; + + var count = FireControl.Lane(hull, b, -0.1f, buffer); + var cells = Enumerable.Range(0, count).Select(i => buffer[i].Cell).ToList(); + + Assert.Contains(new int2(0, 0), cells); + Assert.Contains(new int2(1, 1), cells); + Assert.Contains(new int2(2, 2), cells); + Assert.True(cells.IndexOf(new int2(0, 0)) < cells.IndexOf(new int2(1, 1))); + Assert.True(cells.IndexOf(new int2(1, 1)) < cells.IndexOf(new int2(2, 2))); + } + + // LaneRemainderReachesTheHull (Q12-3 = A): a ray that exits a thin, bare hull -- both cells reached, no + // armour anywhere -- delivers its whole remainder to Hull.Durability, not to nowhere. + [Fact] + public void LaneRemainderReachesTheHull() + { + var e = Build(TestSettings(), SolidShape(1, 2), precision: 1f, penetration: 5f); + e.Target.Armor[0, 0] = 0f; e.Target.MaxArmor[0, 0] = 0f; + e.Target.Armor[0, 1] = 0f; e.Target.MaxArmor[0, 1] = 0f; + // Every fixture's target carries a bystander Gun at (0,0) (Entity.Update NREs on an entity that never + // equipped anything) -- irrelevant to this test, but its own 1 durability would otherwise eat 1 of the + // 40 damage as a confound. Zeroing it here removes that, matching what "no armour or item anywhere" + // actually needs. + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + + var hullBefore = e.Target.Hull.Durability; + var outcome = FireUntilHit(e, damageOverride: 40f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.Equal(hullBefore - 40f, e.Target.Hull.Durability, 2); + } + + // Stryker survivor triage (this cut's own changed lines), retargeted to the public owner after 12.4(b) + // deletes Entity.Absorb (docs/fire-control-cut.md, "Retargeted, because Absorb is deleted"): + // ArmorAbsorb's `d > 0f` guard boundary-flipped to `d >= 0f` survived -- at exactly 0 incoming, real code + // emits nothing ("only for incoming > 0"), the mutant would additionally fire ArmorDamage(cell, 0). Not a + // rare float coincidence: a spent lane (rem already 0) walks past every remaining cell in + // FireControl.Apply's own loop, so this is the ordinary "nothing left" case, not an edge case. + [Fact] + public void ArmorAbsorbEmitsNothingAtExactlyZero() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); + e.Target.Armor[1, 1] = 5f; e.Target.MaxArmor[1, 1] = 5f; + + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + + var remainder = e.Target.ArmorAbsorb(new int2(1, 1), 0f); + + Assert.Equal(0f, remainder); + Assert.Equal(0, armorEvents); + Assert.Equal(5f, e.Target.Armor[1, 1]); + } + + // Stryker survivor, retargeted to the public owner after 12.4(b) deletes Entity.Absorb: ItemAbsorb's + // `total <= 0.1f` guard survived at exactly 0.1f -- real code leaves the item untouched, the mutant would + // additionally spend it on the item. A spent lane still walks cells with a remainder of 0, so this is + // pinned as a pool of exactly one contribution, ItemAbsorb's own degenerate case. + [Fact] + public void ItemAbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); + var marker = e.Markers[0]; + + var itemEvents = 0; + using var i = e.Target.ItemDamage.Subscribe(_ => itemEvents++); + + Span incoming = stackalloc float[1] { .1f }; + e.Target.ItemAbsorb(marker, incoming); + + Assert.Equal(0.1f, incoming[0], 3); + Assert.Equal(0, itemEvents); + Assert.Equal(50f, marker.EquippableItem.Durability); + } + + // Stryker survivor: Entity.DamageHull's `damage > .1f` guard boundary-flipped to `damage >= .1f` survived -- + // at exactly 0.1f, real code does nothing; the mutant would subtract it from Hull.Durability and fire + // HullDamage. + [Fact] + public void DamageHullDoesNothingAtExactlyPointOne() + { + var e = Build(TestSettings(), SolidShape(1, 2), precision: 1f); + var before = e.Target.Hull.Durability; + var events = 0; + using var h = e.Target.HullDamage.Subscribe(_ => events++); + + e.Target.DamageHull(0.1f); + + Assert.Equal(0, events); + Assert.Equal(before, e.Target.Hull.Durability); + } + + // Stryker survivor: FireControl.Apply's `target.IncomingHit.OnNext(shot.Source)` (moved here from the + // deleted Entity.ApplyHit's own first line) survived a Statement-to-";" mutation -- nothing asserted that + // it still fires on a resolved, unshielded hit. + [Fact] + public void ApplyFiresIncomingHitOnAResolvedHit() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f); + Entity source = null; + using var s = e.Target.IncomingHit.Subscribe(src => source = src); + + var outcome = FireUntilHit(e, damageOverride: 5f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Same(e.Shooter, source); + } + + // Stryker survivors: FireControl.Apply's two `walked[li] > 0` checks (metalLanes counting, and the + // absorb-loop entry) both boundary-flipped to `walked[li] >= 0` survived -- since a lane count can never be + // negative, `>= 0` is always true, so BOTH mutants make every lane count as "met metal" and enter the + // absorb loop, even a lane that is entirely off the hull. A 1-wide hull with spread 1 (3 lanes: centre plus + // one on each side) puts the two side lanes off-hull by construction -- only the centre lane can ever find + // metal. The metalLanes mutant would divide the shot's damage by 3 instead of 1 (a wrong, smaller number + // reaching the one real cell); the loop-entry mutant would additionally walk the two off-hull lanes with + // `walked[li]` still correctly 0 iterations internally, but still fall through to `hullDamage += rem` + // (rem never consumed), leaking a full extra damagePerLane share into the hull for each miss. + [Fact] + public void LanesThatMissTheHullNeitherDiluteTheSplitNorLeakDamage() + { + var e = Build(TestSettings(), SolidShape(1, 9), precision: .4f, penetration: 0f, damageSpread: 1f); + for (var y = 0; y < 9; y++) { e.Target.Armor[0, y] = 1000f; e.Target.MaxArmor[0, y] = 1000f; } + + var hullBefore = e.Target.Hull.Durability; + var hits = new List<(int2 pos, float damage)>(); + using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x)); + + var outcome = FireUntilHit(e, damageOverride: 30f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.Single(hits); // only the centre lane's own impact cell ever records armour damage + Assert.Equal(30f, hits[0].damage, 2); // the WHOLE damage, not damage/3 -- metalLanes counted only the hit + Assert.Equal(hullBefore, e.Target.Hull.Durability); // nothing leaked to the hull from the off-hull lanes + } + + // Stryker survivor: FireControl.Apply's penetration cutoff (`Entry - impactEntry >= shot.Penetration`) + // boundary-flipped to `>` survived -- not a rare float coincidence: an axis-aligned lane's entry values are + // exact integers (schematic cell spacing), so penetration authored as an exact integer routinely lands + // exactly on a cell's own entry difference. Penetration 2 exactly against a 3-cell column (entries ~0,1,2): + // the third cell sits exactly AT the boundary and must be excluded ("< penetration" is reached; the mutant + // would let it through). + // F3 fix batch (Soul, 2026-09-25): the marker at (1,1) used to carry 1e6 durability, so it alone absorbed + // the whole 30 damage before the walk could ever reach (1,2) -- the boundary cell took 0 whether the `>` + // mutant let it through or not, so the test could not fail. The marker now carries only 10, leaving a + // remainder large enough that (1,2) would visibly lose armour if the walk reached it. + [Fact] + public void PenetrationExcludesACellExactlyAtItsOwnBoundary() + { + var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, penetration: 2f, + markers: new[] { (new int2(1, 1), 10f) }); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[1, 2] = 50f; e.Target.MaxArmor[1, 2] = 50f; // exactly at entry-diff 2 == penetration + var marker = e.Markers[0]; + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 1); attempt++) + outcome = FireUntilHit(e, damageOverride: 30f, attempts: 1); + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit on the centre column within the attempt budget"); + Assert.Equal(new int2(1, 0), outcome.Cell); + + Assert.Equal(0f, marker.EquippableItem.Durability); // fixture: the marker's own 10 is fully spent, 20 left over + Assert.Equal(50f, e.Target.Armor[1, 2]); // exactly at entry-diff 2 -- excluded, not "< penetration" -- the + // remaining 20 would otherwise have visibly damaged it + } + + // F3: a non-integer penetration depth. Entries down an axis-aligned column are exact integers, so a + // fractional penetration is never itself a boundary -- this pins the ordinary "< penetration" comparison at + // a depth that isn't a round number, independent of the exact-integer boundary case above. + [Fact] + public void PenetrationExcludesCellsBeyondANonIntegerDepth() + { + var e = Build(TestSettings(), SolidShape(1, 4), precision: 1f, penetration: 1.5f); + e.Target.Armor[0, 0] = 5f; e.Target.MaxArmor[0, 0] = 5f; // entry-diff 0: always reached + e.Target.Armor[0, 1] = 5f; e.Target.MaxArmor[0, 1] = 5f; // entry-diff 1: within 1.5, reached + e.Target.Armor[0, 2] = 50f; e.Target.MaxArmor[0, 2] = 50f; // entry-diff 2: beyond 1.5, excluded + e.Target.Armor[0, 3] = 50f; e.Target.MaxArmor[0, 3] = 50f; // entry-diff 3: beyond 1.5, excluded + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + + var hullBefore = e.Target.Hull.Durability; + var outcome = FireUntilHit(e, damageOverride: 30f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.Equal(0f, e.Target.Armor[0, 0]); // 5 consumed + Assert.Equal(0f, e.Target.Armor[0, 1]); // 5 consumed + Assert.Equal(50f, e.Target.Armor[0, 2]); // untouched -- beyond the 1.5 depth + Assert.Equal(50f, e.Target.Armor[0, 3]); // untouched -- beyond the 1.5 depth + Assert.Equal(hullBefore - 20f, e.Target.Hull.Durability, 2); // 30 - 5 - 5 left over for the hull + } + + // F4: a U-shaped hull, notched. Two arms (x=0 and x=2) rise from a connecting base (y=0); the left arm has + // a gap at y=1 -- present at y=0,2,3, missing at y=1 (so the lane hits the base at y=0, a gap at y=1, then + // the rest of the arm at y=2,3). An AP round (no blast) with ample penetration must stop at that first gap + // and leave the far side of the notch (y=2,3, "the far prong") untouched. Kills the Stryker survivor that + // deletes Lane's own contiguity break (`FireControl.cs:~895`, M9): without it, Lane would keep walking past + // the gap and Apply would spend damage on cells the real rule says are unreachable. + private static Shape NotchedU() + { + var shape = new Shape(3, 4); + shape[new int2(0, 0)] = true; shape[new int2(0, 2)] = true; shape[new int2(0, 3)] = true; // left arm, notch at y=1 + for (var y = 0; y < 4; y++) shape[new int2(2, y)] = true; // right arm, solid + shape[new int2(1, 0)] = true; // the base connecting the two arms + return shape; + } + + [Fact] + public void AnAPRoundStopsAtTheFirstGapAndLeavesTheFarSideOfTheNotchUntouched() + { + var e = Build(TestSettings(), NotchedU(), precision: 1f, penetration: 10f); // ample: more than the whole hull's depth + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[0, 0] = 5f; e.Target.MaxArmor[0, 0] = 5f; // near side of the notch + e.Target.Armor[0, 2] = 1000f; e.Target.MaxArmor[0, 2] = 1000f; // far side -- must stay untouched + e.Target.Armor[0, 3] = 1000f; e.Target.MaxArmor[0, 3] = 1000f; // far side -- must stay untouched + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + + // The hull's other arm (x=2) carries no armour, so a stray hit landing there while retrying for the + // notched column would leak its own damage straight to the hull -- tracked here per-attempt (reset each + // retry) so only the accepted attempt's own contribution is asserted, not a cross-attempt total. + var hullThisAttempt = 0f; + ShotOutcome outcome = null; + using (e.Target.HullDamage.Subscribe(x => hullThisAttempt += x)) + { + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 0); attempt++) + { + hullThisAttempt = 0f; + outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); + } + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 0, "fixture: needed a hit on the notched arm within the attempt budget"); + Assert.Equal(new int2(0, 0), outcome.Cell); // fixture precondition: the base is always this lane's impact cell + + Assert.Equal(0f, e.Target.Armor[0, 0]); // 5 consumed + Assert.Equal(1000f, e.Target.Armor[0, 2]); // untouched -- beyond the first gap + Assert.Equal(1000f, e.Target.Armor[0, 3]); // untouched -- beyond the first gap + Assert.Equal(35f, hullThisAttempt, 2); // 40 - 5 left over for the hull, not spent past the gap + } + + // F5: side-lane remainders must reach the hull too, axis-aligned so the lanes share no cells and lane + // order cannot matter (Q12-3 = A, "the lane remainder goes into the hull where the lane ends"). A 3x2 + // hull, spread 1 (three one-cell-wide columns), armour only on the facing row: every lane's remainder + // survives the facing cell and must land in the hull. Kills the Stryker survivor that guards the + // hull-remainder add with `if (li == n)` (only the centre lane's remainder would count). + [Fact] + public void EverySideLanesRemainderReachesTheHullNotOnlyTheCentreLanes() + { + var e = Build(TestSettings(), SolidShape(3, 2), precision: .6f, penetration: 2f, damageSpread: 1f); + for (var x = 0; x < 3; x++) + for (var y = 0; y < 2; y++) + { + e.Target.Armor[x, y] = y == 0 ? 1f : 0f; + e.Target.MaxArmor[x, y] = 1f; + var occ = e.Target.GearOccupancy[x, y]; + if (occ != null) occ.EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + } + + // Tracked per-attempt (reset each retry), the same reasoning as the notched-U test above: a stray hit on + // the wrong lateral offset still spends real damage on this hull, and would otherwise contaminate a + // before/after total taken across every retry. + var hullThisAttempt = 0f; + ShotOutcome outcome = null; + using (e.Target.HullDamage.Subscribe(x => hullThisAttempt += x)) + { + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit || outcome.Cell.x != 1); attempt++) + { + hullThisAttempt = 0f; + outcome = FireUntilHit(e, damageOverride: 15f, attempts: 1); + } + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 1, "fixture: needed a hit landing on the centre column within the attempt budget"); + + // Each lane: 15/3 = 5 per lane, minus the facing cell's 1 armour = 4 left over; three lanes -> 12. + Assert.Equal(12f, hullThisAttempt, 2); + } + + // F6/12.4(b): "armour absorbs first" on one cell shared by armour and an occupying item is now pinned on + // the public owner, Detonate, by ABlastIsAbsorbedThroughArmourFirstPerCell in FireControlCut124Tests.cs -- + // this superseded AbsorbSpendsArmourBeforeTheItemOnTheSameCell, which called the now-deleted Entity.Absorb + // directly (docs/fire-control-cut.md, "Absorption order"). + + // F7: direct-hit frame handedness at |fx| > |fy| is otherwise only guarded by Cut 11's Splash tests, which + // 12.4 deletes. A target facing (2,1) normalized (|fx| > |fy|), shot from its own starboard side (computed + // here independently of Entity.ToSchematic, as the standard right-perpendicular of the facing -- the same + // relationship ToSchematic is supposed to implement, not a call into it). Only starboard-half cells (x >= + // width/2 in the hull's own schematic frame) carry armour; port cells (x < width/2) do not. Kills a flip of + // ToSchematic's right axis when |fx| > |fy| (M6a). + [Fact] + public void ADirectHitFromStarboardDamagesStarboardCellsWhenFacingIsMostlyLateral() + { + var shape = SolidShape(5, 5); + var facing = normalize(float2(2, 1)); // |fx| > |fy| + var e = Build(TestSettings(), shape, precision: .4f, penetration: 0f, targetFacing: facing); + + // Starboard is the standard right-perpendicular of the facing (forward.y, -forward.x) -- computed here, + // not read from Entity.ToSchematic. + var starboardWorld = float2(facing.y, -facing.x); + e.Shooter.Position = e.Target.Position + float3(starboardWorld.x, 0, starboardWorld.y) * 100f; // shooter sits to starboard + + for (var x = 0; x < 5; x++) + for (var y = 0; y < 5; y++) + { + var starboard = x >= 3; // starboard half; x == 2 (the middle column) is left ambiguous and unarmoured + e.Target.Armor[x, y] = x <= 1 ? 0f : starboard ? 1f : 0f; + e.Target.MaxArmor[x, y] = 1f; + } + + var hitCells = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hitCells.Add(x.pos)); + for (var i = 0; i < 80; i++) { FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); } + + Assert.True(hitCells.Count > 5, $"expected several hits, got {hitCells.Count}"); + foreach (var c in hitCells) + Assert.True(c.x >= 3, $"starboard should face the shot, but {c} is on the port side"); + } + + // ===================== F1: an independent direct-hit model at angled bearings ===================== + // Every existing test through Fire commits at (0,+-1) -- ARayThroughACornerCrossesTheCornerCell calls Lane + // directly instead. This model is an exact double-precision ray/box walk, independent of + // FireControl.Lane/AlongBearing/SlabAxis, ported from the Soul probe (SoulApply123.cs) that verified this + // cut's arithmetic against production to 14,984/15,000 shots (the 16 mismatches were a float boundary). + // Used only to compute these tests' own expected values -- never to call production. + private struct MCell { public int2 C; public double En, Ex; } + + private static List ModelLane(Shape shape, double bx, double by, double s) + { + double lx = -by, ly = bx; + var all = new List(); + foreach (var c in shape.Coordinates) + { + double t0 = double.NegativeInfinity, t1 = double.PositiveInfinity; var ok = true; + void Ax(double d, double p0, double lo, double hi) + { + if (!ok) return; + if (Math.Abs(d) < 1e-12) { if (!(p0 > lo && p0 < hi)) ok = false; return; } + var a = (lo - p0) / d; var q = (hi - p0) / d; + t0 = Math.Max(t0, Math.Min(a, q)); t1 = Math.Min(t1, Math.Max(a, q)); + } + Ax(bx, s * lx, c.x - .5, c.x + .5); + Ax(by, s * ly, c.y - .5, c.y + .5); + if (ok && t1 - t0 > 1e-7) all.Add(new MCell { C = c, En = t0, Ex = t1 }); + } + all.Sort((p, q) => p.En.CompareTo(q.En)); + var walked = new List(); + for (var i = 0; i < all.Count; i++) + { + if (i > 0 && all[i].En > all[i - 1].Ex + 1e-4) break; + walked.Add(all[i]); + } + return walked; + } + + private static double[,] ToD(float[,] a) + { + var d = new double[a.GetLength(0), a.GetLength(1)]; + for (var i = 0; i < a.GetLength(0); i++) for (var j = 0; j < a.GetLength(1); j++) d[i, j] = a[i, j]; + return d; + } + + private sealed class DirectHitModel { public double[,] Armor; public Dictionary Items; public double Hull; } + + // Single lane (spread 0 -- F2's lane-spacing ruling only concerns spread >= 1): armour, then the occupying + // item, then the hull, near-to-far, clipped to the penetration depth from the impact cell's own entry. + private static DirectHitModel ModelDirectHit(Engagement e, Shape shape, double[,] armorBefore, + Dictionary itemsBefore, double bx, double by, double s, double damage, double penetration) + { + var r = new DirectHitModel { Armor = (double[,]) armorBefore.Clone(), Items = new Dictionary(itemsBefore) }; + var lane = ModelLane(shape, bx, by, s); + if (lane.Count == 0) { r.Hull = damage; return r; } + var rem = damage; var en0 = lane[0].En; + for (var i = 0; i < lane.Count; i++) + { + if (i > 0 && lane[i].En - en0 >= penetration) break; + var c = lane[i].C; + if (rem > 0) { var a = r.Armor[c.x, c.y]; r.Armor[c.x, c.y] = Math.Max(a - rem, 0); rem = Math.Max(rem - a, 0); } + if (rem > .1) + { + var item = e.Target.GearOccupancy[c.x, c.y]; + if (item != null) { var d = r.Items[item]; r.Items[item] = Math.Max(d - rem, 0); rem = Math.Max(rem - d, 0); } + } + } + r.Hull = rem; + return r; + } + + // F1: a full per-cell parity check against the independent model above, at three angled bearings -- 45 + // degrees, a shallow angle (within about 11 degrees of an axis) and one with |bx| > |by|. Kills a 0.5-cell + // walk (M4), a mirrored bearing when |bx| > |by| (M6b) and a bearing snapped to the nearest axis (M20): all + // three produce a visibly wrong set of cells or a wrong entry order against this model at these bearings. + [Fact] + public void DirectHitAtAnAngledBearingMatchesAnIndependentModel() + { + void Check(float2 travelDirection, int seed) + { + var shape = SolidShape(5, 5); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 3f); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + + var rng = new System.Random(seed); + foreach (var c in shape.Coordinates) + { + var a = (float) Math.Round(rng.NextDouble() * 6, 2); + e.Target.Armor[c.x, c.y] = a; e.Target.MaxArmor[c.x, c.y] = Math.Max(a, 1f); + } + // Equipment includes the ship's own EquippedHull -- excluded here, or randomising its durability + // would corrupt Hull.Durability itself, not a schematic-cell item. + foreach (var it in e.Target.Equipment) + if (it.EquippableItem != e.Target.Hull) + it.EquippableItem.Durability = (float) Math.Round(rng.NextDouble() * 15, 2); + + var armorBefore = ToD(e.Target.Armor); + var itemsBefore = e.Target.Equipment.Where(x => x.EquippableItem != e.Target.Hull) + .ToDictionary(x => x, x => (double) x.EquippableItem.Durability); + + ShotOutcome outcome = null; + double hullEv = 0; + using (e.Target.HullDamage.Subscribe(x => hullEv += x)) + { + for (var attempt = 0; attempt < 400 && (outcome == null || !outcome.Hit); attempt++) + outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + // fixture precondition: default facing makes the schematic bearing equal the world travel direction. + var actualTravel = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + Assert.Equal(travelDirection.x, actualTravel.x, 3); + Assert.Equal(travelDirection.y, actualTravel.y, 3); + + var model = ModelDirectHit(e, shape, armorBefore, itemsBefore, outcome.Bearing.x, outcome.Bearing.y, outcome.Lateral, 40, 3); + + foreach (var c in shape.Coordinates) + Assert.Equal(model.Armor[c.x, c.y], e.Target.Armor[c.x, c.y], 2); + foreach (var kv in model.Items) + Assert.Equal(kv.Value, (double) kv.Key.EquippableItem.Durability, 2); + Assert.Equal(model.Hull, hullEv, 2); + } + + Check(normalize(float2(1, 1)), seed: 1); // 45 degrees + Check(normalize(float2(.2f, 1)), seed: 2); // shallow: ~11 degrees off the y-axis + Check(normalize(float2(2, 1)), seed: 3); // |bx| > |by| + } + + // F1: the spec's own diagonal case (Probe1's corner-crossing lane), through the full Fire -> Commit -> + // Apply path rather than a direct Lane call. Retries until a hit's own ArmorDamage events show the lane + // walking exactly the three corner cells in order -- kills the same M4/M6b/M20 mutants as above from a + // different angle: a 0.5-cell march, a mirrored bearing or an axis-snapped bearing would each produce some + // other event sequence (or none matching), and 400 attempts would never find this exact one. + [Fact] + public void DiagonalDirectHitReachesEveryCornerCellInOrderThroughFireCommitApply() + { + var shape = SolidShape(3, 3); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 100f); // ample: never cuts the walk short + e.Shooter.Position = e.Target.Position - float3(1, 0, 1) * (100f / (float) Math.Sqrt(2)); // exactly 45 degrees + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + + // Everything bare except the three corner cells (light armour, so damage passes through every one of + // them into whatever comes next): a bearing this close to 45 degrees admits the diagonal's own row/column + // neighbours too (their shadow overlaps the corner at this angle -- confirmed by sweeping FireControl.Lane + // directly over s), so the walked lane is usually five cells, not three; the spec only requires the + // three corner cells to be present and in that relative order (Probe1's own "reaches (2,2)"), the same + // standard ARayThroughACornerCrossesTheCornerCell already pins for a direct Lane call. + void ResetArmor() + { + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 0f; e.Target.MaxArmor[c.x, c.y] = 0f; } + e.Target.Armor[0, 0] = 5f; e.Target.MaxArmor[0, 0] = 5f; + e.Target.Armor[1, 1] = 5f; e.Target.MaxArmor[1, 1] = 5f; + e.Target.Armor[2, 2] = 5f; e.Target.MaxArmor[2, 2] = 5f; + } + ResetArmor(); + + var events = new List(); + using var sub = e.Target.ArmorDamage.Subscribe(x => events.Add(x.pos)); + var found = false; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 400 && !found; attempt++) + { + events.Clear(); + outcome = FireUntilHit(e, damageOverride: 40f, attempts: 1); + var i0 = events.IndexOf(new int2(0, 0)); + var i1 = events.IndexOf(new int2(1, 1)); + var i2 = events.IndexOf(new int2(2, 2)); + found = outcome != null && outcome.Hit && i0 >= 0 && i1 > i0 && i2 > i1; + if (!found) ResetArmor(); // a hit landing off the diagonal may have spent some of these cells' armour + } + Assert.True(found, "fixture: needed a hit whose lane reaches (0,0), then (1,1), then (2,2), in that order"); + + Assert.Equal(0f, e.Target.Armor[0, 0]); + Assert.Equal(0f, e.Target.Armor[1, 1]); + Assert.Equal(0f, e.Target.Armor[2, 2]); + } + + // ===================== F2: spread lanes diffuse sideways instead of overlapping ===================== + // Operator ruling (2026-09-25): "I would prefer if the overlapping damage cells were diffused sideways to + // thicken the line rather than doubling up." FireControl.Apply now spaces lanes by the shadow width a cell + // casts at the committed bearing rather than by one cell. These tests pin the resulting BEHAVIOUR -- no + // cell struck twice, mirrored damage, a footprint that widens at an angle -- not the spacing value itself + // (operator, correcting an earlier framing: "test behavior, not shape"). + + // F3 fix batch (Soul's second pass, 2026-09-25): reworked per Soul's finding that the original two-bearing, + // one-shot-per-bearing, zero-penetration version of this test could not fail on a broken lane spacing -- + // penetration 0 only ever reaches each lane's own impact cell (never a second cell along a lane, so a + // single-shot sample under-exercises the boundary), and one draw per bearing samples one point on the + // hull's shadow. (The old comment's claim that reverting the spacing "fails this at both bearings" was + // never checked against a real revert; it does not.) Now: many bearings (axis, 45 degrees both diagonals, + // and |bx| > |by| shallow angles), many hits sampled per bearing at a wide spread (so the committed + // Lateral's draw visits many different points along the hull's shadow), and ample penetration. Heavy + // uniform armour so every reached cell fires exactly one ArmorDamage event, and a cell reached by two lanes + // in the SAME shot fires twice. + // F1's own structural fix (`FireControl.Lanes`, now built on top of `FireControl.Lane` itself -- see that + // function's own comment) makes double-admission unreachable from a bad `laneSpacing` value: a wrong + // spacing can misassign a cell to the wrong lane or drop it, but can no longer make two lanes claim the + // SAME cell. Hand verification (fc123fix2-mut.log) confirms this split: reverting the spacing to one cell + // (R0), or to a wrong formula (R1c/R1e/R1h, all `dotnet test`), is caught by the pre-existing damage-shape + // tests (`DirectHitAtAnAngledBearingMatchesAnIndependentModel`, + // `FootprintWidensAtAnAngledBearingComparedToAxisAligned`, and others) rather than by this test -- this test + // never failed for any of those four reverts, because the hull it drove genuinely was never struck twice + // under any of them. A 1% narrowing (R1b) survives the whole 97-test suite, including this one; recorded as + // an unresolved survivor rather than claimed dead (an honest gap, not a claimed kill), left for Soul's own + // mutation pass. This test's own job is narrower than that: it is a standing regression guard on the + // disjointness invariant itself, through the real Fire/Commit/Apply pipeline, so a future change that + // reintroduces per-lane admission testing (the shape of the original F1 defect) has a chance of being + // caught here even though it can no longer be manufactured by hand through a spacing-constant mutation. + + // ===================== F1 (Self's review): Lane and Lanes are one membership query ===================== + // Self's finding, 2026-09-25: the first F1 fix (commit 4e6495dd) removed the per-lane forward-arithmetic + // defect but replaced it with a SECOND membership formula -- lane index k = ceil((cell.Lo - centreLateral) + // / laneSpacing), agreeing with FireControl.Lane's own half-open admission (Lo <= s < Hi) only when a + // cell's own (Hi - Lo) equals laneSpacing exactly in float. `FireControl.Lanes` is now built entirely out + // of `FireControl.Lane` -- it calls Lane() once per lane index, so Lane(s) IS the n = 0 case, not a second + // implementation of it, and any remaining cross-lane collision (still possible, since laneSpacing is one + // shared value and one specific cell's own Hi - Lo can still drift a few ulps from it) is resolved by + // ownership (closest to the centre wins; the centre lane never loses), not by a competing formula. This + // test pins that contract directly through the PUBLIC FireControl.Lanes and FireControl.Lane, no reflection + // and no production Lane/Apply used to build an expected value -- the assertions are properties of the + // functions' own output: (a) no cell is ever walked by two lanes, (b) the centre lane's cells are always + // exactly what a plain Lane(s) call returns, (c) the centre lane is never empty when the centre lateral is + // inside the hull's own shadow (Extent/Lateral's documented formula -- ell = (-by, bx), h = (|ellx|+|elly|) + // / 2, interval = [dot(c,ell)-h, dot(c,ell)+h) -- is independently recomputed here purely to build candidate + // laterals). Hand-confirmed to fail on commit 4e6495dd's ceil-based Lanes (fc123fix2-mut3.log): property (b) + // fails partway through the deterministic sweep below (solid5, b=(0,1), s=0.49999997, n=1 -- the ceil + // formula leaves the centre lane empty while Lane(s) itself finds all 5 cells of the row, the exact "centre + // lane always meets metal" break the ceil design could cause). Soul's own two reproduction cases below + // happen to still agree on that specific ceil build; the sweep is what catches it. + [Fact] + public void LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull() + { + static float2 Ell(float2 b) => float2(-b.y, b.x); + static (float Lo, float Hi) CellExtent(int2 c, float2 ell) + { + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + var centre = dot((float2) c, ell); + return (centre - h, centre + h); + } + static float Spacing(float2 b) { var ell = Ell(b); return Math.Abs(ell.x) + Math.Abs(ell.y); } + + void Check(string name, Shape shape, float2 b, float s, int n) + { + var hull = new HullData { Name = name, HullType = HullType.Ship, Shape = shape }; + var spacing = Spacing(b); + var laneCount = 2 * n + 1; + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) buffers[li] = new LaneCell[shape.Coordinates.Length]; + var walked = FireControl.Lanes(hull, b, s, n, spacing, buffers); + + // (a) no cell appears in two lanes. + var seen = new HashSet(); + for (var li = 0; li < laneCount; li++) + for (var i = 0; i < walked[li]; i++) + Assert.True(seen.Add(buffers[li][i].Cell), + $"{name} b=({b.x:R},{b.y:R}) s={s:R} n={n}: cell {buffers[li][i].Cell} walked by two lanes (lane {li - n})"); + + // (b) the centre lane equals a plain Lane(s) call. + var solo = new LaneCell[shape.Coordinates.Length]; + var soloWalked = FireControl.Lane(hull, b, s, solo); + Assert.True(soloWalked == walked[n], $"{name} b=({b.x:R},{b.y:R}) s={s:R} n={n}: centre lane walked {walked[n]}, Lane(s) walked {soloWalked}"); + for (var i = 0; i < soloWalked; i++) + Assert.Equal(solo[i].Cell, buffers[n][i].Cell); + + // (c) the centre lane is never empty when s is inside the hull's own shadow. + var ell = Ell(b); + var insideShadow = shape.Coordinates.Any(c => { var (lo, hi) = CellExtent(c, ell); return s >= lo && s < hi; }); + if (insideShadow) + Assert.True(walked[n] > 0, $"{name} b=({b.x:R},{b.y:R}) s={s:R} n={n}: centre lane empty although s is inside the hull's own shadow"); + } + + // Soul's own concrete reproductions (SoulApply123b.Disjoint, 2026-09-25). + Check("solid5", SolidShape(5, 5), float2(0, 1), -0.50000006f, 1); + Check("solid9", SolidShape(9, 9), normalize(float2(-0.69478226f, 0.7192201f)), -5.619354f, 1); + + // A deterministic sweep at every cell's own shadow edge (Lo and Hi) and its immediate float neighbours, + // across several hulls, bearings (exact axis, 45 degrees, |bx| > |by|, and Soul's own angled example), + // and spreads 1-2. + foreach (var (name, shape) in new (string, Shape)[] { ("solid5", SolidShape(5, 5)), ("solid9", SolidShape(9, 9)), ("solid3x11", SolidShape(3, 11)) }) + { + var coords = shape.Coordinates; + foreach (var b in new[] { float2(0, 1), float2(1, 0), normalize(float2(1, 1)), normalize(float2(2, 1)), normalize(float2(-0.69478226f, 0.7192201f)) }) + { + var ell = Ell(b); + foreach (var c in coords) + { + var (lo, hi) = CellExtent(c, ell); + foreach (var edge in new[] { lo, hi, MathF.BitIncrement(lo), MathF.BitDecrement(lo), MathF.BitIncrement(hi), MathF.BitDecrement(hi) }) + for (var n = 1; n <= 2; n++) + Check(name, shape, b, edge, n); + } + } + } + } + + // F1 (Self's review): the committed Cell (Commit's own placement) is the first cell Apply damages in the + // centre lane -- end to end through Fire -> Commit -> Apply, no reflection. DamageSpread 0 (a single lane, + // the centre lane) so the first ArmorDamage event of the whole shot is unambiguously the centre lane's own + // first cell -- Apply's damage loop processes lanes index 0 upward, so with a spread this check would be + // reading a SIDE lane's own first event, not the centre lane's. Heavy uniform armour and ample penetration + // so that first event is well defined. + [Fact] + public void CommittedCellIsTheFirstCellTheCentreLaneDamages() + { + var bearings = new[] + { + float2(0, 1), normalize(float2(1, 1)), normalize(float2(-1, 1)), + normalize(float2(2, 1)), normalize(float2(1, 2)) + }; + foreach (var travelDirection in bearings) + { + var shape = SolidShape(9, 9); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 100f, damageSpread: 0f); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + var firstHit = new int2?[1]; + using var s = e.Target.ArmorDamage.Subscribe(x => { if (firstHit[0] == null) firstHit[0] = x.pos; }); + var outcome = FireUntilHit(e, damageOverride: 50f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.NotNull(firstHit[0]); + Assert.Equal(outcome.Cell, firstHit[0].Value); + } + } + [Fact] + public void NoCellIsStruckByTwoLanesInOneShotAtAnyBearing() + { + // Within the fixture's own FiringArc (170 degrees, i.e. +-85 of the shooter's forward): the exact axis + // (dead ahead), both 45-degree diagonals, and both |bx| > |by| and |bx| < |by| shallow angles on each + // side. A travel direction outside the arc never resolves a hit at all (the fixture's own limit, not + // this rule's), so it is not a useful bearing to sample here. + var bearings = new[] + { + float2(0, 1), // exact axis + normalize(float2(1, 1)), normalize(float2(-1, 1)), // 45 degrees + normalize(float2(2, 1)), normalize(float2(-2, 1)), // |bx| > |by| + normalize(float2(1, 2)), normalize(float2(-1, 2)) // |bx| < |by| + }; + const int hitsWanted = 300; + + foreach (var travelDirection in bearings) + { + var shape = SolidShape(9, 9); + var e = Build(TestSettings(), shape, precision: .02f, penetration: 100f, damageSpread: 2f); // ample penetration, 5 lanes; a wide spread samples many points along the shadow + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + + var thisShot = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => thisShot.Add(x.pos)); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + + var got = 0; + for (var attempt = 0; attempt < 20000 && got < hitsWanted; attempt++) + { + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + thisShot.Clear(); + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride: 50f); + e.Zone.Update(.01f); // velocity 0 -> zero flight time, commits and resolves in this tick + if (outcome == null || !outcome.Hit) continue; + got++; + Assert.Equal(thisShot.Count, thisShot.Distinct().Count()); // no cell fires ArmorDamage twice within this shot + } + Assert.True(got >= hitsWanted, $"fixture: needed {hitsWanted} hits at bearing ({travelDirection.x:R},{travelDirection.y:R}), got {got}"); + } + } + + // The footprint widens at an angled bearing rather than concentrating: the same spread, on the same hull, + // covers more lateral distance at 45 degrees than axis-aligned. Reverting the lane spacing to one cell + // removes this widening (every bearing gets the same one-cell spacing regardless of angle). + [Fact] + public void FootprintWidensAtAnAngledBearingComparedToAxisAligned() + { + double Span(float2 travelDirection) + { + var shape = SolidShape(9, 9); + var e = Build(TestSettings(), shape, precision: 1f, penetration: 0f, damageSpread: 2f); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + foreach (var c in shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + var hits = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x.pos)); + var outcome = FireUntilHit(e, damageOverride: 50f); + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.True(hits.Count >= 3, $"fixture: needed several lanes to find metal, got {hits.Count}"); + + var b = normalize(travelDirection); + var ell = float2(-b.y, b.x); + var projections = hits.Select(c => (double) dot((float2) c, ell)).ToList(); + return projections.Max() - projections.Min(); + } + + var axisSpan = Span(float2(0, 1)); + var angledSpan = Span(normalize(float2(1, 1))); + + Assert.True(angledSpan > axisSpan * 1.2, $"the footprint should widen at an angled bearing: axis {axisSpan}, 45deg {angledSpan}"); + } + + // ===================== F8: proportional absorption when a multi-cell item is shared between lanes ===================== + // Operator ruling (2026-09-25, docs/fire-control-cut.md, Cut 12.3 status): "proportional absorption; go." + // When two or more lanes of one shot reach the same multi-cell item, the item absorbs their COMBINED + // incoming damage (up to its own remaining durability) and each lane's leftover is in proportion to what + // it brought -- replacing the old behaviour where whichever lane was processed first (always the + // lower-indexed, physically left-of-travel lane) drained the item and every other lane inherited whatever + // it left behind. + + // F8: the ruling's own literal case, through Fire/Commit/Apply (no reflection). Tool-hardpoint items + // (Bar, Marker) can only occupy INTERIOR cells (Entity.ItemFits, `hullData.InteriorCells[itemCoord]` -- + // Shape.Shrink needs all 8 neighbours present, the same constraint DamageIsAbsorbedInOrderAlongTheRay's own + // comment already notes for a single-cell marker on a 3x3 hull). A marker sitting BEHIND the shared item + // needs its own interior row too, so this fixture is 4 cells deep: SolidShape(5,4)'s two interior rows are + // y=1 (the Bar, columns 1-3) and y=2 (one marker behind each of the Bar's own two cells). Note + // Entity.ItemDamage reports each lane's own INCOMING contribution, not the clamped/actual-absorbed amount + // (the same convention a single Absorb call already has -- an overkill hit against a nearly-dead item still + // reports the whole swing), so it cannot distinguish proportional from sequential absorption by itself; + // the item's own durability (the ACTUAL total absorbed) and each marker's own durability (the ACTUAL + // leftover that reached it) are the two numbers this test reads. + // With damageSpread 1 retried until the committed impact is column 2 (the middle of the three interior + // columns), the lanes are columns {1,2,3}: the item's own two cells (1,1)/(2,1) are each a DIFFERENT lane's + // own facing cell, so armour placed directly on them (5 and 15) gives the two lanes different post-armour + // remainders even though Apply splits the shot's damage evenly per lane before armour: D1 = 30-5 = 25 + // (column 1), D2 = 30-15 = 15 (column 2). Column 3 carries no item, an unrelated private lane. + private (float leftoverLow, float leftoverHigh, float absorbed) RunSharedItemCase(float itemDurability) + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 1), itemDurability) }, + markers: new[] { (new int2(1, 2), 1000f), (new int2(2, 2), 1000f) }); + var bar = e.Target.GearOccupancy[1, 1]; // (1,1) and (2,1) are the same Bar + + void ResetState() + { + for (var x = 0; x < 5; x++) + for (var y = 0; y < 4; y++) + { + e.Target.Armor[x, y] = 0f; + e.Target.MaxArmor[x, y] = 20f; + } + e.Target.Armor[1, 1] = 5f; + e.Target.Armor[2, 1] = 15f; + bar.EquippableItem.Durability = itemDurability; + e.Markers[0].EquippableItem.Durability = 1000f; // behind (1,1) + e.Markers[1].EquippableItem.Durability = 1000f; // behind (2,1) + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + } + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 400 && (outcome == null || !outcome.Hit || outcome.Cell.x != 2); attempt++) + { + ResetState(); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 2, "fixture: needed a hit on the centre interior column within the attempt budget"); + + var leftover1 = 1000f - e.Markers[0].EquippableItem.Durability; // column 1's own leftover (D1=25) + var leftover2 = 1000f - e.Markers[1].EquippableItem.Durability; // column 2's own leftover (D2=15) + var absorbed = itemDurability - bar.EquippableItem.Durability; + return leftover1 <= leftover2 ? (leftover1, leftover2, absorbed) : (leftover2, leftover1, absorbed); + } + + // Item durability 30 < D1+D2 (40): the item takes all 30 of its own durability (absorbed = min(40,30)); the + // combined 10 leftover splits 25*(10/40)=6.25 and 15*(10/40)=3.75 -- proportional to what each lane + // brought, not to which lane Apply happens to visit first. Under the pre-fix-batch code (sequential, one + // lane draining the item before the other carries on) the two leftovers are not this pair at all -- see + // MirrorSymmetryHoldsWithASharedMultiCellItem below for the hand-confirmed pre-fix numbers on this exact + // shape of fixture. + [Fact] + public void ProportionalAbsorptionSplitsTheLeftoverBetweenTheLanes() + { + var (leftoverLow, leftoverHigh, absorbed) = RunSharedItemCase(itemDurability: 30f); + Assert.Equal(3.75f, leftoverLow, 2); // 15 * (10/40) + Assert.Equal(6.25f, leftoverHigh, 2); // 25 * (10/40) + Assert.Equal(30f, absorbed, 2); // the item's own durability, fully spent + } + + // Item durability 50 >= D1+D2 (40): the item takes everything either lane brought; nothing continues past + // it into either marker. + [Fact] + public void ProportionalAbsorptionLeavesNothingWhenTheItemAbsorbsEverything() + { + var (leftoverLow, leftoverHigh, absorbed) = RunSharedItemCase(itemDurability: 50f); + Assert.Equal(0f, leftoverLow, 2); + Assert.Equal(0f, leftoverHigh, 2); + Assert.Equal(40f, absorbed, 2); + } + + // F8: mirror symmetry with a multi-cell item, deterministic. Two fixtures on SolidShape(9,4) (interior rows + // y=1, the Bar's own row, and y=2, the marker row behind it -- see RunSharedItemCase above), fired straight + // ahead (bearing (0,1)) at damageSpread 1, each retried until the committed impact is the true centre + // column (x=4, as CommittedCellIsTheFirstCellTheCentreLaneDamages already relies on for this bearing), so + // the three lanes are always columns {3,4,5}. Fixture B's item and armour are fixture A's own layout + // reflected about column 4 (mirror(x) = 8-x) -- A's Bar spans {3,4} (centre lane shares it with the LEFT + // side lane); B's spans {4,5} (centre lane shares it with the RIGHT side lane), the mirror image of A's + // placement. Column 4 is the self-mirror point and carries the same armour (15) on both; column 3 (A, + // private) mirrors column 5 (B, private), both armour 2 (irrelevant to the assertion, just present so that + // lane is metal). + // + // Compares the SORTED list of each fixture's own two marker leftovers -- not the bearing or the lateral + // draw (which would also have to mirror the float lane-spacing arithmetic to stay exact). A's two D values + // are 25 (column 3) and 15 (column 4); B's are 15 (column 4) and 25 (column 5) -- the same two D values on + // both fixtures, so the proportional rule (sum then split, independent of which physical lane is which) + // gives the SAME sorted leftover pair on both. Item durability 20 (not equal to either D -- 25 was tried + // first and turned out degenerate: durability exactly equal to D_high made both fixtures land on {0,15} by + // coincidence under the pre-fix code too, since one contributor is always driven to exactly 0 either way). + // Hand-confirmed against commit d1cc047a (this cut's own parent, before the fix batch) on this exact + // fixture shape at itemDurability 20: A's leftovers sorted {0, 20} vs B's sorted {5, 15} -- different, so + // the assertion below fails there. See this cut's own report for the raw per-fixture numbers. + [Fact] + public void MirrorSymmetryHoldsWithASharedMultiCellItem() + { + const float itemDurability = 20f; + + (float low, float high) RunFixture(bool mirrored) + { + var barAnchor = mirrored ? new int2(4, 1) : new int2(3, 1); + var markerCells = mirrored + ? new[] { (new int2(4, 2), 1000f), (new int2(5, 2), 1000f) } + : new[] { (new int2(3, 2), 1000f), (new int2(4, 2), 1000f) }; + var e = Build(TestSettings(), SolidShape(9, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (barAnchor, itemDurability) }, markers: markerCells); + var bar = e.Target.GearOccupancy[barAnchor.x, barAnchor.y]; + + void ResetState() + { + for (var x = 0; x < 9; x++) + for (var y = 0; y < 4; y++) + { + e.Target.Armor[x, y] = 0f; + e.Target.MaxArmor[x, y] = 20f; + } + if (!mirrored) { e.Target.Armor[3, 1] = 5f; e.Target.Armor[4, 1] = 15f; e.Target.Armor[5, 1] = 2f; } + else { e.Target.Armor[4, 1] = 15f; e.Target.Armor[5, 1] = 5f; e.Target.Armor[3, 1] = 2f; } + bar.EquippableItem.Durability = itemDurability; + e.Markers[0].EquippableItem.Durability = 1000f; + e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; // remove the bystander Gun's own 1 durability + } + + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 600 && (outcome == null || !outcome.Hit || outcome.Cell.x != 4); attempt++) + { + ResetState(); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit && outcome.Cell.x == 4, "fixture: needed a hit on the true centre column within the attempt budget"); + + var l0 = 1000f - e.Markers[0].EquippableItem.Durability; + var l1 = 1000f - e.Markers[1].EquippableItem.Durability; + return l0 <= l1 ? (l0, l1) : (l1, l0); + } + + var a = RunFixture(mirrored: false); + var b = RunFixture(mirrored: true); + + Assert.Equal(a.low, b.low, 2); + Assert.Equal(a.high, b.high, 2); + } + + // F8 (Soul's owed test, "Owed with the pending ruling's batch" in docs/fire-control-cut.md, Cut 12.3 + // status): many axis-aligned spread-n shots on a wide hull, each must strike exactly 2n+1 facing cells -- + // no column dropped. This is a standing regression guard on the lane-spacing invariant itself (independent + // of the proportional-absorption fix batch's own item coordination), pinned through the real + // Fire/Commit/Apply pipeline. Hand-confirmed to fail with R1b (lane spacing narrowed by 1%, + // `laneSpacing = (shadowExtent.Hi - shadowExtent.Lo) * .99f` at FireControl.cs) because that narrowing + // eventually drops a column at a spacing-boundary lateral -- see this cut's own report for the exact + // FireControl.cs edit and count. + [Fact] + public void EveryAxisAlignedSpreadShotStrikesExactlyTwoNPlusOneFacingCells() + { + var e = Build(TestSettings(), SolidShape(15, 2), precision: .02f, penetration: 0f, damageSpread: 3f); // n=3 -> 7 lanes + for (var x = 0; x < 15; x++) + for (var y = 0; y < 2; y++) + { + e.Target.Armor[x, y] = 1000f; + e.Target.MaxArmor[x, y] = 1000f; + } + + var got = 0; + var shortfalls = 0; + for (var attempt = 0; attempt < 8000 && got < 400; attempt++) + { + var hits = new List(); + using var s = e.Target.ArmorDamage.Subscribe(x => hits.Add(x.pos)); + var outcome = FireUntilHit(e, damageOverride: 70f, attempts: 1); + if (outcome == null || !outcome.Hit) continue; + // Only impacts safely inside the hull count: within 3 columns of either edge, a side lane + // legitimately runs off the hull (fewer than 7 facing columns is correct there, not a spacing + // defect) -- restricting to the interior keeps this test about the spacing invariant alone. + if (outcome.Cell.x < 3 || outcome.Cell.x > 11) continue; + got++; + if (hits.Select(h => h.x).Distinct().Count() != 7) shortfalls++; // 2*3+1 facing columns, one hit each + } + Assert.True(got >= 400, $"fixture: needed 400 interior-column hits, got {got}"); + Assert.Equal(0, shortfalls); + } + + // ===================== F8/Soul: the model sweep, rewritten as an independent fixed point ===================== + // Soul's finding (2026-09-25): the earlier sweep copied FireControl.Apply's own structure (a survey of + // shared items, then an AdvanceLane/Resolve loop, lane-driven) closely enough that reverting production to + // a plain sequential per-lane absorb (M1, no pooling at all) still passed it. A model built the same way + // production is built can share production's own blind spot. `FixedPointItemResolution` below settles items + // in a DIFFERENT order from production's own inline, per-deposit check: it repeats PASSES over every lane + // (armour only, never coupled across lanes) and then, once a WHOLE pass of every lane is done, checks every + // currently open pool for readiness in one batch (`ReadyToSettle`) -- production instead asks the readiness + // question the instant a single lane deposits, mid-walk, never waiting for a whole pass. `ReadyToSettle` + // still re-derives its answer fresh from each other lane's CURRENT position every time it is asked (not + // from a table built once up front): a precomputed-once reach table was tried first and produced a real + // deadlock on a non-convex item crossed twice by the SAME lane, because a set keyed by item identity cannot + // tell a two-lane first pool apart from the one-lane pool the item's second visit degenerates into. A + // genuine cross-item cycle (every remaining lane blocked, nothing left to converge) is not modelled at all -- + // this sweep gates its own draws (`LanesReaching`, below) so a cycle is never actually presented to this + // fixed point. Finding one here would mean the gating missed a real case, not a numbers question for this + // function to answer with its own tie-break; see the cycle-break check at the end of this function. + // + // Geometry itself is NOT reimplemented a third time: which cells belong to which lane is read directly from + // `FireControl.Lanes`, the one public function this whole cut already treats as the single geometric owner + // (`LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull` above pins its own contract independently). + // An earlier version of this rewrite reimplemented lane membership with the plain double-precision + // `ModelLane` walk (the same primitive F1's own single-lane model uses); reading the real `Lanes` output + // instead removes any chance of the model and production disagreeing about which cell belongs to which + // lane, leaving only genuine float32-vs-double arithmetic drift in the absorption chain -- small enough + // that the tolerance below could actually be tightened, rather than only claimed tightened. + private static (double[,] armor, Dictionary items, double hull) FixedPointItemResolution( + Entity target, HullData hullData, double[,] armorBefore, Dictionary itemsBefore, + float2 bearing, float lateral, float damage, float penetration, int n) + { + var armor = (double[,]) armorBefore.Clone(); + var items = new Dictionary(itemsBefore); + var laneCount = 2 * n + 1; + var ell = float2(-bearing.y, bearing.x); + var laneSpacing = Math.Abs(ell.x) + Math.Abs(ell.y); // the same shadow-width formula Extent/Lateral own + + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) buffers[li] = new LaneCell[hullData.Shape.Coordinates.Length]; + var walked = FireControl.Lanes(hullData, bearing, lateral, n, laneSpacing, buffers); + + // The one clip production performs, against `Lanes`' own real output -- a two-line geometric fact, not + // the resolution order under test. + var cells = new List[laneCount]; + for (var li = 0; li < laneCount; li++) + { + var clipped = new List(); + if (walked[li] > 0) + { + var impactEntry = buffers[li][0].Entry; + var count = 1; + while (count < walked[li] && buffers[li][count].Entry - impactEntry < penetration) count++; + for (var i = 0; i < count; i++) clipped.Add(buffers[li][i].Cell); + } + cells[li] = clipped; + } + var metal = cells.Count(c => c.Count > 0); + var per = (double) damage / metal; + + var at = new int[laneCount]; // how many of this lane's own cells are fully settled + var carry = new double[laneCount]; // damage this lane carries into cell `at[li]` + var done = new bool[laneCount]; + for (var li = 0; li < laneCount; li++) { done[li] = cells[li].Count == 0; carry[li] = done[li] ? 0 : per; } + + var hull = 0.0; + var openPool = new Dictionary>(); + + void Land(int li) { done[li] = true; hull += carry[li]; } + + // Every non-finished lane, at the moment this is called (always AFTER a full pass over every lane, never + // mid-pass), is either done or sitting blocked at exactly one item cell -- there is no third "still + // walking" state left once a pass completes. So "no other unfinished lane can still reach this item" + // reduces to: does `item` occur anywhere in an OTHER non-contributing lane's remaining cell list, from + // where it currently sits onward. Unlike a precomputed table keyed by item identity, this re-derives + // the answer fresh each time from each lane's CURRENT position -- which is what correctly demotes a + // non-convex item's SECOND visit by the same lane to a solo pool once every other lane has already + // passed the item for good, without needing a separate notion of "which visit number" this is. + bool ReadyToSettle(EquippedItem item, Dictionary contributors) + { + for (var li2 = 0; li2 < laneCount; li2++) + { + if (done[li2] || contributors.ContainsKey(li2)) continue; + for (var i = at[li2]; i < cells[li2].Count; i++) + if (target.GearOccupancy[cells[li2][i].x, cells[li2][i].y] == item) return false; + } + return true; + } + + void Settle(EquippedItem item) + { + var deposits = openPool[item]; + openPool.Remove(item); // a later visit opens a fresh pool, the same rule as production + var total = deposits.Values.Sum(); + // Entity.ItemAbsorb's own .1f gate, decided once on the pool total -- an earlier version of this + // model omitted it entirely (absorbing any positive total, however small) and it took a real + // production trace to catch: a 0.03 pooled total should pass straight through untouched, the same + // as a solo 0.03 hit always has, not vanish into an item with plenty of spare durability. + var before = items[item]; + var absorbed = total > 0.1 ? Math.Min(total, before) : 0.0; + items[item] = Math.Max(before - absorbed, 0); + var fraction = total > 0 ? absorbed / total : 0; + foreach (var (lane, amount) in deposits) + { + carry[lane] = amount - amount * fraction; + at[lane]++; + if (at[lane] >= cells[lane].Count) Land(lane); + } + } + + // The fixed point: repeat PASSES over lanes (armour only, never coupled across lanes) and then over + // every currently open item (settle whichever now has its whole precomputed reach set deposited) until + // a whole pass changes nothing. + var progressed = true; + while (progressed) + { + progressed = false; + + for (var li = 0; li < laneCount; li++) + { + if (done[li]) continue; + while (at[li] < cells[li].Count) + { + var c = cells[li][at[li]]; + var item = target.GearOccupancy[c.x, c.y]; + // Already deposited here on an earlier pass, still waiting on the item to settle -- do NOT + // touch armour or deposit again; this cell is not revisited until Settle advances `at[li]`. + if (item != null && openPool.TryGetValue(item, out var waiting) && waiting.ContainsKey(li)) break; + + // Armour applies at EVERY cell this lane reaches, item or not (armour is never shared -- + // Entity.ArmorAbsorb runs unconditionally on a cell before any item check, and this model + // mirrors that ordinary per-lane physics without needing to copy production's own walk). + if (carry[li] > 0) { var a = armor[c.x, c.y]; armor[c.x, c.y] = Math.Max(a - carry[li], 0); carry[li] = Math.Max(carry[li] - a, 0); } + if (item == null) + { + at[li]++; + progressed = true; + continue; + } + if (!openPool.TryGetValue(item, out var deposits)) openPool[item] = deposits = new Dictionary(); + deposits[li] = carry[li]; + progressed = true; + break; // this lane goes no further until the item settles + } + if (!done[li] && at[li] >= cells[li].Count) Land(li); + } + + foreach (var item in new List(openPool.Keys)) + if (ReadyToSettle(item, openPool[item])) { Settle(item); progressed = true; } + } + + // Cycle break: the model never meets a cycle. This sweep gates its own draws (`LanesReaching`, below) so + // a genuine cross-item cycle -- every remaining lane blocked, nothing left to converge -- is never + // actually presented to this fixed point. A pool still open here means the gating let a real cycle + // through, which is a defect in the sweep, not a numbers question this function should silently answer + // with its own tie-break. + if (openPool.Count > 0) + throw new InvalidOperationException( + "FixedPointItemResolution: the model never meets a cycle -- a pool is still open after the fixed " + + "point settled, which means this sweep's own gating let a genuine cross-item cycle through."); + + return (armor, items, hull); + } + + // How many of a shot's own lanes geometrically reach `item` at all, at the COMMITTED bearing/lateral -- read + // from the real `FireControl.Lanes`/clip, the same as `FixedPointItemResolution` above. Used only to gate + // the sweep below onto draws that actually exercise cross-lane sharing, the same way RunSharedItemCase and + // MirrorSymmetryHoldsWithASharedMultiCellItem retry until their own committed column lands where they need + // it. + private static int LanesReaching(HullData hullData, float2 bearing, float lateral, int n, float penetration, Entity target, EquippedItem item) + { + var laneCount = 2 * n + 1; + var ell = float2(-bearing.y, bearing.x); + var laneSpacing = Math.Abs(ell.x) + Math.Abs(ell.y); + var buffers = new LaneCell[laneCount][]; + for (var li = 0; li < laneCount; li++) buffers[li] = new LaneCell[hullData.Shape.Coordinates.Length]; + var walked = FireControl.Lanes(hullData, bearing, lateral, n, laneSpacing, buffers); + + var count = 0; + for (var li = 0; li < laneCount; li++) + { + if (walked[li] == 0) continue; + var impactEntry = buffers[li][0].Entry; + var clipped = 1; + while (clipped < walked[li] && buffers[li][clipped].Entry - impactEntry < penetration) clipped++; + var reached = false; + for (var i = 0; i < clipped; i++) + if (target.GearOccupancy[buffers[li][i].Cell.x, buffers[li][i].Cell.y] == item) { reached = true; break; } + if (reached) count++; + } + return count; + } + + [Fact] + public void ModelSweepAgreesWithProductionIncludingSharedMultiCellItems() + { + var rng = new System.Random(20260925); + // Height 4: an L-shaped item's 2x2 bounding box needs TWO consecutive interior rows (rows 1 and 2 for + // height 4 -- Entity.ItemFits requires every one of an item's own cells to be an interior cell, the + // same constraint RunSharedItemCase's own comment explains); a plain 2x1 bar only needs one but is kept + // on the same row for a uniform itemY across both item shapes. + var shapes = new[] { SolidShape(9, 4), SolidShape(7, 4) }; + var bearings = new[] { float2(0, 1), normalize(float2(1, 1)), normalize(float2(2, 1)) }; + var trials = 0; + + foreach (var shape in shapes) + foreach (var travelDirection in bearings) + { + for (var t = 0; t < 15; t++) + { + var useLBar = t % 2 == 0; + var itemX = 1 + rng.Next(shape.Width - 3); + const int itemY = 1; // the only row that is interior with room for a 2-row-tall L above it (height 4) + // Soul: durability BELOW a single lane's own share (damage 40 over up to 5 lanes, so roughly + // 8-13 per lane before armour) guarantees the item overflows whenever it is shared -- the + // per-lane split always matters, never a coincidental full-absorb-either-way case. + var durability = (float) (2 + rng.NextDouble() * 4); + var e = useLBar + ? Build(TestSettings(), shape, precision: 1f, penetration: 3f, damageSpread: 2f, + lBars: new[] { (new int2(itemX, itemY), durability) }) + : Build(TestSettings(), shape, precision: 1f, penetration: 3f, damageSpread: 2f, + bars: new[] { (new int2(itemX, itemY), durability) }); + e.Shooter.Position = e.Target.Position - float3(travelDirection.x, 0, travelDirection.y) * 100f; + var sharedItem = e.Target.GearOccupancy[itemX, itemY]; + + foreach (var c in shape.Coordinates) + { + var a = (float) Math.Round(rng.NextDouble() * 6, 2); + e.Target.Armor[c.x, c.y] = a; e.Target.MaxArmor[c.x, c.y] = Math.Max(a, 1f); + } + + var armorBefore = ToD(e.Target.Armor); + var itemsBefore = e.Target.Equipment.Where(x => x.EquippableItem != e.Target.Hull) + .ToDictionary(x => x, x => (double) x.EquippableItem.Durability); + var hullBefore = e.Target.Hull.Durability; + + // Every rejected attempt below still fires a real shot and mutates real state -- reset back to + // the captured baseline before each one, or a rejected miss/mismatch would silently corrupt the + // "before" snapshot the accepted shot is later compared against (the bug that produced this + // sweep's very first false failure while writing this rewrite: armour and item durability had + // already been chipped by earlier rejected attempts by the time an accepted shot was found). + void ResetState() + { + foreach (var c in shape.Coordinates) e.Target.Armor[c.x, c.y] = (float) armorBefore[c.x, c.y]; + foreach (var kv in itemsBefore) kv.Key.EquippableItem.Durability = (float) kv.Value; + e.Target.Hull.Durability = hullBefore; + } + + const int n = 2; // damageSpread 2 -> floor(2.5) = 2 + ShotOutcome outcome = null; + var hullEvent = 0.0; + using (e.Target.HullDamage.Subscribe(d => hullEvent += d)) + { + // Retry until the shot both hits AND actually shares `sharedItem` between >=2 lanes -- + // the property this test exists to exercise, not left to chance. + for (var attempt = 0; attempt < 1200; attempt++) + { + ResetState(); + hullEvent = 0.0; + var o = FireUntilHit(e, damageOverride: 40f, attempts: 1); + if (o == null || !o.Hit) continue; + if (LanesReaching(e.HullData, o.Bearing, o.Lateral, n, 3f, e.Target, sharedItem) < 2) continue; + outcome = o; + break; + } + } + if (outcome == null) continue; // fixture: rare exhaustion of the shared-hit budget, skip this draw + trials++; + + var model = FixedPointItemResolution(e.Target, e.HullData, armorBefore, itemsBefore, + outcome.Bearing, outcome.Lateral, 40f, 3f, n); + + // Tightened from an earlier `< .1` (Soul, 2026-09-25). That wide tolerance was masking two + // different things at once: genuine float32-vs-double arithmetic drift (small, ~1e-3) AND this + // sweep's own model disagreeing with `Lanes`' rare cross-lane cell-ownership tie-break (large + // enough to read as a clean 0.03-0.09, reproducible, NOT noise -- caught and removed above by + // reading lane membership from the real `FireControl.Lanes` instead of reimplementing it). + // With that class of disagreement gone, what's left really is arithmetic noise. + const double tolerance = .01; + foreach (var c in shape.Coordinates) + Assert.True(Math.Abs(model.armor[c.x, c.y] - e.Target.Armor[c.x, c.y]) < tolerance, + $"armor{c}: model {model.armor[c.x, c.y]:F4} production {e.Target.Armor[c.x, c.y]:F4}"); + foreach (var kv in model.items) + Assert.True(Math.Abs(kv.Value - (double) kv.Key.EquippableItem.Durability) < tolerance, + $"item#{kv.Key.GetHashCode()}: model {kv.Value:F4} production {kv.Key.EquippableItem.Durability:F4}"); + Assert.True(Math.Abs(model.hull - hullEvent) < tolerance, + $"hull: model {model.hull:F4} production {hullEvent:F4}"); + } + } + Assert.True(trials >= 40, $"fixture: needed at least 40 shared-item trials (of up to 90 draws), got {trials}"); + } + + // ===================== Soul's own probes (Cut 12.3, one-apply-path fix batch) ===================== + // These pin the rules the earlier pooled-but-still-branching implementation could not: the cycle tie-break + // (two non-convex items crossed by two lanes in opposite relative order), the pooled path's own threshold + // gate, and a zero deposit not stalling the pool it belongs to. + + private static Shape CellsShape(int w, int h, params (int x, int y)[] cells) + { + var s = new Shape(w, h); + foreach (var c in cells) s[new int2(c.x, c.y)] = true; + return s; + } + + private static void ZeroArmor(Engagement e, int w, int h) + { + for (var x = 0; x < w; x++) + for (var y = 0; y < h; y++) { e.Target.Armor[x, y] = 0f; e.Target.MaxArmor[x, y] = 20f; } + } + + private static ShotOutcome FireCentre(Engagement e, int centreX, Action reset, float damage) + { + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 800 && (outcome == null || !outcome.Hit || outcome.Cell.x != centreX); attempt++) + { + reset(); + outcome = FireUntilHit(e, damageOverride: damage, attempts: 1); + } + Assert.True(outcome != null && outcome.Hit && outcome.Cell.x == centreX, "fixture: centre hit"); + return outcome; + } + + // A genuine cycle: lane col2 reaches item X before item Y down its own walk; lane col3 reaches Y before X. + // Neither item can resolve in either lane's own causal order, so the cycle rule (resolve every open pool at + // once) must fire. Fixture B is fixture A reflected about the centre column -- mirror symmetry means the + // two per-lane leftovers (the outer marker's and the centre marker's) must come out identical on both, and + // total damage must still be fully conserved (armour + items + hull == the shot's own damage) on both. + [Fact] + public void CycleTieBreakIsMirrorSymmetric() + { + (float outer, float centre, float conservation) Run(bool mirrored) + { + var custom = mirrored + ? new[] + { + ("SoulX", CellsShape(3, 4, (1, 0), (2, 0), (2, 1), (2, 2), (2, 3), (1, 3), (0, 3)), new int2(3, 1), 10f), + ("SoulY", CellsShape(2, 2, (0, 0), (0, 1), (1, 1)), new int2(3, 1), 10f) + } + : new[] + { + ("SoulX", CellsShape(3, 4, (1, 0), (0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3)), new int2(1, 1), 10f), + ("SoulY", CellsShape(2, 2, (1, 0), (1, 1), (0, 1)), new int2(2, 1), 10f) + }; + var markers = mirrored + ? new[] { (new int2(4, 3), 1000f), (new int2(3, 3), 1000f) } + : new[] { (new int2(2, 3), 1000f), (new int2(3, 3), 1000f) }; + var e = Build(TestSettings(), SolidShape(7, 6), precision: .6f, penetration: 10f, damageSpread: 1f, + markers: markers, custom: custom); + var x = e.Custom["SoulX"]; + var y = e.Custom["SoulY"]; + var hullBefore = 0f; + void Reset() + { + ZeroArmor(e, 7, 6); + e.Target.Armor[mirrored ? 4 : 2, 1] = 10f; + x.EquippableItem.Durability = 10f; + y.EquippableItem.Durability = 10f; + e.Markers[0].EquippableItem.Durability = 1000f; + e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + e.Target.Hull.Durability = 1000000f; + hullBefore = e.Target.Hull.Durability; + } + FireCentre(e, 3, Reset, 90f); + var armorLost = 10f; + for (var xx = 0; xx < 7; xx++) for (var yy = 0; yy < 6; yy++) armorLost -= e.Target.Armor[xx, yy]; + var itemsLost = (10f - x.EquippableItem.Durability) + (10f - y.EquippableItem.Durability) + + (1000f - e.Markers[0].EquippableItem.Durability) + (1000f - e.Markers[1].EquippableItem.Durability); + var hullLost = hullBefore - e.Target.Hull.Durability; + var outerMarker = 1000f - e.Markers[0].EquippableItem.Durability; + var centreMarker = 1000f - e.Markers[1].EquippableItem.Durability; + return (outerMarker, centreMarker, armorLost + itemsLost + hullLost); + } + var a = Run(false); + var b = Run(true); + Assert.Equal(90f, a.conservation, 2); // nothing lost: the whole shot lands somewhere + Assert.Equal(90f, b.conservation, 2); + Assert.Equal(a.outer, b.outer, 2); // mirror symmetry: the tie-break carries no left/right bias + Assert.Equal(a.centre, b.centre, 2); + } + + // The item's own .1f threshold is decided ONCE on the pooled total, never per contribution. Two lanes + // striking a shared 2x1 bar, each depositing the SAME post-armour remainder: with 0.04 each (sum 0.08, + // still under .1f) nothing should absorb, matching two separate sub-threshold solo hits. With 0.06 each + // (sum 0.12, OVER .1f, but NEITHER contribution alone clears it) the item must still absorb -- a mutant + // that moved the threshold onto each contribution individually, instead of the pool total, would leave + // this second case untouched, since 0.06 never clears .1f by itself. + [Fact] + public void PooledPathHonoursTheItemThresholdLikeTheSoloPath() + { + (float lost, int events) RunShared(float perLanePostArmour) + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, bars: new[] { (new int2(1, 1), 50f) }); + var bar = e.Target.GearOccupancy[1, 1]; + void Reset() + { + ZeroArmor(e, 5, 4); + var armor = 30f - perLanePostArmour; // damagePerLane is 90/3 = 30 (damageSpread 1, 3 lanes) + e.Target.Armor[1, 1] = armor; e.Target.Armor[2, 1] = armor; e.Target.MaxArmor[1, 1] = 30; e.Target.MaxArmor[2, 1] = 30; + bar.EquippableItem.Durability = 50f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + var n = 0; + ShotOutcome o = null; + for (var attempt = 0; attempt < 800 && (o == null || !o.Hit || o.Cell.x != 2); attempt++) + { + Reset(); + n = 0; + using var sub = e.Target.ItemDamage.Subscribe(_ => n++); + o = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.NotNull(o); + Assert.True(o.Hit && o.Cell.x == 2, "fixture: needed a hit on the centre interior column within the attempt budget"); + return (50f - bar.EquippableItem.Durability, n); + } + + var underThreshold = RunShared(0.04f); // sum 0.08: under .1f either way + Assert.Equal(0f, underThreshold.lost, 4); + Assert.Equal(0, underThreshold.events); + + var overThreshold = RunShared(0.06f); // sum 0.12: over .1f as a pool, under .1f per contribution + Assert.Equal(0.12f, overThreshold.lost, 3); + // Soul's own event-cardinality pin: a mutation that deletes the pooled ItemDamage.OnNext call survived + // every other test in this file, because most fixtures only ever check durability. One event per + // contributing lane (both contributed > 0 here), not one event per resolve. + Assert.Equal(2, overThreshold.events); + } + + // A lane whose armour eats its entire share before ever reaching a shared item still deposits (a zero + // amount) and must not stall the pool waiting for a contribution that will never come with more than zero + // in it -- the pool resolves as soon as every OTHER lane that could still reach the item is done, whatever + // this lane itself brought. + [Fact] + public void ZeroDepositDoesNotStall() + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 1), 10f) }, markers: new[] { (new int2(1, 2), 1000f), (new int2(2, 2), 1000f) }); + var bar = e.Target.GearOccupancy[1, 1]; + void Reset() + { + ZeroArmor(e, 5, 4); + e.Target.Armor[1, 0] = 1000f; e.Target.MaxArmor[1, 0] = 1000f; e.Target.Armor[2, 1] = 15f; + bar.EquippableItem.Durability = 10f; + e.Markers[0].EquippableItem.Durability = 1000f; e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + FireCentre(e, 2, Reset, 90f); + Assert.Equal(0f, bar.EquippableItem.Durability, 3); // the bar's own 10 durability, fully spent by column 2 alone + Assert.Equal(0f, 1000 - e.Markers[0].EquippableItem.Durability, 3); // column 1's own zero deposit: nothing reaches its marker + Assert.Equal(5f, 1000 - e.Markers[1].EquippableItem.Durability, 3); // column 2's own leftover (30-15-10=5) reaches its marker + } + + // Soul's own claim (2026-09-25, fix batch on 166b6202): a zero-deposit contributor -- column 1's lane here, + // whose armour eats its whole share before it ever reaches the bar -- must never fire a phantom ItemDamage + // event for that zero. Same fixture as ZeroDepositDoesNotStall above (that test pins durability and marker + // leftovers; this one pins the event itself, which Entity.ItemAbsorb's `incoming[i] > 0f` guard decides). + // Kills a boundary flip of that guard to `>=` (a zero deposit would then fire) and a swap that reports the + // post-split share instead of the incoming one. + [Fact] + public void ZeroDepositContributorFiresNoItemDamageEvent() + { + var e = Build(TestSettings(), SolidShape(5, 4), precision: .6f, penetration: 2.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 1), 10f) }, markers: new[] { (new int2(1, 2), 1000f), (new int2(2, 2), 1000f) }); + var bar = e.Target.GearOccupancy[1, 1]; + void Reset() + { + ZeroArmor(e, 5, 4); + e.Target.Armor[1, 0] = 1000f; e.Target.MaxArmor[1, 0] = 1000f; e.Target.Armor[2, 1] = 15f; + bar.EquippableItem.Durability = 10f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + + // A fresh subscription per attempt (the PooledPathHonoursTheItemThresholdLikeTheSoloPath convention, + // above): a rejected attempt still fires a real shot, and its events must not contaminate the accepted + // attempt's own count. + var barEvents = new List(); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 800 && (outcome == null || !outcome.Hit || outcome.Cell.x != 2); attempt++) + { + Reset(); + barEvents.Clear(); + using var sub = e.Target.ItemDamage.Subscribe(x => { if (x.item == bar) barEvents.Add(x.damage); }); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.True(outcome != null && outcome.Hit && outcome.Cell.x == 2, "fixture: needed a hit on the centre interior column within the attempt budget"); + + Assert.Single(barEvents); // column 2's own deposit only -- column 1's zero deposit fires nothing + Assert.Equal(15f, barEvents[0], 3); // column 2's post-armour remainder (30 - 15 armour) + } + + // An item crossed by a lane whose CLIPPED walk ends before the item's own cell on that lane must not wait + // for that lane -- reach is decided by the clipped walk (what Apply's own penetration clip left), never by + // the lane's raw, unclipped geometry. A 2x1 bar at row 2; penetration 1.5 clips every lane to rows 0-1, so no + // lane can ever reach the bar at all -- if StillReachableByAnotherLane read the raw walk instead, a lane + // that (on the raw geometry) also crosses the bar's row would look like a future reacher and the other + // lane's own pool would stall waiting for it forever. + [Fact] + public void ClippedLaneDoesNotHoldThePool() + { + // 2x1 bar at (1,2)-(2,2); straight shot, lanes cols 1,2,3; penetration 1.5 -> each lane reaches rows 0,1 only. + var e = Build(TestSettings(), SolidShape(5, 5), precision: .6f, penetration: 1.5f, damageSpread: 1f, + bars: new[] { (new int2(1, 2), 10f) }); + var bar = e.Target.GearOccupancy[1, 2]; + void Reset() + { + ZeroArmor(e, 5, 5); + bar.EquippableItem.Durability = 10f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + + var hull = 0f; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 800 && (outcome == null || !outcome.Hit || outcome.Cell.x != 2); attempt++) + { + Reset(); + hull = 0f; + using var sub = e.Target.HullDamage.Subscribe(d => hull += d); + outcome = FireUntilHit(e, damageOverride: 90f, attempts: 1); + } + Assert.True(outcome != null && outcome.Hit && outcome.Cell.x == 2, "fixture: needed a hit on the centre column within the attempt budget"); + + Assert.Equal(10f, bar.EquippableItem.Durability, 3); // never reached -- clipped out before row 2 + Assert.Equal(90f, hull, 2); // the whole shot (3 lanes x 30, no armour, nothing absorbed) reaches the hull + } + + // A lane's own LATER cell of the same item never holds that item's pool open -- only OTHER lanes are future + // reachers (StillReachableByAnotherLane's own `alreadyContributed` check, F1 fix batch). Lane column 1 + // crosses a vertical 1x2 item X twice (X spans rows 1-2 in that single column), then a 2x1 bar Z it shares + // with column 2's lane at row 3. Column 2 reaches Z first and must wait for column 1; column 1's two visits + // to X are each a solo pool (nothing else ever reaches X) and must resolve at once rather than stalling on + // column 1's own second visit, so column 1 goes on to reach Z and the pool there is the genuine + // {column 1: 25, column 2: 30} -- not a cycle-rule resolve of Z with column 2 alone (which deleting the + // `alreadyContributed` guard produces instead). + [Fact] + public void OwnRepeatVisitDoesNotHoldThePool() + { + var e = Build(TestSettings(), SolidShape(5, 6), precision: .6f, penetration: 10f, damageSpread: 1f, + bars: new[] { (new int2(1, 3), 20f) }, markers: new[] { (new int2(1, 4), 1000f), (new int2(2, 4), 1000f) }, + custom: new[] { ("SoulV", CellsShape(1, 2, (0, 0), (0, 1)), new int2(1, 1), 5f) }); + var z = e.Target.GearOccupancy[1, 3]; + var x = e.Custom["SoulV"]; + void Reset() + { + ZeroArmor(e, 5, 6); + z.EquippableItem.Durability = 20f; + x.EquippableItem.Durability = 5f; + e.Markers[0].EquippableItem.Durability = 1000f; + e.Markers[1].EquippableItem.Durability = 1000f; + e.Target.GearOccupancy[0, 0].EquippableItem.Durability = 0f; + } + + FireCentre(e, 2, Reset, 90f); + var l1 = 1000f - e.Markers[0].EquippableItem.Durability; + var l2 = 1000f - e.Markers[1].EquippableItem.Durability; + Assert.Equal(0f, z.EquippableItem.Durability, 3); + Assert.Equal(25f * 35f / 55f, l1, 2); // column 1's own leftover after X (30 - 5, X's own 5 durability): 15.909 + Assert.Equal(30f * 35f / 55f, l2, 2); // column 2's leftover pooled with column 1's at Z (durability 20): 19.091 + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs new file mode 100644 index 000000000..d55bc6df6 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -0,0 +1,2661 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 12.4(b) (docs/fire-control-cut.md, "The detonation primitive"): behavioural tests for Detonate -- the +// blast-as-area owner that replaces FireControl.Splash and Entity.Absorb -- and for Apply's switch on the +// frozen Fuse (null/Proximity/Contact/Delayed). Builds its own fixtures, the convention every cut's test file +// follows, with the rules this cut's own tests must obey: SchematicCellSize 2 throughout (the 12.3 fixture's +// cell size of 1 would hide a missing `/ SchematicCellSize`), the target off the origin, and a hull whose +// centre of mass is not an integer cell. +public sealed class FireControlCut124Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut124-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut124Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings(float commitHorizon = .5f) => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = commitHorizon, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // 5 wide x 4 tall: centre of mass (2, 1.5) -- not an integer cell, the fixture rule this whole file obeys. + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // The interior cell (1,1) of a 3x3 solid hull, ringed by its 8 border cells -- Shape.Shrink needs all 8 + // neighbours present, which only the centre of a 3x3 has (the same fixture 12.3's BuriedCockpitNeedsPenetration + // and PenetratorBurrowsBeforeItBursts both need: an item reachable only by penetrating the ring). + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public HullData HullData; + public EquippedItem Cockpit; + public Dictionary Custom = new Dictionary(); + } + + // The gun's behaviour data: a plain InstantWeaponData, or a LockWeaponData whose lock either grows to full + // (LockAngle 180 admits any bearing) or never grows (LockAngle 0 admits none, so it only decays). A stat + // term on Range lets a test scale it below its authored value through the power supply. + private static InstantWeaponData WeaponBehavior(float damage, float range, StatSource? rangeTerm, float velocity, float penetration, + bool lockWeapon, bool lockAcquires) + { + var data = lockWeapon ? new LockWeaponData + { + LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(lockAcquires ? 180 : 0), + DirectionImpact = Constant(1), Decay = Constant(1) + } : new InstantWeaponData(); + data.Damage = Constant(damage); + data.Range = rangeTerm == null ? Constant(range) + : new PerformanceStat { Min = range, Max = range, Terms = { new StatTerm { Source = rangeTerm.Value, Exponent = 1f } } }; + data.MinRange = Constant(0); + data.Velocity = Constant(velocity); + data.Spread = Constant(0); + data.DamageSpread = Constant(0); + data.Penetration = Constant(penetration); + data.Count = Constant(1); + data.BurstTime = Constant(0); + data.Cooldown = Constant(1000); + data.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + return data; + } + + private Engagement Build( + GameplaySettings settings, Shape hullShape, + float2? targetFacing = null, float targetRange = 100f, float velocity = 0f, + WeaponFuse? fuse = null, float blastRadius = 0f, float penetration = 0f, float damage = 500f, + WeaponModifiers modifiers = WeaponModifiers.None, + bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true, + int2? cockpitCell = null, float cockpitDurability = 1000f, + (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null, + float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f, + float hardpointArc = 0f, StatSource? rangeTerm = null, bool lockWeapon = false, bool lockAcquires = true) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000000, Mass = 1000, Armor = 0, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = gunCell ?? new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape(), FiringArc = hardpointArc } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blastRadius, WeaponModifiers = modifiers, + Behaviors = { WeaponBehavior(damage, weaponRange, rangeTerm, velocity, penetration, lockWeapon, lockAcquires) } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData { Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } + }); + cache.Upsert(new GearData + { + Name = "Cockpit", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new CockpitData() } + }); + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + var barShape = new Shape(2, 1); + foreach (var v in barShape.AllCoordinates) barShape[v] = true; + cache.Upsert(new GearData + { + Name = "Bar", Hardpoint = HardpointType.Tool, Shape = barShape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + if (custom != null) + foreach (var cu in custom) + cache.Upsert(new GearData { Name = cu.Name, Hardpoint = HardpointType.Tool, Shape = cu.Shape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut124", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + EquippableItem MakeWeapon(int lot) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName("Gun")), Durability = 1, Lot = lot + }; + + var lot = 1; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var gun = MakeWeapon(lot++); + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var targetGun = MakeWeapon(lot++); + Assert.True(target.TryEquip(targetGun, gunCell ?? new int2(0, 0))); + + EquippedItem cockpit = null; + if (cockpitCell != null) + { + var ci = Make("Cockpit", lot++, cockpitDurability); + Assert.True(target.TryEquip(ci, cockpitCell.Value), $"cockpit must fit at {cockpitCell.Value}"); + cockpit = target.Equipment.Single(x => x.EquippableItem == ci); + } + + var customItems = new Dictionary(); + if (custom != null) + foreach (var cu in custom) + { + var ci0 = Make(cu.Name, lot++, cu.Durability); + Assert.True(target.TryEquip(ci0, cu.Cell), $"custom {cu.Name} must fit at {cu.Cell}"); + customItems[cu.Name] = target.Equipment.Single(x => x.EquippableItem == ci0); + } + + if (equipShield) + { + var reactor = Make("Reactor", lot++, 10); + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + var shield = Make("Shield", lot++, 10); + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + // Off-origin (the fixture rule): the shooter sits away from the world origin, and the target sits + // `targetRange` ahead of it along the shooter's own +z, so a stationary target's TravelDirection is + // exactly (0,1) when targetFacing is left at its default. + shooter.Position = float3(-53, 0, -101); + target.Position = shooter.Position + float3(0, 0, targetRange); + if (targetFacing != null) target.Direction = normalize(targetFacing.Value); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + if (equipShield) target.Shield.Item.Enabled.Value = shieldActive; + + zone.Update(1f); // fire times nonzero, same convention 12.3's fixture uses + + return new Engagement + { + Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, + HullData = hullData, Cockpit = cockpit, Custom = customItems + }; + } + + // Numeric cross-check, independent of FireControl's own exact-overlap implementation: the area of a disc + // (centred at world point (cx,cy), radius r) that lies inside the axis-aligned rectangle [xlo,xhi]x[ylo,yhi], + // by fine grid sampling. Used only to compute an EXPECTED value from first principles; the tests that need + // exact values instead (conservation, exact half-splits) get them from geometry, not sampling. + private static double DiscAreaInRect(double cx, double cy, double r, double xlo, double xhi, double ylo, double yhi, int samples = 1000) + { + var bxlo = Math.Max(xlo, cx - r); + var bxhi = Math.Min(xhi, cx + r); + var bylo = Math.Max(ylo, cy - r); + var byhi = Math.Min(yhi, cy + r); + if (bxhi <= bxlo || byhi <= bylo) return 0; + long inside = 0; + var dx = (bxhi - bxlo) / samples; + var dy = (byhi - bylo) / samples; + for (var i = 0; i < samples; i++) + { + var x = bxlo + (i + .5) * dx; + var dxc = x - cx; + for (var j = 0; j < samples; j++) + { + var y = bylo + (j + .5) * dy; + var dyc = y - cy; + if (dxc * dxc + dyc * dyc <= r * r) inside++; + } + } + return inside * dx * dy; + } + + private static double DiscAreaInCell(double cx, double cy, double r, int2 cell, int samples = 1000) => + DiscAreaInRect(cx, cy, r, cell.x - .5, cell.x + .5, cell.y - .5, cell.y + .5, samples); + + // ==== Geometry ==== + + // TheDiscIsConservedOverTheGrid: on a large solid, armourless, itemless hull that contains the disc + // entirely, the total hull damage delivered equals `damage`, at three different disc placements. + // Kills: sampling in place of the exact overlap; the normaliser (2r)^2 in place of pi*r^2; a bounding box + // that clips the rim cells; a dropped chord breakpoint -- any of these would move the total away from + // `damage` even though the whole disc lies on metal. + [Theory] + [InlineData(0f, 0f, 0.3f)] // radius under one cell, centred on a cell's own integer coordinate + [InlineData(.5f, .5f, 1.7f)] // centred on a cell corner + [InlineData(.37f, -.21f, 1.2f)] // an arbitrary off-grid centre + public void TheDiscIsConservedOverTheGrid(float ox, float oy, float radius) + { + var e = Build(TestSettings(), SolidShape(21, 21)); + var centreCell = new int2(10, 10); // well inside the 21x21 grid at any of the radii above + var worldCentre = e.Target.Position.xz + float2(ox, oy) * e.Items.GameplaySettings.SchematicCellSize; + // Shift so the schematic centre lands at centreCell + (ox,oy): CenterOfMass is (10,10) for a 21x21 + // solid hull, so the untranslated target position already maps there. + var before = e.Target.Hull.Durability; + + FireControl.Detonate(e.Zone, worldCentre, radius * e.Items.GameplaySettings.SchematicCellSize, 100f, DamageType.Kinetic); + + var delivered = before - e.Target.Hull.Durability; + Assert.Equal(100f, delivered, 2); + } + + // EachCellTakesItsShareOfTheDisc: a solid hull with no items, and per-cell armour large enough that + // ArmorAbsorb never depletes it -- so the ArmorDamage event for a cell reports that cell's raw share. + // Pass: the named cell's reported share matches damage * overlap/(pi r^2), overlap integrated numerically + // by the test. Kills: an even split over the covered cells (Splash's rule), and a share decided by the + // cell's distance from the centre rather than its overlap area. + [Fact] + public void EachCellTakesItsShareOfTheDisc() + { + var e = Build(TestSettings(), SolidShape(9, 9)); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1e6f; e.Target.MaxArmor[c.x, c.y] = 1e6f; } + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var com = e.HullData.Shape.CenterOfMass; // (4,4) for a 9x9 solid hull + var namedCell = new int2(5, 4); // one cell off-centre laterally + var radius = 2.3; // world units under cellSize*4, comfortably covering several cells + + var deposits = new Dictionary(); + using (e.Target.ArmorDamage.Subscribe(x => deposits[x.pos] = x.damage)) + { + FireControl.Detonate(e.Zone, e.Target.Position.xz, (float) radius, 1000f, DamageType.Kinetic); + } + + var expectedOverlap = DiscAreaInCell(com.x, com.y, radius / cellSize, namedCell); + var expectedShare = 1000.0 * expectedOverlap / (Math.PI * (radius / cellSize) * (radius / cellSize)); + Assert.True(deposits.TryGetValue(namedCell, out var actual), "named cell took no share at all"); + Assert.Equal(expectedShare, actual, 1); + } + + // TheShareOffTheHullIsLost (was ExternalBlastWastesMostOfItsEnergy): the disc is centred exactly on the + // hull's own edge, so exactly half its area sits off the grid by construction (no numeric integration + // needed -- a disc split by a straight line through its centre halves exactly). Pass: the delivered total + // is half the blast's damage. Kills: renormalising the shares over the occupied cells (which would deliver + // the full damage). + [Fact] + public void TheShareOffTheHullIsLost() + { + var e = Build(TestSettings(), SolidShape(5, 4)); // x: 0..4 (CoM.x = 2), left edge at schematic x = -0.5 + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + // World point at schematic (-0.5, 1.5): CoM.y = 1.5 (the fixture's own non-integer centre), 2.5 cells + // to port of CoM.x -- exactly the hull's left edge, with default facing (0,1) so world x is schematic x. + var worldCentre = e.Target.Position.xz + float2(-2.5f * cellSize, 0); + var before = e.Target.Hull.Durability; + + // Radius 0.5 cells: the on-hull half never reaches the next cell boundary at schematic x = 0.5. + FireControl.Detonate(e.Zone, worldCentre, .5f * cellSize, 100f, DamageType.Kinetic); + + var delivered = before - e.Target.Hull.Durability; + Assert.Equal(50f, delivered, 1); + } + + // CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius: a long thin hull whose centre of mass lies well + // beyond the blast radius, while its near end lies inside. Kills: Splash's own centre-distance cull. + [Fact] + public void CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius() + { + var e = Build(TestSettings(), SolidShape(1, 20)); // CoM at schematic (0, 9.5) + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + // The near end (schematic y = 0) is `9.5 * cellSize` world units from CoM; the blast sits right there, + // far outside a radius that would ever reach the centre. + var nearEndWorld = e.Target.Position.xz + float2(0, -9.5f * cellSize); + var before = e.Target.Hull.Durability; + + FireControl.Detonate(e.Zone, nearEndWorld, cellSize, 100f, DamageType.Kinetic); + + Assert.True(e.Target.Hull.Durability < before, "the near end of a long hull must take damage even though its centre is far outside the radius"); + } + + // Per-cell armour large enough that ArmorAbsorb never depletes it: every ArmorDamage event then reports its + // cell's raw share, whether or not an item sits on the cell. + private static void ArmourEverything(Engagement e) + { + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1e6f; e.Target.MaxArmor[c.x, c.y] = 1e6f; } + } + + // The independent expectation: `damage` shared over a disc of radius rCells centred at schematic (cx,cy) in + // proportion to numeric overlap with each occupied cell. + private static double ExpectedDelivered(Shape shape, double cx, double cy, double rCells, double damage) + { + var overlap = 0.0; + foreach (var c in shape.Coordinates) overlap += DiscAreaInCell(cx, cy, rCells, c); + return damage * overlap / (Math.PI * rCells * rCells); + } + + // ==== 12.4 fix batch F1: a disc tangent to a cell edge must not count the whole square ==== + + // A cell edge exactly r from the centre, with the piece's midpoint at 0, used to fall through + // `h < yhi` / `-h > ylo` into the "whole rectangle" branch, so the square counted in full. BlastRadius 1 + // world unit is half a cell (edges at +-.5 from an integer centre of mass), 3 is one and a half (edges at + // +-.5 and +-1.5): both are tangent to cell edges. Measured through Fire before the fix: 127.32 and 100.80 + // delivered of 100. + [Theory] + [InlineData(1f)] + [InlineData(3f)] + public void ATangentDiscDeliversExactlyItsDamageThroughFire(float blastRadius) + { + var e = Build(TestSettings(), SolidShape(5, 5), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: blastRadius, damage: 100f); + ArmourEverything(e); + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.Equal(100f, total, 2); + } + + [Theory] + [InlineData(1f)] + [InlineData(3f)] + public void ATangentDiscDeliversExactlyItsDamageThroughDetonate(float radius) + { + var e = Build(TestSettings(), SolidShape(5, 5)); + ArmourEverything(e); + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius, 100f, DamageType.Kinetic); + Assert.Equal(100f, total, 2); + } + + // ==== 12.4 fix batch F2: the exact overlap is the only decider of "the disc touches the hull" ==== + + // An L-shaped hull whose centre of mass sits far from the far end of its long arm. The blast lands on that + // arm's end, 14 world units from the centre of mass -- beyond radius + half the bounding box's diagonal + // measured from it, which the old candidate cull used, so the cull threw away a blast that overlaps a + // cell. Expected value: a numeric overlap of the cells, not the code under test. + [Fact] + public void ABlastAtTheFarEndOfAnLShapedHullLands() + { + var s = new Shape(10, 4); + foreach (var c in s.AllCoordinates) s[c] = c.x < 4 || c.y == 0; + var e = Build(TestSettings(), s); + ArmourEverything(e); + var com = e.HullData.Shape.CenterOfMass; + var p = float2(9.9f, 0f); + var expected = ExpectedDelivered(e.HullData.Shape, p.x, p.y, 1.0, 100.0); + Assert.True(expected > 10.0, $"fixture: the blast must overlap the hull, expected {expected}"); + + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz + (p - com) * 2f, 2f, 100f, DamageType.Kinetic); + + Assert.Equal(expected, total, 1); + } + + // The same rule on the shipped hulls: a blast just grazing the corner farthest from the centre of mass. The + // corners of the shipped Longinus and LonginusX reach past the old cull's box-half-diagonal bound. + [Theory] + [InlineData("Longinus")] + [InlineData("LonginusX")] + public void AGrazingBlastAtAShippedHullsFarthestCornerLands(string hullName) + { + Shape shape; + var cache = OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), out _); + try { shape = cache.GetByName(hullName).Shape; } + finally { cache.Dispose(); } + + var com = shape.CenterOfMass; + var far = float2.zero; + var best = -1f; + foreach (var c in shape.Coordinates) + foreach (var (dx, dy) in new[] { (-.5f, -.5f), (.5f, -.5f), (-.5f, .5f), (.5f, .5f) }) + { + var corner = float2(c.x + dx, c.y + dy); + var d = length(corner - com); + if (d > best) { best = d; far = corner; } + } + // One cell of radius, centred .9 cell outward of the corner: the disc reaches the corner by .1 cell. + var centre = far + normalize(far - com) * .9f; + + var e = Build(TestSettings(), shape, gunCell: shape.Coordinates[0]); + ArmourEverything(e); + var expected = ExpectedDelivered(shape, centre.x, centre.y, 1.0, 100.0); + Assert.True(expected > .05, $"fixture: the blast must graze the hull, expected {expected}"); + + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz + (centre - com) * 2f, 2f, 100f, DamageType.Kinetic); + + Assert.Equal(expected, total, 1); + } + + // ==== 12.4 fix batch F3: a lethal blast must not break the entity enumeration ==== + + private Ship AddShip(Engagement e, float3 position, float hullDurability, int lot) + { + var hullRef = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Hull")); + var ship = new Ship(e.Items, e.Zone, new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = lot }, new EntitySettings()); + var marker = new EquippableItem + { + Data = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Marker")), Durability = 1000000, Lot = lot + 1 + }; + Assert.True(ship.TryEquip(marker, new int2(1, 1))); + e.Zone.Entities.Add(ship); + ship.Activate(); + ship.Position = position; + return ship; + } + + // A blast that kills an entity mid-pass: death removes it from Zone.Entities, and the next MoveNext of the + // live enumeration threw. The shot was never removed, so it detonated again the next tick (the probe saw + // 2.69 then 5.38 on the target). Pass: nothing throws, the shot resolves and leaves exactly once, and the + // target is damaged exactly once. + [Fact] + public void ALethalBlastResolvesOnceAndBreaksNothing() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var near = AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300); + var far = AddShip(e, e.Shooter.Position + float3(-5000, 0, 0), 1f, 302); + + var resolved = 0; + var targetDamage = 0f; + using var r = e.Zone.ShotResolved.Subscribe(_ => resolved++); + using var a = e.Target.ArmorDamage.Subscribe(x => targetDamage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => targetDamage += x); + using var i = e.Target.ItemDamage.Subscribe(x => targetDamage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + SafeAssert.NoShots(e.Zone); + Assert.Equal(1, resolved); + SafeAssert.NotIn(e.Zone, near); // fixture: the blast really was lethal to a ship mid-pass + SafeAssert.In(e.Zone, far); + Assert.True(targetDamage > 0f, "fixture: the target must be inside the blast"); + var afterFirstTick = targetDamage; + + e.Zone.Update(.01f); + + Assert.Equal(1, resolved); + Assert.Equal(afterFirstTick, targetDamage, 4); + } + + // AOneBlastKillsEveryShipInsideIt: three ships, each dead to one hit, inside one blast. Each takes hull + // damage exactly once and dies exactly once, and the shot leaves the queue once. Kills: the snapshot loop + // breaking after the first kill (the other two would never be touched). + [Fact] + public void AOneBlastKillsEveryShipInsideIt() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var ships = new[] + { + AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300), + AddShip(e, e.Target.Position + float3(-8, 0, 0), 1f, 302), + AddShip(e, e.Target.Position + float3(0, 0, 8), 1f, 304), + }; + var hullHits = new int[3]; + var deaths = new int[3]; + var subs = new List(); + for (var n = 0; n < 3; n++) + { + var k = n; + subs.Add(ships[k].HullDamage.Subscribe(_ => hullHits[k]++)); + subs.Add(ships[k].Death.Subscribe(_ => deaths[k]++)); + } + var resolved = 0; + subs.Add(e.Zone.ShotResolved.Subscribe(_ => resolved++)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + foreach (var s in subs) s.Dispose(); + + Assert.Equal(new[] { 1, 1, 1 }, hullHits); + Assert.Equal(new[] { 1, 1, 1 }, deaths); + foreach (var s in ships) Assert.False(e.Zone.Entities.Contains(s), "a killed ship must have left the zone"); + SafeAssert.NoShots(e.Zone); + Assert.Equal(1, resolved); + } + + // AShieldlessShipOutsideTheDiscIsNotTouched: nothing of any kind reaches an entity the disc does not + // overlap. Kills: an uncovered entity falling through to the damage call (`covered ??= new List`). + [Fact] + public void AShieldlessShipOutsideTheDiscIsNotTouched() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 100f); + var outside = AddShip(e, e.Target.Position + float3(60, 0, 0), 1f, 300); + var touched = 0; + using var a = outside.ArmorDamage.Subscribe(_ => touched++); + using var h = outside.HullDamage.Subscribe(_ => touched++); + using var i = outside.ItemDamage.Subscribe(_ => touched++); + var targetHit = 0f; + using var t = e.Target.HullDamage.Subscribe(x => targetHit += x); + var armourHit = 0f; + using var ta = e.Target.ArmorDamage.Subscribe(x => armourHit += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(0, touched); + Assert.Equal(1f, outside.Hull.Durability); + Assert.True(e.Zone.Entities.Contains(outside)); + Assert.True(targetHit + armourHit > 0f, "fixture: the blast must land on the target"); + } + + // ADestroyedCockpitRaisesDeathOnce: the blast destroys the cockpit, Death removes and deactivates the + // entity, and the blast's own DamageHull then still runs; no subscriber sees a second Death. + [Fact] + public void ADestroyedCockpitRaisesDeathOnce() + { + var e = Build(TestSettings(), SolidShape(3, 3), cockpitCell: new int2(1, 1), cockpitDurability: 10f); + e.Target.Hull.Durability = 10f; // fragile enough that the blast's own hull damage is lethal too + var deaths = new List(); + using var d = e.Target.Death.Subscribe(deaths.Add); + var hullEvents = 0; + using var h = e.Target.HullDamage.Subscribe(_ => hullEvents++); + + FireControl.Detonate(e.Zone, e.Target.Position.xz, .6f, 5000f, DamageType.Kinetic); + + Assert.Equal(new[] { CauseOfDeath.CockpitDestroyed }, deaths); + Assert.False(e.Zone.Entities.Contains(e.Target), "the dead target must have left the zone"); + Assert.True(hullEvents >= 1, "fixture: the blast's hull damage still lands after the cockpit dies"); + Assert.True(e.Target.Hull.Durability < .01f, "fixture: and that hull damage is itself lethal"); + } + + // ADirectHullKillRaisesDeathOnce: the non-blast path (Entity.DamageHull straight, as a projectile hit does) + // raises Death once, and further damage to the corpse does not raise it again. + [Fact] + public void ADirectHullKillRaisesDeathOnce() + { + var e = Build(TestSettings(), SolidShape(3, 3)); + var deaths = 0; + using var d = e.Target.Death.Subscribe(_ => deaths++); + + e.Target.DamageHull(2000000f); + e.Target.DamageHull(5f); + + Assert.Equal(1, deaths); + } + + // ==== Absorption order ==== + + // ABlastIsAbsorbedThroughArmourFirstPerCell (replaces AbsorbSpendsArmourBeforeTheItemOnTheSameCell): a disc + // wholly inside one armoured item cell, so its share is the whole damage -- the three damage levels of the + // superseded test. Kills: the item absorbing before the armour, and armour being skipped. + [Fact] + public void ABlastIsAbsorbedThroughArmourFirstPerCell() + { + var cell = new int2(1, 1); + Engagement Fresh() => Build(TestSettings(), SolidShape(3, 3), custom: new[] { ("Marker", new Shape(), cell, 50f) }); + + void Reset(Engagement e) { e.Target.Armor[cell.x, cell.y] = 10f; e.Target.MaxArmor[cell.x, cell.y] = 10f; e.Custom["Marker"].EquippableItem.Durability = 50f; } + var cellSize = 2f; + var worldCentre = Fresh().Target.Position.xz; // recomputed fresh below per case (CoM == cell (1,1) for a 3x3 hull) + + { + var e = Fresh(); Reset(e); + FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 5f, DamageType.Kinetic); // below armour + Assert.Equal(5f, e.Target.Armor[cell.x, cell.y], 2); + Assert.Equal(50f, e.Custom["Marker"].EquippableItem.Durability, 2); + Assert.Equal(1000000f, e.Target.Hull.Durability, 1); + } + { + var e = Fresh(); Reset(e); + FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 25f, DamageType.Kinetic); // between armour and armour+durability + Assert.Equal(0f, e.Target.Armor[cell.x, cell.y], 2); + Assert.Equal(35f, e.Custom["Marker"].EquippableItem.Durability, 2); // 25 - 10 armour = 15 excess + Assert.Equal(1000000f, e.Target.Hull.Durability, 1); + } + { + var e = Fresh(); Reset(e); + FireControl.Detonate(e.Zone, e.Target.Position.xz, .3f * cellSize, 80f, DamageType.Kinetic); // above both + Assert.Equal(0f, e.Target.Armor[cell.x, cell.y], 2); + Assert.Equal(0f, e.Custom["Marker"].EquippableItem.Durability, 2); + Assert.Equal(1000000f - 20f, e.Target.Hull.Durability, 1); // 80 - 10 armour - 50 item = 20 + } + } + + // ArmourProtectsOnlyItsOwnCellsShare: a 2x1 item with armour on one cell only, and a disc symmetric about + // the seam so both cells take the same share. Pass: the item takes (shareA - armourA)+ + shareB. Kills: + // pooling the raw shares and then applying armour to the pool (which would let cell A's plate eat cell B's + // share). + [Fact] + public void ArmourProtectsOnlyItsOwnCellsShare() + { + var cellA = new int2(1, 1); + var cellB = new int2(2, 1); // Bar occupies (1,1)-(2,1) + var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 1000f) }); + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var com = e.HullData.Shape.CenterOfMass; + + e.Target.Armor[cellA.x, cellA.y] = 100f; e.Target.MaxArmor[cellA.x, cellA.y] = 100f; + e.Target.Armor[cellB.x, cellB.y] = 0f; e.Target.MaxArmor[cellB.x, cellB.y] = 0f; + + // Centred exactly on the seam between the two cells (schematic x = 1.5), same y as both cells: by + // symmetry shareA == shareB exactly, no numeric integration needed. + var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - com) * cellSize; + var radius = 1f; // world units -- stays within the seam's own pair of cells (each 1 cell wide) + + // Armour (100) exceeds a symmetric share (damage/2), so pick damage large enough that shareA - armourA + // is positive: shareA ~= damage/2 (by symmetry, exactly, regardless of the exact overlap fraction -- + // both cells see the identical geometry mirrored across the seam). + FireControl.Detonate(e.Zone, worldCentre, radius, 400f, DamageType.Kinetic); + + var shareEach = 200f; // damage/2, by the seam symmetry + var expectedItemLoss = Math.Max(0f, shareEach - 100f) + shareEach; // (shareA - armourA)+ + shareB + Assert.Equal(1000f - expectedItemLoss, e.Custom["Bar"].EquippableItem.Durability, 1); + Assert.Equal(0f, e.Target.Armor[cellA.x, cellA.y], 1); // cell A's own plate is spent by its own share (200 > 100), same as any single-cell absorb + } + + private static Shape BarShape() + { + var shape = new Shape(2, 1); + foreach (var v in shape.AllCoordinates) shape[v] = true; + return shape; + } + + // AMultiCellItemAbsorbsItsCoveredCellsAsOnePool (the ProbeC2 geometry): a 2x1 item with no armour, and a + // disc straddling its seam that puts less than .1 on each cell but more than .1 on the two together. Pass: + // the item absorbs the sum. Kills: resolving the item per cell in any form -- Absorb resurrected, ItemAbsorb + // called per deposit, or a pool that resolves before every covered cell has deposited. + [Fact] + public void AMultiCellItemAbsorbsItsCoveredCellsAsOnePool() + { + var cellA = new int2(1, 1); + var cellB = new int2(2, 1); + var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 50f) }); + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + + // Centred exactly on the seam, radius small enough that the whole disc is contained within the two + // cells combined (radius 0.4 cells < 0.5 cells' worth of margin either side of the seam). + var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - e.HullData.Shape.CenterOfMass) * cellSize; + var radiusCells = .4f; + // Full disc retained (contained within the pair of cells), split evenly by the seam symmetry: total + // share = damage exactly, .08 on each side when damage = .16. + FireControl.Detonate(e.Zone, worldCentre, radiusCells * cellSize, .16f, DamageType.Kinetic); + + Assert.Equal(50f - .16f, e.Custom["Bar"].EquippableItem.Durability, 3); + } + + // ItemDamageFiresOncePerCoveredCell: the same pooled geometry as above. Pass: the number of ItemDamage + // events equals the number of covered cells with a positive post-armour deposit (two), each carrying that + // cell's own deposit. Kills: one event per item, and events for zero-deposit cells. + [Fact] + public void ItemDamageFiresOncePerCoveredCell() + { + var cellA = new int2(1, 1); + var e = Build(TestSettings(), SolidShape(4, 3), custom: new[] { ("Bar", BarShape(), cellA, 50f) }); + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var worldCentre = e.Target.Position.xz + (float2(1.5f, cellA.y) - e.HullData.Shape.CenterOfMass) * cellSize; + + var events = new List(); + using (e.Target.ItemDamage.Subscribe(x => events.Add(x.damage))) + { + FireControl.Detonate(e.Zone, worldCentre, .4f * cellSize, .16f, DamageType.Kinetic); + } + + Assert.Equal(2, events.Count); + Assert.All(events, amt => Assert.Equal(.08f, amt, 2)); + } + + // ==== Symmetry and frame ==== + + // MirroredBlastsDoMirroredDamage: a hull, its armour and its items all mirror-symmetric about the bow + // axis, facing (2,1)/sqrt(5). Two independent engagements, blasted at world points mirrored across the + // target's own bow axis -- the mirror computed here from the facing, not through ToSchematic. Pass: + // per-cell armour and item durability mirror. Kills: the +.5 cell convention returning in the overlap, a + // missing or wrong CenterOfMass anchor, and any scheduling rule that depends on enumeration order. + [Fact] + public void MirroredBlastsDoMirroredDamage() + { + var facing = normalize(float2(2, 1)); + var markerLeft = new int2(1, 1); + var markerRight = new int2(3, 1); // mirror pair about x = 2 on a 5-wide hull + + Engagement Fresh() + { + var e = Build(TestSettings(), SolidShape(5, 3), targetFacing: facing, + custom: new[] { ("MarkerL", new Shape(), markerLeft, 200f), ("MarkerR", new Shape(), markerRight, 200f) }); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 40f; e.Target.MaxArmor[c.x, c.y] = 40f; } + return e; + } + + // Reflect a world-space offset across the target's own bow axis (its forward direction), computed here + // independently of Entity.ToSchematic: reflecting across the line through the origin along `forward` + // negates the lateral (starboard) component and keeps the along-bow component. + float2 Mirror(float2 offset, float2 forward) + { + var along = dot(offset, forward) * forward; + var lateral = offset - along; + return along - lateral; + } + + var e1 = Fresh(); + var e2 = Fresh(); + var cellSize = e1.Items.GameplaySettings.SchematicCellSize; + var offset = float2(6f, 3f); // an arbitrary world offset from the target, not aligned to any axis + var mirroredOffset = Mirror(offset, facing); + + FireControl.Detonate(e1.Zone, e1.Target.Position.xz + offset, 3f * cellSize, 300f, DamageType.Kinetic); + FireControl.Detonate(e2.Zone, e2.Target.Position.xz + mirroredOffset, 3f * cellSize, 300f, DamageType.Kinetic); + + foreach (var c in e1.HullData.Shape.Coordinates) + { + var mirroredCell = new int2(4 - c.x, c.y); + Assert.Equal(e1.Target.Armor[c.x, c.y], e2.Target.Armor[mirroredCell.x, mirroredCell.y], 1); + } + Assert.Equal(e1.Custom["MarkerL"].EquippableItem.Durability, e2.Custom["MarkerR"].EquippableItem.Durability, 1); + Assert.Equal(e1.Custom["MarkerR"].EquippableItem.Durability, e2.Custom["MarkerL"].EquippableItem.Durability, 1); + Assert.Equal(e1.Target.Hull.Durability, e2.Target.Hull.Durability, 1); + } + + // ABlastDamagesTheCellsNearestIt. This succeeds SplashDamagesTheSideTheBlastCameFrom + // (FireControlCut11Tests.cs) and SplashIsDirectional (FireControlCut4Tests.cs). Kills: a flip of + // ToSchematicPoint's right axis, an x/y swap, and a missing `/ SchematicCellSize`. + [Theory] + [InlineData(0f, 1f)] + [InlineData(1f, 0f)] + [InlineData(2f, 1f)] + [InlineData(-.6f, .8f)] + public void ABlastDamagesTheCellsNearestIt(float fx, float fy) + { + var facing = normalize(float2(fx, fy)); + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: facing); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + // Starboard computed here independently of FireControl -- the facing rotated clockwise. + var starboard = float2(facing.y, -facing.x); + var port = -starboard; + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var blastWorld = e.Target.Position.xz + float3(port.x, 0, port.y).xz * 10f; + + var damaged = new HashSet(); + using (e.Target.ArmorDamage.Subscribe(x => damaged.Add(x.pos))) + { + FireControl.Detonate(e.Zone, blastWorld, 7f, 500f, DamageType.Kinetic); + } + + // Port cells (schematic x < CoM.x) must take damage; starboard cells (schematic x > CoM.x) must not. + // The centre column (x == CoM.x, only when CoM.x is an integer) is excluded from the assertion since + // it can legitimately fall on either side depending on float rounding. + var com = e.HullData.Shape.CenterOfMass; + var portDamaged = false; + foreach (var c in e.HullData.Shape.Coordinates) + { + if (c.x < com.x - .5f) { if (damaged.Contains(c)) portDamaged = true; } + if (c.x > com.x + .5f) Assert.False(damaged.Contains(c), $"facing ({fx},{fy}): starboard cell {c} took damage from a port blast"); + } + Assert.True(portDamaged, $"facing ({fx},{fy}): no port cell took damage"); + } + + // ==== Shields (Q12-9 = A) ==== + + // ShieldPaysForWhatReachesIt: the reserve drops by the entity's covered share, not by the whole blast. The + // disc is centred exactly on the hull's edge (as in TheShareOffTheHullIsLost), so exactly half the raw + // damage is the covered share -- a shield capacity between the covered share and the raw damage tells the + // two apart cleanly. Kills: charging the full damage. + [Fact] + public void ShieldPaysForWhatReachesIt() + { + var e = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 70f); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); // charge the reserve to full + var cellSize = e.Items.GameplaySettings.SchematicCellSize; + var worldCentre = e.Target.Position.xz + float2(-2.5f * cellSize, 0); + + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); // fixture: the reserve is exactly full + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70.5f)); + + // damage 100, half retained (50) -- 50 <= capacity 70, so a correct implementation absorbs it whole and + // never breaks; a "charge the full 100" mutant would find 100 > 70 and break the shield instead. + var cellEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => cellEvents++); + FireControl.Detonate(e.Zone, worldCentre, .5f * cellSize, 100f, DamageType.Kinetic); + + Assert.False(e.Target.Shield.Broken); + Assert.Equal(0, cellEvents); // the shield took it all, nothing reached a cell + // The reserve dropped by the covered share and by nothing else: 70 - 50 = 20 left. It is read through the + // public query as a threshold probe, since no recharge runs between Detonate and here. + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 19.9f)); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 20.1f)); + } + + // An entity the disc does not touch has nothing to decide: it is skipped whole, its active shield is not + // consulted and nothing is dealt. The blast lands 20 cells away from a shielded hull. + [Fact] + public void AnUntouchedEntitysShieldIsNotConsulted() + { + var e = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 70f); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); + var events = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => events++); + using var h = e.Target.HullDamage.Subscribe(_ => events++); + + FireControl.Detonate(e.Zone, e.Target.Position.xz + float2(40f, 0f), 2f, 1000f, DamageType.Kinetic); + + Assert.Equal(0, events); + Assert.False(e.Target.Shield.Broken); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); + } + + // ABlastShotsShieldIsDecidedAtDetonation (Q12-9 = A): Commit decides no shield for a shot that carries a + // fuse, whatever the shield's own state at commit -- Detonate decides every blast's shield, live. Pinned + // directly on the field Commit would otherwise set: ShotOutcome.Shielded/ShieldBroken must stay false for a + // fused shot even when the shield could have absorbed the whole raw damage at commit. Kills: Commit's + // shield block left ungated for fused shots. + [Fact] + public void ABlastShotsShieldIsDecidedAtDetonation() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 4, damage: 50, + equipShield: true, shieldCapacity: 1000f); // capacity far exceeds the raw damage + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 50f)); // the shield COULD absorb the whole shot + + ShotOutcome outcome = null; + using var s = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.False(outcome.Shielded); + Assert.False(outcome.ShieldBroken); + } + + // The other half of Q12-9 = A: the shield's state at arrival, not at commit, decides. A contact shot in + // 2 s of flight commits 1.5 s in; the shield is flipped between commit and arrival, and the blast (radius + // 15 cells, so the whole 20-cell hull lies inside the disc and the covered share is exactly + // damage * 20 / (pi * 15^2)) must follow the state it finds. The reserve is read through the public + // CanTakeHit as a threshold probe: nothing recharges between Detonate and the read. + [Fact] + public void AShieldRaisedBetweenCommitAndArrivalAbsorbsTheBlast() + { + const float capacity = 1000f; + var share = 50f * 20f / (PI * 15f * 15f); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 30f, damage: 50f, + equipShield: true, shieldCapacity: capacity, shieldActive: false); + ArmourEverything(e); + var cellDamage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => cellDamage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => cellDamage += x); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); + Assert.True(committed.Hit); + Assert.False(committed.Shielded); + Assert.False(committed.ShieldBroken); + + e.Target.Shield.Item.Enabled.Value = true; // up before arrival, down at commit + for (var i = 0; i < 4; i++) e.Zone.Update(.05f); + Assert.True(e.Zone.PendingShots.Any(s => s.ShotId == shotId), "fixture: the shot must still be in flight"); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - .1f), "fixture: the reserve must be full"); + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); + + Assert.Equal(0f, cellDamage); // the shield decided at arrival, so no cell took anything + Assert.False(e.Target.Shield.Broken); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - share - .05f)); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - share + .05f)); + } + + // And the reverse: up at commit, down at arrival -- the blast finds no shield and lands on the cells, and + // the reserve is not charged. + [Fact] + public void AShieldDroppedBetweenCommitAndArrivalDoesNotAbsorbTheBlast() + { + const float capacity = 1000f; + var share = 50f * 20f / (PI * 15f * 15f); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 30f, damage: 50f, + equipShield: true, shieldCapacity: capacity); + ArmourEverything(e); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + var armour = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => armour += x.damage); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); + Assert.False(committed.Shielded); + + e.Target.Shield.Item.Enabled.Value = false; + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); + + Assert.Equal(share, armour, 2); // the whole covered share landed on the cells + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - .1f)); // and the reserve was not charged + } + + // ==== Fuses (through Fire, SchematicCellSize 2) ==== + + // PenetratorBurrowsBeforeItBursts, which pins Q12-8 in both halves. A 3x3 solid hull's only interior cell + // (1,1) carries a Cockpit item, ringed by the hull's own 8 border cells. + [Fact] + public void PenetratorBurrowsBeforeItBursts() + { + // With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, and the + // armour of every ring cell on the lane is unchanged. Nothing is absorbed along the burrow. + { + var e = Build(TestSettings(), SolidShape(3, 3), penetration: 1.5f, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 40f, + cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; // the cockpit's own cell carries no armour + + var before = e.Cockpit.EquippableItem.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(before - 40f, e.Cockpit.EquippableItem.Durability, 1); + foreach (var c in e.HullData.Shape.Coordinates) + if (!(c.x == 1 && c.y == 1)) + Assert.Equal(1000f, e.Target.Armor[c.x, c.y], 1); + } + + // A contact fuse with the same stats leaves the cockpit untouched (P sits at the impact cell, on the + // ring, never reaching the buried cockpit). + { + var e = Build(TestSettings(), SolidShape(3, 3), penetration: 1.5f, fuse: WeaponFuse.Contact, blastRadius: .3f, damage: 40f, + cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + var before = e.Cockpit.EquippableItem.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(before, e.Cockpit.EquippableItem.Durability); + } + + // With the fuse removed and everything else held, the shot is a dumb AP round: the lane's armour is + // spent in order, and the cockpit takes only what the ring left (12.3). Damage in a line. + { + var e = Build(TestSettings(), SolidShape(3, 3), penetration: 2f, fuse: null, blastRadius: 0f, damage: 40f, + cockpitCell: new int2(1, 1), cockpitDurability: 1000f); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 5f; e.Target.MaxArmor[c.x, c.y] = 5f; } + e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; + + var before = e.Cockpit.EquippableItem.Durability; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 200 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(before - 35f, e.Cockpit.EquippableItem.Durability, 1); // 40 - the front ring cell's 5 armour + } + } + + // TheFusePointStaysOnMetal: a delayed fuse with a penetration deeper than the hull clamps to the far edge. + [Fact] + public void TheFusePointStaysOnMetal() + { + var e = Build(TestSettings(), SolidShape(1, 5), penetration: 100f, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 100f); + // The far edge of a 1x5 hull, straight ahead of the shooter (bearing (0,1)), is schematic y = 4.5 (the + // exit of cell (0,4)); the fuse point clamps there instead of past the hull. Schematic x is the hull's + // own CenterOfMass.x (0, the only column). + var comX = e.HullData.Shape.CenterOfMass.x; + var rCells = .3 / e.Items.GameplaySettings.SchematicCellSize; // BlastRadius is in world units + + // Independent expectation, from the test's own area integral centred on the far edge. + var expected = 0f; + foreach (var c in e.HullData.Shape.Coordinates) + expected += (float) (100.0 * DiscAreaInCell(comX, 4.5, rCells, c) / (Math.PI * rCells * rCells)); + + var before = e.Target.Hull.Durability; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + var delivered = before - e.Target.Hull.Durability; + Assert.Equal(expected, delivered, 0); + } + + // TheFusePointStaysOnMetal, the concave half: a 1x5 column with cell (0,2) missing. A lane's walk ends at + // its first gap, so the reached metal is cells (0,0) and (0,1) whatever the penetration, and the fuse point + // must clamp to the last reached cell's exit (schematic y 1.5, the metal's edge) rather than sit at + // entry + penetration inside the gap (y 2.4 at penetration 2.9) or on the far segment the walk never + // reached (y 2.7 at 3.2). Entries run from -.5, so at penetration 1.5 the point is y 1.0, inside cell (0,1), + // with the disc wholly on metal. + [Theory] + [InlineData(1.5f, 1.0)] + [InlineData(2.9f, 1.5)] + [InlineData(3.2f, 1.5)] + public void TheFusePointStaysOnMetalAcrossAConcaveGap(float penetration, double pointY) + { + var shape = SolidShape(1, 5); + shape[new int2(0, 2)] = false; + var e = Build(TestSettings(), shape, penetration: penetration, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 100f); + var comX = e.HullData.Shape.CenterOfMass.x; + var rCells = .3 / e.Items.GameplaySettings.SchematicCellSize; + var expected = ExpectedDelivered(e.HullData.Shape, comX, pointY, rCells, 100.0); + + var before = e.Target.Hull.Durability; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(expected, before - e.Target.Hull.Durability, 0); + } + + // A contact or delayed blast needs a committed hit, exactly like a direct hit's own gate: on a miss nothing + // happens -- no blast, no IncomingHit. Accuracy 0 pins the roll to a miss. Kills: ApplyBlastHit without its + // `Outcome.Hit` guard, which would detonate at a point derived from a miss's empty geometry. + [Theory] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AContactOrDelayedBlastOnAMissDoesNothing(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 500f, accuracy: 0f); + ArmourEverything(e); + var before = e.Target.Hull.Durability; + var events = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => events++); + using var h = e.Target.HullDamage.Subscribe(_ => events++); + using var i = e.Target.ItemDamage.Subscribe(_ => events++); + using var hit = e.Target.IncomingHit.Subscribe(_ => events++); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.False(outcome.Hit); // fixture: a guaranteed miss + Assert.Equal(ShotResult.Miss, outcome.Result); + SafeAssert.NoShots(e.Zone); + Assert.Equal(0, events); + Assert.Equal(before, e.Target.Hull.Durability); + } + + // Operator ruling 2026-09-30: "fused weapons without a lock explode at max range." A fused shot with no + // target bursts at the weapon's Range along its aim (Entity.LookDirection), whatever its fuse. The fixture + // aims the shooter along (2,1), a non-axis facing; witnesses sit at the burst point and beside it. Radius 4 + // with the witness hull 5x4 cells of 2 world units: a witness centred on the burst point is covered, and + // one 30 units off the aim line is not. + private const float NoLockRange = 60f; + + private static float3 AimLine(Engagement e) + { + var aim = normalize(float2(2, 1)); + return e.Shooter.Position + float3(aim.x, 0, aim.y) * NoLockRange; + } + + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AFusedShotWithNoTargetBurstsAtMaxRangeAlongTheAimLine(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange); + Aim(e, float2(2, 1)); + e.Shooter.Target.Value = null; + var burst = AimLine(e); + var onLine = AddShip(e, burst, 1f, 300); + var offLine = AddShip(e, burst + float3(-30, 0, 30), 1f, 302); + var shooterHit = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => shooterHit += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => shooterHit += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => shooterHit += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(0f, shooterHit); + SafeAssert.NoShots(e.Zone); + Assert.False(e.Zone.Entities.Contains(onLine), "the blast must reach the burst point (fixture: 1 durability, so any damage kills it)"); + Assert.True(e.Zone.Entities.Contains(offLine), "the blast must not reach 42 units off the aim line"); + } + + // The no-lock shot flies to max range at the weapon's Velocity, like a targeted shot flies to its intercept. + [Fact] + public void ANoLockFusedShotFliesToMaxRangeAtItsVelocity() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + Aim(e, float2(2, 1)); + e.Shooter.Target.Value = null; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(NoLockRange / 20f, shot.ArrivalTime - shot.FireTime, 4); + var burst = AimLine(e); + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + } + + // A targeted shot is unchanged. Expected values computed at the base commit (b66ba524, before the no-lock + // rule), not read back from this code: the target's total damage from a proximity blast at the intercept. + [Fact] + public void ATargetedFusedShotIsUnchangedByTheNoLockRule() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(e.Target.Position.x, shot.BurstPosition.x, 3); + Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.Equal(5.65884256f, damage, 3); + } + + // ==== Operator rulings 2026-09-30 (Soul's pass on the no-lock fix): aim, invalid target, contact, shooter ==== + + private static void Aim(Engagement e, float2 direction) => e.Shooter.LookDirection = float3(direction.x, 0, direction.y); + + private static float3 PointAlong(Engagement e, float2 direction, float distance) => + e.Shooter.Position + float3(direction.x, 0, direction.y) * distance; + + private static bool Alive(Engagement e, Entity entity) => e.Zone.Entities.Contains(entity); + + // Ruling 1: an aim beyond the mount's arc flies along the nearest edge of that arc. The mount is (0,1), the + // arc 170 degrees (edge 85), and the aim (+-1,-1) sits 135 degrees off it; the witness on the edge dies and + // the one under the unclamped aim does not (they are 50 units apart). Kills: no clamp; the turn taken the + // wrong way round (the mirrored edge holds no witness). + [Theory] + [InlineData(1f)] + [InlineData(-1f)] + public void ANoLockShotOutsideTheMountArcFliesAlongTheNearestEdgeOfIt(float side) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(side, -1)); + var edge = float2(side * sin(radians(85f)), cos(radians(85f))); + var onEdge = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 300); + var underAim = AddShip(e, PointAlong(e, normalize(float2(side, -1)), NoLockRange), 1f, 302); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.False(Alive(e, onEdge), "the blast must land on the arc's edge"); + Assert.True(Alive(e, underAim), "the blast must not follow an aim the arc does not allow"); + } + + // Ruling 1: an aim inside the arc is used as it is (unnormalised here), and a shooter aiming at nothing + // (LookDirection zero) fires down its mount. Kills: reading MountDirection instead of the aim; a missing + // zero-aim fallback, which would normalise to NaN and burst nowhere. + [Fact] + public void ANoLockShotFliesAlongTheAimAndDownTheMountWhenNothingIsAimed() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + var mount = float2(0, 1); + var aim = normalize(float2(2, 1)); + var onAim = AddShip(e, PointAlong(e, aim, NoLockRange), 1f, 300); + var onMount = AddShip(e, PointAlong(e, mount, NoLockRange), 1f, 302); + + Aim(e, float2(6, 3)); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.False(Alive(e, onAim), "the blast must follow the aim"); + Assert.True(Alive(e, onMount), "the mount direction is not the aim"); + + Aim(e, float2(0, 0)); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.False(Alive(e, onMount), "with nothing aimed the blast follows the mount"); + } + + public enum Invalid { OutOfRange, NotVisible, Unlocked } + + // Ruling 2: a selected target that is out of range, not visible or not locked is not valid targeting data, + // and counts as no lock -- the round bursts at max range along the aim, and the target is left alone. Kills: + // reading "no lock" as Target == null alone (the round would burst at the target's intercept, 100 / 40 / 40 + // units out); dropping the range, visibility or lock gate from PFire's `designated`. The lock row needs a + // LockWeapon whose lock never grows; its control, below, is the same gun with the lock acquired. (A closed + // arc is not an invalid target: see AFusedShotAtATargetOutsideTheArc...) + [Theory] + [InlineData(Invalid.OutOfRange)] + [InlineData(Invalid.NotVisible)] + [InlineData(Invalid.Unlocked)] + public void AnInvalidTargetCountsAsNoLock(Invalid reason) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: reason == Invalid.OutOfRange ? 100f : 40f, + lockWeapon: reason == Invalid.Unlocked, lockAcquires: false); + var aim = normalize(float2(2, 1)); + if (reason == Invalid.NotVisible) e.Shooter.VisibleEntities.Remove(e.Target); + Aim(e, aim); + var burst = PointAlong(e, aim, NoLockRange); + var onAim = AddShip(e, burst, 1f, 300); + var targetBefore = e.Target.Hull.Durability; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == null, "an invalid target must not ride with the round"); // not Assert.Null: a failure would format the entity graph and overflow the stack + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.False(Alive(e, onAim), "the blast must land at max range along the aim"); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + } + + // The lock row's control: the same LockWeapon once its lock has grown carries its target to the intercept. + // Kills: a fixture whose lock could never grow (which would make the unlocked row above pass for nothing). + [Fact] + public void ALockedTargetRidesWithTheRound() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f, lockWeapon: true, lockAcquires: true); + Aim(e, float2(0, 1)); + e.Zone.Update(.1f); + Assert.True(((LockWeapon) e.Weapon).IsLocked, "fixture: the lock must have grown"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "a locked, visible, in-range, in-arc target rides with the round"); + Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); + } + + // The out-of-arc ruling (operator, 2026-09-30): a fused round at a target it holds valid data on but cannot + // bear on flies along the aim clamped to the mount's arc and bursts at the TARGET'S range, not at max range. + // The mount is flipped to (0,-1) so the target 40 ahead of the shooter is 180 degrees off it, in range, + // visible and unlocked by nothing: the arc is the only closed gate. The aim (1,1) is 135 degrees off the + // mount, so it clamps to the 85-degree edge on its own side. Three witnesses: on the edge at 40 (dies), on + // the edge at max range 60 (lives: the burst is at target range), under the unclamped aim at 40 (lives: the + // clamp holds). Kills: dropping `inArc` from PFire (the target would ride and burst at 40 along the target + // line); bursting at Range instead of the target's range; skipping the clamp. Contact is left out: its round + // stops on the first hull it meets, and these witnesses are hulls. + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Delayed)] + public void AFusedShotAtATargetOutsideTheArcBurstsAtTheTargetsRangeAlongTheClampedAim(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f); + e.Shooter.Direction = float2(0, -1); + var look = normalize(float2(1, 1)); + Aim(e, look); + var mount = float2(0, -1); + var edge = float2(mount.x * cos(radians(85f)) - mount.y * sin(radians(85f)), mount.x * sin(radians(85f)) + mount.y * cos(radians(85f))); + var atTargetRange = AddShip(e, PointAlong(e, edge, 40f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 302); + var underAim = AddShip(e, PointAlong(e, look, 40f), 1f, 304); + var targetBefore = e.Target.Hull.Durability; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == null, "a target the weapon cannot bear on must not ride with the round"); + var burst = PointAlong(e, edge, 40f); + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.False(Alive(e, atTargetRange), "the blast must land at the target's range along the clamped aim"); + Assert.True(Alive(e, atMaxRange), "the round bursts at the target's range, not at max range"); + Assert.True(Alive(e, underAim), "the aim is clamped to the arc's edge"); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + } + + // The trigger and Fire share one bearing test (InArc) and differ only in the fused weapon's exemption, so + // this pins the pair at the layer a player uses: Activate. With the target out of arc and the weapon + // otherwise clear, a fused weapon fires a round; the same weapon without a fuse stays silent. Kills: + // removing the exemption from FireControl.ArcPermitsFire (the fused row goes silent); removing the arc gate + // itself (the plain row fires); widening the exemption to a weapon with a blast radius but no fuse. + [Theory] + [InlineData(WeaponFuse.Proximity, true)] + [InlineData(null, false)] + public void OnlyAFusedWeaponFiresAtATargetOutsideItsArc(WeaponFuse? fuse, bool fires) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f); + e.Shooter.Direction = float2(0, -1); + Aim(e, float2(0, -1)); + Assert.Equal(fires, e.Weapon.ArcAllowsFire); + var committed = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++); + + e.Weapon.Activate(); + for (var i = 0; i < 5; i++) e.Zone.Update(.01f); + + Assert.Equal(fires ? 1 : 0, committed); + } + + // ==== Operator ruling 2026-09-30, batch 4: the arming distance ==== + // + // A round never bursts closer than its blast radius (its arming distance), measured from the shooter's + // position and blind to hull size: the fixture's shooter hull is a 5x5 block of 2-unit cells, and a blast of 30 + // arms at 30, not 30 plus whatever the hull reaches. A shooter close to its own burst takes the splash. + + private static float Planar(float3 from, float3 to) => length((to - from).xz); + + // A target inside the arming distance is not detonated on: the round flies on to the arming distance, exactly + // the blast radius from the shooter (35 would be the hull-inclusive figure this replaced). An in-arc target + // stays the round's target and the burst is pushed out along the line to it; the out-of-arc row is a target 10 + // away behind the mount, inside the blast radius, with the mount flipped so the arc is closed: no target rides + // and the burst is on the aim. Kills: no arming distance; a hull term in it; an in-arc round demoted to no + // target; an out-of-arc round that keeps one. + [Theory] + [InlineData(5f, false)] + [InlineData(10f, true)] + public void ACloseBurstIsPushedOutToTheBlastRadius(float targetRange, bool outOfArc) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: targetRange); + var aim = float2(0, 1); + if (outOfArc) + { + e.Shooter.Direction = float2(0, -1); + aim = float2(0, -1); + } + Aim(e, aim); + var burst = PointAlong(e, aim, 30f); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True((shot.Target == e.Target) == !outOfArc, "only a round with a target it can bear on keeps it"); + Assert.Equal(burst.x, shot.BurstPosition.x, 2); + Assert.Equal(burst.z, shot.BurstPosition.z, 2); + } + + // A round pushed out to its arming distance is still a targeted round (operator ruling 2026-09-30): if its target + // leaves the zone before it resolves, it does not burst. The target is 5 units away, inside a blast of 30, and + // the round flies 1.5 seconds; the target is gone after the first tick. The round resolves as a miss with no + // burst point, and the shooter standing inside the would-be disc is untouched. Kills: demoting a close round + // to no target (the round then bursts on schedule and hurts the shooter). + [Fact] + public void APushedOutRoundWhoseTargetLeavesTheZoneDoesNotBurst() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, float2(0, 1)); + var hurt = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => hurt += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => hurt += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => hurt += x.damage); + ShotOutcome resolved = null; + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + Assert.True(SafeAssert.OnlyShot(e.Zone).Target == e.Target, "fixture: the pushed-out round keeps its target"); + e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(3f); + + SafeAssert.NoShots(e.Zone); + Assert.NotNull(resolved); + Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.False(resolved.HasBurstPoint, "a round whose target is gone does not burst"); + Assert.Equal(0f, hurt); + } + + public enum Publication { Committed, Resolved } + + // A presentation observer that throws costs its own notification and nothing more: the shot's queue state is + // settled before anything is published, so it is not committed or resolved a second time on every later tick + // (the queue used to wedge, re-throwing forever). The first tick throws; the second does not, the shot is gone + // or committed exactly once. Kills: publishing before the shot is committed in the list or removed from it. + [Theory] + [InlineData(Publication.Committed)] + [InlineData(Publication.Resolved)] + public void AThrowingObserverCannotWedgeTheShotQueue(Publication throwsOn) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + var commits = 0; + var resolutions = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => { commits++; if (throwsOn == Publication.Committed) throw new InvalidOperationException("observer"); }); + using var r = e.Zone.ShotResolved.Subscribe(_ => { resolutions++; if (throwsOn == Publication.Resolved) throw new InvalidOperationException("observer"); }); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + Assert.ThrowsAny(() => e.Zone.Update(.01f)); + var second = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(second); + Assert.Equal(1, commits); + Assert.True(resolutions <= 1, $"resolved {resolutions} times"); + } + + // The same for the miss a departed target publishes: the shot is removed before that notification, so an + // observer that throws on it cannot keep the shot in the queue to be cancelled, and thrown at, on every tick. + // Kills: publishing the miss before removing the shot. + [Fact] + public void AThrowingObserverOfADepartedTargetsMissCannotWedgeTheShotQueue() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + using var c = e.Zone.ShotCommitted.Subscribe(_ => throw new InvalidOperationException("observer")); + e.Zone.Entities.Remove(e.Target); + + Assert.ThrowsAny(() => e.Zone.Update(.01f)); + var second = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(second); + SafeAssert.NoShots(e.Zone); + } + + public enum Observer { HullDamage, ArmorDamage, IncomingHit } + + // Nothing the queue does is left to a user callback that may throw: a shot is out of the queue before its + // damage is applied, so a damage observer that throws costs the rest of that blast, once, and the shot is not + // applied again on every later tick (its blast, and the observer, used to repeat forever). The observer throws + // on its first call; the following tick is clean and the observer has been called exactly once. Contact for + // the hit report (a roll can miss, so it retries), Proximity for the damage events. + // Kills: applying the shot's damage before removing it from the queue. + [Theory] + [InlineData(Observer.HullDamage)] + [InlineData(Observer.ArmorDamage)] + [InlineData(Observer.IncomingHit)] + public void AThrowingDamageObserverIsCalledOnceAndCannotWedgeTheShotQueue(Observer observer) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: observer == Observer.IncomingHit ? WeaponFuse.Contact : WeaponFuse.Proximity, + blastRadius: 4f, penetration: 1f, damage: 100f); + if (observer == Observer.ArmorDamage) ArmourEverything(e); + var calls = 0; + void Throw() { calls++; throw new InvalidOperationException("observer"); } + using var a = observer == Observer.ArmorDamage ? e.Target.ArmorDamage.Subscribe(_ => Throw()) : Disposable.Empty; + using var h = observer == Observer.HullDamage ? e.Target.HullDamage.Subscribe(_ => Throw()) : Disposable.Empty; + using var i = observer == Observer.IncomingHit ? e.Target.IncomingHit.Subscribe(_ => Throw()) : Disposable.Empty; + + for (var attempt = 0; attempt < 60 && calls == 0; attempt++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + Record.Exception(() => e.Zone.Update(.01f)); + } + Assert.Equal(1, calls); + var later = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(later); + Assert.Equal(1, calls); + SafeAssert.NoShots(e.Zone); + } + + // A round pushed out to its arming distance flies the arming distance: its arrival is that far off, not the + // target's own (5 units, a quarter second) range. 30 units at 20 units/s is 1.5 seconds. Kills: a flight + // distance of the target's range for a pushed-out round. + [Fact] + public void APushedOutRoundDoesNotArriveBeforeItHasFlownTheArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "fixture: the pushed-out round keeps its target"); + Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); + } + + // The same for a no-lock round: a target 10 out behind a mount whose arc is closed is designated but out of + // arc, so the round flies the aim to the target's range, inside the blast radius, and is pushed out to 30. It + // arrives after 30 units at 20 units/s, not after the 10 it was aimed at. Kills: a no-lock flight distance + // that ignores the arming distance. + [Fact] + public void APushedOutNoLockRoundDoesNotArriveBeforeItHasFlownTheArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 10f); + e.Shooter.Direction = float2(0, -1); + Aim(e, float2(0, -1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == null, "fixture: out of arc, the round carries no target"); + Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); + } + + // What the shooter's own hull takes from one round at `targetRange`, counting only rounds that hit when the + // fuse needs a hit (a roll can miss, so it retries). + private static float ShooterSplash(Engagement e, bool needsHit) + { + var taken = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => taken += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => taken += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => taken += x.damage); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + for (var attempt = 0; attempt < 60; attempt++) + { + taken = 0f; + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + if (!needsHit || outcome != null && outcome.Hit) return taken; + } + throw new InvalidOperationException("no round hit in 60 attempts"); + } + + // The arming ruling holds for every fuse, at the point a contact or delayed round detonates on a hull: no + // burst nearer to the shooter than the blast radius. A round that hits a target inside the radius (5 out), or + // one whose near face is inside it (a target 32 out has its face at 28), is detonated on at the radius (30), + // so the shooter takes what any burst 30 out on its line deals: the amount a proximity round at exactly 30 + // deals. Kills: a hull-point detonation that ignores the arming distance (the shooter then took 7.07 from a + // blast 30 at 5 out, more than the armed burst). + [Theory] + [InlineData(WeaponFuse.Contact, 5f)] + [InlineData(WeaponFuse.Delayed, 5f)] + [InlineData(WeaponFuse.Contact, 32f)] + public void AContactOrDelayedBlastNeverDetonatesNearerThanTheArmingDistance(WeaponFuse fuse, float targetRange) + { + var armed = ShooterSplash(Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, penetration: 1f, + damage: 100f, weaponRange: 60f, targetRange: 30f), needsHit: false); + Assert.True(armed > 0f, "fixture: the shooter's own hull takes splash from a burst at the radius"); + + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f, + damage: 100f, weaponRange: 60f, targetRange: targetRange); + var splash = ShooterSplash(e, needsHit: true); + + Assert.Equal(armed, splash, 3); + } + + // A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its + // intercept. Kills: demoting every fused round to the aim. + [Fact] + public void ATargetBeyondTheArmingDistanceStillRidesWithTheRound() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 40f); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "a target the disc clears the shooter to reach is still the round's target"); + Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); + } + + // A contact round stops on the first hull it crosses, but never nearer than its arming distance: a blocker + // whose near face is 2 units out (centred 6) is passed, and the round bursts at its blast radius, 4. Kills: a + // contact point taken as the hull face regardless of arming. + [Fact] + public void AContactRoundNeverStopsNearerThanItsArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + AddShip(e, PointAlong(e, float2(0, 1), 6f), 1f, 300); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.Equal(e.Shooter.Position.z + 4f, outcome.BurstPoint.y, 2); + } + + // A round fired from inside another hull is already in metal: its contact point is its arming distance. The + // host is a column with a 30-unit gap after the cell the muzzle sits in (cells 0-2 and 18-21, the muzzle in + // cell 2), so a contact search that skips the cell the origin is inside finds the far block instead of the + // muzzle's own. Kills: reading a cell's Entry for its Exit (Soul's M7). + [Fact] + public void AContactRoundFiredFromInsideAnotherHullBurstsAtTheArmingDistance() + { + var column = new Shape(1, 22); + foreach (var y in new[] { 0, 1, 2, 18, 19, 20, 21 }) column[new int2(0, y)] = true; + var e = Build(TestSettings(), column, velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var offset = e.Target.ToWorldPoint(float2(0, 2)) - e.Target.Position.xz; + e.Target.Position = float3(e.Shooter.Position.x - offset.x, 0, e.Shooter.Position.z - offset.y); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(e.Shooter.Position.z + 4f, committed.BurstPoint.y, 2); + } + + // A fused round is refused, per round and at the moment Fire reads Range, when its arming distance (the blast + // radius, 30) exceeds that Range: 29.5 refuses, 30.01 fires, and a power-starved Range (authored 100, forced to about + // 10 the way Soul's probe did after the trigger has passed) refuses although the authored figure clears + // it. Fire returns 0 and queues nothing. Kills: a gate that reads the authored Range; `>=` for `>`. + [Theory] + [InlineData(29.5f, false, false)] + [InlineData(30.01f, false, true)] + [InlineData(100f, true, false)] + public void AFusedRoundWhoseArmingDistanceExceedsRangeIsRefusedByFire(float range, bool starvedOfPower, bool fires) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: range, rangeTerm: starvedOfPower ? StatSource.PowerSupply : (StatSource?) null); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + if (starvedOfPower) + { + typeof(EquippedItem).GetProperty("PowerSupply").GetSetMethod(true).Invoke(e.WeaponItem, new object[] { .1f }); + e.Shooter.Resolver.InvalidateSource(e.WeaponItem, StatSource.PowerSupply); + e.Weapon.GetType().GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(e.Weapon, null); + Assert.True(e.Weapon.Range < 30f, "fixture: the runtime Range must have fallen below the arming distance"); + } + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + Assert.Equal(fires, shotId != 0); + Assert.Equal(fires ? 1 : 0, e.Zone.PendingShots.Count); + } + + // The boundary itself: a Range exactly equal to the arming distance (the blast radius) fires. Kills: `>=` for + // `>`. + [Fact] + public void ARangeExactlyEqualToTheArmingDistanceFires() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 30f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + + Assert.True(FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter) != 0, "a Range equal to the arming distance must fire"); + } + + // The same refusal through the weapon a player uses: a refused round sounds nothing (OnFire never runs) and + // queues nothing, and the same weapon with Range to spare fires once. Kills: Execute still announcing a + // refused round (Fire's 0 handed to the presentation as a shot id). + [Theory] + [InlineData(29.5f, 0)] + [InlineData(40f, 1)] + public void ARefusedRoundIsNotAnnounced(float range, int fired) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: range); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var announced = 0; + var committed = 0; + e.Weapon.OnFire += _ => announced++; + using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++); + + e.Weapon.Activate(); + for (var i = 0; i < 5; i++) e.Zone.Update(.01f); + + Assert.Equal(fired, announced); + Assert.Equal(fired, committed); + } + + // Planar, everywhere the gate, the clamp and the burst meet (R7). A target 50 units above the plane at a + // planar range of 40 is 64 units away in three dimensions: inside a Range of 60 by the plane, outside it by + // the straight line. It is designated, rides with the round, and the HUD's diagnostic agrees. Kills: a 3D + // length in PFire's range or Inspect's. + [Fact] + public void ATargetsHeightDoesNotMoveTheRangeGate() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 60f, targetRange: 40f); + e.Target.Position += float3(0, 50, 0); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "the target is inside Range in the plane, so it is valid data"); + Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); + var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.True(diagnostic.Designated, "the HUD's designation is planar too"); + Assert.Equal(40f, diagnostic.Range, 2); + } + + // The F4 clamp is planar as well: the target's height does not change how far the burst is pulled back. + [Fact] + public void TheRangeClampIgnoresTheTargetsHeight() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 100f, targetRange: 90f); + e.Target.Position += float3(0, 200, 0); + e.Target.Velocity = float2(6f, 8f); + var intercept = FireControl.PredictedIntercept(e.Weapon, e.Shooter, e.Target); + Assert.True(Planar(e.Shooter.Position, intercept) > 100f, "fixture: the intercept must lie beyond Range in the plane"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(100f, Planar(e.Shooter.Position, shot.BurstPosition), 2); + } + + // Nothing bursts beyond max range. A target receding faster than the round would be led past Range by the + // intercept; the burst is pulled back along the line to the intercept, to exactly Range. The target moves + // (6,8), so the direction is off-axis and a clamp that moved the point along the wrong line shows. + // Kills: no clamp (the probe burst 180 units out on Range 100); a clamp on one axis only. + [Fact] + public void ATargetedProximityBurstIsPulledBackToMaxRange() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 100f, targetRange: 90f); + e.Target.Velocity = float2(6f, 8f); + var intercept = FireControl.PredictedIntercept(e.Weapon, e.Shooter, e.Target); + var origin = e.Shooter.Position; + var toIntercept = normalize((intercept - origin).xz); + Assert.True(length((intercept - origin).xz) > 100f, "fixture: the intercept must lie beyond Range"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(origin.x + toIntercept.x * 100f, shot.BurstPosition.x, 2); + Assert.Equal(origin.z + toIntercept.y * 100f, shot.BurstPosition.z, 2); + } + + // What a proximity round's commit says. A round whose roll missed still detonates, so it commits as a Burst + // at the intercept, and only a roll that landed is a Hit; a contact or delayed round detonates only on a hit, + // so its guaranteed miss (accuracy 0) stays a Miss and explodes nowhere. Kills: typing a proximity miss as a + // Miss (the HUD said MISS over a real explosion); typing every proximity round a Burst (no Hit among the + // twelve); dropping the burst point from a Hit (it would detonate at the origin). + [Fact] + public void AProximityRoundThatMissesCommitsAsABurstAtTheIntercept() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, accuracy: 0f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.Equal(e.Target.Position.x, committed.BurstPoint.x, 3); + Assert.Equal(e.Target.Position.z, committed.BurstPoint.y, 3); + Assert.Equal(5.65884256f, damage, 3); + } + + [Fact] + public void AProximityRoundThatLandsCommitsAsAHitAndStillDetonates() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var results = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => results.Add(o)); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + for (var n = 0; n < 12; n++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(12, results.Count); + Assert.True(results.All(o => o.Result != ShotResult.Miss), "a proximity round always detonates, so it is never a Miss"); + Assert.True(results.Any(o => o.Result == ShotResult.Hit), "the stationary, fully-accurate target must be hit at least once"); + Assert.True(results.All(o => abs(o.BurstPoint.x - e.Target.Position.x) < 1e-3f && abs(o.BurstPoint.y - e.Target.Position.z) < 1e-3f), + "a hit detonates at the intercept too, not at the origin"); + Assert.True(results.All(o => o.HasBurstPoint), "a proximity outcome names its burst point, hit or not: presentation retargets to it"); + Assert.True(damage > 0f, "the blasts must have landed"); + } + + [Theory] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AContactOrDelayedRoundThatMissesIsAMissAndDoesNotDetonate(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 100f, accuracy: 0f); + var bystander = AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300); + ShotOutcome resolved = null; + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(resolved); + Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.False(resolved.HasBurstPoint, "a contact or delayed round has no frozen burst point"); + Assert.True(Alive(e, bystander), "a missed contact or delayed round detonates nowhere"); + } + + // A targeted fused round commits at its horizon, not at Fire: the target's facing is read late (Bearing + // timing), and only a round with no target commits at once. 100 units at 20/s is a 5 s flight, so with the + // 0.5 s horizon it commits at 4.5 s. Kills: CommitTime = now for every fused shot (the round would be + // committed after the first tick). + [Fact] + public void ATargetedFusedShotCommitsAtItsHorizonNotAtFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(shot.FireTime + 4.5f, shot.CommitTime, 3); + e.Zone.Update(.01f); + Assert.False(e.Zone.PendingShots[0].Committed, "a targeted round reads the target's facing late"); + e.Zone.Update(4.5f); + Assert.True(e.Zone.PendingShots[0].Committed, "and commits once its horizon is reached, with the round still in flight"); + } + + // Ruling 3 (operator, 2026-09-30): a no-lock round of any fuse stops on the first hull its aim line crosses + // before max range and detonates there; with the line clear it bursts at max range. The blocker's near face + // sits 21 units out (a 5x4-cell hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a + // fuse that ignores hulls (a Proximity or Delayed round used to fly on to max range). + [Theory] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Delayed)] + public void ANoLockRoundOfAnyFuseStopsOnTheFirstHullItCrosses(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 302); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + const float expectedReach = 21f; + Assert.Equal(e.Shooter.Position.z + expectedReach, outcome.BurstPoint.y, 2); + Assert.Equal(e.Shooter.Position.x, outcome.BurstPoint.x, 2); + Assert.False(Alive(e, blocker)); + Assert.True(Alive(e, atMaxRange), "the witness at max range is beyond the stop"); + } + + // Ruling 3: a hull beyond max range is not on the round's way, and the shooter's own hull, which the line + // starts inside, is never a candidate. Here the target (100 out) lies on the line of a 60-range Contact + // round. Kills: a burst point beyond the max-range point; a Contact search that admits the shooter. + [Fact] + public void AContactRoundWithNoLockBurstsAtMaxRangeWhenNoHullIsInReach() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var targetBefore = e.Target.Hull.Durability; + var shooterHit = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => shooterHit += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => shooterHit += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => shooterHit += x.damage); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(e.Shooter.Position.z + NoLockRange, outcome.BurstPoint.y, 2); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + Assert.Equal(0f, shooterHit); + } + + // Ruling 3: a Contact round bursting short of max range arrives when it has flown that far -- 21 of 60 units + // at 20 units/s is 1.05 s into a 3 s flight -- and the commit says so through ArrivalIn. Kills: an arrival + // left at the max-range time (the round would still be pending at 1.2 s, and ArrivalIn would read ~3). + [Fact] + public void AContactBurstShortOfMaxRangeArrivesWhenTheRoundHasFlownThatFar() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.NotNull(outcome); + Assert.Equal(1.05f - .01f, outcome.ArrivalIn, 2); + Assert.True(Alive(e, blocker)); + + e.Zone.Update(1.1f); + SafeAssert.NoShots(e.Zone); + Assert.False(Alive(e, blocker)); + } + + // Ruling 4: a round outlives its shooter. A no-lock proximity round whose shooter leaves the zone in flight + // still detonates at max range, whether the shooter goes before or after the commit; a targeted proximity + // round does the same at its intercept, for the same damage a shooter who stayed would have dealt (the value + // measured for ATargetedFusedShotIsUnchangedByTheNoLockRule at b66ba524, 5.65884256). Kills: Step + // cancelling a shot whose source has left the zone. + [Theory] + [InlineData(0)] + [InlineData(1)] + public void ANoLockRoundStillBurstsAfterItsShooterLeavesTheZone(int updatesBeforeLeaving) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var witness = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 300); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + for (var i = 0; i < updatesBeforeLeaving; i++) e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(4f); + + SafeAssert.NoShots(e.Zone); + Assert.False(Alive(e, witness), "the round must still burst at max range"); + } + + [Fact] + public void ATargetedFusedRoundStillBurstsAfterItsShooterLeavesTheZone() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(6f); + + SafeAssert.NoShots(e.Zone); + Assert.Equal(5.65884256f, damage, 3); + } + + // Ruling 4, direct hits: rounds fired at a target and in flight when their shooter leaves still hit it. A dozen + // shots each roll their own dice (seeded by shot id), and with a stationary target and a full-accuracy + // targeting system nearly all land; the test needs only that all resolve and some hit, and that each hit + // still names the departed shooter (identity carried, never asked anything). + [Fact] + public void ADirectRoundStillHitsAfterItsShooterLeavesTheZone() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, damage: 5f); + var resolved = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved.Add(o)); + var struck = 0; + using var s = e.Target.IncomingHit.Subscribe(src => { if (src == e.Shooter) struck++; }); + + for (var i = 0; i < 12; i++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(6f); + + SafeAssert.NoShots(e.Zone); + Assert.Equal(12, resolved.Count); + Assert.True(resolved.Any(o => o.Hit), "at least one round must land on the stationary target"); + Assert.Equal(resolved.Count(o => o.Hit), struck); + } + + // Ruling 6: what a commit came to is typed. A no-lock burst is neither a hit nor a miss, and reads as neither. + // Kills: MakeOutcome collapsing a burst back to a miss (the debug HUD read MISS for a real explosion). + [Fact] + public void ANoLockBurstCommitsAsABurstNotAMiss() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(2, 1)); + ShotOutcome committed = null, resolved = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.True(committed.HasBurstPoint); + Assert.False(committed.Hit); + var burst = PointAlong(e, normalize(float2(2, 1)), NoLockRange); + Assert.Equal(burst.x, committed.BurstPoint.x, 3); + Assert.Equal(burst.z, committed.BurstPoint.y, 3); + Assert.True(ReferenceEquals(committed, resolved), "the resolution republishes the commit unchanged"); + } + + // ==== Soul's probes on hands/fuse-fix2 (2026-09-30), adopted as tests ==== + + private static float2 Dir(float degreesFromMount) => float2(sin(radians(degreesFromMount)), cos(radians(degreesFromMount))); + + private static float2 Clamped(Engagement e, float2 look) + { + Aim(e, look); + return FireControl.AimDirection(e.WeaponItem, e.Shooter); + } + + private static void Near(float2 expected, float2 actual, int digits = 3) + { + Assert.Equal(expected.x, actual.x, digits); + Assert.Equal(expected.y, actual.y, digits); + } + + // AimDirection at the arc's edges: the mount is (0,1) and the arc 170 degrees, so the edge is 85. On the edge + // and inside are untouched, just outside turns to the edge on its own side, straight behind turns to the + // positive edge, a zero aim (and one too short to normalise) is the mount, and a NaN aim never poisons the + // result. Kills: a clamp that turns the wrong way; an edge off by a degree; a missing zero-aim fallback. + [Fact] + public void TheAimClampHoldsAtTheEdgesOfTheArc() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + Near(Dir(85), Clamped(e, Dir(85))); + Near(Dir(85), Clamped(e, Dir(85.5f))); + Near(Dir(-85), Clamped(e, Dir(-86))); + Near(Dir(84), Clamped(e, Dir(84))); + Near(Dir(0), Clamped(e, Dir(0))); + Near(Dir(85), Clamped(e, Dir(180 - 1e-3f)), 2); + Near(float2(0, 1), Clamped(e, float2(0, 0))); + Near(float2(0, 1), Clamped(e, float2(5e-4f, 0))); + Near(Dir(85), Clamped(e, float2(2e-3f, 0))); + e.Shooter.LookDirection = float3(0, 1, 0); + Near(float2(0, 1), FireControl.AimDirection(e.WeaponItem, e.Shooter)); + e.Shooter.LookDirection = float3(float.NaN, 0, float.NaN); + var nan = FireControl.AimDirection(e.WeaponItem, e.Shooter); + Assert.False(float.IsNaN(nan.x) || float.IsNaN(nan.y), "a NaN aim must not produce a NaN flight direction"); + } + + [Fact] + public void AFullCircleMountPassesEveryAim() + { + var settings = TestSettings(); + settings.FiringArc = 360; + var e = Build(settings, SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + Near(float2(0, -1), Clamped(e, float2(0, -3))); + Near(normalize(float2(-1, -1)), Clamped(e, float2(-5, -5))); + } + + [Fact] + public void AZeroWidthArcIsTheMount() + { + var settings = TestSettings(); + settings.FiringArc = 0; + var e = Build(settings, SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + foreach (var d in new[] { 0f, 1e-3f, 30f, 90f, 179f, -120f }) + Near(float2(0, 1), Clamped(e, Dir(d)), 4); + } + + // The hardpoint's authored arc overrides the default (90 here, so the edge is 45), and the arc turns with the + // mount: a shooter facing (1,1) has its edge 45 degrees counter-clockwise of that when aimed along -x. + [Fact] + public void TheHardpointsOwnArcAndTheRotatedMountSetTheEdge() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f, hardpointArc: 90f); + Near(Dir(45), Clamped(e, float2(1, 0))); + Near(Dir(-45), Clamped(e, Dir(-100))); + e.Shooter.Direction = normalize(float2(1, 1)); + var mount = normalize(float2(1, 1)); + var edgeCcw = float2(mount.x * cos(radians(45)) - mount.y * sin(radians(45)), mount.x * sin(radians(45)) + mount.y * cos(radians(45))); + Near(edgeCcw, Clamped(e, float2(-1, 0))); + } + + // A contact round with no lock commits at Fire, against the poses of that tick: a hull that sails onto the + // aim line afterwards is not in its way, and one that leaves it afterwards does not un-stop it. Kills: + // committing later than Fire (the blocker that arrives would stop the round); re-deciding the burst point + // from live poses. + [Fact] + public void AContactRoundIgnoresAHullThatCrossesItsLineAfterFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f) + float3(300, 0, 0), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.Equal(e.Shooter.Position.z + 60f, committed.BurstPoint.y, 2); + blocker.Position = PointAlong(e, float2(0, 1), 25f); + e.Zone.Update(4f); + + SafeAssert.NoShots(e.Zone); + Assert.True(Alive(e, blocker), "the round flew through the hull that arrived after Fire and burst at max range"); + } + + [Fact] + public void AContactRoundStillBurstsAtAHullThatLeavesItsLineAfterFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 302); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(e.Shooter.Position.z + 21f, committed.BurstPoint.y, 2); + blocker.Position += float3(300, 0, 0); + e.Zone.Update(4f); + + Assert.True(Alive(e, blocker), "the hull moved away; the round still burst at its old face"); + Assert.True(Alive(e, atMaxRange), "and did not continue to max range"); + } + + // The contact point is measured from where the round left (FireOrigin), not from where its shooter is when + // the commit tick runs. Unblocked, a max-range burst point is frozen at Fire either way; with a blocker 21 + // units out the origin matters. Kills: Commit reading the shooter's live position (Soul's M6). + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AShooterMovingAfterFireDoesNotMoveTheContactPoint(bool withBlocker) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var origin = e.Shooter.Position; + if (withBlocker) AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Shooter.Position += float3(25f, 0, 10f); + e.Zone.Update(.01f); + + Assert.Equal(origin.x, committed.BurstPoint.x, 2); + Assert.Equal(origin.z + (withBlocker ? 21f : 60f), committed.BurstPoint.y, 2); + } + + // A hull wholly behind the muzzle is not on the round's way. Kills: admitting cells whose exit lies behind + // the origin (the round would burst at the hull behind it, at distance zero). + [Fact] + public void AContactRoundIgnoresAHullBehindTheMuzzle() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + AddShip(e, PointAlong(e, float2(0, -1), 25f), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(e.Shooter.Position.z + 60f, committed.BurstPoint.y, 2); + } + + // The intent ruling: a round fired at a valid target that has left the zone does not burst, at either stage + // (before or after its commit), whatever its fuse -- it resolves as a Miss, and nothing standing beside where + // the target was, nor the shooter, takes blast damage. Kills: a round that detonates at its frozen burst point + // after its target is gone. + [Theory] + [InlineData(WeaponFuse.Proximity, false)] + [InlineData(WeaponFuse.Contact, false)] + [InlineData(WeaponFuse.Delayed, false)] + [InlineData(WeaponFuse.Proximity, true)] + [InlineData(WeaponFuse.Contact, true)] + [InlineData(WeaponFuse.Delayed, true)] + public void ARoundWhoseTargetLeavesTheZoneDoesNotBurst(WeaponFuse fuse, bool committedFirst) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 500f); + var bystanders = new Entity[] + { + AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300), + AddShip(e, e.Target.Position + float3(-8, 0, 12), 1f, 302) + }; + var damage = 0f; + var subscriptions = new List(); + foreach (var o in bystanders.Append(e.Shooter)) + { + subscriptions.Add(o.ArmorDamage.Subscribe(x => damage += x.damage)); + subscriptions.Add(o.HullDamage.Subscribe(x => damage += x)); + subscriptions.Add(o.ItemDamage.Subscribe(x => damage += x.damage)); + } + var resolved = new List(); + subscriptions.Add(e.Zone.ShotResolved.Subscribe(o => resolved.Add(o))); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(committedFirst ? 4.6f : .01f); + Assert.Equal(committedFirst, e.Zone.PendingShots[0].Committed); + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(6f); + + foreach (var s in subscriptions) s.Dispose(); + SafeAssert.NoShots(e.Zone); + Assert.True(bystanders.All(b => Alive(e, b)), "no bystander may take blast damage"); + Assert.Equal(0f, damage); + Assert.Equal(ShotResult.Miss, SafeAssert.Only(resolved).Result); + } + + // A no-lock round outlives a shooter destroyed outright in flight, for every fuse: Death removes the shooter + // and the round still bursts at max range and resolves once as a Burst. + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void ANoLockRoundStillBurstsAfterItsShooterIsDestroyed(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var witness = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 300); + var resolved = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved.Add(o)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Shooter.DamageHull(1e9f); + e.Zone.Update(4f); + + Assert.False(Alive(e, e.Shooter)); + Assert.False(Alive(e, witness), "the round must still burst at max range"); + Assert.Equal(ShotResult.Burst, SafeAssert.Only(resolved).Result); + } + + // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre + // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At + // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. + [Fact] + public void ToWorldPointIsTheInverseOfToSchematicPointAtANonAxisFacing() + { + var facing = normalize(float2(2, 1)); + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: facing); + var t = e.Target; + var com = e.HullData.Shape.CenterOfMass; + var starboard = float2(facing.y, -facing.x); + + var world = t.Position.xz + float2(7f, -3f); + var back = t.ToWorldPoint(t.ToSchematicPoint(world)); + Assert.Equal(world.x, back.x, 3); + Assert.Equal(world.y, back.y, 3); + + var oneToStarboard = t.ToWorldPoint(com + float2(1, 0)); + Assert.Equal(t.Position.x + 2f * starboard.x, oneToStarboard.x, 3); + Assert.Equal(t.Position.z + 2f * starboard.y, oneToStarboard.y, 3); + var oneToBow = t.ToWorldPoint(com + float2(0, 1)); + Assert.Equal(t.Position.x + 2f * facing.x, oneToBow.x, 3); + Assert.Equal(t.Position.z + 2f * facing.y, oneToBow.y, 3); + } + + // A contact blast on a hull at facing (2,1): the shot enters over the starboard side, off the hull's own + // centre line, so the blast point's lateral offset from the centre of mass is not zero and a flipped right + // axis in ToWorldPoint would mirror it onto the far side. Placement is asserted per cell: the impact cell + // takes a share, and no cell farther than one from it takes anything (radius .15 cells). Kills: a flip of + // ToWorldPoint's right axis, which the host's round trip would otherwise hide from every total. + [Fact] + public void AContactBlastLandsOnTheCellItHitAtANonAxisFacing() + { + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: float2(2, 1), fuse: WeaponFuse.Contact, blastRadius: .3f, damage: 100f); + ArmourEverything(e); + var deposits = new Dictionary(); + using var a = e.Target.ArmorDamage.Subscribe(x => deposits[x.pos] = (deposits.TryGetValue(x.pos, out var d) ? d : 0f) + x.damage); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.True(deposits.ContainsKey(outcome.Cell), $"the impact cell {outcome.Cell} took nothing; damaged: {string.Join(",", deposits.Keys)}"); + foreach (var cell in deposits.Keys) + Assert.True(Math.Abs(cell.x - outcome.Cell.x) <= 1 && Math.Abs(cell.y - outcome.Cell.y) <= 1, + $"cell {cell} took damage far from the impact cell {outcome.Cell}"); + } + + // TurningAfterCommitDoesNotMoveTheBlastOnItsHost: a contact-fuse shot. After commit and before arrival, the + // host turns and moves. Pass: the host's per-cell damage equals that of the same shot with no turn. Kills: + // taking P from the live bearing, or from Outcome.Cell's centre in place of the lane point. + [Fact] + public void TurningAfterCommitDoesNotMoveTheBlastOnItsHost() + { + Dictionary ArmorDamageByCell(Action afterCommit) + { + var e = Build(TestSettings(commitHorizon: .5f), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 3f, damage: 100f); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } + + // Per cell, not a total: a blast that lands on the wrong cells deals the same total. + var perCell = new Dictionary(); + using var a = e.Target.ArmorDamage.Subscribe(x => perCell[x.pos] = (perCell.TryGetValue(x.pos, out var d) ? d : 0f) + x.damage); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + // Step to just past commit (flight time 2s, commit horizon 0.5s -> commits at t ~= 1.5s). + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) + e.Zone.Update(.05f); + + afterCommit?.Invoke(e); + + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) + e.Zone.Update(.05f); + + return perCell; + } + + var noTurn = ArmorDamageByCell(null); + var turned = ArmorDamageByCell(e => + { + e.Target.Direction = normalize(float2(1, 0)); + e.Target.Position += float3(50, 0, -30); + }); + + Assert.NotEmpty(noTurn); + Assert.Equal(noTurn.Keys.OrderBy(k => k.x).ThenBy(k => k.y), turned.Keys.OrderBy(k => k.x).ThenBy(k => k.y)); + foreach (var cell in noTurn.Keys) Assert.Equal(noTurn[cell], turned[cell], 2); + } + + // LabelsDoNotDecideBehaviour: a weapon labelled Airburst with no fuse resolves as a direct hit, and a + // weapon with Fuse = Proximity and no label detonates. + [Fact] + public void LabelsDoNotDecideBehaviour() + { + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: null, blastRadius: 0f, damage: 50f, + modifiers: WeaponModifiers.Airburst); + var before = e.Target.Hull.Durability; + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.True(armorEvents > 0); // a direct hit -- one lane's cells, never an area + } + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 20f, damage: 500f, + modifiers: WeaponModifiers.None); + var before = e.Target.Hull.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.True(e.Target.Hull.Durability < before); + } + } + + // AFuseWithoutARadiusIsADirectHit: Fuse = Contact with a null or zero radius does lane damage. + [Fact] + public void AFuseWithoutARadiusIsADirectHit() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Contact, blastRadius: 0f, damage: 50f); + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(ShotResult.Hit, outcome.Result); + Assert.True(armorEvents > 0); + var shot = e.Zone.PendingShots.Count == 0 ? null : (PendingShot?) e.Zone.PendingShots[0]; + Assert.True(!shot.HasValue, "the shot must be resolved and removed"); // resolved and removed -- confirms it went through Apply's null-fuse path to completion + } + + // ARadiusWithoutAFuseIsADirectHit (Soul F1): the symmetric inert half -- BlastRadius > 0 but the catalog + // record carries no Fuse. Fire freezes Fuse as null regardless of the radius, so this resolves as a direct + // hit exactly like AFuseWithoutARadiusIsADirectHit above. Kills: Fire freezing "detonates" from BlastRadius + // alone, without also requiring a Fuse. + [Fact] + public void ARadiusWithoutAFuseIsADirectHit() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: null, blastRadius: 40f, damage: 50f); + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Null(shot.Fuse); + Assert.Equal(40f, shot.BlastRadius); + + var armorEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.True(armorEvents > 0); // a direct hit -- not spread over a disc + } + + // AContactBlastReportsTheHit: IncomingHit fires once, for the host, on a committed contact hit, and never + // for a proximity burst. + [Fact] + public void AContactBlastReportsTheHit() + { + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Contact, blastRadius: 3f, damage: 50f); + var hits = 0; + using var h = e.Target.IncomingHit.Subscribe(_ => hits++); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(1, hits); + } + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 20f, damage: 50f); + var hits = 0; + using var h = e.Target.IncomingHit.Subscribe(_ => hits++); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(0, hits); + } + } + + // Fire freezes a proximity shot's burst point at the predicted intercept of a moving target -- where a shot + // of the weapon's own velocity, fired now, meets the target -- not at the target's position now. The + // expectation is solved here from first principles: |d + v t| = s t for the flight time t, planar, the + // shooter's own motion never fed in. Target 100 ahead, drifting (30,0) against a 50 unit/s shot: t = 2.5 s, + // so the burst sits 75 units to the side of where the target is at Fire. + // Kills: BurstPosition frozen at the target's current position (PredictedIntercept never consulted). + [Fact] + public void ProximityBurstPositionIsThePredictedInterceptForAMovingTarget() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 50f); + var v = float2(30f, 0f); + e.Target.Velocity = v; + + var s = e.Shooter.Position.xz; + var d = e.Target.Position.xz - s; + var a = dot(v, v) - 50f * 50f; + var b = 2f * dot(d, v); + var c = dot(d, d); + var t = (-b - sqrt(b * b - 4f * a * c)) / (2f * a); // a < 0: the positive root + Assert.Equal(2.5f, t, 3); + var expected = e.Target.Position.xz + v * t; + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = e.Zone.PendingShots.Single(x => x.ShotId == shotId); + + Assert.Equal(expected.x, shot.BurstPosition.x, 2); + Assert.Equal(expected.y, shot.BurstPosition.z, 2); + Assert.True(length(shot.BurstPosition.xz - e.Target.Position.xz) > 70f, "the burst must lead the target, not sit on it"); + } + + // Detonate has no radius guard: a nonpositive radius covers no cell (a disc with no area overlaps nothing), + // so it delivers nothing and touches no shield. + [Theory] + [InlineData(0f)] + [InlineData(-5f)] + public void DetonateCannotBeReachedWithANonPositiveRadius(float radius) + { + var e = Build(TestSettings(), SolidShape(5, 4)); + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 100f; e.Target.MaxArmor[c.x, c.y] = 100f; } + var before = e.Target.Hull.Durability; + + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius, 1000f, DamageType.Kinetic); + + Assert.Equal(before, e.Target.Hull.Durability); + Assert.Equal(100f, e.Target.Armor[2, 1], 2); + } + + // ==== Commit's probes: the shipped catalog is neutral, and the field survives a full write/reopen. ==== + + private static string FindRepoRoot() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) + return dir.FullName; + throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); + } + + private static CultCache OpenReadOnlyRealCatalog(string catalogPath, out SingleFileMessagePackBackingStore store) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + var cache = new CultCache(registry); + store = new SingleFileMessagePackBackingStore(catalogPath, true); + cache.AddBackingStore(store, AetheriaStores.CatalogTypes); + return cache; + } + + // Every WeaponItemData in the shipped catalog has a null BlastRadius and a null Fuse, and the weapon + // schema's own migration report is CompatibleDrift with only slot 32 defaulted and no other slots touched. + [Fact] + public void ShippedCatalogIsNeutralOnBlastRadiusAndFuse() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var cache = OpenReadOnlyRealCatalog(gameData, out var store); + try + { + var weapons = cache.GetAll().ToList(); + Assert.NotEmpty(weapons); + foreach (var w in weapons) + { + Assert.Null(w.BlastRadius); + Assert.Null(w.Fuse); + } + + var reports = store.LastSchemaMigrationReports.Where(r => r.LocalSchemaName == "aetheria.weaponitemdata").ToList(); + Assert.NotEmpty(reports); + foreach (var report in reports) + { + Assert.Equal(CultSchemaMigrationKind.CompatibleDrift, report.Kind); + Assert.Equal(new[] { 32 }, report.DefaultedMissingSlots); + Assert.Empty(report.IgnoredExtraSlots); + } + } + finally + { + cache.Dispose(); + } + } + + // A WeaponItemData round-trip for each WeaponFuse value and null, crossed with radius null, 0 and + // positive, through a written and reopened temporary copy of the shipped catalog. + [Fact] + public void WeaponFuseAndBlastRadiusRoundTripThroughAWrittenCatalog() + { + var src = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var fuseValues = new WeaponFuse?[] { null, WeaponFuse.Contact, WeaponFuse.Proximity, WeaponFuse.Delayed }; + var radiusValues = new float?[] { null, 0f, 12.5f }; + + foreach (var fuse in fuseValues) + foreach (var radius in radiusValues) + { + var tmp = Path.Combine(_root, "roundtrip-" + Guid.NewGuid().ToString("N") + ".cc"); + File.Copy(src, tmp); + try + { + { + var cache = OpenReadOnlyRealCatalog(tmp, out _); + cache.Dispose(); + } + var writeCache = new CultCache(CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null))); + writeCache.AddBackingStore(new SingleFileMessagePackBackingStore(tmp, false), AetheriaStores.CatalogTypes); + var w = writeCache.GetAll().OrderBy(x => x.Name).First(); + w.Fuse = fuse; + w.BlastRadius = radius; + writeCache.Upsert(w); + writeCache.FlushAsync().Wait(); + writeCache.Dispose(); + + var readCache = OpenReadOnlyRealCatalog(tmp, out _); + var reopened = readCache.GetAll().OrderBy(x => x.Name).First(); + Assert.Equal(fuse, reopened.Fuse); + Assert.Equal(radius, reopened.BlastRadius); + readCache.Dispose(); + } + finally + { + File.Delete(tmp); + } + } + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs new file mode 100644 index 000000000..30cf46356 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -0,0 +1,1390 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 12.2 (docs/fire-control-cut.md, "Where a hit lands"): a hit's placement is priced and drawn from one +// silhouette, read at the bearing the target's facing implies AT COMMIT, not at fire. Builds its own +// fixtures, the convention every cut's test file follows. Fixtures follow 11.3: the shooter sits off the +// origin, fire times are nonzero, facings include ones where |fx| > |fy|, and weapon spread is nonzero +// wherever PSpread is asserted. +public sealed class FireControlCut12Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut12-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut12Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = .3f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // S1 fix batch: a non-convex hull for the empty-lane sweep -- a solid square with its centre cell punched + // out, the same "holed7" shape Soul's own EdgeSweep probe used. + private static Shape HoledShape(int w, int h) + { + var shape = SolidShape(w, h); + shape[new int2(w / 2, h / 2)] = false; + return shape; + } + + // A unit direction at `d` degrees, matching Soul's own probe convention (SoulProbe122b.Deg) -- used here to + // name an exact facing/bearing for a crash repro. + private static float2 Deg(double d) => float2((float) Math.Sin(d * Math.PI / 180), (float) Math.Cos(d * Math.PI / 180)); + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public HullData HullData; + public EquippedItem[] Markers; + public EquippedItem BigMarker; + public int2[] BigMarkerCells; + public EquippedItem LMarker; + public int2[] LMarkerCells; + } + + // A shooter off the origin (Cut 11.3), a target over a caller-supplied hull shape, a full-strength + // targeting system (Accuracy 1, Resolution huge, Tracking huge) so only the geometry under test moves the + // number, and an optional shield. markerCells equips the catalog's bare "Marker" Tool stub at each given + // interior cell -- returned in the same order -- for tests that need to name a specific cell (bow, stern). + private Engagement Build( + GameplaySettings settings, Shape hullShape, float precision, + float velocity = 0, float spread = 0, float2? targetFacing = null, + bool equipShield = false, float shieldCapacity = 30, + int2[] markerCells = null, float accuracy = 1f, float penetration = 0, bool equipBigMarker = false, + // S1 fix batch (Hands, 2026-09-25): a caller-chosen zone name -- Cut 6.1's own convention is one fixed + // name so a fixture is replayable byte-for-byte, but Soul's own crash repros (Zone.CombatSeed is + // StableHash(name)) each need their OWN exact name to reproduce a specific die roll. + string zoneName = "Cut12", + // S7 fix batch (Hands, 2026-09-25): Tracking authored through Build, like Precision and Accuracy already + // are, instead of a caller patching PendingShot.Tracking after Fire (TheHudEstimateIsTheCommitPrice's own + // former shape) -- one authoring path for every frozen shooter stat. + float tracking = 100000f, + // S8 fix batch (Hands, 2026-09-25): an L-shaped (3-cell, not 2x2) Tool item -- its true centroid differs + // from both any single cell and the bounding-box centre a mutant could substitute (N11), which a + // rectangular BigMarker cannot distinguish. + bool equipLMarker = false) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000, Armor = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + // The shooter's own hull is a fixed, always-roomy shape -- distinct from the target's caller-supplied + // hullShape, which some tests (BroadsideIsEasierThanHeadOn's 2-wide hull, TheSigmaFloorHoldsAtHalfACell's + // 1-wide lane) deliberately make too narrow to have any interior at all, so a Tool item (the shooter's + // own Targeting system) could never fit on it. + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(5), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0), + Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(tracking) + } } + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + // F7 (Soul's fix batch, 2026-09-24): a 2x2 Tool item -- every "Marker" fixture elsewhere is the + // catalog's 1x1 stub, where ResolveAimPoint's average (sum of cells / cell count) can't be + // distinguished from summing then multiplying, or from just reading the first cell. + var bigMarkerShape = new Shape(2, 2); + foreach (var c in bigMarkerShape.AllCoordinates) bigMarkerShape[c] = true; + cache.Upsert(new GearData + { + Name = "BigMarker", Hardpoint = HardpointType.Tool, Shape = bigMarkerShape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + // S8 fix batch: an L-tromino (3 of a 2x2's 4 cells) -- its centroid ((0,0)+(1,0)+(0,1))/3 = (1/3,1/3) is + // neither any one of its own cells nor the bounding box's centre (.5,.5), so a fixture built from it can + // tell a true centroid apart from both. + var lMarkerShape = new Shape(2, 2) { [new int2(0, 0)] = true, [new int2(1, 0)] = true, [new int2(0, 1)] = true }; + cache.Upsert(new GearData + { + Name = "LMarker", Hardpoint = HardpointType.Tool, Shape = lMarkerShape, Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 24; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + // Cut 6.1's own convention (FireControlCut6Tests.cs:62-63): every engagement shares one fixed + // GalaxyZone name, so every Zone this fixture builds shares one CombatSeed and every statistical test + // is replayable byte-for-byte across runs. A GUID-suffixed name here (this file's own earlier defect) + // reseeded the die on every run, which is exactly what made TurningArmourIntoTheShotTakesItOnTheArmour + // flaky (F2, Soul's fix batch, 2026-09-24). + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var lot = 1; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var gun = Make("Gun", lot++, 1); + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + + // A bystander item on the Hardpoint slot every hull shape has (position (0,0) needs no Shrink-interior, + // unlike a Tool) -- Entity.Update's `foreach (var item in _orderedEquipment)` NREs if an entity was + // never equipped with anything at all (_orderedEquipment stays null until TryEquip first assigns it), + // the same reason FireControlCut9Tests.BuildLockScenario equips its own bystander with a token gun. + var targetGun = Make("Gun", lot++, 1); + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + var markers = markerCells?.Select(cell => + { + var m = Make("Marker", lot++, 1000000); + Assert.True(target.TryEquip(m, cell), $"marker must fit at {cell}"); + return target.Equipment.Single(x => x.EquippableItem == m); + }).ToArray(); + + EquippedItem bigMarker = null; + int2[] bigMarkerCells = null; + if (equipBigMarker) + { + var bm = Make("BigMarker", lot++, 1000000); + Assert.True(target.TryFindSpace(bm, out var bmPos)); + Assert.True(target.TryEquip(bm, bmPos)); + bigMarker = target.Equipment.Single(x => x.EquippableItem == bm); + bigMarkerCells = hullShape.Coordinates.Where(v => target.GearOccupancy[v.x, v.y] == bigMarker).ToArray(); + Assert.Equal(4, bigMarkerCells.Length); // fixture precondition: the full 2x2 footprint landed + } + + EquippedItem lMarker = null; + int2[] lMarkerCells = null; + if (equipLMarker) + { + var lm = Make("LMarker", lot++, 1000000); + Assert.True(target.TryFindSpace(lm, out var lmPos)); + Assert.True(target.TryEquip(lm, lmPos)); + lMarker = target.Equipment.Single(x => x.EquippableItem == lm); + lMarkerCells = hullShape.Coordinates.Where(v => target.GearOccupancy[v.x, v.y] == lMarker).ToArray(); + Assert.Equal(3, lMarkerCells.Length); // fixture precondition: the full 3-cell L footprint landed + } + + if (equipShield) + { + var reactor = Make("Reactor", lot++, 10); + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + var shield = Make("Shield", lot++, 10); + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + // Cut 11.3: the shooter sits off the origin -- a shooter at float3.zero makes `target - source` and + // `target + source` the same vector, hiding a whole class of sign mutants. + shooter.Position = float3(37, 0, -11); + target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter + if (targetFacing != null) target.Direction = targetFacing.Value; + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + if (equipShield) + { + target.Shield.Item.Enabled.Value = true; + for (var i = 0; i < 20; i++) zone.Update(.1f); // charge from empty (Cut 11's own Charge() convention) + } + + // Cut 11.3: fire times are nonzero -- warm up past a bare zone.Update(0f) so FireTime is never 0. + zone.Update(1f); + + return new Engagement + { + Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, + HullData = hullData, Markers = markers, BigMarker = bigMarker, BigMarkerCells = bigMarkerCells, + LMarker = lMarker, LMarkerCells = lMarkerCells + }; + } + + private static List FireMany(Engagement e, int count) + { + var outcomes = new List(); + e.Zone.ShotResolved.Subscribe(o => outcomes.Add(o)); + for (var i = 0; i < count; i++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + return outcomes; + } + + // Independent reimplementation of FireControl.Lane's slab test, from the spec's own formulas, over the + // hull's public Shape.Coordinates -- used to check production's placement against an outcome's own frozen + // Bearing/Lateral without calling FireControl.Lane itself. + private static int2 NearestOccupiedCellAtLateral(HullData hull, float2 bearing, float lateral) + { + var ell = float2(-bearing.y, bearing.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + int2? best = null; + var bestEntry = float.PositiveInfinity; + + foreach (var c in hull.Shape.Coordinates) + { + var centre = dot((float2) c, ell); + if (lateral < centre - h || lateral >= centre + h) continue; + + var cf = (float2) c; + var entry = float.NegativeInfinity; + var exit = float.PositiveInfinity; + var valid = true; + foreach (var axis in new[] { 0, 1 }) + { + var bAxis = axis == 0 ? bearing.x : bearing.y; + var cAxis = axis == 0 ? cf.x : cf.y; + var val = axis == 0 ? lateral * ell.x : lateral * ell.y; + var lo = cAxis - .5f; + var hi = cAxis + .5f; + if (Math.Abs(bAxis) < 1e-9f) + { + if (val < lo || val > hi) valid = false; + continue; + } + var t1 = (lo - val) / bAxis; + var t2 = (hi - val) / bAxis; + entry = Math.Max(entry, Math.Min(t1, t2)); + exit = Math.Min(exit, Math.Max(t1, t2)); + } + if (!valid || entry > exit) continue; + + if (entry < bestEntry) + { + bestEntry = entry; + best = c; + } + } + + Assert.True(best != null, "fixture: no occupied cell reached at this lateral offset"); + return best.Value; + } + + // Stryker survivors on this cut's own changed lines (2026-09-24 run): TravelDirection's point-blank + // ternary (both directions), and Fire's target-null guard around it, were never actually exercised at a + // shooter/target pair where the real direction could not coincidentally equal the (0,1) fallback. Kills: + // forcing either ternary branch; skipping the null-target guard (which would NRE on PredictedIntercept). + [Fact] + public void TravelDirectionMatchesThePredictedInterceptAndFallsBackAtPointBlank() + { + var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f); + + // A diagonal placement -- neither axis-aligned with the fallback (0,1), so a mutant that always + // returns the fallback (or always skips the null-target guard) cannot pass by coincidence. + e.Shooter.Position = float3(10, 0, -5); + e.Target.Position = float3(40, 0, 65); + var expected = normalize((e.Target.Position - e.Shooter.Position).xz); + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Equal(expected.x, pending.TravelDirection.x, 4); + Assert.Equal(expected.y, pending.TravelDirection.y, 4); + + // Point-blank: shooter and target at the exact same position (weapon Velocity 0, so PredictedIntercept + // returns target.Position exactly) -- TravelDirection must fall back rather than normalize a zero + // vector into NaN. + e.Target.Position = e.Shooter.Position; + var pointBlankId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pointBlank = e.Zone.PendingShots.Single(s => s.ShotId == pointBlankId); + Assert.Equal(0f, pointBlank.TravelDirection.x); + Assert.Equal(1f, pointBlank.TravelDirection.y); + + // No target: Fire's own null-target branch must use the fallback directly, not call TravelDirection + // (which reads target.Position and would NRE). + e.Shooter.Target.Value = null; + var noTargetId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var noTarget = e.Zone.PendingShots.Single(s => s.ShotId == noTargetId); + Assert.Equal(0f, noTarget.TravelDirection.x); + Assert.Equal(1f, noTarget.TravelDirection.y); + } + + // Stryker survivor: diagnostic.PFire (Accuracy * PSensor, an optional presentation field) was never + // checked at a PSensor strictly between 0 and 1, so Accuracy * PSensor and Accuracy / PSensor (the + // mutant) were indistinguishable at PSensor == 1 (the fixture's usual full-reveal convention). Resolution + // 1000 gives an razor-thin demand window (ceiling ~= .1009 against a .1 threshold); info exactly halfway + // through it gives PSensor exactly .5. + [Fact] + public void DiagnosticPFireIsAccuracyTimesPSensor() + { + var e = Build(TestSettings(), SolidShape(6, 12), precision: .6f); + e.Shooter.EntityInfoGathered[e.Target] = .10045f; // halfway through the [.1, .1009] demand window + var d = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.True(d.PSensor > .05f && d.PSensor < .95f, $"fixture: PSensor ({d.PSensor}) must be strictly between 0 and 1"); + Assert.Equal(d.Accuracy * d.PSensor, d.PFire, 4); + } + + // Stryker survivors: CommitProbability's own product (PFire * pDeviation * PSpread * pOnHull) was only + // ever exercised with pDeviation exactly 1 (a stationary target relative to its fire-time projection), + // which makes "* pDeviation" and "/ pDeviation" (and boundary flips around 0) indistinguishable. A + // synthetic PendingShot (bypassing Fire, the same convention FireControlCut9Tests.FireSynthetic uses) + // pins every factor to a distinct, non-1 value and checks the exact product against an independent + // recomputation. + [Fact] + public void CommitProbabilityIsTheExactProductOfItsFourFactors() + { + var e = Build(TestSettings(), SolidShape(7, 9), precision: .5f, spread: 6f); + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + + // A jink between fire and commit gives pDeviation a real, non-1 value distinct from PFire and PSpread. + pending.FireTime = e.Zone.Time - 1f; + pending.PFire = .8f; + pending.Tracking = 20f; + e.Zone.PendingShots[e.Zone.PendingShots.FindIndex(s => s.ShotId == shotId)] = pending; + e.Target.Position += float3(6, 0, 0); // jink after the (frozen) fire-time projection + + var p = FireControl.CommitProbability(pending, e.Zone.Time, out var sil); + + var pDeviation = FireControl.DeviationProbability(pending, e.Zone.Time, out var deviation); + Assert.True(deviation > 0f, "fixture: the jink must actually move the target off its projection"); + Assert.True(pDeviation > .05f && pDeviation < .95f, $"fixture: pDeviation ({pDeviation}) must be strictly between 0 and 1"); + Assert.True(sil.POnHull > .05f && sil.POnHull < .95f, $"fixture: pOnHull ({sil.POnHull}) must be strictly between 0 and 1"); + var pSpread = e.HullData.Shape.Width > 0 + ? Math.Min(1f, (float) (180.0 / Math.PI * Math.Atan(.5 * sil.Span / pending.FireRange)) / (pending.Spread / 2f)) + : 1f; + var expected = pending.PFire * pDeviation * pSpread * sil.POnHull; + Assert.Equal(expected, p, 4); + } + + // :~483 fix batch (Hands, 2026-09-25): `if (p <= 0f) return 0f;` in CommitProbability is a guard against + // building a silhouette for a shot that cannot hit -- Stryker's own `p < 0` boundary mutant (excluding only + // the single value p == 0 exactly) survived because nothing exercised the guard at exactly 0. + // 12.3 fix batch correction: the previous version of this test set `shot.PFire = 0f` on a pending shot by + // hand, then asserted `sil.Intervals == null` -- a shape assertion on CommitProbability's own struct + // default, not a behaviour a bug could actually violate (any caller reading `sil` after a real miss reads + // the same default regardless of whether a Silhouette was built and discarded). Made behavioural per the + // 12.2 postmortem: PFire == 0 is now produced through the real gate (closing the shooter's own + // VisibleEntities, the same way S4's InvisibleTargetStillResolvesAtItsRealFlightTime does), and the claim + // "no Silhouette was ever built" is checked the only way that is actually true of a function's own + // behaviour: it allocates nothing. The `p < 0` mutant lets p == 0 fall through to build a real Silhouette + // (Array.Sort, ResolveAimPoint, an owned Interval[]), which is not zero-allocation -- this test kills it + // the same way S3's own zero-allocation pins do, not by re-reading a field the mutant never touches. + [Fact] + public void ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette() + { + var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f); + e.Shooter.VisibleEntities.Remove(e.Target); // closes the real gate -- PFire == 0 through the actual path + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Equal(0f, shot.PFire); // fixture precondition: the gate is actually closed + + for (var i = 0; i < 50; i++) FireControl.CommitProbability(shot, e.Zone.Time, out _); // JIT warm-up + + var before = GC.GetAllocatedBytesForCurrentThread(); + var p = FireControl.CommitProbability(shot, e.Zone.Time, out var sil); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0f, p); + Assert.Equal(0, sil.Count); + Assert.Equal(0, allocated); // no Silhouette was built: Array.Sort/ResolveAimPoint never ran + + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + e.Zone.Update(.01f); + Assert.NotNull(outcome); + Assert.False(outcome.Hit, "fixture: PFire 0 must commit as a clean miss"); + } + + // S4 fix batch (Hands, 2026-09-25): F8 (Soul's earlier fix batch, 2026-09-24) moved range/flightTime + // computation ahead of the visibility gate, so Fire's own flight time reflects the real distance even when + // PFire prices the shot at 0 -- but nothing behavioural pinned that. N4 (Soul's mutation set: "range after + // visibility gate", reverting F8) survived every existing test. A shot at a target the shooter cannot + // currently see must still resolve, as a miss (PFire gates it to 0), at the arrival time the real + // range/velocity imply -- not immediately (range 0) and not never (flight time 0 means instant resolve). + [Fact] + public void InvisibleTargetStillResolvesAtItsRealFlightTime() + { + var e = Build(TestSettings(), SolidShape(5, 5), precision: 1f, velocity: 20); // range 100 -> 5s flight + e.Shooter.VisibleEntities.Remove(e.Target); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Equal(0f, pending.PFire); // fixture precondition: the visibility gate is actually closed + var expectedArrival = pending.FireTime + 100f / 20f; // the real range (100) over the real velocity (20) + Assert.Equal(expectedArrival, pending.ArrivalTime, 3); + + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + e.Zone.Update(4.9f); + Assert.Null(outcome); // not yet: N4's bug (range 0) would have resolved this instantly + e.Zone.Update(.2f); // past the real 5s arrival + Assert.NotNull(outcome); + Assert.False(outcome.Hit, "fixture: PFire 0 (not visible) must resolve as a miss"); + } + + // Stryker survivors: ResolveAimPoint's fallback (aimed but currently occupying zero cells -> hull centre + // of mass) and its multi-cell average were both untested -- every existing marker fixture uses the + // catalog's 1x1 "Marker" stub, where sum/length and sum*length agree at length 1, and no fixture ever + // destroys or unequips an aimed item before firing. + [Fact] + public void AimPointFallsBackWhenTheAimedItemNoLongerOccupiesAnyCell() + { + var aim = new int2(2, 2); + var e = Build(TestSettings(), SolidShape(6, 6), precision: 1000f, markerCells: new[] { aim }); + var aimed = e.Markers[0]; + Assert.True(e.Shooter.TrySelectTargetItem(aimed)); + + // The aimed item is still equipped (Entity.TryUnequip refuses on an active entity, and reveal/aim + // selection reads Equipment, not GearOccupancy) but no longer occupies any hull cell -- the shape a + // destroyed or relocated item's footprint would leave. CellsOf then returns an empty (non-null) array, + // and ResolveAimPoint must fall back to the hull's own centre of mass rather than average zero cells + // (0/0 = NaN). + e.Target.GearOccupancy[aim.x, aim.y] = null; + + var outcomes = FireMany(e, 400); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 50, $"expected a healthy number of hits, got {hits.Count}"); + foreach (var outcome in hits) + Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, off the hull's own schematic -- the aim point fallback produced NaN"); + } + + // F7 (Soul's fix batch, 2026-09-24): ResolveAimPoint's average was only ever exercised with a 1x1 item + // (every other fixture's "Marker"), where sum/length and sum*length agree and "first cell" and "average" + // are the same cell. A 2x2 "BigMarker" gives a genuine 4-cell footprint: its true centroid is checked two + // ways -- pOnHull does not collapse (the aim point is on the hull, not scaled off it by sum*length), and, + // from broadside, the empirical mean lateral offset of hits centres on the real centroid's own lane, not + // on any single one of its four cells. + [Fact] + public void AimingAtAnItemBiggerThanOneCellCentresOnItsCentroid() + { + var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: float2(1, 0), equipBigMarker: true); + Assert.True(e.Shooter.TrySelectTargetItem(e.BigMarker)); + + var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + var bearing = normalize(e.Target.ToSchematic(travelDirection)); + var ell = float2(-bearing.y, bearing.x); + var centroid = e.BigMarkerCells.Aggregate(float2.zero, (t, c) => t + (float2) c) / e.BigMarkerCells.Length; + var expectedA = dot(centroid, ell); + + // pOnHull must not collapse -- a sum*length aim point would be scaled several cells off the hull + // entirely, driving pOnHull toward zero even at Precision 1 (sigma 1, comfortably inside a 9x9 hull). + var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.True(hud.POnHull > .3f, $"pOnHull ({hud.POnHull}) collapsed -- the aim point is likely off the hull"); + + var outcomes = FireMany(e, 3000); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 300, $"expected a healthy number of hits, got {hits.Count}"); + + var empiricalMeanLateral = hits.Average(o => (double) o.Lateral); + // Sigma is 1 cell; a "first cell only" aim point sits at least .5 cell off the true centroid on a 2x2 + // footprint, well outside this tolerance for a few-hundred-shot mean. + Assert.True(Math.Abs(empiricalMeanLateral - expectedA) < .25, + $"empirical mean lateral offset {empiricalMeanLateral:F3} does not track the centroid's own lane {expectedA:F3}"); + } + + // S8 fix batch (Hands, 2026-09-25): the BigMarker test above only ever exercises a 2x2 (square) footprint, + // whose true centroid and bounding-box centre are the same point -- N11 (aim point = bounding-box centre + // instead of the cell average) survived because nothing could tell the two apart. An L-tromino (3 of a + // 2x2's 4 cells) makes them different points: this fixture pins the aim point against the exact centroid + // the test computes itself, and separately checks it against the wrong (bounding-box) candidate to prove + // the fixture actually distinguishes them. + [Fact] + public void AimingAtAnLShapedItemCentresOnItsCentroidNotItsBoundingBox() + { + // A diagonal facing (not axis-aligned) puts the L-shape's actual asymmetry -- its centroid sits off + // the bounding-box centre along the diagonal, not along either grid axis -- fully onto the lateral + // axis, instead of losing most of it to the axis a purely axis-aligned facing would leave unmeasured. + var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: normalize(float2(1, 1)), equipLMarker: true); + Assert.True(e.Shooter.TrySelectTargetItem(e.LMarker)); + + var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + var bearing = normalize(e.Target.ToSchematic(travelDirection)); + var ell = float2(-bearing.y, bearing.x); + var centroid = e.LMarkerCells.Aggregate(float2.zero, (t, c) => t + (float2) c) / e.LMarkerCells.Length; + var boundingBoxCentre = (float2(e.LMarkerCells.Min(c => c.x), e.LMarkerCells.Min(c => c.y)) + + float2(e.LMarkerCells.Max(c => c.x), e.LMarkerCells.Max(c => c.y))) / 2f; + Assert.True(lengthsq(centroid - boundingBoxCentre) > .01f, "fixture: the L-shape's centroid and bounding-box centre must actually differ"); + + var expectedA = dot(centroid, ell); + var wrongA = dot(boundingBoxCentre, ell); + Assert.True(Math.Abs(expectedA - wrongA) > .15f, "fixture: the two candidate aim points must project to distinguishably different lateral values"); + + var outcomes = FireMany(e, 3000); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 300, $"expected a healthy number of hits, got {hits.Count}"); + + var empiricalMeanLateral = hits.Average(o => (double) o.Lateral); + Assert.True(Math.Abs(empiricalMeanLateral - expectedA) < .1, + $"empirical mean lateral offset {empiricalMeanLateral:F3} (bounding-box centre would predict {wrongA:F3}) does not track the true centroid's own lane {expectedA:F3}"); + } + + // BroadsideIsEasierThanHeadOn: kills the bearing being ignored and the old max(W,H) bound. A 2x12 hull is + // much easier to hit broadside (its long axis facing the shot) than head-on (its short axis facing it). + [Fact] + public void BroadsideIsEasierThanHeadOn() + { + float PAt(float2 facing) + { + var e = Build(TestSettings(), SolidShape(2, 12), precision: .5f, spread: 4, targetFacing: facing); + return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PSpread; + } + + float POnHullAt(float2 facing) + { + var e = Build(TestSettings(), SolidShape(2, 12), precision: .5f, targetFacing: facing); + return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).POnHull; + } + + // The shooter fires from world +z toward the target (TravelDirection ~ world +z, since the target sits + // straight ahead). Facing (0,1) (bow toward the shooter, forward parallel to travel) puts the ship's + // SHORT axis (width 2) across the bearing -- head-on. Facing (1,0) puts its LONG axis (height 12) + // across the bearing -- broadside. + var headOnPSpread = PAt(float2(0, 1)); + var broadsidePSpread = PAt(float2(1, 0)); + Assert.True(broadsidePSpread > headOnPSpread, + $"broadside PSpread ({broadsidePSpread}) must exceed head-on PSpread ({headOnPSpread})"); + + var headOnPOnHull = POnHullAt(float2(0, 1)); + var broadsidePOnHull = POnHullAt(float2(1, 0)); + Assert.True(broadsidePOnHull > headOnPOnHull, + $"broadside pOnHull ({broadsidePOnHull}) must exceed head-on pOnHull ({headOnPOnHull})"); + } + + // TurningArmourIntoTheShotTakesItOnTheArmour (the ruling's own case): a slow shot at a target whose stern + // is exposed at Fire. The target turns its armoured bow to face the incoming shot (present the bow toward + // where the shot is travelling FROM) before the shot commits. Every hit must land on the bow row -- + // Bearing timing reads the facing live, at Commit, not frozen at Fire. + [Fact] + public void TurningArmourIntoTheShotTakesItOnTheArmour() + { + var settings = TestSettings(); + // Flight time well above CommitHorizon (.5s): range 100 at velocity 20 -> 5s flight, commits at 4.5s. + var e = Build(settings, SolidShape(3, 11), precision: .4f, velocity: 20, targetFacing: null); + + var outcomes = new List(); + using var r = e.Zone.ShotResolved.Subscribe(outcomes.Add); + + // Repeat the same fire-then-turn sequence several times (the roll can still miss outright) and check + // the set of hits, not one shot. + for (var i = 0; i < 60; i++) + { + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + var travelDirection = pending.TravelDirection; + + // At Fire: bow faces the same way the shot travels -- the stern is exposed to the incoming shot. + e.Target.Direction = travelDirection; + e.Zone.Update(1f); // short of commit (4.5s) + + // Before commit: turn the bow to face the incoming shot head-on. + e.Target.Direction = -travelDirection; + e.Zone.Update(4f); // past commit (4.5s), short of arrival (5s) + e.Zone.Update(2f); // past arrival + } + + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 10, $"expected a healthy number of hits to check, got {hits.Count}"); + foreach (var outcome in hits) + Assert.True(outcome.Cell.y >= 5.5f, $"expected every hit to land on the bow row (y>=5.5), got cell {outcome.Cell}"); + } + + // S1 (Soul's second pass, 2026-09-25): at a non-axis-aligned bearing, a lateral draw exactly at an + // interval's Lo used to touch only a cell's corner -- Lane's own shadow prefilter admitted the cell but the + // independent SlabAlongB slab test rejected it on float rounding, Lane returned zero cells, and Commit's + // own guard threw InvalidOperationException out of Zone.Update (R3 violation: a hit that already passed + // its roll must always land on metal). Soul reproduced this live, not through reflection into a private + // method: zone name Cut12-edge-68442709 (StableHash seeds Zone.CombatSeed, so this exact name reproduces + // the exact die roll), a 7x9 hull, Precision 2, target facing 1 degree, shot 1. Fixed by deleting the + // second, disagreeing gate (this fix batch's FireControl.cs change) -- this pins the exact repro against a + // real Commit, not a reflection call, and checks the placement lands where the roll priced it. + [Fact] + public void ShotAtBearingLoTouchingOnlyACellCornerHitsMetal() + { + var e = Build(TestSettings(), SolidShape(7, 9), precision: 2f, targetFacing: Deg(1), zoneName: "Cut12-edge-68442709"); + + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.True(outcome.Hit, "fixture: this exact seed must roll a hit (this is the repro, not a general property)"); + Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, not on the hull's own schematic"); + + var ell = float2(-outcome.Bearing.y, outcome.Bearing.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + var centre = dot((float2) outcome.Cell, ell); + Assert.True(outcome.Lateral >= centre - h && outcome.Lateral < centre + h, + $"hit cell {outcome.Cell}'s own projected lateral interval [{centre - h},{centre + h}) does not contain the drawn Lateral {outcome.Lateral}"); + } + + // S1: the same class of empty-lane crash on the ruling's own TurningArmourIntoTheShotTakesItOnTheArmour + // geometry (3x11 hull, velocity 20, Precision .4, the fire-then-turn sequence that fixture uses) -- + // Soul's search found a zone name (Cut12-edge-64078070) whose draw lands on an interval edge after the + // target turns its bow into the shot. + [Fact] + public void ShotAtBearingLoOnTurningArmourHitsMetal() + { + var e = Build(TestSettings(), SolidShape(3, 11), precision: .4f, velocity: 20, zoneName: "Cut12-edge-64078070"); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + var travelDirection = pending.TravelDirection; + + // At Fire: bow faces the same way the shot travels (stern exposed). Before commit: turn the bow to + // face the incoming shot head-on, TurningArmourIntoTheShotTakesItOnTheArmour's own sequence. + e.Target.Direction = travelDirection; + e.Zone.Update(1f); + e.Target.Direction = -travelDirection; + + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + e.Zone.Update(4f); // past commit (4.5s), short of arrival (5s) + e.Zone.Update(2f); // past arrival + + Assert.NotNull(outcome); + Assert.True(outcome.Hit, "fixture: this exact seed/sequence must roll a hit (this is the repro, not a general property)"); + Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, not on the hull's own schematic"); + Assert.True(outcome.Cell.y >= 5.5f, $"expected the hit to land on the bow row (y>=5.5), got cell {outcome.Cell}"); + + var ell = float2(-outcome.Bearing.y, outcome.Bearing.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + var centre = dot((float2) outcome.Cell, ell); + Assert.True(outcome.Lateral >= centre - h && outcome.Lateral < centre + h, + $"hit cell {outcome.Cell}'s own projected lateral interval [{centre - h},{centre + h}) does not contain the drawn Lateral {outcome.Lateral}"); + } + + // S1 sweep: across many bearings and precisions, on hulls including a non-convex (holed) one, Lane must + // find at least one occupied cell for every lateral offset at an interval's own Lo, and just below its Hi + // -- the exact edges LateralDraw's own half-open clamp can produce (u=0 draws Lo; u near 1 draws just + // below Hi). An empty lane at either edge is precisely the class of crash S1 fixed; this is the general + // property the two named-seed repros above are specific instances of. + [Fact] + public void LaneNeverEmptiesAtAnIntervalEdge() + { + var shapes = new[] { SolidShape(7, 9), SolidShape(3, 11), HoledShape(7, 7) }; + var precisions = new[] { .3f, .5f, 1f, 2f }; + var failures = new List(); + + foreach (var shape in shapes) + { + var hull = new HullData { Name = "Sweep", HullType = HullType.Ship, Shape = shape }; + var buffer = new LaneCell[shape.Coordinates.Length]; + for (var deg = 0; deg < 360; deg += 3) + { + var b = Deg(deg); + foreach (var precision in precisions) + { + var sil = FireControl.Silhouette(null, hull, null, b, precision); + for (var k = 0; k < sil.Count; k++) + { + var iv = sil.Intervals[k]; + if (FireControl.Lane(hull, b, iv.Lo, buffer) == 0) + failures.Add($"{shape.Width}x{shape.Height} deg={deg} p={precision} k={k} at Lo={iv.Lo:R}"); + var justBelowHi = Math.Max(iv.Lo, iv.Hi - 1e-4f - 1e-5f); + if (FireControl.Lane(hull, b, justBelowHi, buffer) == 0) + failures.Add($"{shape.Width}x{shape.Height} deg={deg} p={precision} k={k} just below Hi={iv.Hi:R}"); + } + } + } + } + + Assert.True(failures.Count == 0, $"{failures.Count} empty lanes at an interval edge, e.g.: {string.Join("; ", failures.Take(5))}"); + } + + // Carried into 12.3's brief (12.2 Soul pass 3): pins Lane's own seam contract directly, against a 2x1 hull + // -- the smallest fixture where a seam actually falls between two occupied cells. At b=(0,1), ell=(-1,0), + // so cell (0,0)'s shadow is [-.5,.5) and cell (1,0)'s is [.5,1.5): s=-0.5 must land only in (0,0)'s column, + // and s=0.5 (the shared seam, exclusive on (0,0)'s side and where (1,0)'s own interval starts) must find + // NOTHING here, because this fixture's hull is only 1 cell wide at y=0 -- there is no (1,0). Mutation: the + // `s >= lateral.Hi` admission test at FireControl.cs:~817 flipped to `s > lateral.Hi` would let s=0.5 + // through as still touching (0,0) (0.5 is not > 0.5), which on a hull that DID have a second column would + // interleave the two columns' lanes at the seam -- reproduced directly here as "an empty lane's cell count + // is wrong", not by requiring the second column to exist. + [Fact] + public void LaneSeamIsHalfOpen() + { + var shape = new Shape(1, 1) { [new int2(0, 0)] = true }; + var hull = new HullData { Name = "Seam", HullType = HullType.Ship, Shape = shape }; + var b = float2(0, 1); + var buffer = new LaneCell[shape.Coordinates.Length]; + + var atLo = FireControl.Lane(hull, b, -0.5f, buffer); + Assert.Equal(1, atLo); + Assert.Equal(new int2(0, 0), buffer[0].Cell); + + var atHi = FireControl.Lane(hull, b, 0.5f, buffer); + Assert.Equal(0, atHi); // the seam itself: half-open on the Hi side, so this cell's own shadow excludes it + } + + // Carried into 12.3's brief (12.2 Soul pass 3): AlongBearing/SlabAxis had no test of their own VALUES -- + // every mutant there survived in 12.2 because only buffer[0]'s Cell was ever read (S1's own shadow + // prefilter, Extent, decided admission; AlongBearing's entry/exit were computed but unchecked). 12.3's own + // walk (FireControl.Apply) reads Entry for penetration and ordering, so a wrong entry/exit here is now a + // wrong damage cell, not dead arithmetic. + // F10 fix batch (Soul, 2026-09-25): the previous, single-bearing version of this test wrote out SlabAxis's + // OWN threshold (`1e-9f`) and clamp a second time, so a mutant that only moved that threshold (C7b, 1e-9 to + // .2) agreed with both copies identically and survived. This version never special-cases a near-zero + // bearing component at all: IEEE division by a signed zero already yields the correctly signed infinity, so + // an axis with no motion is unconstrained without a hand-written epsilon to disagree with SlabAxis's own. + // Checked at several bearings, including one within about 11 degrees of an axis -- inside SlabAxis's + // mutated .2f threshold but nowhere near the real 1e-9f one, so C7b now has to answer for a visibly wrong + // entry/exit instead of an unstressed one. + [Fact] + public void AlongBearingReturnsTheExactSlabIntersectionAtSeveralBearings() + { + var shape = SolidShape(5, 5); + var hull = new HullData { Name = "Diagonal", HullType = HullType.Ship, Shape = shape }; + + (double Entry, double Exit) ExactSlabIndependent(int2 c, double bx, double by, double ellx, double elly, double s) + { + var valX = s * ellx; var valY = s * elly; + var t1X = (c.x - .5 - valX) / bx; var t2X = (c.x + .5 - valX) / bx; + var t1Y = (c.y - .5 - valY) / by; var t2Y = (c.y + .5 - valY) / by; + var entry = Math.Max(Math.Min(t1X, t2X), Math.Min(t1Y, t2Y)); + var exit = Math.Min(Math.Max(t1X, t2X), Math.Max(t1Y, t2Y)); + return (entry, exit); + } + + void Check(float2 bRaw, float s) + { + var b = normalize(bRaw); + var ell = float2(-b.y, b.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2.0; // Extent's own half-width, written out independently + var expectedCells = shape.Coordinates + .Select(c => (Cell: c, Centre: (double) dot((float2) c, ell))) + .Where(t => s >= t.Centre - h && s < t.Centre + h) // Extent's own half-open shadow + .Select(t => t.Cell) + .OrderBy(c => dot((float2) c, b)) + .ThenBy(c => c.x).ThenBy(c => c.y) + .ToList(); + Assert.True(expectedCells.Count >= 3, $"fixture: b={b.x:R},{b.y:R} s={s} must cross several cells, got {expectedCells.Count}"); + + var buffer = new LaneCell[shape.Coordinates.Length]; + var count = FireControl.Lane(hull, b, s, buffer); + var actualCells = Enumerable.Range(0, count).Select(i => buffer[i].Cell).ToList(); + + Assert.Equal(expectedCells, actualCells); // same cells, same order -- entry ordering and contiguity agree + + for (var i = 0; i < count; i++) + { + var (expectedEntry, expectedExit) = ExactSlabIndependent(buffer[i].Cell, b.x, b.y, ell.x, ell.y, s); + Assert.Equal(expectedEntry, buffer[i].Entry, 3); + Assert.Equal(expectedExit, buffer[i].Exit, 3); + } + } + + Check(float2(2, 1), 0.13f); // neither axis-aligned nor 45 degrees (the original fixture) + Check(float2(1, .15f), .3f); // ~8.5 degrees off the x-axis -- by sits inside C7b's mutated .2f threshold + Check(float2(.15f, 1), -.4f); // the same, off the y-axis -- bx sits inside C7b's mutated .2f threshold + } + + // Cut 12.3: replaces ExpectedMarchCells, which reproduced Entity.ApplyHit's own deleted 0.5-step march -- + // that rule is gone with the function. The rule this now pins is the NEW thing FireControl.Apply's lane + // walk adds on top of Lane (Lane's own cell set/order/entry values are independently pinned elsewhere, by + // AlongBearingReturnsTheExactSlabIntersectionAtSeveralBearings and the LaneNeverEmptiesAtAnIntervalEdge sweep): the + // penetration cutoff. So this calls the real, already-verified FireControl.Lane for the walked cells and + // their entry parameters, then applies the cutoff independently -- kept only while within `penetration` + // cells of the first (impact) cell's own entry; the impact cell is always kept, matching the deleted + // `> .5f` threshold's intent at zero cost. + private static List ExpectedLaneCells(HullData hull, float2 bearing, float lateral, float penetration) + { + var buffer = new LaneCell[hull.Shape.Coordinates.Length]; + var walked = FireControl.Lane(hull, bearing, lateral, buffer); + + var result = new List(); + if (walked == 0) return result; + var impactEntry = buffer[0].Entry; + for (var i = 0; i < walked; i++) + { + if (i > 0 && buffer[i].Entry - impactEntry >= penetration) break; + result.Add(buffer[i].Cell); + } + return result; + } + + // TurningAfterCommitChangesNothing (R4, closes L464/L465): after commit, turning the target and moving the + // shooter must not change where the damage actually lands -- Apply reads no live position or facing. + // F5 (Soul's fix batch, 2026-09-24): the original fixture fired with Penetration 0, so the penetration march + // never ran and never actually read the bearing Apply passed it -- a mutant that fed Apply a live + // (post-turn) bearing instead of the committed one survived (M2a) because nothing downstream of Cell ever + // consumed Bearing. Penetration 3 walks the lane several cells deep (Cut 12.3: FireControl.Apply's own lane + // walk, replacing the deleted Entity.ApplyHit march), so the full damaged-cell set is asserted against the + // committed Bearing/Lateral, not the live one. + // Cut 12.3 fix: this fixture's hull carries the flat 1000 armour every Build() call authors, and the Gun's + // own Damage is a fixed 5 -- fine for the old even-split model (every cell in the march took an + // undetectable-but-nonzero 5/N share), fatal for the new sequential one, where the very first (impact) cell + // alone would absorb the whole 5 and leave nothing for the rest of a several-cell-deep lane to show. Lowered + // to 0.05 per cell here so 5 damage plainly carries the full penetration-3 span. + [Fact] + public void TurningAfterCommitChangesNothing() + { + var settings = TestSettings(); + var e = Build(settings, SolidShape(7, 7), precision: .5f, velocity: 20, penetration: 3); // 5s flight, commits at 4.5s + foreach (var v in e.HullData.Shape.Coordinates) { e.Target.Armor[v.x, v.y] = 0.05f; e.Target.MaxArmor[v.x, v.y] = 0.05f; } + + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + // The roll can still miss outright -- retry (a fresh shot each time; the previous miss already + // resolved and left the pending queue empty) until one hits. + for (var attempt = 0; attempt < 30 && (committed == null || !committed.Hit); attempt++) + { + committed = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(4.6f); // past commit (4.5s), short of arrival (5s) + } + Assert.NotNull(committed); + Assert.True(committed.Hit, "precondition: this fixture's shot must hit"); + + var expectedCell = NearestOccupiedCellAtLateral(e.HullData, committed.Bearing, committed.Lateral); + Assert.Equal(expectedCell, committed.Cell); // sanity: production's own Lane agrees with the independent recompute + + var expectedFromCommitted = ExpectedLaneCells(e.HullData, committed.Bearing, committed.Lateral, 3f).ToHashSet(); + + var before = (float[,]) e.Target.Armor.Clone(); + + // After commit, before arrival: the target turns 90 degrees and the shooter moves. + var postTurnFacing = float2(1, 0); + e.Target.Direction = postTurnFacing; + e.Shooter.Position += float3(500, 0, 500); + + // Fixture precondition: the (wrong) march a live-bearing bug would produce must actually differ from + // the committed one, or this test cannot tell the two apart. "Live" here means exactly what a mutant + // recomputing the bearing fresh at Apply time would read: the current TravelDirection (now stale, + // post-move) folded through the target's NEW facing -- both public FireControl functions, not a + // hand-rolled copy of the frame math. + var liveTravelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + var wrongBearing = normalize(e.Target.ToSchematic(liveTravelDirection)); + var expectedFromLive = ExpectedLaneCells(e.HullData, wrongBearing, committed.Lateral, 3f).ToHashSet(); + Assert.NotEqual(expectedFromCommitted, expectedFromLive); + + e.Zone.Update(2f); // past arrival (5s) + + foreach (var cell in expectedFromCommitted) + Assert.True(e.Target.Armor[cell.x, cell.y] < before[cell.x, cell.y], + $"cell {cell} is on the committed lane and must have taken damage, regardless of the post-commit turn"); + + foreach (var cell in expectedFromLive) + if (!expectedFromCommitted.Contains(cell)) + Assert.Equal(before[cell.x, cell.y], e.Target.Armor[cell.x, cell.y]); + } + + // PlacementAndProbabilityShareOneSilhouette replaces 6d's PlacementAndProbabilityShareOneKernel: pins that + // CommitProbability's number and Commit's own placement are read from the exact same Silhouette. A + // diagonal bearing (|fx| ~ |fy|) makes individual cells' raw shadow intervals overlap heavily before + // merging, so a skipped merge (double-counted mass) or a swapped ell/b would show up here. + [Fact] + public void PlacementAndProbabilityShareOneSilhouette() + { + var settings = TestSettings(); + const float precision = .45f; + var hullShape = SolidShape(7, 9); + // Aim off the hull's own centre of mass (near a corner) so the Gaussian's mass is not symmetric + // inside the shadow -- a perturbed sigma or a mis-signed ell moves the analytic and reported numbers + // apart even though both would otherwise report something close to 1. + var aim = new int2(1, 1); + var e = Build(settings, hullShape, precision, spread: 0, targetFacing: normalize(float2(1f, 1f)), + markerCells: new[] { aim }); + Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0])); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Same(e.Markers[0], pending.Aimed); + + var bearing = normalize(e.Target.ToSchematic(pending.TravelDirection)); + var ell = float2(-bearing.y, bearing.x); + var sigma = Math.Max(1f / precision, .5f); + var aimPoint = (float2) aim; // ResolveAimPoint on a single-cell marker: that cell itself + var a = dot(aimPoint, ell); + + // Independent shadow: raw per-cell intervals from the hull's own public Shape.Coordinates, sorted and + // merged by this test, not by calling FireControl.Silhouette. + var raw = e.HullData.Shape.Coordinates + .Select(c => dot((float2) c, ell)) + .Select(centre => (Lo: centre - (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f, Hi: centre + (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f)) + .OrderBy(iv => iv.Lo) + .ToList(); + var merged = new List<(float Lo, float Hi)>(); + foreach (var iv in raw) + { + if (merged.Count > 0 && iv.Lo <= merged[merged.Count - 1].Hi) + merged[merged.Count - 1] = (merged[merged.Count - 1].Lo, Math.Max(merged[merged.Count - 1].Hi, iv.Hi)); + else + merged.Add(iv); + } + + // Numeric integral of the Gaussian pdf over the merged union -- independent of erf, and of any private + // FireControl helper. + double Mass((float Lo, float Hi) iv) + { + const int steps = 4000; + var width = (iv.Hi - iv.Lo) / steps; + if (width <= 0) return 0; + double sum = 0; + for (var i = 0; i <= steps; i++) + { + var x = iv.Lo + i * width; + var z = (x - a) / sigma; + var pdf = Math.Exp(-z * z / 2.0) / (sigma * Math.Sqrt(2.0 * Math.PI)); + sum += pdf * (i == 0 || i == steps ? .5 : 1.0); + } + return sum * width; + } + var expectedPOnHull = merged.Sum(Mass); + Assert.True(expectedPOnHull > .05 && expectedPOnHull < .95, $"fixture: pOnHull ({expectedPOnHull}) must be non-degenerate to exercise the merge, got a saturated or collapsed value"); + + var commitP = FireControl.CommitProbability(pending, e.Zone.Time, out var sil); + // Fixture isolation: PFire = Accuracy(1) x PSensor(1, full reveal) = 1; pDeviation = 1 (target hasn't + // moved since FireTargetPosition was frozen); PSpread = 1 (Spread 0's own early-out). So the reported + // commit price collapses to exactly pOnHull, the number this test computed independently. + Assert.Equal((float) expectedPOnHull, commitP, 3); + Assert.Equal((float) expectedPOnHull, sil.POnHull, 3); + + // Empirical: the hit rate and the per-interval Lateral shares both match the analytic union. + const int shots = 4000; + var outcomes = FireMany(e, shots); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > shots * expectedPOnHull * .5, $"expected roughly {shots * expectedPOnHull:0} hits, got {hits.Count}"); + Assert.True(Math.Abs(hits.Count / (double) shots - expectedPOnHull) < .05, $"empirical hit rate {hits.Count / (double) shots:F3} does not track analytic pOnHull {expectedPOnHull:F3}"); + + foreach (var (lo, hi) in merged) + { + var expectedShare = Mass((lo, hi)) / expectedPOnHull; + if (expectedShare < .05) continue; // too little mass to measure reliably over 4000 shots + var empiricalShare = hits.Count(o => o.Lateral >= lo - 1e-3f && o.Lateral < hi + 1e-3f) / (double) hits.Count; + Assert.True(Math.Abs(expectedShare - empiricalShare) < .1, $"interval [{lo:F2},{hi:F2}): expected share {expectedShare:F3}, empirical {empiricalShare:F3}"); + } + } + + // F3 (Soul's fix batch, 2026-09-24): every 12.2 fixture, and every shipped hull (Soul's own 360-degree + // sweep), projects to exactly ONE merged interval at its test bearing -- LateralDraw's walk past the + // first interval (the `cumulative += mass` loop continuation) and a Silhouette built with the wrong sigma + // (M12) were both unreachable. This hull is two 2-wide prongs (x in {0,1} and {5,6}, all rows) separated + // by a real gap (x in 2..4, unoccupied) -- a dead-ahead bearing (ell=(-1,0)) gives two disjoint shadow + // intervals, symmetric around the hull's own centre of mass (3,2), which sits in the gap. + [Fact] + public void TwoProngHullSplitsTheScatterAcrossBothIntervals() + { + var shape = new Shape(7, 5); + foreach (var x in new[] { 0, 1, 5, 6 }) + for (var y = 0; y < 5; y++) + shape[new int2(x, y)] = true; + + const float sigma = 1.5f; + const float precision = 1f / sigma; + var e = Build(TestSettings(), shape, precision); // unaimed: centre of mass (3,2) + + // The bearing a shot would freeze, read directly (not through an actual Fire call, which would leave + // a stray shot in the queue for FireMany to double-count). + var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + var bearing = normalize(e.Target.ToSchematic(travelDirection)); + var ell = float2(-bearing.y, bearing.x); + var a = dot(shape.CenterOfMass, ell); + + // Independent shadow (raw per-cell intervals, sorted and merged), the same convention + // PlacementAndProbabilityShareOneSilhouette uses. + var raw = shape.Coordinates + .Select(c => dot((float2) c, ell)) + .Select(centre => (Lo: centre - (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f, Hi: centre + (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f)) + .OrderBy(iv => iv.Lo) + .ToList(); + var merged = new List<(float Lo, float Hi)>(); + foreach (var iv in raw) + { + if (merged.Count > 0 && iv.Lo <= merged[merged.Count - 1].Hi) + merged[merged.Count - 1] = (merged[merged.Count - 1].Lo, Math.Max(merged[merged.Count - 1].Hi, iv.Hi)); + else + merged.Add(iv); + } + Assert.Equal(2, merged.Count); // fixture precondition: a real two-interval shadow + + double Mass((float Lo, float Hi) iv) + { + const int steps = 4000; + var width = (iv.Hi - iv.Lo) / steps; + double sum = 0; + for (var i = 0; i <= steps; i++) + { + var x = iv.Lo + i * width; + var z = (x - a) / sigma; + var pdf = Math.Exp(-z * z / 2.0) / (sigma * Math.Sqrt(2.0 * Math.PI)); + sum += pdf * (i == 0 || i == steps ? .5 : 1.0); + } + return sum * width; + } + var mass0 = Mass(merged[0]); + var mass1 = Mass(merged[1]); + var totalMass = mass0 + mass1; + Assert.True(totalMass > .05 && totalMass < .95, $"fixture: total pOnHull ({totalMass}) must be non-degenerate"); + Assert.True(Math.Abs(mass0 - mass1) / totalMass < .05, "fixture: the two prongs must be near-symmetric for a clean 50/50 check"); + + var outcomes = FireMany(e, 8000); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 800, $"expected a healthy number of hits, got {hits.Count}"); + + var inProngA = hits.Count(o => o.Cell.x <= 1); + var inProngB = hits.Count(o => o.Cell.x >= 5); + Assert.Equal(hits.Count, inProngA + inProngB); // every hit lands in one prong or the other, never the gap + var empiricalShareA = inProngA / (double) hits.Count; + var expectedShareA = mass0 / totalMass; + Assert.True(Math.Abs(empiricalShareA - expectedShareA) < .05, + $"prong A's empirical share {empiricalShareA:F3} does not match its analytic share {expectedShareA:F3}"); + + // S2 fix batch (Hands, 2026-09-25): within-prong SHAPE, pinned by ratio, not just direction. Prong A is + // x in {0,1}, projected lateral centres 0 and -1 respectively (ell=(-1,0)), a=-3. The old + // "atNearColumn > atFarColumn" check survived M12/M12b (an entire second silhouette built at sigma + // floor .5 or at sigma*sqrt(2)) and the sigma/sqrt(2) Stryker mutants at :~753/:~750, because every one + // of those wrong sigmas still puts more mass on the nearer column -- direction alone can't tell a + // sigma of 1.5 from a sigma of 1.5*sqrt(2). The RATIO can: Soul's own figures are ~2.9 at the true + // sigma against ~1.73 at sigma*sqrt(2), well separated at 8000 shots. The near and far columns' own raw + // (unmerged) intervals are exactly the two ends of `raw` for prong A, reused here rather than + // recomputed. + var nearInterval = raw.First(iv => Math.Abs(iv.Lo - (-1.5f)) < 1e-3f); // x=1: [-1.5,-0.5) + var farInterval = raw.First(iv => Math.Abs(iv.Lo - (-0.5f)) < 1e-3f); // x=0: [-0.5,0.5) + var nearMass = Mass(nearInterval); + var farMass = Mass(farInterval); + var analyticRatio = nearMass / farMass; + Assert.True(analyticRatio > 2.3, $"fixture: analytic near/far ratio ({analyticRatio:F3}) must be well clear of the wrong-sigma ratio (~1.73) to actually distinguish the two"); + + var atNearColumn = hits.Count(o => o.Cell.x == 1); + var atFarColumn = hits.Count(o => o.Cell.x == 0); + Assert.True(atNearColumn > 0 && atFarColumn > 0, $"expected hits on both columns, got near={atNearColumn} far={atFarColumn}"); + var empiricalRatio = atNearColumn / (double) atFarColumn; + Assert.True(Math.Abs(empiricalRatio - analyticRatio) < .6, + $"empirical near/far ratio {empiricalRatio:F3} ({atNearColumn}/{atFarColumn}) does not track the analytic ratio {analyticRatio:F3} -- a sigma perturbed by sqrt(2) would give ~1.73 instead"); + } + + // HitsLandOnTheFacingEdge supersedes EveryHitLandsOnMetal: on a concave (holed) hull, every hit's Cell is + // occupied, AND nothing occupied precedes it in its own lane -- the test recomputes the lane from the + // outcome's own frozen Bearing/Lateral, independently of FireControl.Lane, and checks production picked + // the nearest cell, not merely an occupied one. + [Fact] + public void HitsLandOnTheFacingEdge() + { + var shape = new Shape(7, 7); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + shape[new int2(3, 3)] = false; // the hole + + var aim = new int2(1, 1); + var e = Build(TestSettings(), shape, precision: .3f, targetFacing: normalize(float2(.8f, .6f)), markerCells: new[] { aim }); + Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0])); + + var outcomes = FireMany(e, 1500); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 100, $"expected a healthy number of hits to check, got {hits.Count}"); + + foreach (var outcome in hits) + { + Assert.True(shape[outcome.Cell], $"hit landed on {outcome.Cell}, not part of the hull's own schematic"); + var expected = NearestOccupiedCellAtLateral(e.HullData, outcome.Bearing, outcome.Lateral); + Assert.Equal(expected, outcome.Cell); + } + } + + // AimingAtAnItemCentresTheScatterOnItsLane closes L585/L587 and replaces AimingAtTheSternHitsTheStern, + // whose fixture fired from the target's own stern and would pass for the wrong reason (any placement rule + // that merely biases toward whatever is nearest the shooter, rather than toward the aim point specifically, + // would also pass a stern-fired, stern-aimed shot). From broadside, hits aimed at the stern land mostly in + // the stern half and hits aimed at the bow mostly in the bow half; from dead astern, hits aimed at the bow + // land on the stern edge (the near edge, since the aim point pulls the group toward the bow but every cell + // between the stern edge and the bow marker is on the lane first). + [Fact] + public void AimingAtAnItemCentresTheScatterOnItsLane() + { + var settings = TestSettings(); + var hullShape = SolidShape(3, 11); + var bow = new int2(1, 9); // y=9: firmly in the bow half (y>=5.5) + var stern = new int2(1, 1); // y=1: firmly in the stern half + + double SternFraction(float2 facing, int2 aimCell) + { + var e = Build(settings, hullShape, precision: .6f, targetFacing: facing, markerCells: new[] { aimCell }); + var aimed = e.Markers[0]; + Assert.True(e.Shooter.TrySelectTargetItem(aimed)); + Assert.Same(aimed, e.Shooter.ResolvedTargetItem); + + var outcomes = FireMany(e, 600); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 100, $"expected a healthy number of hits, got {hits.Count}"); + return hits.Count(o => o.Cell.y < 5.5f) / (double) hits.Count; + } + + // Broadside: forward perpendicular to the shot's travel direction (world ~+z), so both bow and stern + // are roughly equidistant from the shooter and the aim point, not the entry side, drives the scatter. + var sternAimedBroadside = SternFraction(float2(1, 0), stern); + var bowAimedBroadside = SternFraction(float2(1, 0), bow); + Assert.True(sternAimedBroadside > .7, $"broadside, aimed at the stern: expected mostly stern hits, got sternFraction={sternAimedBroadside}"); + Assert.True(bowAimedBroadside < .3, $"broadside, aimed at the bow: expected mostly bow hits, got sternFraction={bowAimedBroadside}"); + + // Dead astern: forward parallel to the shot's travel direction (bow points away from the shooter), so + // the shot enters through the stern regardless of aim -- aiming at the bow still lands on the near + // (stern) edge, because the stern cells are on the lane first. + var bowAimedAstern = SternFraction(float2(0, 1), bow); + Assert.True(bowAimedAstern > .7, $"dead astern, aimed at the bow: expected the near (stern) edge to still take the hits, got sternFraction={bowAimedAstern}"); + } + + // TheHudEstimateIsTheCommitPrice: for an in-flight shot, the value the HUD's own estimate (through + // FireControl.CommitProbability, the function ActionGameManager's pending-shot line now calls) and the p + // Commit itself rolls against are the same number at the commit tick. + // F4 (Soul's fix batch, 2026-09-24): the original test called CommitProbability twice and compared the + // two calls to each other -- a self-consistency check that never observed what Commit's own roll actually + // uses. Survivors: Commit applying PSpread twice (M15), and Commit rolling against PFire x pOnHull alone, + // dropping deviation and PSpread entirely (M16) -- both invisible to a test that never rolls anything. + // This version fires many shots against one fixed, non-trivial configuration (nonzero weapon Spread, and + // a target jinked off its fire-time projection so pDeviation is strictly less than 1) and checks the + // EMPIRICAL hit fraction over the real Commit path against the HUD's own forecast, with a tolerance sized + // from the sample (3 sigma of a Bernoulli(p, n)). + // S7 fix batch (Hands, 2026-09-25): Tracking is now authored through Build's own `tracking` parameter (one + // authoring path for every frozen shooter stat, matching Precision/Accuracy) instead of patching + // PendingShot.Tracking after every Fire -- the operator's own rule (2026-09-22) is that a fixture should + // author its state up front, not reach into a frozen field post hoc. An authored Tracking of 6 against this + // 5x7 hull and jink gives pDeviation ~= .33, strictly between 0 and 1, with no patching. + [Fact] + public void TheHudEstimateIsTheCommitPrice() + { + var e = Build(TestSettings(), SolidShape(5, 7), precision: .5f, velocity: 40, spread: 6f, tracking: 6f); // 2.5s flight, commits at 2.0s + var origin = e.Target.Position; + const int shots = 800; + + var pendingSnapshots = new List(); + for (var i = 0; i < shots; i++) + { + e.Target.Position = origin; // reset before each Fire so every shot freezes the same FireTargetPosition + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.001f); + var index = e.Zone.PendingShots.FindIndex(s => s.ShotId == shotId); + pendingSnapshots.Add(e.Zone.PendingShots[index]); + } + + // One jink, after every shot is queued, before any of them commit -- pDeviation < 1 for all of them, + // uniformly (FireTargetVelocity is 0, so DeviationProbability's projection never drifts with elapsed + // time; every shot sees the same deviation regardless of the small firing-order time skew above). + e.Target.Position = origin + float3(4, 0, 0); + + var hits = 0; + var commits = 0; + using var c = e.Zone.ShotCommitted.Subscribe(o => { commits++; if (o.Hit) hits++; }); + e.Zone.Update(2.1f); // past every shot's 2.0s commit, short of the 2.5s arrival + + Assert.Equal(shots, commits); + + var expected = FireControl.CommitProbability(pendingSnapshots[0], e.Zone.Time, out var sil); + Assert.True(sil.POnHull > .02f && sil.POnHull < .98f, $"fixture: pOnHull ({sil.POnHull}) must be non-degenerate"); + var pDeviation = FireControl.DeviationProbability(pendingSnapshots[0], e.Zone.Time, out var deviation); + Assert.True(deviation > 0f, "fixture: the jink must actually move the target off its projection"); + Assert.True(pDeviation > .02f && pDeviation < .98f, $"fixture: pDeviation ({pDeviation}) must be strictly between 0 and 1"); + Assert.True(expected > .02f && expected < .98f, $"fixture: the commit price ({expected}) must be strictly between 0 and 1"); + + var empirical = hits / (double) shots; + // 3 standard deviations of a Bernoulli(expected, shots) draw, so this fails on the real bug (a + // dropped or duplicated factor) far more often than on sampling noise. + var tolerance = 3.0 * Math.Sqrt(expected * (1 - expected) / shots); + Assert.True(Math.Abs(empirical - expected) < tolerance, + $"empirical hit rate {empirical:F4} over {shots} shots is not within {tolerance:F4} of the HUD's forecast {expected:F4}"); + } + + // TheSigmaFloorHoldsAtHalfACell: pOnHull at Precision 1000 equals pOnHull at Precision 2 on a one-cell + // lane. Cut 12.2's exact integral no longer collapses on its own (POnHullNeverCollapsesAboveThePrecisionCliff + // no longer catches a deleted floor), so this is the test that kills "delete SigmaFloor" -- without the + // floor, sigma at Precision 1000 would be 0.001, and the Gaussian would collapse almost entirely inside a + // single cell's own width rather than sharing the whole one-cell lane the way Precision 2 (sigma .5, + // already at the floor) does. + [Fact] + public void TheSigmaFloorHoldsAtHalfACell() + { + // A 1-wide, 9-tall hull: every occupied cell's shadow along a dead-ahead bearing collapses onto the + // same single-cell-wide lane (ell has one axis zero, so h and the per-cell interval width degenerate + // to the schematic's own cell width) -- "a one-cell lane" per the spec's own name for this test. A + // 1-wide hull has no interior cell at all (Shrink needs a full ring), so this fires unaimed -- the + // hull's own centre of mass, (0,4), is already the middle of the lane. + var shape = SolidShape(1, 9); + + float POnHullAt(float precision) + { + var e = Build(TestSettings(), shape, precision); + return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).POnHull; + } + + var at2 = POnHullAt(2f); + var at1000 = POnHullAt(1000f); + Assert.Equal(at2, at1000, 4); + } + + // ShieldIsOmnidirectional: an active shield absorbs the same shot identically from bow, stern and beam -- + // the shield gate in Commit reads only DamageType/Damage, never the bearing this cut introduces. + [Fact] + public void ShieldIsOmnidirectional() + { + var settings = TestSettings(); + bool AbsorbsFrom(float2 facing) + { + var e = Build(settings, SolidShape(5, 5), precision: 1f, targetFacing: facing, equipShield: true, shieldCapacity: 1000); + var before = e.Target.Hull.Durability; + var outcomes = FireMany(e, 50); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 5, $"expected a healthy number of hits, got {hits.Count}"); + Assert.True(hits.All(o => o.Shielded), "every hit must be absorbed by the shield regardless of facing"); + Assert.Equal(before, e.Target.Hull.Durability); + return true; + } + + Assert.True(AbsorbsFrom(float2(0, 1))); // bow toward the shot + Assert.True(AbsorbsFrom(float2(0, -1))); // stern toward the shot + Assert.True(AbsorbsFrom(float2(1, 0))); // beam + } + + // S3 fix batch (Hands, 2026-09-25): Lane's own comment ("never allocates on its own") and Silhouette's + // pooled path are pinned directly, instead of trusting the comment. Measured (Release, LonginusX-shaped + // fixture unavailable to this test file -- a 7x9 solid hull is used instead): Lane and Silhouette (given a + // caller-supplied buffer) allocate exactly 0 bytes over 1000 calls, confirming the S3 change itself + // (Array.Sort(T[], int, int) resolving Interval/LaneCell's own IComparable at JIT time, not the + // (T[], int, int, IComparer) overload that wrapped even a cached singleton comparer in a fresh delegate + // per call). A full gated-in HitProbability call is NOT zero-allocation (measured ~120 B/call, Release) -- + // 12.3 fix batch correction: that cost is exactly three GetBehavior calls (Accuracy, + // Resolution, Precision, ~40 B each), each walking Entity.Equipment (a ReactiveCollection, heap-allocated + // enumerator per foreach) -- NOT `Behaviors` (a plain array, the earlier wording here was wrong; see the + // corrected comment on Forecast). It predates this cut and is out of its scope. The loose `< 500_000` bound + // below is a regression trip-wire, not a proof: a reintroduced per-call IComparer wrapper (~184 B/call) + // would pass it easily. The two zero-allocation asserts just above are what actually catch that regression + // -- Lane and Silhouette are exactly where such a wrapper would reappear -- so the bound here only needs to + // catch something far larger, such as a reintroduced unpooled Interval[]/LaneCell[] on this path. + [Fact] + public void LaneAndSilhouetteAllocateNothingGivenAPooledBuffer() + { + var e = Build(TestSettings(), SolidShape(7, 9), precision: .5f); + var hull = e.HullData; + var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target); + var bearing = normalize(e.Target.ToSchematic(travelDirection)); + var sil = FireControl.Silhouette(e.Target, hull, null, bearing, .5f); + var laneBuffer = new LaneCell[hull.Shape.Coordinates.Length]; + var intervalBuffer = new Interval[hull.Shape.Coordinates.Length]; + + for (var i = 0; i < 100; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); // JIT warm-up + for (var i = 0; i < 100; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); + + // Steady state, not first-use: under a whole-suite run the runtime's background tier-up is delayed by the + // other tests' JIT activity, so a one-off allocation (measured: 1232 bytes over 1000 calls, in 1 of 12 + // full-suite runs and 0 of 100 isolated runs) can land inside a single window. That is a cost paid once. + // A per-call allocation, the regression this test guards, lands in EVERY round, so the smallest round + // over a bounded number of rounds is the steady-state allocation. + long SmallestRoundAllocation(Action round) + { + var smallest = long.MaxValue; + for (var r = 0; r < 20 && smallest != 0; r++) + { + var start = GC.GetAllocatedBytesForCurrentThread(); + round(); + smallest = Math.Min(smallest, GC.GetAllocatedBytesForCurrentThread() - start); + } + return smallest; + } + + var laneAllocated = SmallestRoundAllocation(() => { for (var i = 0; i < 1000; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); }); + Assert.True(laneAllocated == 0, $"1000 Lane calls allocated {laneAllocated} bytes in every round; \"never allocates\" is not true"); + + var silhouetteAllocated = SmallestRoundAllocation(() => { for (var i = 0; i < 1000; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); }); + Assert.True(silhouetteAllocated == 0, $"1000 buffered Silhouette calls allocated {silhouetteAllocated} bytes in every round"); + + // Recorded, not asserted zero: the full gated-in HitProbability call still allocates, from Entity's own + // collection enumeration (GetBehavior over ReactiveCollection), not from anything this fix batch + // touches. A loose upper bound catches a real regression (e.g. a reintroduced comparer wrapper) without + // pretending Entity's own cost is Lane's or Silhouette's to fix here. + for (var i = 0; i < 100; i++) FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + var before = GC.GetAllocatedBytesForCurrentThread(); + var sum = 0f; + for (var i = 0; i < 1000; i++) sum += FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + var hitProbabilityAllocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(sum > 0f, "fixture: this must be a real, gated-in call, not a gated-out one"); + Assert.True(hitProbabilityAllocated < 500_000, $"1000 gated-in HitProbability calls allocated {hitProbabilityAllocated} bytes -- well above Entity's own ~120 B/call baseline; likely a reintroduced Lane/Silhouette allocation"); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs new file mode 100644 index 000000000..5ee04751a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -0,0 +1,308 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 4 (docs/fire-control-cut.md): pins the two rules this cut actually adds -- a continuous weapon is a +// sequence of rolls rather than a continuous truth, and splash (mine/airburst blast) is one rule applied to +// every entity in radius, directional over each target's own hull. FireAuthorityTests.cs already pins the +// roll itself (seeding, commit horizon, the shield-absorb branch, the damage rule); this file builds its own +// minimal fixtures rather than reusing that one's InstantWeapon-shaped Engagement, the same convention that +// file's own comment establishes. +public sealed class FireControlCut4Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut4-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut4Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // A 5x5 fully-solid hull, the same shape FireAuthorityTests.Build uses. + private static HullData MakeHullData(float armor = 0) => new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(), Durability = 1000, Mass = 1000, Armor = armor, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + private static Shape SolidShape() + { + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // ---- Cut 4's beam rule: ConstantWeapon rolls once per BeamResolveInterval, not once per tick and not + // once for the whole burst. ---- + + private sealed class BeamEngagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public ConstantWeapon Weapon; + } + + private BeamEngagement BuildBeam(GameplaySettings settings, float damage, float targetRange = 100) + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(MakeHullData()); + cache.Upsert(new GearData + { + Name = "Beam", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ConstantWeaponData + { + Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = 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, + // Cut 6d (docs/fire-control-cut.md): Precision now feeds pOnHull (the dart-throw kernel's sigma) + // for every shot, aimed or not -- 0 would balloon sigma and starve this fixture's rolls, which + // don't care about aim placement. See FireAuthorityTests.Build's own comment on this same change. + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(1), Resolution = Constant(1), Precision = Constant(2), Tracking = Constant(1000) + } } + }); + // Powers the beam: with no Reactor, PowerBus grants nothing and ConstantWeapon.Execute safes itself + // off (Item.PowerSupply <= 1e-4f) after the first tick, same rake FireAuthorityTests.ShieldTakesHit + // notes for its own Reactor/Shield pair. + 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.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[9] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Beam")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (ConstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var reactorRef = items.ItemData.RefOf(items.ItemData.GetByName("Reactor")); + var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 5 }; + Assert.True(shooter.TryFindSpace(reactor, out var rpos)); + Assert.True(shooter.TryEquip(reactor, rpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Beam")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(0f); // warm up resolved stats, same as FireAuthorityTests.Build + + return new BeamEngagement { Items = items, Zone = zone, Shooter = shooter, Target = target, Weapon = weapon }; + } + + // A beam is a sequence of discrete rolls, not a continuous truth: over one second it produces exactly + // 1/BeamResolveInterval outcomes (the damage each carries is FireControlCut11Tests.ADamageOverrideIsWhatTheShotCarries). Interval (.25s) + // and tick (.125s) are both exact dyadic fractions so float accumulation cannot drift the count -- and the + // tick is strictly smaller than the interval, so the two are distinguishable from a "one roll per tick" + // mutant (which would produce 8 outcomes here, not 4) and from a "one roll per burst" mutant (1, not 4). + [Fact] + public void BeamRollsPerInterval() + { + var settings = TestSettings(); + settings.BeamResolveInterval = .25f; + var e = BuildBeam(settings, damage: 10); + + var outcomes = new List(); + e.Zone.ShotResolved.Subscribe(outcome => { outcomes.Add(outcome); }); + + e.Weapon.Activate(); + for (var i = 0; i < 8; i++) e.Zone.Update(.125f); // 1 second in 8 ticks of .125s + + Assert.Equal(4, outcomes.Count); // 1s / .25s per roll + Assert.All(outcomes, o => Assert.True(o.Hit)); + } + + // ---- Regression: FireControl.Fire must run whether or not a presentation is listening. Both + // InstantWeapon.Execute and ConstantWeapon.Execute used to write `OnFire?.Invoke(FireControl.Fire(...))` + // inline -- C#'s null-conditional short-circuits the WHOLE expression, argument included, whenever the + // event has no subscriber, so with no Unity EntityInstance wired up (a headless run, + // docs/headless-playground-cut.md) the shot silently never fired. Caught by this cut's own + // BeamRollsPerInterval fixture; this test pins the discrete-weapon half of the same fix, which no + // existing test could catch because FireAuthorityTests.cs always calls FireControl.Fire directly, never + // through Trigger()/Execute(). Mutation: inline the call back into the null-conditional. ---- + [Fact] + public void InstantWeaponFiresWithoutAnyOnFireSubscriber() + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(MakeHullData()); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), + Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger { Lots = { [1] = new Lot { Origin = new Attributed(), Quality = 1 } } }; + var items = new ItemManager(cache, ledger, TestSettings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 1 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.EquippableItem == gun).Behaviors.Single(b => b is Weapon); + + zone.Entities.Add(shooter); + shooter.Activate(); + zone.Update(0f); // warm up resolved stats + + // No target, no OnFire subscriber -- exactly a headless shooter with no target and no presentation. + var resolvedCount = 0; + zone.ShotResolved.Subscribe(_ => resolvedCount++); + + weapon.Activate(); // Trigger() -> queues a burst + zone.Update(.016f); // one ordinary tick -- Execute() should fire and the zero-velocity shot resolves same tick + + Assert.Equal(1, resolvedCount); + } + + // ---- Cut 4's blast rule (12.4(b) renames FireControl.Splash to Detonate, and areas replace the + // directional half-hull split): FireControl.Detonate is an unconditional area effect, no roll. Tested + // directly against the function, no weapon/gear catalog needed -- Entity.MapEntity (called from the Entity + // constructor) is enough to give a Ship its Armor/GearOccupancy/Hull. ---- + + private ProvenanceLedger _splashLedger; + + private (ItemManager Items, Zone Zone) BuildSplashZone(GameplaySettings settings, float armor = 0) + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(MakeHullData(armor)); + cache.FlushAsync().Wait(); + + _splashLedger = new ProvenanceLedger(); + var items = new ItemManager(cache, _splashLedger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + return (items, zone); + } + + private Ship AddShip(ItemManager items, Zone zone, float3 position, float2 direction, int lot) + { + _splashLedger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var hull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = lot }; + var ship = new Ship(items, zone, hull, new EntitySettings()); + ship.Position = position; + ship.Direction = direction; + zone.Entities.Add(ship); + return ship; + } + + // A blast is one rule, not per-effect: every entity within radius takes damage, one outside radius takes + // none. Mutation: damage only the nearest entity in range instead of every one. + [Fact] + public void ABlastHitsEveryEntityItCovers() + { + var (items, zone) = BuildSplashZone(TestSettings()); + var near = AddShip(items, zone, float3(10, 0, 0), float2(0, 1), 1); + var far = AddShip(items, zone, float3(20, 0, 0), float2(0, 1), 2); + var outOfRange = AddShip(items, zone, float3(1000, 0, 0), float2(0, 1), 3); + + var nearBefore = near.Hull.Durability; + var farBefore = far.Hull.Durability; + var outBefore = outOfRange.Hull.Durability; + + FireControl.Detonate(zone, float2.zero, radius: 30, damage: 50, damageType: DamageType.Kinetic); + + Assert.True(near.Hull.Durability < nearBefore); + Assert.True(far.Hull.Durability < farBefore); + Assert.Equal(outBefore, outOfRange.Hull.Durability); + } + + // Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in + // FireControlCut124Tests.cs succeeds this test (SplashIsDirectional) and Cut11Tests' + // SplashDamagesTheSideTheBlastCameFrom -- a blast is now an area (12.4(b)), not a directional half-hull + // split, so "which cells take it" is pinned by the disc's own coverage, not a facing-rotated half-shape. +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs new file mode 100644 index 000000000..e1366721e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -0,0 +1,644 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 5 (docs/fire-control-cut.md, "the rules that landed in one path and not its twin"): pins 5.1-5.7, each +// a rule the campaign declared and implemented on one path while leaving it off the path beside it. Builds +// its own fixtures, the same convention every earlier cut's test file establishes, rather than reusing +// FireAuthorityTests'/FireControlCut4Tests'/TargetingSystemTests' private helpers. +public sealed class FireControlCut5Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut5-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut5Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + // Cut 6d (docs/fire-control-cut.md): high enough that pOnHull saturates to 1 at this fixture's 5x5 + // hull's own centre of mass -- see FireAuthorityTests.TestSettings' own comment on this same value. + UnaidedPrecision = 2f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // ---- A discrete-weapon shooter/target engagement, the same shape FireAuthorityTests.Build uses, with an + // optional weak shield (capacity small enough that a real hit overwhelms it, for 5.2). ---- + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + } + + private Engagement Build( + GameplaySettings settings, + float damage = 10, float range = 1000, float minRange = 0, float velocity = 0, + float spread = 0, float damageSpread = 0, float penetration = 0, + // Cut 6d (docs/fire-control-cut.md): Precision now feeds every shot's pOnHull (the dart-throw kernel's + // sigma), aimed or not -- see FireAuthorityTests.Build's own comment on this same default change. 0 + // would balloon sigma and starve this fixture's hit tests, which don't care about aim placement. + float accuracy = 1, float resolution = 1, float precision = 1, float tracking = 1000, + float targetRange = 100, bool equipTargeting = true, float hullDurability = 1000, float armor = 0, + bool equipShield = false, float shieldCapacity = 1000, + Action beforeActivate = null) + { + var hullShape = SolidShape(5, 5); + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = hullDurability, Mass = 1000, Armor = armor, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(damage), Range = Constant(range), MinRange = Constant(minRange), + Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(damageSpread), + Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(accuracy), Resolution = Constant(resolution), Precision = Constant(precision), Tracking = Constant(tracking) + } } + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + // Capacity is deliberately the caller's own knob: 5.2 needs a shield too weak to CanTakeHit the + // shot under test, so its damage overwhelms this reserve outright. + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + if (equipTargeting) + { + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + } + + var targetHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + if (equipShield) + { + var reactorRef = items.ItemData.RefOf(items.ItemData.GetByName("Reactor")); + var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 10 }; + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + + var shieldRef = items.ItemData.RefOf(items.ItemData.GetByName("Shield")); + var shield = new EquippableItem { Data = shieldRef, Durability = 10, Lot = 11 }; + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + } + + beforeActivate?.Invoke(items, shooter, target); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + if (equipShield) target.Shield.Item.Enabled.Value = true; + + zone.Update(0f); // warm up resolved stats + + return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon }; + } + + // ---- 5.1: Tracking is a floor, not a cliff (Soul finding 3). ---- + + // FireControl.Tracking's own unaided fallback: an entity with no working targeting system used to fall + // back to 0, which Commit's old ternary turned into a hard wall (deviation < .01f ? 1 : 0) -- Q4's + // "really, really bad" became "impossible." Mutation: revert the fallback to a literal 0f. + [Fact] + public void UnaidedTrackingFallsBackToSettingNotZero() + { + var e = Build(TestSettings(), equipTargeting: false); + Assert.Equal(TestSettings().UnaidedTracking, FireControl.Tracking(e.Shooter)); + } + + // The wall itself, end to end: an unaided shooter (Tracking = UnaidedTracking, not 0) against a target + // that strayed a real but forgivable distance from its fire-time position still lands hits over a run of + // shots. Mutation: restore Commit's deviation<.01f wall (or equivalently, the 0f fallback above) -- either + // makes hitCount deterministically 0 for every shot in this run, since the jink (3 units) is comfortably + // above the old wall's .01f tolerance. + [Fact] + public void TrackingIsAFloorNotACliff() + { + var settings = TestSettings(); + settings.UnaidedAccuracy = 1f; // isolate this test from Q4's own low ceiling + settings.UnaidedTracking = 10f; + // velocity: 0 keeps every shot's flight time (and so its commit horizon) at zero, so a single + // zone.Update call both commits and resolves it -- no multi-tick float accumulation to muddy exactly + // how much deviation each shot judges, unlike a long flight stepped in many small dt increments. + var e = Build(settings, damage: 5, velocity: 0, accuracy: 1, resolution: 1, spread: 0, + targetRange: 100, equipTargeting: false); + e.Items.Random = new Random(9001u); + + var hits = 0; + e.Zone.ShotResolved.Subscribe(o => { if (o.Hit) hits++; }); + + var basePosition = e.Target.Position; + for (var i = 0; i < 30; i++) + { + e.Target.Position = basePosition; // Fire captures this as FireTargetPosition + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Target.Position = basePosition + float3(3, 0, 0); // jink: a real, forgivable deviation (< Tracking) + e.Zone.Update(.01f); // flight time 0 -> commits and resolves in this one call + } + + Assert.True(hits > 0); + } + + // ---- 5.2: a shot the shield cannot absorb breaks it (Soul finding 4). ---- + + // An unabsorbable hit (damage far past the reserve's Capacity) is decided ShieldBroken at commit and + // Apply performs Break() before routing the remainder to the hull. Mutation: delete shield.Break() from + // Apply (FireControl.cs, the map's own named mutation) -- the shield never breaks and the very next shot + // is absorbed again on schedule instead of paying RestoreDuration. + [Fact] + public void UnabsorbableHitBreaksShield() + { + var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0, + equipShield: true, shieldCapacity: 5); // 50 damage against a 5-capacity reserve: CanTakeHit is false + for (var i = 0; i < 20; i++) e.Zone.Update(.1f); // charge the (small) reserve to full + Assert.False(e.Target.Shield.Broken); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 50)); + + var beforeHull = e.Target.Hull.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.1f); + + Assert.True(e.Target.Shield.Broken); + Assert.True(e.Target.Hull.Durability < beforeHull); // the shield didn't absorb it -- the hull did + } + + // ---- 5.3: continuous weapons obey their arc (Soul finding 9). ---- + + private sealed class BeamEngagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public ConstantWeapon Weapon; + } + + private BeamEngagement BuildBeam(GameplaySettings settings, float3 targetPosition) + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }); + cache.Upsert(new GearData + { + Name = "Beam", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ConstantWeaponData + { + Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), + Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + 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.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Beam")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (ConstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + var reactorRef = items.ItemData.RefOf(items.ItemData.GetByName("Reactor")); + var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 3 }; + Assert.True(shooter.TryFindSpace(reactor, out var rpos)); + Assert.True(shooter.TryEquip(reactor, rpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Beam")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = targetPosition; + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(0f); // warm up resolved stats + + return new BeamEngagement { Items = items, Zone = zone, Shooter = shooter, Weapon = weapon }; + } + + // A side-mounted beam does not fire forward: StanceAllowsFire alone used to gate ConstantWeapon, so a + // target well outside a narrow arc still drew power and fired. Mutation: drop ArcAllowsFire from either + // gate (PowerRequest or Execute's safety check), restoring StanceAllowsFire-only gating. + [Fact] + public void ConstantWeaponObeysArc() + { + var settings = TestSettings(); + settings.FiringArc = 60; // narrow -- an abeam target is well outside it + var e = BuildBeam(settings, float3(100, 0, 0)); // directly abeam + + e.Weapon.Activate(); + Assert.True(e.Weapon.Firing); // Activate() sets _firing unconditionally, same as InstantWeapon.Trigger + + // Cut 5b (5b.1): pin PowerRequest's own arc term here, before Execute ever runs. _firing is still true + // and StanceAllowsFire is true, so ArcAllowsFire is the only thing this call can be testing -- asking + // only after Zone.Update (below) would be vacuous, because Execute's own gate already drives _firing + // to false first, and PowerRequest short-circuits on _firing regardless of what ArcAllowsFire mutates + // to. Without this line, a mutation that drops ArcAllowsFire from PowerRequest alone was a survivor: + // Execute's separate, correct gate still zeroed Firing and resolvedCount by the time the old + // post-Update PowerRequest assertion ran, so that assertion was trivially satisfied either way. + Assert.Equal(0f, e.Weapon.PowerRequest(.1f)); + + var resolvedCount = 0; + e.Zone.ShotResolved.Subscribe(_ => resolvedCount++); + e.Zone.Update(.1f); // ConstantWeapon.Execute runs -- its own arc gate should safe it off on this tick + + Assert.False(e.Weapon.Firing); + Assert.Equal(0f, e.Weapon.PowerRequest(.1f)); // trivially 0 now (_firing is false) -- kept as a sanity check + Assert.Equal(0, resolvedCount); + } + + // ---- 5.4: one bearing test, including at zero range (Soul finding 11). ---- + + private (ItemManager items, Ship shooter, Ship target, EquippedItem weaponItem) BuildPointBlank(GameplaySettings settings) + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(3, 3), Durability = 1000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData() } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 3 }, new EntitySettings()); + var targetGun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 4 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + return (items, shooter, target, weaponItem); + } + + // A planar bearing shorter than 1e-6 (point-blank -- target co-located with the firer) bears rather than + // falling through normalize's NaN. Mutation: drop InArc's lengthsq guard, restoring the NaN comparison + // that silently returns false. + [Fact] + public void PointBlankBearingAlwaysBears() + { + var (_, shooter, target, weaponItem) = BuildPointBlank(TestSettings()); + shooter.Position = float3(5, 0, 5); + target.Position = shooter.Position; // zero-length bearing + + Assert.True(FireControl.InArc(weaponItem, target.Position - shooter.Position)); + + // The shared path: Weapon.ArcAllowsFire (the trigger gate every shooter passes through) must agree, + // now that it no longer carries its own special case and defers to InArc entirely. + var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon); + shooter.Target.Value = target; + Assert.True(weapon.ArcAllowsFire); + } + + // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification): Weapon.ArcAllowsFire must + // actually reflect InArc's real answer, not just agree with it at point-blank range. Mutation: hardcode + // ArcAllowsFire's body to `true` -- the point-blank assertion above stays green, but a genuinely abeam + // target (outside even a 170-degree arc) must read false. + [Fact] + public void WeaponArcAllowsFireReflectsRealBearing() + { + var settings = TestSettings(); + settings.FiringArc = 60; // narrow enough that abeam is unambiguously out + var (_, shooter, target, weaponItem) = BuildPointBlank(settings); + shooter.Position = float3.zero; + target.Position = float3(100, 0, 0); // directly abeam + shooter.Target.Value = target; + + var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon); + Assert.False(weapon.ArcAllowsFire); + + target.Position = float3(0, 0, 100); // dead ahead + Assert.True(weapon.ArcAllowsFire); + } + + // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification): the player's own trigger + // (InstantWeapon.Trigger, reached through Activate()) must actually refuse to queue a shot when + // ArcAllowsFire is false -- Q2's ruling that manual and programmatic fire are one truth, pinned at the + // one place every shooter's trigger passes through. Mutation: delete + // `if (!ArcAllowsFire) return;` from InstantWeapon.Trigger -- Activate() would then queue and fire a + // burst even dead abeam. + [Fact] + public void PlayerTriggerObeysArcGate() + { + var settings = TestSettings(); + settings.FiringArc = 60; + var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, targetRange: 100); + e.Shooter.Position = float3.zero; + e.Target.Position = float3(100, 0, 0); // abeam -- outside the narrow 60-degree arc + e.Shooter.Target.Value = e.Target; + + var resolvedCount = 0; + e.Zone.ShotResolved.Subscribe(_ => resolvedCount++); + + e.Weapon.Activate(); // the player's own trigger path (CanFire -> Trigger() -> the arc gate) + e.Zone.Update(.1f); + + Assert.Equal(0, resolvedCount); + } + + // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification), rewritten to Detonate (12.4(b), + // "Retargeted, because Absorb is deleted" / Q12-9 = A): the blast's shield branch must actually break an + // unabsorbed shield, not merely route the remainder to the hull. A radius this much larger than the hull + // covers essentially the whole disc's damage on the target's own cells, overwhelming a 5-capacity reserve + // however the exact per-cell shares fall -- the expected amount is not loosened, it was never asserted + // precisely here (only that the shield breaks). Mutation: delete `shield.Break()` from Detonate -- the + // target's hull still takes the blast (ABlastHitsEveryEntityItCovers stays green), but the shield itself + // never registers the overwhelming hit and keeps absorbing on schedule. + [Fact] + public void ABlastBreaksUnabsorbedShield() + { + var e = Build(TestSettings(), damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0, + equipShield: true, shieldCapacity: 5); + for (var i = 0; i < 20; i++) e.Zone.Update(.1f); // charge the (small) reserve to full + Assert.False(e.Target.Shield.Broken); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 500)); + + // radius 1, centred on the target's own position (its hull's centre of mass): well inside the 5x5 + // solid hull's interior, so the disc never spills off the hull and the covered share equals the whole + // damage exactly, the same amount Splash always charged. + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 500, damageType: DamageType.Kinetic); + + Assert.True(e.Target.Shield.Broken); + } + + // ---- 5.5: a destroyed subsystem stops being the aim point (Soul finding 10). ---- + + private (ItemManager items, Ship target, EquippedItem big, EquippedItem medium) BuildRevealTarget(GameplaySettings settings) + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(6, 4), Durability = 1000, Mass = 1000 + }); + var bigShape = new Shape(2, 1); + bigShape[new int2(0, 0)] = true; + bigShape[new int2(1, 0)] = true; + cache.Upsert(new GearData + { + Name = "Big", Hardpoint = HardpointType.Tool, Shape = bigShape, Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new GearData + { + Name = "Medium", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); + + var bigRef = items.ItemData.RefOf(items.ItemData.GetByName("Big")); + var big = new EquippableItem { Data = bigRef, Durability = 1, Lot = 2 }; + Assert.True(target.TryFindSpace(big, out var bigPos)); + Assert.True(target.TryEquip(big, bigPos)); + var bigItem = target.Equipment.Single(e => e.EquippableItem == big); + + var mediumRef = items.ItemData.RefOf(items.ItemData.GetByName("Medium")); + var medium = new EquippableItem { Data = mediumRef, Durability = 1, Lot = 3 }; + Assert.True(target.TryFindSpace(medium, out var mediumPos)); + Assert.True(target.TryEquip(medium, mediumPos)); + var mediumItem = target.Equipment.Single(e => e.EquippableItem == medium); + + zone.Entities.Add(target); + target.Activate(); + + return (items, target, bigItem, mediumItem); + } + + // A destroyed item drops out of the ranking entirely: it is never revealed regardless of info, and the + // survivor's own tier closes up over the gap rather than leaving one. Mutation: drop the Durability filter + // from IsRevealed's ranking (a destroyed item keeps its old tier and TargetItem keeps aiming at a corpse). + [Fact] + public void DestroyedItemStopsBeingAimPoint() + { + var settings = TestSettings(); // Armor .2, Gear .8 + var (items, target, big, medium) = BuildRevealTarget(settings); + var observerHullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var observer = new Ship(items, target.Zone, new EquippableItem { Data = observerHullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); + target.Zone.Entities.Add(observer); + observer.Activate(); + observer.Target.Value = target; + + // With both items alive: Big (index 0) reveals at .2, Medium (index 1) at .8. + observer.EntityInfoGathered[target] = .3f; + Assert.True(FireControl.IsRevealed(observer, big)); + Assert.False(FireControl.IsRevealed(observer, medium)); + Assert.True(observer.TrySelectTargetItem(big)); + + // Destroy Big: it drops out of the ranking outright, so it can never be revealed again regardless of + // info, and it stops being a valid aim point on the very next read -- no clearing loop needed. + big.EquippableItem.Durability = 0f; + Assert.False(FireControl.IsRevealed(observer, big)); + Assert.Null(observer.ResolvedTargetItem); + + // Medium is now the only survivor, so it inherits index 0's own tier (.2), not its old index-1 tier + // (.8) -- the gap closes rather than leaving Medium stranded behind a tier meant for a corpse. + Assert.True(FireControl.IsRevealed(observer, medium)); + } + + // ---- 5.6: death removes the ship, in the simulation (Soul finding 7). ---- + + // Zone itself removes a dead entity from Entities -- not only Unity's own loot-drop subscription, which + // this test suite never runs. Mutation: remove the ObserveAdd/Death subscription from Zone's constructor + // (or narrow it to only entities added through the deserialization loop) -- the target then never leaves + // Entities and DeadEntityStopsTakingShots-shaped assertions would only pass by calling Remove by hand. + [Fact] + public void DeathRemovesShipFromSimulation() + { + var e = Build(TestSettings(), damage: 10000, velocity: 0, accuracy: 1, resolution: 1, spread: 0, + hullDurability: 5, armor: 0); // one hit is lethal + + 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); + SafeAssert.NotIn(e.Zone, e.Target); + } + + // ---- 5.7: delete the two carried-and-unread fields (Soul finding 12). ---- + + // PendingShot.PredictedIntercept and .FlightTime decided nothing (Commit judges deviation against + // FireTargetPosition/FireTargetVelocity, never the stored intercept) and are gone. Mutation: re-add either + // field to the struct. + [Fact] + public void PendingShotCarriesNoDeadFields() + { + var fields = typeof(PendingShot).GetFields().Select(f => f.Name).ToList(); + Assert.DoesNotContain("PredictedIntercept", fields); + Assert.DoesNotContain("FlightTime", fields); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs new file mode 100644 index 000000000..788655802 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -0,0 +1,343 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 6b (docs/fire-control-cut.md, "the dice stop being shared, and the flak cannon works again"): pins 6.1 +// (a shot's dice are a pure function of zone identity and shot id, not a shared stream) and 6.2 (airburst +// resolves in the simulation, via Splash instead of Apply, never both). Builds its own fixtures, the same +// convention every earlier cut's test file establishes. +public sealed class FireControlCut6Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut6-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut6Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // ---- A discrete-weapon shooter/target engagement, the same shape FireControlCut5Tests.Build uses. Every + // engagement shares the fixed GalaxyZone name "Test", so every Zone built from this fixture shares one + // CombatSeed (R1's stable identity) -- exactly the substrate 6.1's determinism claim depends on. ---- + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + } + + private Engagement Build( + GameplaySettings settings, + float damage = 10, float range = 1000, float velocity = 0, float spread = 0, + float accuracy = 1, float resolution = 1, float tracking = 100000, + float targetRange = 100, WeaponFuse? fuse = null, float blastRadius = 0) + { + var hullShape = SolidShape(5, 5); + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + // Cut 6b, 6.2 (renamed 12.4(a)): a real WeaponItemData (not the bare GearData earlier cuts' fixtures + // use) -- it is the one catalog type carrying Fuse and BlastRadius. WeaponModifiers no longer decides + // behaviour (Q12-6), so the fixture does not bother setting it. + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, + BlastRadius = blastRadius, + Behaviors = { new InstantWeaponData + { + Damage = Constant(damage), Range = Constant(range), MinRange = Constant(0), + Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0), + Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + // Cut 6d (docs/fire-control-cut.md): Precision now feeds pOnHull (the dart-throw kernel's + // sigma) for every shot, aimed or not -- 0 would balloon sigma and starve this fixture's hit + // tests, none of which care about aim placement. See FireAuthorityTests.Build's own comment. + Accuracy = Constant(accuracy), Resolution = Constant(resolution), Precision = Constant(1), Tracking = Constant(tracking) + } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 100000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 100000, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + // A target with no non-hull equipment never runs TryEquip, so Entity's _orderedEquipment (only ever + // populated there) stays null and Entity.Update NREs on the very first tick -- give it an inert item + // purely so that array gets initialized, the same reason FireControlCut5Tests' fixtures always equip + // a target gun. + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(0f); // warm up resolved stats + + return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon }; + } + + // ---- 6.1: a shot's dice belong to the shot (Soul finding 6). ---- + + // A fixed fight, run twice from the same zone identity (CombatSeed comes from the fixed GalaxyZone name + // "Test", shared by both engagements), with an arbitrary number of unrelated draws from ItemManager.Random + // between shots in the second run. Outcomes must be identical shot-for-shot. Mutation: restore + // `shot.Source.ItemManager.Random` as Commit's generator -- the unrelated draws below then advance the + // shared stream Commit reads from, so the second run's hit/cell sequence diverges from the first's. This + // is the test Soul's own hermetic-fixture probe could never be (docs/fire-control-cut.md, 6.1): a fixture + // that never draws anything unrelated cannot see a shared-stream defect. + [Fact] + public void SameFightRollsSameThroughUnrelatedDraws() + { + var settings = TestSettings(); + + List<(bool hit, int2 cell)> RunFight(Action unrelatedDraws) + { + var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, targetRange: 100); + var outcomes = new List<(bool, int2)>(); + e.Zone.ShotResolved.Subscribe(o => outcomes.Add((o.Hit, o.Cell))); + for (var i = 0; i < 8; i++) + { + unrelatedDraws?.Invoke(e.Items, i); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); // flight time 0 (velocity 0) -> commits and resolves in this one call + } + return outcomes; + } + + var baseline = RunFight(null); + // A different, growing number of unrelated draws before each shot -- exactly "how many draws came + // before it" perturbing a shared stream would be sensitive to. + var perturbed = RunFight((items, i) => { for (var k = 0; k < i * 3 + 1; k++) items.Random.NextFloat(); }); + + Assert.Equal(baseline.Count, perturbed.Count); + Assert.True(baseline.Count > 0); + Assert.Equal(baseline, perturbed); + } + + // A shot's outcome is a function of its own id: firing many shots at an identical, unmoving target (same + // frozen payload in every way but ShotId, which increments per shot) must not always land on the same + // cell. Mutation: drop ShotId from the seed -- every shot in a zone would then build the exact same local + // generator, so every one of these draws the same cell every time (no variety at all across the run). + [Fact] + public void ShotIdDecidesTheDie() + { + var settings = TestSettings(); + var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, targetRange: 100); + + var cells = new List(); + e.Zone.ShotResolved.Subscribe(o => { if (o.Hit) cells.Add(o.Cell); }); + + for (var i = 0; i < 12; i++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + + Assert.True(cells.Count > 1); + Assert.True(cells.Distinct().Count() > 1, "Every shot landed on the same cell -- ShotId is not reaching the seed."); + } + + // ---- 6.2: airburst resolves in the simulation (Soul finding 5). ---- + + // A proximity-fuse shot detonates unconditionally, at arrival, with no roll (12.4(b)'s "Which model a shot + // uses") -- it damages everything in radius regardless of the discrete hit roll (Cut 4's rule, kept). + // Accuracy 0 pins PBase (and so Commit's hit) to false with no dependence on the random draw, so a direct + // hit would apply no damage whatsoever (Apply's own gate on shot.Outcome.Hit). A proximity shot must still + // deal its area damage. Mutation: route a proximity fuse through the direct-hit branch instead -- harmless + // here (that branch still no-ops on a miss), so this test alone does not catch that mutation; + // AirburstAndDiscreteNeverDoubleUp below does, with a guaranteed hit where an extra direct-hit application + // is not a no-op. + [Fact] + public void ProximityDetonatesEvenOnAGuaranteedMiss() + { + var settings = TestSettings(); + var e = Build(settings, damage: 500, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000, + targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); + + var before = e.Target.Hull.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.True(e.Target.Hull.Durability < before, "An airburst shot must splash regardless of the discrete hit roll."); + } + + // The double-application Soul was told to hunt for: with a guaranteed hit (accuracy 1), a proximity shot's + // total damage must equal a bare Detonate call's own damage -- not more. Mutation: route the shot through + // the direct-hit branch as well as Detonate at arrival -- the direct-hit cell would land on top of + // Detonate's area damage, so the shot's total damage exceeds a lone Detonate call's. + [Fact] + public void AirburstAndDiscreteNeverDoubleUp() + { + var settings = TestSettings(); + + // 12.4(b)'s area model spreads damage over pi*r^2, so a radius this much larger than the 5x5 target + // needs enough damage that the covered share still clears DamageHull's .1f threshold (matching + // ProximityDetonatesEvenOnAGuaranteedMiss's own damage, above). + var e = Build(settings, damage: 500, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, + targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); + var before = e.Target.Hull.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + var actualDelta = before - e.Target.Hull.Durability; + + // A fresh, identically-shaped target, damaged by one bare Detonate call with the same parameters + // FireControl.Fire freezes for this weapon (BurstPosition, for a stationary velocity-0 target, is + // exactly its own position -- PredictedIntercept falls back to target.Position below weapon.Velocity's + // .01f floor). + var e2 = Build(settings, damage: 500, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, + targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); + var before2 = e2.Target.Hull.Durability; + FireControl.Detonate(e2.Zone, e2.Target.Position.xz, 100, 500, DamageType.Kinetic); + var expectedDelta = before2 - e2.Target.Hull.Durability; + + Assert.True(expectedDelta > 0); + Assert.Equal(expectedDelta, actualDelta, 3); + } + + // The other side: a shot with no fuse must never detonate, even one whose frozen burst position (unread, + // for a null-fuse shot) would land exactly at zero range on a stationary target -- the zero-radius edge + // case a careless "detonate unconditionally" mutant would still pass through, since distance 0 is never + // greater than radius 0. Accuracy 0 pins the discrete roll to a guaranteed miss, so the correct path + // (Apply's null-fuse branch, gated on Outcome.Hit) deals no damage at all. Mutation: detonate unconditionally + // at arrival instead of switching on the frozen Fuse -- Detonate does not check Hit, so it would still deal + // damage even though this shot never froze a fuse. + [Fact] + public void NoFuseNeverDetonates() + { + var settings = TestSettings(); + var e = Build(settings, damage: 1000, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000, + targetRange: 50); + + var before = e.Target.Hull.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(before, e.Target.Hull.Durability); + } + + // ---- Cut 12.4(a): Fire decides "detonates" once, from the weapon's own data fields, never from a label + // (Q12-6, "WeaponModifiers are labels, not behaviour"). A fuse without a radius is inert: it freezes null, + // not the weapon's Fuse. ---- + + // Mutation: freeze `weaponItemData.Fuse` unconditionally instead of gating it on `blastRadius > 0f` -- a + // weapon that carries a fuse but no radius would then freeze a fuse it should not, and Apply's future + // fuse switch (12.4(b)) would try to detonate a shot with nothing to detonate with. + [Fact] + public void FireFreezesFuseOnlyWhenBlastRadiusIsPositive() + { + var settings = TestSettings(); + + var withRadius = Build(settings, fuse: WeaponFuse.Delayed, blastRadius: 40); + FireControl.Fire(withRadius.Weapon, withRadius.WeaponItem, withRadius.Shooter); + var shotWithRadius = withRadius.Zone.PendingShots[0]; + Assert.Equal(WeaponFuse.Delayed, shotWithRadius.Fuse); + Assert.Equal(40f, shotWithRadius.BlastRadius); + + var withoutRadius = Build(settings, fuse: WeaponFuse.Delayed, blastRadius: 0); + FireControl.Fire(withoutRadius.Weapon, withoutRadius.WeaponItem, withoutRadius.Shooter); + var shotWithoutRadius = withoutRadius.Zone.PendingShots[0]; + Assert.Null(shotWithoutRadius.Fuse); + Assert.Equal(0f, shotWithoutRadius.BlastRadius); + } + + // ---- negative: ItemManager.Random no longer appears in FireControl.cs; Airburst no longer appears + // anywhere under Assets/Scripts/Gameplay (Projectile.cs's fields and ProjectileManager.cs's write-back are + // deleted, not merely unused). Enforced by tests/mutation_tests_fire_control_cut6.sh's negative-grep step, + // not restated here as a source-scanning unit test. +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs new file mode 100644 index 000000000..2bd717924 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs @@ -0,0 +1,277 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 6c (docs/fire-control-cut.md, "the formula gets fixed, and the single point of failure"): pins 6c.1 +// (Resolution is a benefit, not a cost -- HitProbability takes its reciprocal) and 6c.2 (a galaxy with no +// seller of a required targeting design degrades the loadout instead of throwing). Builds its own fixtures, +// the same convention every earlier cut's test file establishes. +public sealed class FireControlCut6cTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut6c-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut6cTests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // ---- 6c.1: a shooter/target engagement, resolution-parameterised, the same shape FireAuthorityTests' + // and FireControlCut5Tests' own Build helpers use. ---- + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + } + + private Engagement Build(GameplaySettings settings, float resolution, float targetRange = 100, float range = 200) + { + var hullShape = SolidShape(5, 5); + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(10), Range = Constant(range), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), + Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(1f), Resolution = Constant(resolution), Precision = Constant(0f), Tracking = Constant(1000f) + } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.SetIff(target, true); + + zone.Update(0f); // warm-up: resolves the targeting system's stats before HitProbability reads them + + return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon }; + } + + // 6c.1 (operator ruling 2026-09-19): Resolution is a benefit -- higher stays better -- so two identical + // engagements differing only in Resolution must have the HIGHER-Resolution one score the higher (or + // equal, at saturation) HitProbability at every info level between detection and full, strictly higher + // at a level where neither has yet saturated. Mutation: read Resolution as the ceiling directly instead + // of taking its reciprocal (the shipped Cut 6a behaviour) -- that inverts the relationship, so the low- + // Resolution engagement scores higher and this test goes red. + [Fact] + public void HigherResolutionYieldsHigherOrEqualHitProbability() + { + var settings = TestSettings(); + var low = Build(settings, resolution: 2f); + var high = Build(settings, resolution: 4f); + + foreach (var info in new[] { .15f, .2f, .3f, .5f, .75f, .99f }) + { + low.Shooter.EntityInfoGathered[low.Target] = info; + high.Shooter.EntityInfoGathered[high.Target] = info; + var pLow = FireControl.HitProbability(low.Weapon, low.Shooter, low.Target); + var pHigh = FireControl.HitProbability(high.Weapon, high.Shooter, high.Target); + Assert.True(pHigh >= pLow - 1e-5f, $"at info {info}: expected higher Resolution (4) to score >= lower Resolution (2), got pHigh={pHigh} pLow={pLow}"); + } + + // Neither design is saturated at info .3 (ceilings are .55 and .325 respectively, per the spec's own + // worked numbers), so the higher-Resolution engagement must be strictly ahead here. + low.Shooter.EntityInfoGathered[low.Target] = .3f; + high.Shooter.EntityInfoGathered[high.Target] = .3f; + var pLowMid = FireControl.HitProbability(low.Weapon, low.Shooter, low.Target); + var pHighMid = FireControl.HitProbability(high.Weapon, high.Shooter, high.Target); + Assert.True(pHighMid > pLowMid, $"expected strictly higher at info .3, got pHigh={pHighMid} pLow={pLowMid}"); + } + + // 6c.2: a galaxy that contains no seller of the one targeting design in the catalog still generates an + // armed loadout instead of throwing InvalidLoadoutException, and the resulting entity resolves the + // unaided fallback (Cut 5.1's floor), not a targeting system it was never able to equip. + // Mutation: restore `required: true` on the targeting-system RandomProduct call in + // LoadoutGenerator.FillInterior (the shipped Cut 6a behaviour) -- that throws instead of degrading. + [Fact] + public void NoSellerDegradesToUnaidedInsteadOfThrowing() + { + string Run() => Path.Combine(_root, "run.cc"); + string Player() => Path.Combine(_root, "player.cc"); + + CultRecordRef maker, targetingMaker; + using (var seedCache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + seedCache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + maker = seedCache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" }); + targetingMaker = seedCache.Upsert(new Faction { Name = "Targeting Maker", ShortName = "TGM" }); + + var hullShape = SolidShape(5, 5); + var hull = seedCache.Upsert(new HullData + { + Name = "Armed", HullType = HullType.Ship, Shape = hullShape, Price = 100, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }); + var gun = seedCache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, Behaviors = { new InstantWeaponData() } }); + var cargo = seedCache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 }); + var capacitor = seedCache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); + var targeting = seedCache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new TargetingSystemData() } }); + + // Everything but the targeting system is sold by Maker, who IS in the galaxy. Targeting is sold + // only by Targeting Maker, who is NOT -- the single point of failure 6c.2 fixes. + foreach (var (name, design, factionMaker) in new[] + { + ("Armed by Maker", hull.Key, maker), ("Gun by Maker", gun.Key, maker), + ("Crate by Maker", cargo.Key, maker), ("Cap by Maker", capacitor.Key, maker), + ("Targeting by Targeting Maker", targeting.Key, targetingMaker) + }) + seedCache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = factionMaker }); + seedCache.FlushAsync().Wait(); + } + + // RunSave.Commit needs a run store to write SavedGame/SavedZone into (a catalog-only cache is "home" + // to neither), the same three-store shape AetheriaStores.Open(Catalog, Run, Player) gives the game + // in every build (RunSaveTests.Open). + var cache = AetheriaStores.Open(Catalog, Run(), Player()); + _openCaches.Add(cache); + + // A minimal one-zone galaxy whose faction roster excludes Targeting Maker entirely, the same + // SavedGame/Galaxy round trip RunSaveTests uses to build a real Galaxy without standing up full + // procedural generation. + var game = new SavedGame + { + Factions = new[] { maker }, + Relationships = new[] { FactionRelationship.Neutral }, + HomeZones = new Dictionary { { 0, 0 } }, + BossZones = new Dictionary(), + DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], + Exit = -1 + }; + var zones = new[] + { + new SavedZone { Name = "Zone 0", Position = float2(0, 0), AdjacentZones = Array.Empty(), Factions = new[] { 0 }, Owner = 0, Contents = null } + }; + RunSave.Commit(cache, game, zones, new ProvenanceLedger()); + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + Assert.False(galaxy.ContainsFaction(targetingMaker)); + + var random = new Random(1u); + var settings = TestSettings(); + var log = new List(); + var items = new ItemManager(cache, new ProvenanceLedger(), settings, log.Add); + var generator = new LoadoutGenerator(ref random, items, galaxy, null, null, .5f); + + var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Armed"); + + Assert.NotNull(pack); // must not have thrown InvalidLoadoutException + Assert.DoesNotContain(pack.Equipment, e => (cache.Get(e.item.Data) as GearData)?.Behaviors.Any(b => b is TargetingSystemData) ?? false); + Assert.Contains(log, l => l.Contains("targeting", StringComparison.OrdinalIgnoreCase)); + + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var entity = EntitySerializer.Unpack(items, zone, pack); + zone.Entities.Add(entity); + entity.Activate(); + zone.Update(0f); + + Assert.Null(entity.GetBehavior()); + Assert.Equal(settings.UnaidedAccuracy, FireControl.Accuracy(entity), 4); + Assert.Equal(settings.UnaidedTracking, FireControl.Tracking(entity), 4); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs new file mode 100644 index 000000000..b45a1d20c --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs @@ -0,0 +1,280 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 6d (docs/fire-control-cut.md, "where a hit lands is a dart throw, not a coin flip"): pins the Gaussian +// kernel that replaces FireControl.Commit's coin flip -- one function, w(cell) = exp(-d^2/2*sigma^2), that +// HitProbability's pOnHull and Commit's cell draw both read. Builds its own fixtures, the same convention +// every earlier cut's test file establishes. +public sealed class FireControlCut6dTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut6d-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut6dTests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = .3f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public HullData HullData; + } + + // A shooter/target engagement over a caller-supplied hull shape, with a full-strength (Accuracy 1, + // Resolution huge, Tracking huge, zero weapon Spread) targeting system so HitProbability collapses to + // Accuracy * pSensor * pSpread * pOnHull = 1 * 1 * 1 * pOnHull at full info and zero deviation -- the one + // factor this cut's tests care about isolated from the other three (the same "neutralise everything but + // the term under test" convention FireControlCut6cTests.Build uses for Resolution). + // markerCell, when given, equips the catalog's bare "Marker" Tool stub at that exact interior cell on the + // target and selects it as the shooter's aim point -- done here, before Activate(), because Entity.TryEquip + // refuses once an entity is active (the same reason every earlier cut's fixture equips extra items through + // a beforeActivate hook rather than after Build returns). + private Engagement Build(GameplaySettings settings, Shape hullShape, float precision, float targetRange = 100, int2? markerCell = null) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), + Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f) + } } + }); + // Durability far above anything a single shot deals: an aim point this cut's tests fire hundreds or + // thousands of rounds at must survive being hit directly for the whole run, or Cut 5.5's "a destroyed + // item drops out of the reveal ranking" rule pulls the aim out from under later shots mid-sample and + // silently corrupts the landing distribution for everything fired after that point. + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 20; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + // Durability high enough to survive thousands of direct 10-damage hits over a test's whole sample -- + // several of this cut's tests fire that many shots to get a stable landing distribution, and an entity + // whose hull dies partway through would both stop taking further hits and bias the remaining sample. + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + EquippableItem marker = null; + if (markerCell != null) + { + var markerRef = items.ItemData.RefOf(items.ItemData.GetByName("Marker")); + marker = new EquippableItem { Data = markerRef, Durability = 1000000, Lot = 6 }; + Assert.True(target.TryEquip(marker, markerCell.Value)); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.SetIff(target, true); + shooter.EntityInfoGathered[target] = 1f; // full reveal: pSensor = 1 + + zone.Update(0f); // warm-up: resolves weapon/targeting stats before HitProbability/Fire read them + + if (marker != null) + { + var markerItem = target.Equipment.Single(x => x.EquippableItem == marker); + Assert.True(shooter.TrySelectTargetItem(markerItem)); + Assert.Same(markerItem, shooter.ResolvedTargetItem); + } + + return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, HullData = hullData }; + } + + // Fires `count` shots and returns the resolved outcomes. Velocity 0 (the weapon fixture's own Constant(0)) + // gives every shot a zero flight time, so one small zone.Update both commits and resolves it -- no + // deviation-over-time noise to control for, matching FireControlCut6cTests' own "commits and resolves in + // this one call" convention. + private List FireMany(Engagement e, int count) + { + var outcomes = new List(); + e.Zone.ShotResolved.Subscribe(o => outcomes.Add(o)); + for (var i = 0; i < count; i++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + return outcomes; + } + + // AimingAtTheSternHitsTheStern is superseded by FireControlCut12Tests.AimingAtAnItemCentresTheScatterOnItsLane + // (docs/fire-control-cut.md, Cut 12.2): this fixture fired from the target's own stern at a stern-aimed + // marker, so a placement rule that merely biases toward whatever is nearest the shooter -- not toward the + // aim point specifically -- would also have passed it. The replacement fires from broadside and from dead + // astern, and checks both a stern- and a bow-aimed shot from each, which a nearest-the-entry-side bug + // cannot pass. + + // A 5x5 blob (x:0-4, y:0-4) plus a 3x3 "pod" (x:6-8, y:1-3) joined to it by a single-cell neck at (5,2) -- + // an appendage connected to the main mass at only one point, the schematic shape of a thin limb. The pod's + // own centre (7,2) is technically an interior cell (Shape.Shrink needs a full 8-neighbour ring, which a + // bare single isolated cell -- tried first -- can never satisfy: Tool gear can only equip to a cell fully + // enclosed by the schematic, so a true one-cell-wide extremity can never itself carry an aimable item). The + // pod supplies that ring locally while still being almost entirely surrounded by open space one ring + // further out, so the kernel still prices it far worse than the blob's own, much better-supported centre. + private static Shape BlobWithLimb(out int2 limb) + { + var shape = new Shape(9, 5); + for (var x = 0; x < 5; x++) + for (var y = 0; y < 5; y++) + shape[new int2(x, y)] = true; + shape[new int2(5, 2)] = true; // neck + for (var x = 6; x < 9; x++) + for (var y = 1; y < 4; y++) + shape[new int2(x, y)] = true; // pod + limb = new int2(7, 2); // pod centre -- the aim point + return shape; + } + + // ThinLimbCostsHitChance: HitProbability against an aim point on a single-cell extremity is strictly lower + // than against the centre of mass, at the same Precision -- and the gap widens as Precision falls (within + // the well-tuned band this campaign's catalog designs live in; both probabilities collapse toward each + // other again at the extreme where sigma dwarfs the whole hull, which is not the regime any authored + // design occupies). Mutation: drop pOnHull from the probability. Must die -- with pOnHull gone, aiming at + // the limb and aiming at the centre of mass score identically (Accuracy * pSensor * pSpread, no spatial + // term at all). + // Cut 12.2 (docs/fire-control-cut.md): pOnHull is now the exact 1D integral of the Gaussian over the + // target's lateral shadow rather than a 2D discrete-cell sum, but the shape of the rule is unchanged -- a + // limb's shadow interval is a small sliver of the hull's own span, so far less of the Gaussian mass + // centred on it actually falls on metal than when the same Gaussian is centred on the much wider mass of + // cells around the centre of mass. + [Fact] + public void ThinLimbCostsHitChance() + { + var hullShape = BlobWithLimb(out var limb); + + float CenterProbability(float precision) + { + var e = Build(TestSettings(), hullShape, precision); + return FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); // unaimed: hull's own centre of mass + } + + float LimbProbability(float precision) + { + var e = Build(TestSettings(), hullShape, precision, markerCell: limb); + return FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + } + + var gaps = new List<(float Precision, float Gap)>(); + foreach (var precision in new[] { 1.5f, 1f, .6f }) + { + var center = CenterProbability(precision); + var onLimb = LimbProbability(precision); + Assert.True(onLimb < center, $"at precision {precision}: expected aiming at the limb ({onLimb}) to score lower than the centre of mass ({center})"); + gaps.Add((precision, center - onLimb)); + } + + for (var i = 1; i < gaps.Count; i++) + Assert.True(gaps[i].Gap > gaps[i - 1].Gap, + $"expected the centre-vs-limb gap to widen as Precision falls from {gaps[i - 1].Precision} to {gaps[i].Precision}, " + + $"got {gaps[i - 1].Gap} then {gaps[i].Gap}"); + } + + // PlacementAndProbabilityShareOneKernel is superseded by + // FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette (docs/fire-control-cut.md, Cut 12.2): + // the 2D discrete-cell kernel this test's ExpectedKernel replicated is gone -- Silhouette replaces it with + // an exact 1D Gaussian integral over the hull's lateral shadow, which the new test recomputes + // independently by numeric integration over a union of intervals it builds itself. + + // EveryHitLandsOnMetal is superseded by FireControlCut12Tests.HitsLandOnTheFacingEdge (docs/fire-control-cut.md, + // Cut 12.2): the new test pins the stronger rule directly -- not merely "occupied," but "nothing occupied + // precedes it in its own lane" -- recomputed independently from the outcome's own frozen Bearing/Lateral on + // a concave (holed) hull. +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs new file mode 100644 index 000000000..c278bcf58 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -0,0 +1,290 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 7 (docs/fire-control-cut.md): 7.1, the fifth mutation Soul found survived 197 green tests -- +// FireControl.Fire freezing the raw TargetItem.Value instead of the reveal-re-checked ResolvedTargetItem. +// Builds its own shooter/target fixture, the same shape FireAuthorityTests uses for Cut 3, rather than +// reusing that file's private Build helper (the established convention in this campaign's test files). +public sealed class FireControlCut7Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut7-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut7Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + // Detection well below the armor tier, which is well below the gear tier, so an aimed interior item can + // decay out of its own reveal while the weapon itself (index 0, the armor tier) and general detection + // both stay satisfied -- the shot still fires, only the aim point should be lost. + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .3f, + TargetGearInfoThreshold = .9f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + AgentMinHitProbability = 0f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + } + + // A trimmed copy of FireAuthorityTests.Build: one Sensors-hardpoint weapon plus a Tool-slot targeting + // system on the shooter, a target ship with a matching weapon (index 0 in IsRevealed's ranking) and, + // through beforeActivate, a second Tool item to aim at (index 1 -- the gear tier). + private Engagement Build(GameplaySettings settings, Action beforeActivate) + { + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(50), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), + Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(1), Resolution = Constant(1), Precision = Constant(1), Tracking = Constant(1000) + } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 10; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + beforeActivate?.Invoke(items, shooter, target); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, 100); + shooter.Target.Value = target; + shooter.SetIff(target, true); + + zone.Update(0f); // warm-up: resolves weapon/targeting stats before Fire reads them + + return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon }; + } + + // 7.1 (Soul's fifth survivor): FireControl.Fire must freeze source.ResolvedTargetItem, not the raw + // source.TargetItem.Value -- the aim point is re-checked against reveal at the instant the trigger is + // pulled, same as every other read of it. Mutation: read TargetItem.Value directly. Must die. + // + // The target's non-hull equipment ranks the hardpoint weapon first (armor tier, index 0 of 2) and the + // interior "Targeting" item last (gear tier, index 1 of 2, i.e. exactly TargetGearInfoThreshold). Info is + // dropped to a level between the two tiers: the weapon stays revealed (so the shot still fires, PBase > + // 0) but the aimed item's own tier is no longer met -- the reveal re-check, not a global blackout, is + // what must drop the aim point. + [Fact] + public void AimedItemDropsOutWhenRevealDecaysBeforeFire() + { + EquippableItem aimedGear = null; + var settings = TestSettings(); + var e = Build(settings, (items, shooter, target) => + { + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + aimedGear = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 6 }; + Assert.True(target.TryFindSpace(aimedGear, out var pos)); + Assert.True(target.TryEquip(aimedGear, pos)); + }); + var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear); + + // Fully reveal and select the interior item as the aim point. + e.Shooter.EntityInfoGathered[e.Target] = 1f; + Assert.True(e.Shooter.TrySelectTargetItem(aimedItem)); + Assert.Same(aimedItem, e.Shooter.ResolvedTargetItem); + + // Decay to halfway between the armor and gear tiers: the weapon (armor tier) stays revealed, the + // aimed item (gear tier) does not. The raw field still holds the item -- nothing clears it -- but the + // read accessor must now return null. + e.Shooter.EntityInfoGathered[e.Target] = (settings.TargetArmorInfoThreshold + settings.TargetGearInfoThreshold) / 2f; + Assert.Same(aimedItem, e.Shooter.TargetItem.Value); // raw field: untouched by decay + Assert.Null(e.Shooter.ResolvedTargetItem); // read path: re-checked, and drops it + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + + Assert.True(shot.PFire > 0f); // the shot is still worth taking -- this isn't a blackout, just a lost aim point + Assert.Null(shot.Aimed); // the frozen snapshot must not carry an aim point reveal no longer supports + } + + // 7.3/7.4 (docs/fire-control-cut.md): the campaign's only assertion that the shipped catalog is + // readable by the shipped code at all, not only by a synthetic fixture. Cut 2 shipped a targeting-system + // requirement with no data to satisfy it and every green synthetic-fixture suite missed it; merging Cuts + // 6b and 6c separately shipped a non-nullable field no existing record carried, and neither cut's own + // synthetic-fixture tests ever opened real data, so AetheriaStores.Open throwing outright on the shipped + // catalog (fixed at c896489b) passed every gate until an operator opened it by hand. Any exception here + // -- deserialization or generation -- is a failure; this is not scoped to "did a design exist." + [Fact] + public void ShippedCatalogOpensAndGeneratesAnArmedHull() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + using var cache = OpenReadOnlyRealCatalog(gameData); + + // Force every EquippableItemData -- WeaponItemData included, the type the broken merge could not + // read -- to actually be enumerated. AetheriaStores.Open already deserializes every record eagerly + // at attach time (CultCache.PullAll), so this line is what turns that into a promise this test + // enforces rather than an accident of the current backing-store implementation: a future lazy-read + // change that skipped WeaponItemData specifically would still be caught here. + var equippable = cache.GetAll().ToList(); + Assert.NotEmpty(equippable); + Assert.Contains(equippable, e => e is WeaponItemData); + + var hull = cache.GetByName("LonginusX"); + Assert.NotNull(hull); + + var settings = new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + var log = new List(); + var items = new ItemManager(cache, new ProvenanceLedger(), settings, log.Add); + var random = new Random(1u); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate == hull); + + Assert.NotNull(pack); // Cut 2's own mutation (drop the targeting designs' products): this goes null + Assert.Contains(pack.Equipment, e => (cache.Get(e.item.Data) as GearData)?.Behaviors.Any(b => b is TargetingSystemData) ?? false); + } + + private static string FindRepoRoot() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) + return dir.FullName; + throw new DirectoryNotFoundException("Run from inside the Aetheria repository -- built test output was not under a recognizable repo root."); + } + + // AetheriaStores.Open (Assets/Scripts/ServerShared/AetheriaStores.cs) uses `new CultCache()`, whose + // default registry (CultDocumentRegistry.Shared) auto-discovers every [CultDocument] type loaded + // ANYWHERE in the current process by scanning the whole AppDomain -- including this very test + // assembly's own TestCatalogGlobal (AetheriaStoresTests.cs: [CultGlobal], routed to the catalog through + // PersonalityAttribute). The shipped catalog naturally carries no record of a test-only type it has + // never heard of, so opening it through the shared, process-wide registry throws "no + // aetheria.tests.catalogglobal record" -- a same-process, cross-assembly artifact of this test suite, + // not something the real game process (which never loads TestCatalogGlobal) can ever hit. + // + // This reproduces AetheriaStores.Open's own composition and validation exactly, scoped to a registry + // built only from the shipped assembly's own [CultDocument] types (CultDocumentRegistry.ForTypes does + // not re-scan the AppDomain the way the default constructor's Refresh() does), so this test still opens + // the catalog the same way the game does -- same backing store, same missing-global check, same R-heat + // validation loop -- without the test assembly's own fixture type poisoning it. + private static CultCache OpenReadOnlyRealCatalog(string catalogPath) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + var cache = new CultCache(registry); + try + { + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes); + var missing = cache.Registry.AllDescriptors.FirstOrDefault(descriptor => + descriptor.IsGlobal && + AetheriaStores.CatalogTypes.Any(home => home.IsAssignableFrom(descriptor.DocumentType)) && + 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."); + foreach (var data in cache.GetAll()) + { + StatValidation.ValidateHeatResponse(data); + StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); + StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); + } + foreach (var data in cache.GetAll()) + { + StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); + StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); + } + return cache; + } + catch + { + cache.Dispose(); + throw; + } + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs new file mode 100644 index 000000000..96fb785d1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -0,0 +1,338 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 8 (docs/fire-control-cut.md, "a removed entity is a dead entity, and the anchors the diagnostics +// moved"): pins 8.1 (removal and deactivation are one transition) and 8.2 (a deactivated entity does not +// update). Builds its own fixtures, the same convention every earlier cut's test file establishes. +public sealed class FireControlCut8Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut8-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut8Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 2f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // ---- A discrete-weapon shooter/target engagement, the same shape FireControlCut5Tests.Build uses. ---- + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + } + + private Engagement Build(GameplaySettings settings, float damage = 10, float hullDurability = 1000) + { + var hullShape = SolidShape(5, 5); + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = hullDurability, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), + Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(1), Resolution = Constant(1), Precision = Constant(1000), Tracking = Constant(1000) + } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 5 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 3 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 4 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, 100); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(0f); // warm up resolved stats + + return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon }; + } + + // ---- A LockWeapon-carrying ship with a live target, the exact indexer shape from the operator's own + // crash (LockWeapon.cs:98, docs/fire-control-cut.md Cut 8 header): Entity.EntityInfoGathered[Entity.Target.Value] + // read through a raw indexer. ---- + + private sealed class LockScenario + { + public ItemManager Items; + public Zone Zone; + public Ship Locker; + public Ship Bystander; + } + + private LockScenario BuildLockScenario(GameplaySettings settings) + { + var hullShape = SolidShape(5, 5); + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "LockGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new LockWeaponData + { + Damage = Constant(0), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), + Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), + BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, + LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170), + DirectionImpact = Constant(1), Decay = Constant(1) + } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var lockerHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var locker = new Ship(items, zone, lockerHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("LockGun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(locker.TryEquip(gun, new int2(0, 0))); + + var bystanderHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 3 }; + var bystander = new Ship(items, zone, bystanderHull, new EntitySettings()); + var bystanderGunRef = items.ItemData.RefOf(items.ItemData.GetByName("LockGun")); + var bystanderGun = new EquippableItem { Data = bystanderGunRef, Durability = 1, Lot = 4 }; + Assert.True(bystander.TryEquip(bystanderGun, new int2(0, 0))); // _orderedEquipment must not be empty/null for Update to run + + zone.Entities.Add(locker); + zone.Entities.Add(bystander); + locker.Activate(); + bystander.Activate(); + + locker.Position = float3.zero; + bystander.Position = float3(0, 0, 100); + locker.LookDirection = float3(0, 0, 1); + locker.Target.Value = bystander; + locker.EntityInfoGathered[bystander] = 1f; + locker.SetIff(bystander, true); + + zone.Update(0f); // warm up resolved stats -- brings weapon Online/Active up + + return new LockScenario { Items = items, Zone = zone, Locker = locker, Bystander = bystander }; + } + + // ---- 8.1/8.2: removal and deactivation are one transition, and a deactivated entity does not update. ---- + + // The always-runs prefix of Entity.Update (TargetRange, temperature, visibility decay, equipment + // performance) used to execute unconditionally, even on an entity that had already been torn down -- + // Deactivate() clears EntityInfoGathered/VisibleEntities/etc, but nothing stopped Update from still + // touching the rest of the entity's state every subsequent call it remained reachable (a stale snapshot + // slot in the same Zone.Update pass, or a docked/child entity still walked by its parent's own Children + // loop). Mutation: drop the `if (!_active) return;` guard -- TargetRange keeps recomputing against a + // live target after the entity is dead, instead of freezing at its last value. + [Fact] + public void DeadEntityDoesNotUpdate() + { + var e = Build(TestSettings()); + e.Target.Target.Value = e.Shooter; // give the target its own TargetRange to freeze + e.Zone.Update(.1f); + var rangeBeforeDeath = e.Target.TargetRange; + + e.Target.Deactivate(); + Assert.False(e.Target.Active); + + e.Shooter.Position += float3(1000, 0, 0); // a live target would recompute a very different TargetRange + e.Target.Update(.1f); + + Assert.Equal(rangeBeforeDeath, e.Target.TargetRange); + } + + // The named crash shape, as a regression pin: LockWeapon.cs:98 reaches + // Entity.EntityInfoGathered[Entity.Target.Value] through a raw indexer. Deactivate() clears + // EntityInfoGathered but does not null the entity's own Target -- so a torn-down entity whose Target is + // still set is exactly the shape of the operator's crash. This must never throw, however the entity came + // to be inactive. + [Fact] + public void LockWeaponOnDeactivatedEntityDoesNotThrow() + { + var s = BuildLockScenario(TestSettings()); + s.Locker.Update(.1f); // let the lock behavior run once while alive, same as normal play + + s.Locker.Deactivate(); + Assert.False(s.Locker.Active); + Assert.DoesNotContain(s.Bystander, s.Locker.EntityInfoGathered.Keys); + Assert.Equal(s.Bystander, s.Locker.Target.Value); // Deactivate does not null the entity's own Target + + var ex = Record.Exception(() => s.Locker.Update(.1f)); + Assert.Null(ex); + } + + // 8.1: Zone's own Death subscription must pair removal with deactivation, exactly like TryDock always has. + // Mutation: remove the Deactivate() call from Zone's Death subscription -- the target leaves Entities but + // stays active, holding live subscriptions and a stale, uncleared EntityInfoGathered/VisibleEntities. + [Fact] + public void DeathDeactivates() + { + var e = Build(TestSettings(), damage: 10000, hullDurability: 5); // one hit is lethal + Assert.True(e.Target.Active); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.1f); + + Assert.True(e.Target.Hull.Durability < .01f); + SafeAssert.NotIn(e.Zone, e.Target); + Assert.False(e.Target.Active); + Assert.Empty(e.Target.EntityInfoGathered); + 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) + // reads Entity.Equipment on Entity.HullDamage, independent of Entity.Death/Deactivate. EntityInstance + // itself is a MonoBehaviour and cannot run headlessly (it lives in the Gameplay assembly, not + // Aetheria.Shared), so the actual drop call (ZoneRenderer.DropItem) is an operator check, not something + // this suite reaches. What this suite CAN confirm headlessly is the data precondition that call depends + // on: Deactivate() must not clear Equipment, regardless of subscription order relative to Zone's own + // Death-triggered removal. ---- + + // Mirrors EntityInstance's own subscription shape (HullDamage, re-checking Durability itself) established + // AFTER the target has already joined the zone -- so Zone's Death-derived Deactivate (subscribed at join + // time, per 8.1) fires first on the same HullDamage.OnNext call, and this handler observes Equipment + // strictly after that. If Deactivate ever starts clearing Equipment, this goes red. + [Fact] + public void LootStillDropsOnDeath_EquipmentSurvivesDeactivate() + { + var e = Build(TestSettings(), damage: 10000, hullDurability: 5); // one hit is lethal + + List equipmentAtDeath = null; + e.Target.HullDamage.Subscribe(_ => + { + if (e.Target.Hull.Durability < .01f) equipmentAtDeath = e.Target.Equipment.ToList(); + }); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.1f); + + Assert.True(e.Target.Hull.Durability < .01f); + Assert.False(e.Target.Active); // Zone's Death subscription (subscribed before this test's own) already + // deactivated the target by the time this handler runs. + Assert.NotNull(equipmentAtDeath); + Assert.NotEmpty(equipmentAtDeath); // the target's own gun -- Deactivate must not have cleared Equipment + + // Operator check, not reachable headlessly: confirm in play that ZoneRenderer.DropItem still fires for + // each surviving equipment/cargo entry (EntityInstance.cs:292-325) after this cut, i.e. that loot + // actually drops on a live kill, not only that the data behind it survived Deactivate(). + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs new file mode 100644 index 000000000..d824d4278 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -0,0 +1,604 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 9 (docs/fire-control-cut.md, "the die is not a die"): pins 9.1 (the hit roll must be uniform on +// [0,1), not a step function fixed by CombatSeed alone -- Cut 6b's own prescription, shipped through three +// cuts, never once measured), 9.2 (pOnHull must stay monotonic in Precision over the whole domain instead of +// collapsing to zero past a cliff no shipped hull's non-integral centre of mass can avoid) and 9.3 (a Target +// surviving a dock/die/undock cycle must not crash LockWeapon). Builds its own fixtures, the same convention +// every earlier cut's test file establishes. +public sealed class FireControlCut9Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut9-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut9Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = .3f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // --------------------------------------------------------------------------------------------------- + // 9.1: the die itself. No weapon or targeting gear -- these tests drive FireControl.Commit directly + // through synthetic PendingShots (FireSynthetic below), bypassing Fire() entirely, so PBase and ShotId + // are exact test inputs rather than products of another cut's own Accuracy/Resolution/Spread/pOnHull + // arithmetic. + // --------------------------------------------------------------------------------------------------- + + private sealed class DieEngagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + } + + // zoneName seeds Zone.CombatSeed (StableHash of the GalaxyZone's Name) -- distinct names give distinct + // zone seeds, the way TheDieIsUniform sweeps several of them. + private DieEngagement BuildDieEngagement(GameplaySettings settings, string zoneName) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + // A bare, inert item -- not read by anything this test cares about, but Entity.Update's + // `foreach (var item in _orderedEquipment)` NREs if an entity was never equipped with anything at + // all (_orderedEquipment stays null until TryEquip/RemoveEquip first assign it), the same reason + // FireControlCut8Tests.BuildLockScenario equips its own bystander with a token gun. + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var markerRef = items.ItemData.RefOf(items.ItemData.GetByName("Marker")); + + // Durability high enough to survive the thousands of direct hits TheDieIsUniform's bisection fires + // at it -- an entity whose hull dies partway through would stop taking further hits and bias + // whatever's left of the sample. + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + var shooterMarker = new EquippableItem { Data = markerRef, Durability = 1000000, Lot = 3 }; + Assert.True(shooter.TryFindSpace(shooterMarker, out var shooterMarkerPos)); + Assert.True(shooter.TryEquip(shooterMarker, shooterMarkerPos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 2 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetMarker = new EquippableItem { Data = markerRef, Durability = 1000000, Lot = 4 }; + Assert.True(target.TryFindSpace(targetMarker, out var targetMarkerPos)); + Assert.True(target.TryEquip(targetMarker, targetMarkerPos)); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, 100); + + return new DieEngagement { Items = items, Zone = zone, Shooter = shooter, Target = target }; + } + + // Fires one synthetic shot with an exact ShotId and PFire, bypassing FireControl.Fire's own Accuracy/ + // Resolution/Spread/pOnHull arithmetic -- 9.1's tests own the roll, not the factors that feed it. + // FireTargetPosition/Velocity match the target's actual (stationary) position and FireTime equals the + // zone's own time, so DeviationProbability commits to exactly 1. Spread stays 0 (PSpread's own early-out, + // neutral regardless of silhouette) and TravelDirection points straight at the target through a + // full-strength, unaimed silhouette (Aimed unset, so ResolveAimPoint falls back to the hull's own centre + // of mass; SolidShape(5,5) at this cut's floored sigma puts every real shadow interval many sigma from + // the hull's edge), so pOnHull sits indistinguishable from 1 -- PFire is then the one number Commit's die + // reads, same as PBase was before 12.2. CommitTime/ArrivalTime are set in the past so the shot commits and + // resolves inside the very next zone.Update call, synchronously, before this method returns. + private bool FireSynthetic(DieEngagement e, int shotId, float pFire, float precision = 1000f) + { + bool? hit = null; + using var sub = e.Zone.ShotResolved.Subscribe(o => + { + if (o.ShotId == shotId) hit = o.Hit; + }); + var shot = new PendingShot + { + ShotId = shotId, + Source = e.Shooter, + Target = e.Target, + Damage = 1f, + Penetration = 0f, + DamageSpread = 0f, + DamageType = DamageType.Kinetic, + PFire = pFire, + Tracking = 1e6f, + Precision = precision, + TravelDirection = normalize((e.Target.Position - e.Shooter.Position).xz), + Spread = 0f, + FireRange = length((e.Target.Position - e.Shooter.Position).xz), + FireTargetPosition = e.Target.Position, + FireTargetVelocity = float3.zero, + FireTime = e.Zone.Time, + CommitTime = e.Zone.Time - 1f, + ArrivalTime = e.Zone.Time - 1f + }; + e.Zone.PendingShots.Add(shot); + e.Zone.Update(0.001f); + if (hit == null) throw new InvalidOperationException($"synthetic shot {shotId} did not resolve"); + return hit.Value; + } + + // Bisects the exact roll (the p at which Hit flips from false to true) for one shot id: since Commit's + // die is `hit = random.NextFloat() < p` with random seeded purely from (zone, shotId), the threshold p + // this converges to *is* that shot's draw, recovered through the public Commit/ShotResolved surface + // rather than by reaching into FireControl's private RNG plumbing. + private float BisectRoll(DieEngagement e, int shotId) + { + float lo = 0f, hi = 1f; + for (var i = 0; i < 24; i++) + { + var mid = (lo + hi) / 2f; + if (FireSynthetic(e, shotId, mid)) hi = mid; else lo = mid; + } + return (lo + hi) / 2f; + } + + // 9.1 (Soul C1, blocking): the defect this whole cut exists for. A single xorshift round does not + // diffuse a seed that differs from its neighbours only in its low bits (shot.ShotId), so every zone's + // first draw was a near-constant function of ShotId, span ~0.125 -- a shot was a step function of p, + // fixed at zone creation, not a roll. Mutation: delete the fmix32 finalizer FireControl.Commit applies + // before constructing its Random. Must die on both assertions below (a mixed-in seed produces both a + // wide roll span and a hit fraction that tracks p; the pre-fix code fails both). + [Fact] + public void TheDieIsUniform() + { + var settings = TestSettings(); + // Several distinct zone seeds (Zone.CombatSeed = GalaxyZone.Name.StableHash()) -- 9.1's defect is a + // property of the diffusion, not of any one unlucky seed, so a single zone passing would prove + // nothing about the fix. + var zoneNames = new[] { "Cut9-Alpha", "Cut9-Beta", "Cut9-Gamma", "Cut9-Delta", "Cut9-Epsilon" }; + var intermediatePs = new[] { .3f, .6f }; + + foreach (var zoneName in zoneNames) + { + var e = BuildDieEngagement(settings, zoneName); + + // Roll span across a dense run of *consecutive* shot ids -- 9.1's defect is specifically that + // ShotId only ever perturbs the seed's low bits, so this is the range that actually exercises + // it (a step of 877 up to 20000 flips high bits of ShotId itself and gets a wide span even + // pre-fix, proving nothing about the diffusion). + var rolls = new List(); + for (var shotId = 0; shotId < 300; shotId++) + rolls.Add(BisectRoll(e, shotId)); + var span = rolls.Max() - rolls.Min(); + Console.WriteLine($"[TheDieIsUniform] zone={zoneName} roll span={span:F4} min={rolls.Min():F4} max={rolls.Max():F4} n={rolls.Count}"); + Assert.True(span > 0.2f, + $"zone {zoneName}: roll span {span:F4} across {rolls.Count} shot ids is not substantially more than 0.2 -- the die is still a step function fixed at zone creation"); + + // Hit fraction at an intermediate p must track p, not sit near 0 or 1 (9.1's step-function + // symptom: a zone either always hits or never hits once p crosses its one fixed threshold). + foreach (var p in intermediatePs) + { + const int n = 500; + var hits = 0; + for (var shotId = 0; shotId < n; shotId++) + if (FireSynthetic(e, 1_000_000 + shotId, p)) hits++; + var fraction = hits / (float) n; + Console.WriteLine($"[TheDieIsUniform] zone={zoneName} p={p} hit fraction={fraction:F3} ({hits}/{n})"); + Assert.True(Math.Abs(fraction - p) < 0.08f, + $"zone {zoneName}, p={p}: observed hit fraction {fraction:F3} over {n} shots is not near p -- die is not uniform"); + } + } + } + + // --------------------------------------------------------------------------------------------------- + // 9.2: pOnHull must stay monotonic in Precision over the whole domain. A neutral shooter/target pair + // (Accuracy 1, huge Resolution/Tracking, zero weapon Spread), the same isolation convention + // FireControlCut6dTests.Build uses, so HitProbability's PBase collapses to exactly pOnHull. + // --------------------------------------------------------------------------------------------------- + + private sealed class KernelEngagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public Weapon Weapon; + } + + private KernelEngagement BuildKernelEngagement(GameplaySettings settings, Shape hullShape, float precision, float spread = 0) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(spread), DamageSpread = Constant(0), + Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + // Cut 11: a finite Resolution, so pSensor actually varies with info. At 1000 it saturated to exactly 1 + // for any info above the detection threshold, which made `x * pSensor` and `x / pSensor` the same + // number and let a mutant of Inspect's product survive HitProbabilityMatchesInspect. + Accuracy = Constant(1f), Resolution = Constant(2f), Precision = Constant(precision), Tracking = Constant(100000f) + } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 5; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut9-Kernel", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon); + + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, 100); + shooter.Target.Value = target; + shooter.SetIff(target, true); + shooter.EntityInfoGathered[target] = 1f; // full reveal: pSensor = 1 + + zone.Update(0f); // warm-up: resolves weapon/targeting stats before Inspect reads them + + return new KernelEngagement { Items = items, Zone = zone, Shooter = shooter, Target = target, Weapon = weapon }; + } + + // 9.2 (Soul C2, blocking): pOnHull collapsed to zero once sigma (1/Precision) fell below about half a + // cell, because the discrete kernel sum badly undershoots the continuous 2*pi*sigma^2 denominator + // against an aim point that is not exactly on a cell centre -- true of every shipped hull (no centre of + // mass is integral). A 7x13 solid hull reproduces the same off-integer-centre shape without needing the + // real catalog open in this project. + // Cut 12.2 (docs/fire-control-cut.md): Silhouette replaces the discrete sum with an exact 1D Gaussian + // integral over the hull's lateral shadow, which does not undershoot at any sigma -- so deleting + // SigmaFloor no longer reproduces the collapse this test was written to catch (TheSigmaFloorHoldsAtHalfACell + // is what now kills that mutant). This test is kept anyway as a plain regression pin: pOnHull must stay + // well clear of zero, and non-collapsing, across the whole Precision domain, whichever formula computes it. + [Fact] + public void POnHullNeverCollapsesAboveThePrecisionCliff() + { + var settings = TestSettings(); + var precisions = new[] { .1f, .3f, .6f, 1f, 1.19f, 2f, 3f, 4f, 5f, 6f, 8f, 10f, 15f, 25f, 50f, 100f, 250f, 500f, 1000f }; + // Even extents: CenterOfMass is the raw mean of integer cell coordinates (ItemData.cs), with no + // +0.5 cell-centre offset -- an odd extent's mean lands exactly on an occupied cell (no cliff to + // reproduce), while an even one lands on the half-cell boundary between two cells, the same + // off-integer shape every shipped hull's real (irregular) footprint produces (LonginusX 2.5/6.697, + // Zenith 5.5/5.5, Turret 3.5/3.5). + var hullShape = SolidShape(6, 12); + + var samples = new List<(float Precision, float POnHull)>(); + foreach (var precision in precisions) + { + var e = BuildKernelEngagement(settings, hullShape, precision); + var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + samples.Add((precision, diagnostic.POnHull)); + } + + Console.WriteLine("[POnHullNeverCollapsesAboveThePrecisionCliff] " + + string.Join(", ", samples.Select(s => $"P={s.Precision}:{s.POnHull:F4}"))); + + foreach (var (precision, pOnHull) in samples) + Assert.True(pOnHull > 0.05f, $"Precision {precision}: pOnHull collapsed to {pOnHull:F4} -- a weapon this well-aimed must still be able to hit the hull"); + + // Monotonic non-collapse over the *whole* domain: once precision climbs (sigma shrinks) pOnHull must + // not fall back toward zero the way the undamped continuous-integral denominator did past the cliff. + // (Below Precision ~1, pOnHull legitimately rises as the kernel tightens onto the aim point; the + // regression this pins is the second half of the curve heading back down to nothing.) + var peakIndex = samples.Select(s => s.POnHull).ToList().IndexOf(samples.Max(s => s.POnHull)); + for (var i = peakIndex + 1; i < samples.Count; i++) + Assert.True(samples[i].POnHull > samples[peakIndex].POnHull * 0.5f, + $"pOnHull fell from {samples[peakIndex].POnHull:F4} at Precision {samples[peakIndex].Precision} to {samples[i].POnHull:F4} at Precision {samples[i].Precision} -- the cliff is back"); + } + + // --------------------------------------------------------------------------------------------------- + // Cut 10 (docs/fire-control-cut.md): one fire-control model, shared at the level of its factors. + // HitProbability is the hot path -- every AI and turret, per weapon, per tick -- and Inspect is the + // debug HUD's view. The diagnostics refactor (72c0109c) kept them as one model by routing the hot path + // THROUGH Inspect, which made every call build the diagnostic struct and run the aim-point and kernel + // work before checking whether the target was even in range: 9.87 us and 448 B per call, measured by a + // Soul pass, on paths that used to bail in nanoseconds. Both now call the same factor functions. + // --------------------------------------------------------------------------------------------------- + + // The invariant the diagnostics refactor was protecting: the HUD can never report a probability the + // simulation does not use. Inspect.PBase is HitProbability's own answer, so the gates and the product + // have one owner; what is left to defend is that the factors the HUD displays are the ones the + // simulation multiplies, and that a gate the HUD shows closed is one the simulation refuses on. Swept + // across Precision, info levels and a spread cone that does not fill the silhouette -- every factor + // strictly between 0 and 1 somewhere, so a factor dropped, doubled or divided in either caller shows. + [Fact] + public void TheHudShowsTheFactorsTheSimulationMultiplies() + { + var settings = TestSettings(); + var checkedConfigurations = 0; + var sawPartialSpread = false; + // F6 (Soul's fix batch, 2026-09-24): a shooter at float3.zero with both entities at Direction's + // default (0,1) let a mutant that reads the SHOOTER's facing instead of the target's (Bearing(source, + // ...) instead of Bearing(target, ...)) survive -- the two facings coincided, so the substitution was + // invisible. An off-origin shooter and a target facing with |fx| > |fy|, distinct from the shooter's + // own (unused) default facing, make that substitution produce a different, wrong bearing. + var origin = float3(37, 0, -11); + foreach (var precision in new[] { .3f, 1.19f, 5f }) + { + // 6 cells wide at cell size 1 subtends ~3.4 degrees at 100 units, inside an 8-degree cone. + var e = BuildKernelEngagement(settings, SolidShape(6, 12), precision, spread: 8); + e.Shooter.Position = origin; + e.Target.Position = origin + float3(0, 0, 100); + e.Target.Direction = normalize(float2(.8f, .6f)); + + foreach (var info in new[] { .2f, .5f, 1f }) + { + 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.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; + checkedConfigurations++; + } + e.Shooter.EntityInfoGathered[e.Target] = 1f; + + void Closed(string label, Func gate) + { + var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.False(gate(hud), $"{label}: the HUD must show this gate closed"); + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target)); + checkedConfigurations++; + } + + e.Target.Position = origin + float3(0, 0, 5000); + Closed("out of range", d => d.Designated); + e.Target.Position = origin + float3(0, 0, -100); + Closed("behind the mount", d => d.InArc); + + // A shooter far from the origin: at float3.zero `target - source` and `target + source` are the + // same vector, so a sign mutant in the bearing only shows here (500 in range vs 1300 out). + e.Shooter.Position = float3(600, 0, 0); + 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.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.Designated); + + Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget); + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null)); + } + Assert.True(sawPartialSpread, "fixture: the spread cone must not fill the silhouette, or PSpread is 1 and proves nothing"); + Assert.Equal(21, checkedConfigurations); // 3 precisions x (3 info levels + out of range + behind + far shooter + not visible) + } + + // The cost regression itself. A target out of range is the common case for every AI and turret, and it + // must bail before any factor is computed. Mutation: route HitProbability back through Inspect; the + // gated-out call then allocates the aim-point list and the kernel's arrays on every evaluation. + [Fact] + public void GatedOutHitProbabilityAllocatesNothing() + { + var e = BuildKernelEngagement(TestSettings(), SolidShape(6, 12), 1.19f); + e.Target.Position = float3(0, 0, 5000); // out of range, still visible and in arc + + for (var i = 0; i < 100; i++) FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); // JIT warm-up + + var before = GC.GetAllocatedBytesForCurrentThread(); + var sum = 0f; + for (var i = 0; i < 1000; i++) sum += FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0f, sum); + Assert.True(allocated == 0, $"1000 gated-out HitProbability calls allocated {allocated} bytes; the hot path must bail before any factor work"); + } + + // --------------------------------------------------------------------------------------------------- + // 9.3: a Target surviving a dock/die/undock cycle must not crash LockWeapon. Same fixture shape as + // FireControlCut8Tests.BuildLockScenario -- this is that cut's own crash, still reachable. + // --------------------------------------------------------------------------------------------------- + + private sealed class LockScenario + { + public ItemManager Items; + public Zone Zone; + public Ship Locker; + public Ship Bystander; + } + + private LockScenario BuildLockScenario(GameplaySettings settings) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "LockGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new LockWeaponData + { + Damage = Constant(0), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), + Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), + BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, + LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170), + DirectionImpact = Constant(1), Decay = Constant(1) + } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut9-Lock", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var lockerHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var locker = new Ship(items, zone, lockerHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("LockGun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(locker.TryEquip(gun, new int2(0, 0))); + + var bystanderHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 3 }; + var bystander = new Ship(items, zone, bystanderHull, new EntitySettings()); + var bystanderGunRef = items.ItemData.RefOf(items.ItemData.GetByName("LockGun")); + var bystanderGun = new EquippableItem { Data = bystanderGunRef, Durability = 1, Lot = 4 }; + Assert.True(bystander.TryEquip(bystanderGun, new int2(0, 0))); // _orderedEquipment must not be empty/null for Update to run + + zone.Entities.Add(locker); + zone.Entities.Add(bystander); + locker.Activate(); + bystander.Activate(); + + locker.Position = float3.zero; + bystander.Position = float3(0, 0, 100); + locker.LookDirection = float3(0, 0, 1); + locker.Target.Value = bystander; + locker.EntityInfoGathered[bystander] = 1f; + locker.SetIff(bystander, true); + + zone.Update(0f); // warm up resolved stats -- brings weapon Online/Active up + + return new LockScenario { Items = items, Zone = zone, Locker = locker, Bystander = bystander }; + } + + // 9.3 (Soul C3, high): Cut 8 fixed a real liveness defect (LockWeaponOnDeactivatedEntityDoesNotThrow) + // but not the operator's actual crash -- that guard only ever runs on an *active* entity. The live + // precondition is Target.Value set but absent from EntityInfoGathered on an active entity, and docking + // still reaches it: Deactivate disposes the Zone.Entities.ObserveRemove subscription that is the only + // thing that nulls a stale Target, and Activate reseeds EntityInfoGathered from the zone's *current* + // membership without reconciling Target against it. Dock, let the target leave, undock: the first + // LockWeapon tick throws. Mutation: delete Entity.Activate's Target reconciliation. Must die. + [Fact] + public void TargetSurvivingDockDieUndockDoesNotCrashLockWeapon() + { + var s = BuildLockScenario(TestSettings()); + s.Locker.Update(.1f); // lock behavior runs once while both are live and visible, same as normal play + + s.Locker.Deactivate(); // dock + Assert.False(s.Locker.Active); + + // the target dies or leaves while the locker is inactive -- its own Zone.Entities.ObserveRemove + // subscription (the only thing that would have nulled Target) was disposed by Deactivate above, so + // nothing reacts to this removal yet. + s.Zone.Entities.Remove(s.Bystander); + + s.Locker.Activate(); // undock + Assert.True(s.Locker.Active); + // Assert.Null(entity) would hand a live Entity into xUnit's reflection-based failure formatter, + // which recurses through Zone -> Entities -> Ship -> Zone forever; a plain boolean with its own + // message sidesteps that entirely. + Assert.True(s.Locker.Target.Value == null, "the invariant: an active entity's Target is always a live entity it has info on -- a stale reference to the departed Bystander survived Activate's reconciliation"); + + var ex = Record.Exception(() => s.Locker.Update(.1f)); + Assert.Null(ex); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs new file mode 100644 index 000000000..7d79a99b1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -0,0 +1,789 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Operator rulings 2026-09-30 (fuse batch 5): AI and turrets decide per weapon and a fused weapon needs a +// designated target; the HUD forecast shows a fused weapon's outcome and reads the same decision Fire flies; a +// refused round is free, for a discrete weapon and for a beam. Builds its own fixture, the convention every +// fire-control test file follows: its shooter carries several mounts and can hold either kind of weapon. +public sealed class FireControlPerWeaponTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolperweapon-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlPerWeaponTests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings(float minHit) => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = minHit, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Gun + { + public EquippedItem Item; + public Weapon Weapon; + } + + private sealed class Rig + { + public Zone Zone; + public Ship Shooter; + public Ship Target; + public List Guns = new List(); + public Gun Gun => Guns[0]; + public Gun Decoy; + } + + // Mount 0 (None, arc 0) bears on a target dead ahead; (Clockwise, arc 0) is a side mount the default 170 + // degree arc cannot bring to bear on it. Fused: Proximity with `blast`, which is also its arming distance: blast 30 + // arms at 30, so a Range of 29.5 refuses and 40 fires. + private Rig Build( + (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, + float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, + bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false, bool charged = false, bool auto = false) + { + mounts ??= new[] { (ItemRotation.None, 0f) }; + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000 + }; + for (var i = 0; i < mounts.Length; i++) + shooterHullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(), + Rotation = mounts[i].Rotation, FiringArc = mounts[i].Arc + }); + // A second weapon type on its own mount (a group of its own, since groups are per item type), dead ahead + // and unfused, weaker than the gun: what a group pick falls back to when the gun's group is refused. + if (decoy) + shooterHullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(mounts.Length, 0), Shape = new Shape(), + Rotation = ItemRotation.None, FiringArc = 0f + }); + + WeaponData behavior; + if (beam) + behavior = new ConstantWeaponData(); + else if (lockWeapon) + behavior = new LockWeaponData + { + LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(0), DirectionImpact = Constant(1), Decay = Constant(1), + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1) + }; + else if (charged) + behavior = new ChargedWeaponData + { + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst, + ChargeTime = Constant(1), ChargeEnergy = Constant(0), ChargeHeat = Constant(5000), CanFireEarly = true + }; + else if (auto) + behavior = new AutoWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; + else + behavior = new InstantWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; + behavior.Damage = Constant(100); + behavior.Range = Constant(range); + behavior.MinRange = Constant(0); + behavior.Velocity = Constant(0); + behavior.Spread = Constant(0); + behavior.DamageSpread = Constant(0); + behavior.Penetration = Constant(0); + behavior.Energy = Constant(energy); + behavior.Heat = Constant(heat); + behavior.Visibility = Constant(visibility); + behavior.MagazineSize = magazine; + behavior.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blast, + Behaviors = { behavior } + }); + cache.Upsert(new WeaponItemData + { + Name = "Decoy", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = + { + new InstantWeaponData + { + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), Damage = Constant(50), Range = Constant(100), + MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), + Energy = Constant(0), Heat = Constant(0), Visibility = Constant(0), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } + } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData { Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Turret", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TurretControllerData() } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, TestSettings(minHit), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var lot = 1; + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var rig = new Rig(); + for (var i = 0; i < mounts.Length; i++) + { + var gun = Make("Gun", lot++, 1); + Assert.True(shooter.TryEquip(gun, new int2(i, 0))); + var item = shooter.Equipment.Single(x => x.EquippableItem == gun); + rig.Guns.Add(new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }); + } + if (decoy) + { + var d = Make("Decoy", lot++, 1); + Assert.True(shooter.TryEquip(d, new int2(mounts.Length, 0))); + var item = shooter.Equipment.Single(x => x.EquippableItem == d); + rig.Decoy = new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }; + } + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + if (true) + { + var reactor = Make("Reactor", lot++, 10); + Assert.True(shooter.TryFindSpace(reactor, out var rpos)); + Assert.True(shooter.TryEquip(reactor, rpos)); + } + if (turret) + { + var t = Make("Turret", lot++, 1); + Assert.True(shooter.TryFindSpace(t, out var upos)); + Assert.True(shooter.TryEquip(t, upos)); + } + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3(-53, 0, -101); + target.Position = shooter.Position + float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(1f); + + rig.Zone = zone; + rig.Shooter = shooter; + rig.Target = target; + return rig; + } + + private static void Aim(Rig r, float2 direction) => r.Shooter.LookDirection = float3(direction.x, 0, direction.y); + + private static readonly (ItemRotation, float)[] SideThenForward = { (ItemRotation.Clockwise, 0f), (ItemRotation.None, 0f) }; + private static readonly (ItemRotation, float)[] SideMount = { (ItemRotation.Clockwise, 0f) }; + + // ==== Ruling 1: per weapon, and a fused weapon needs a designated target ==== + + // A weapon group shares a trigger, not a verdict: two guns of one type, the first on a side mount that cannot + // bear on the target and the second dead ahead. Only the second fires. Kills: the group's verdict read off its + // first weapon (Combat's old `testWeapon`), which silences the group. + [Fact] + public void CombatDecidesEachWeaponOfAGroupForItself() + { + var r = Build(SideThenForward); + var group = r.Shooter.WeaponGroups.Single(g => g.weapons.Count == 2).weapons; + Assert.True(ReferenceEquals(group[0], r.Guns[0].Weapon), "fixture: the side mount leads the group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Guns[0].Weapon.Firing, "the side-mounted gun cannot bear"); + Assert.True(r.Guns[1].Weapon.Firing, "the forward gun bears and is worth firing"); + } + + // A fused weapon fires whenever its target is designated, whatever the arc: the same group, now fused, fires + // both guns, the side mount included. Kills: Combat reading HitProbability (zero out of arc) for a fused weapon. + [Fact] + public void CombatFiresAFusedWeaponAtADesignatedTargetOutOfArc() + { + var r = Build(SideThenForward, fuse: WeaponFuse.Proximity, blast: 4f); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.True(r.Guns[0].Weapon.Firing, "a designated target is enough, arc or no"); + Assert.True(r.Guns[1].Weapon.Firing); + } + + // The turret makes the same per-weapon decision. Kills: TurretController reading HitProbability, or its own + // range test, for a fused weapon. + [Fact] + public void ATurretFiresAFusedWeaponAtADesignatedTargetOutOfArc() + { + var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, turret: true); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc"); + } + + public enum Withheld { NoTarget, OutOfRange, NotVisible, Unlocked } + + // The AI never fires a fused weapon at nothing: a missing target, one out of range, one not visible and one + // an unlocked LockWeapon holds no lock on are each no reason to fish. The direct-hit control fires (true) on + // the same rig once the target is valid. Kills: `designated` dropped from the fused branch (null included). + [Theory] + [InlineData(Withheld.NoTarget)] + [InlineData(Withheld.OutOfRange)] + [InlineData(Withheld.NotVisible)] + [InlineData(Withheld.Unlocked)] + public void AnAgentNeverFiresAFusedWeaponAtNothing(Withheld reason) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 4f, range: 100f, + targetRange: reason == Withheld.OutOfRange ? 150f : 60f, lockWeapon: reason == Withheld.Unlocked); + var target = r.Target; + if (reason == Withheld.NoTarget) target = null; + if (reason == Withheld.NotVisible) r.Shooter.VisibleEntities.Remove(r.Target); + + Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target)); + } + + [Fact] + public void AnAgentFiresAFusedWeaponAtAValidTargetWhateverItsHitChance() + { + // Accuracy 0 prices every direct hit at zero; the fused weapon is unmoved. + var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, accuracy: 0f); + + Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // A weapon that fires at what it hits keeps the AgentMinHitProbability threshold, per weapon. + // Kills: the threshold dropped for a direct-hit weapon, or applied to a fused one. + [Theory] + [InlineData(.5f, .2f, true)] + [InlineData(.5f, .9f, false)] + public void AnAgentKeepsTheHitProbabilityThresholdForADirectHitWeapon(float accuracy, float minHit, bool fires) + { + var r = Build(accuracy: accuracy, minHit: minHit); + var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); + Assert.True(p > .3f && p < .7f, $"fixture: the shot must price near the accuracy, priced {p}"); + + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // ==== Ruling 2: the HUD forecast is Fire's own decision ==== + + // Inspect's Outcome and BurstReach against what Fire then does with the same rig: Refused iff Fire returns 0, + // and a Burst's reach is the planar distance to the burst point Fire froze. Rows: a target beyond the arming + // distance (burst at its range), one inside it (pushed out to 30), no target (max range), out of arc, a + // Range that cannot reach the arming distance, and a weapon without a fuse. + // Kills: an Inspect that computes its own reach (ignoring the arming push or the Range clamp). + [Theory] + [InlineData(60f, 100f, false, false, FireOutcome.Burst, 60f)] + [InlineData(20f, 100f, false, false, FireOutcome.Burst, 30f)] + [InlineData(60f, 40f, true, false, FireOutcome.Burst, 40f)] + [InlineData(60f, 100f, false, true, FireOutcome.Burst, 60f)] + [InlineData(60f, 29.5f, false, false, FireOutcome.Refused, 0f)] + [InlineData(60f, 29.5f, true, false, FireOutcome.Refused, 0f)] + public void TheForecastIsTheOutcomeFireDelivers(float targetRange, float range, bool noTarget, bool outOfArc, FireOutcome outcome, float reach) + { + var r = Build(outOfArc ? SideMount : null, fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: targetRange); + if (noTarget) r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Shooter.Target.Value); + var shotId = FireControl.Fire(r.Gun.Weapon, r.Gun.Item, r.Shooter); + + Assert.Equal(outcome, d.Outcome); + Assert.Equal(outcome != FireOutcome.Refused, shotId != 0); + if (outcome == FireOutcome.Refused) return; + Assert.Equal(reach, d.BurstReach, 2); + var shot = SafeAssert.OnlyShot(r.Zone); + Assert.Equal(d.BurstReach, length((shot.BurstPosition - shot.FireOrigin).xz), 2); + } + + // A weapon without a fuse forecasts a direct hit and keeps its percentages. Kills: Solve calling everything a burst. + [Fact] + public void AWeaponWithoutAFuseForecastsADirectHit() + { + var r = Build(); + + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Target); + + Assert.Equal(FireOutcome.Direct, d.Outcome); + Assert.True(d.PBase > 0f); + } + + // ==== Ruling 3: a refused round is free ==== + + private sealed class Costs + { + public int Ammo; + public float ChargeSpent; + public int Sounds; + public int Wears; + public float Heat; + public bool Visible; + public int Announced; + public int Shots; + public bool CanFire; + } + + // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, + // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. + private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false, bool auto = false) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single, auto: auto); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon); + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_fire" } } }; + var costs = new Costs(); + r.Gun.Item.AudioEvents.Subscribe(_ => costs.Sounds++); + r.Zone.ShotCommitted.Subscribe(_ => costs.Shots++); // a fused round with no flight commits and resolves in its own tick + r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) costs.Wears++; }); + weapon.OnFire += _ => costs.Announced++; + capacitor.AddCharge(1000f); // a full buffer, so the first round can be paid for + var chargeBefore = capacitor.Charge; + var heatBefore = r.Gun.Item.Temperature; + var ammoBefore = weapon.Ammo; + Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine"); + + // The Range the previous tick's Execute left, before this tick refreshes it: a power-dependent Range that + // has since fallen. Set through the property's own setter, so the trigger sees a Range the round will not. + if (staleRange) typeof(Weapon).GetProperty("Range").GetSetMethod(true).Invoke(weapon, new object[] { 100f }); + if (auto) + { + // AutoWeapon's own trigger: a held weapon whose cooldown has run out retriggers inside Execute. + typeof(Weapon).GetField("_firing", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, true); + typeof(InstantWeapon).GetField("_cooldown", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, -1f); + } + else if (viaTrigger) weapon.Activate(); + else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); + r.Zone.Update(.01f); + + costs.Ammo = ammoBefore - weapon.Ammo; + costs.ChargeSpent = chargeBefore - capacitor.Charge; + costs.Heat = r.Gun.Item.Temperature - heatBefore; + costs.Visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + costs.CanFire = weapon.CanFire; + return costs; + } + + // Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that + // never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no + // visibility, no shot. `single` is a weapon that pays once per burst, at its first unrefused round rather than at each; + // `viaTrigger` false authorises the burst directly (the way a trigger that passed leaves it) on a weapon whose + // Range is short, so the Trigger check cannot be what stops the round and Execute must refuse it itself. + // Kills, one per row of the assertions: a spend that precedes the refusal (Execute or Trigger), an + // announcement, a sound, wear, heat or visibility left ahead of the `continue`. + [Theory] + [InlineData(false, true)] + [InlineData(true, true)] + [InlineData(false, false)] + public void ARefusedDiscreteRoundCostsNothing(bool single, bool viaTrigger) + { + var fired = Pull(40f, single, viaTrigger); + Assert.True(fired.Shots == 1, $"shots {fired.Shots}"); + Assert.True(fired.Ammo == 1, $"ammo spent {fired.Ammo}"); + Assert.True(fired.ChargeSpent > 1f, "a fired round spends its energy"); + Assert.Equal(1, fired.Sounds); + Assert.True(fired.Wears > 0); + Assert.True(fired.Heat > 0f); + Assert.True(fired.Visible); + Assert.Equal(1, fired.Announced); + + var refused = Pull(29.5f, single, viaTrigger); + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Ammo); + Assert.Equal(0f, refused.ChargeSpent); + Assert.Equal(0, refused.Sounds); + Assert.Equal(0, refused.Wears); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused round adds no heat ({refused.Heat} vs {fired.Heat})"); + Assert.False(refused.Visible); + Assert.Equal(0, refused.Announced); + } + + // The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws + // nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of + // that, asks the bus for nothing, and never starts (ARefusedBeamNeverStarts). + // Kills: ConstantWeapon spending (power request, wear, heat, visibility) before the refusal, or not + // consulting FireControl at all. + private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range) + { + var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ConstantWeapon) r.Gun.Weapon; + var wears = 0; + var stops = 0; + var shots = 0; + r.Zone.ShotResolved.Subscribe(_ => shots++); + r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) wears++; }); + weapon.OnStopFiring += () => stops++; + var heatBefore = r.Gun.Item.Temperature; + + weapon.Activate(); + var request = weapon.PowerRequest(.1f); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + + var visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + return (shots, wears, request, visible, r.Gun.Item.Temperature - heatBefore, stops, weapon.Firing); + } + + [Fact] + public void ARefusedBeamCostsNothing() + { + var fired = Burn(40f); + Assert.True(fired.Shots > 0, "control: the beam rolls shots"); + Assert.True(fired.Wears > 0); + Assert.True(fired.Request > 0f); + Assert.True(fired.Visible); + Assert.True(fired.Heat > 0f); + + var refused = Burn(29.5f); + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Wears); + Assert.Equal(0f, refused.Request); + Assert.False(refused.Visible); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused beam adds no heat ({refused.Heat} vs {fired.Heat})"); + Assert.True(refused.Stops == 0 && !refused.Firing, "a refused beam never starts, so it never has to stop"); + } + + // A beam is a stream of rounds, so a refused beam never starts: OnStartFiring drives the beam's visuals and + // audio, and starting it for Execute to stop a tick later would flash a beam that fires nothing. The control + // (Range 40) starts once and keeps firing. Kills: Activate that starts a beam without asking FireControl. + [Theory] + [InlineData(40f, 1)] + [InlineData(29.5f, 0)] + public void ARefusedBeamNeverStarts(float range, int starts) + { + var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ConstantWeapon) r.Gun.Weapon; + var started = 0; + weapon.OnStartFiring += () => started++; + + weapon.Activate(); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + + Assert.Equal(starts, started); + Assert.Equal(starts == 1, weapon.Firing); + } + + // A refused weapon is not one an agent can use: a fused weapon whose arming distance (30) exceeds its Range + // (29.5) is not fired at a target inside that Range, where the same weapon with Range to spare is. + // Kills: AgentFires answering "designated" without asking whether Solve refuses. + [Theory] + [InlineData(29.5f, false)] + [InlineData(40f, true)] + public void AnAgentNeverFiresARefusedFusedWeapon(float range, bool fires) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: 20f); + + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // Combat's group pick counts only weapons that can fire: the refused gun's group out-damages the decoy's, so a + // pick that counted it would select a group whose weapons are then never fired, and the decoy would stay silent. + // Kills: Combat counting a refused weapon's damage toward its group. + [Fact] + public void CombatDoesNotPickAGroupWhoseWeaponIsRefused() + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, targetRange: 20f, decoy: true); + Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the refused gun is the stronger group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Gun.Weapon.Firing, "a refused weapon is never activated"); + Assert.True(r.Decoy.Weapon.Firing, "the group that can fire is the one picked"); + } + + // Combat's group pick counts only weapons whose target is designated: the stronger gun's Range (10) does not reach the + // target (20), so its group is not picked and the decoy's, which does reach, is. Kills: Combat asking + // Designated nothing (a group of weapons that cannot reach the target selected). + [Fact] + public void CombatDoesNotPickAGroupWhoseWeaponCannotReachTheTarget() + { + var r = Build(range: 10f, targetRange: 20f, decoy: true); + Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the short gun is the stronger group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Gun.Weapon.Firing, "a weapon that cannot reach the target is never activated"); + Assert.True(r.Decoy.Weapon.Firing, "the group that reaches is the one picked"); + } + + // Combat's range test is Designated's, not a second one: a target exactly at max range is in reach (the old + // private test was strict). Kills: a range comparison of Combat's own. + [Fact] + public void CombatCountsATargetExactlyAtMaxRangeAsInReach() + { + var r = Build(range: 60f, targetRange: 60f); + // The agent is driven directly, not through the zone, so the shooter stays exactly at max range: the first + // update leaves the root state for combat, the second is combat deciding. + var agent = new Minion(r.Shooter); + agent.Update(.01f); + agent.Update(.01f); + + Assert.True(r.Gun.Weapon.Firing); + } + + // The trigger's threshold is inclusive: a shot priced exactly at AgentMinHitProbability is worth taking, and + // one priced a hair under it is not. Kills: `>` for `>=`. + [Fact] + public void AnAgentFiresAtExactlyTheHitProbabilityThreshold() + { + var probe = Build(accuracy: .5f); + var price = FireControl.HitProbability(probe.Gun.Weapon, probe.Shooter, probe.Target); + Assert.True(price > 0f); + + var atThreshold = Build(accuracy: .5f, minHit: price); + var overThreshold = Build(accuracy: .5f, minHit: MathF.BitIncrement(price)); + + Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target)); + Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target)); + } + + // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid + // (SingleAmmoBurst) at its first round that is not refused, judged with the Range that round flies with. + // The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. The refusal + // starts no cooldown either (operator ruling 2026-09-30): the weapon can fire again at once, while the + // control (Range 40) fired and is cooling down. Covers the player's trigger and AutoWeapon's own. + // Kills: paying for the burst at the trigger; starting the cooldown at the trigger (Trigger or AutoWeapon). + [Theory] + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(false, true)] + public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing(bool single, bool auto) + { + var fired = Pull(40f, single, viaTrigger: true, staleRange: true, auto: auto); + Assert.Equal(1, fired.Shots); + Assert.False(fired.CanFire, "control: a fired burst starts the cooldown"); + + var refused = Pull(29.5f, single, viaTrigger: true, staleRange: true, auto: auto); + + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Ammo); + Assert.Equal(0f, refused.ChargeSpent); + Assert.Equal(0, refused.Announced); + Assert.True(refused.CanFire, "a burst that fired nothing leaves no cooldown"); + } + + // A refused charged weapon never starts charging: no charge heat, no audio, no start event, and it can be + // triggered again at once. The control (Range 40) charges, sounds and heats. + // Kills: ChargedWeapon.Activate that does not ask FireControl. + private (int Starts, int Sounds, float Heat, bool Charging) Charge(float range) + { + var r = Build(charged: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ChargedWeapon) r.Gun.Weapon; + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_charge_play" } } }; + var starts = 0; + var sounds = 0; + weapon.OnStartCharging += () => starts++; + r.Gun.Item.AudioEvents.Subscribe(_ => sounds++); + var heatBefore = r.Gun.Item.Temperature; + + weapon.Activate(); + for (var i = 0; i < 3; i++) r.Zone.Update(.1f); + + return (starts, sounds, r.Gun.Item.Temperature - heatBefore, weapon.Progress > 0f); + } + + [Fact] + public void ARefusedChargedWeaponNeverStartsCharging() + { + var fired = Charge(40f); + Assert.Equal(1, fired.Starts); + Assert.True(fired.Sounds > 0, "control: charging sounds"); + Assert.True(fired.Heat > 0f, "control: charging heats"); + + var refused = Charge(29.5f); + Assert.Equal(0, refused.Starts); + Assert.Equal(0, refused.Sounds); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused weapon adds no charge heat ({refused.Heat} vs {fired.Heat})"); + } + // A triggered burst has not started its cooldown, but it is not ready either: pulled again before its first round + // flies it would restart the burst. Kills: CanFire that reads the cooldown alone. + [Fact] + public void ATriggeredBurstIsNotReadyBeforeItsFirstRound() + { + var r = Build(range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + r.Zone.Update(.01f); // resolves the gun's stats, which size the burst + Assert.True(weapon.CanFire); + + weapon.Activate(); + + Assert.False(weapon.CanFire); + } + + // A charge released with a burst pending has not started its cooldown, but it is not ready either: pressed + // again before that burst's first round it would start a second charge over it. Kills: ChargedWeapon.Activate + // that reads the cooldown alone instead of CanFire. + [Fact] + public void AChargedWeaponPressedAgainBeforeItsReleasedRoundFliesStartsNoSecondCharge() + { + var r = Build(charged: true, range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ChargedWeapon) r.Gun.Weapon; + var starts = 0; + weapon.OnStartCharging += () => starts++; + r.Zone.Update(.01f); // resolves the stats + weapon.Activate(); + for (var i = 0; i < 15; i++) r.Zone.Update(.1f); + Assert.Equal(1, starts); + + weapon.Deactivate(); + weapon.Activate(); + r.Zone.Update(.1f); + + Assert.Equal(1, starts); + Assert.False((bool) typeof(ChargedWeapon).GetField("_charging", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon)); + } + + // Holding the trigger fires once per Cooldown: the round that starts the cooldown pays for the tick it flew in. + // Cooldown 1 at .25 s ticks is a shot every 5 ticks, 12 in 15 s. And a burst whose every round is refused + // leaves no cooldown: the same held trigger is ready every tick. Kills: a first round that starts the cooldown + // at 1 after the tick's decrement (a shot every 6 ticks, 10 in 15 s); a cooldown left by a refused burst. + [Theory] + [InlineData(100f, 12)] + [InlineData(29.5f, 0)] + public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + var fired = 0; + weapon.OnFire += _ => fired++; + r.Zone.Update(.25f); // resolves the stats + var readyTicks = 0; + + for (var i = 0; i < 60; i++) + { + weapon.Activate(); + r.Zone.Update(.25f); + if (weapon.CanFire) readyTicks++; + } + + Assert.Equal(shots, fired); + if (shots == 0) Assert.Equal(60, readyTicks); + } + + // Kills: Trigger that leaves _burstStarted true from the previous burst, so the next burst reads as started + // (and ready) before its first round has flown. + [Fact] + public void ABurstTriggeredAfterAFiredOneIsNotReadyBeforeItsFirstRound() + { + var r = Build(range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + r.Zone.Update(.25f); + weapon.Activate(); + r.Zone.Update(.25f); + Assert.False(weapon.CanFire, "control: the first burst fired and is cooling"); + for (var i = 0; i < 10 && !weapon.CanFire; i++) r.Zone.Update(.25f); + Assert.True(weapon.CanFire, "fixture: the cooldown expired"); + + weapon.Activate(); + + Assert.False(weapon.CanFire); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs new file mode 100644 index 000000000..49753d21f --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -0,0 +1,230 @@ +/* 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.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; + +// Cut 1 (docs/fire-control-cut.md): pins mount direction, arc authoring/defaulting and the headless +// engagement this cut exists to unbreak. Builds its own hull/catalog (the IffAndCombatTests Skiff/Gun +// fixture is fixed to one hardpoint) rather than reusing those private helpers. +public sealed class FireControlTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrol-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlTests() => Directory.CreateDirectory(_root); + + private CultCache _openCache; + + public void Dispose() + { + _openCache?.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .5f, + FiringArc = 120 + }; + + // A hull with one weapon hardpoint per (rotation, firingArc) pair, laid out along the X axis of a 1xN + // shape so each gets its own cell. FiringArc 0 on a hardpoint means "use GameplaySettings.FiringArc". + private (ItemManager items, Ship ship, EquippedItem[] weapons) BuildShipWithHardpoints( + params (ItemRotation rotation, float firingArc)[] hardpoints) + { + var hullShape = new Shape(Math.Max(hardpoints.Length, 3), 3); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + // Mass defaults to 0 (ItemData.cs:285), which zeroes ThermalMass (Entity.cs MapEntity) and NaNs + // UpdateTemperature -- author a real mass so the entity's own temperature stays a real number. + var hullData = new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000 }; + for (var i = 0; i < hardpoints.Length; i++) + hullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(), + Rotation = hardpoints[i].rotation, FiringArc = hardpoints[i].firingArc + }); + + using (var buildCache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + buildCache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + buildCache.Upsert(hullData); + buildCache.Upsert(new GearData + { + // The default [0,100] temperature bounds (ItemData.cs:379-382) don't necessarily cover + // wherever this hull's heat model settles after a tick: author a wide plateau so + // EquippableItemData.Performance stays 1 and the item actually comes Online. + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData() } + }); + buildCache.FlushAsync().Wait(); + } + + _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull + for (var i = 0; i < hardpoints.Length; i++) + ledger.Lots[2 + i] = new Lot { Origin = new Attributed(), Quality = 1 }; // guns + var items = new ItemManager(_openCache, ledger, TestSettings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); + var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }; + var ship = new Ship(items, zone, hull, new EntitySettings()); + + // Entity.MapEntity equips the hull itself into Equipment at int2.zero, so hardpoint 0 (also at + // (0,0) here) shares a cell with the hull's own equipped-item entry: match guns by identity, not + // position. + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var weapons = new EquippedItem[hardpoints.Length]; + for (var i = 0; i < hardpoints.Length; i++) + { + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 + i }; + Assert.True(ship.TryEquip(gun, new int2(i, 0))); + weapons[i] = ship.Equipment.Single(e => e.EquippableItem == gun); + } + + zone.Entities.Add(ship); + ship.Activate(); + + return (items, ship, weapons); + } + + // R6: mount direction is the item's authored rotation, not the hull's facing. A target 90 degrees to + // starboard bears on the Clockwise hardpoint's weapon and not the None hardpoint's, and vice versa dead + // ahead. Mutation: make InArc read Entity.Direction instead of the item's rotation, or swap Rotate's + // Clockwise/CounterClockwise cases -- either breaks one of the four assertions below. + [Fact] + public void ArcFollowsMountRotation() + { + var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f), (ItemRotation.Clockwise, 0f)); + var forward = weapons[0]; + var starboardMounted = weapons[1]; + + var deadAhead = float3(0, 0, 1); + var abeamStarboard = float3(1, 0, 0); + + Assert.True(FireControl.InArc(forward, deadAhead)); + Assert.False(FireControl.InArc(forward, abeamStarboard)); + Assert.True(FireControl.InArc(starboardMounted, abeamStarboard)); + Assert.False(FireControl.InArc(starboardMounted, deadAhead)); + } + + // FiringArc is full width: at the default 120, a target 59 degrees off the mount bears and one at 61 + // does not. Mutation: compare against cos(radians(arc)) instead of cos(radians(arc/2)). + [Fact] + public void ArcBoundaryIsHalfWidth() + { + var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f)); + var weapon = weapons[0]; + + float3 AtAngle(float degrees) + { + var r = radians(degrees); + return float3(sin(r), 0, cos(r)); + } + + Assert.True(FireControl.InArc(weapon, AtAngle(59))); + Assert.False(FireControl.InArc(weapon, AtAngle(61))); + } + + // R7: the simulation is 2D. A target inside the arc horizontally but far above the firer still bears. + // Mutation: normalize and dot the full 3D vector instead of zeroing height first. + [Fact] + public void ArcIsPlanar() + { + var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f)); + var weapon = weapons[0]; + + var highButAhead = float3(0, 1000, 1); + + Assert.True(FireControl.InArc(weapon, highButAhead)); + } + + // The per-hardpoint override wins over the default, and an authored 0 means "use the default," not + // "zero-degree arc." A hardpoint authored FiringArc: 360 bears 170 degrees off; a hardpoint left at 0 + // falls back to GameplaySettings.FiringArc (120) and fails there, but still bears a target only 5 + // degrees off. Mutation: ignore HardpointData.FiringArc entirely (both hardpoints fail at 170), or + // treat an authored 0 as a literal zero-width arc (the second hardpoint then fails even at 5 degrees). + [Fact] + public void HardpointOverrideBeatsDefault() + { + var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 360f), (ItemRotation.None, 0f)); + var turret = weapons[0]; + var defaultArc = weapons[1]; + + float3 AtAngle(float degrees) + { + var r = radians(degrees); + return float3(sin(r), 0, cos(r)); + } + + Assert.True(FireControl.InArc(turret, AtAngle(170))); + Assert.False(FireControl.InArc(defaultArc, AtAngle(170))); + Assert.True(FireControl.InArc(defaultArc, AtAngle(5))); + } + + // The cut's real deliverable: Entity.HardpointTransforms is gone, so a Minion can step through a full + // Zone.Update tick and reach the fire decision without Unity ever having run. Before this cut, + // Combat.cs:100-102 threw KeyNotFoundException on the first combat tick headless, because the AI's aim + // direction was a readback from a renderer that never ran. Mutation: restore the HardpointTransforms + // read in Behavior.Direction / Combat.cs -- this throws again outside Unity. + [Fact] + public void CombatStateStepsHeadless() + { + var (items, shooter, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f)); + var weaponData = (InstantWeaponData) weapons[0].Data.Behaviors.Single(b => b is InstantWeaponData); + weaponData.Damage = new PerformanceStat { Min = 10, Max = 10 }; + weaponData.Cooldown = new PerformanceStat { Min = 1, Max = 1 }; + weaponData.Range = new PerformanceStat { Min = 1000, Max = 1000 }; + weaponData.MinRange = new PerformanceStat { Min = 0, Max = 0 }; + weaponData.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + + var zone = shooter.Zone; + var targetHullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); + var target = new Ship(items, zone, new EquippableItem { Data = targetHullRef, Durability = 1, Lot = 1 }, new EntitySettings()); + // Entity._orderedEquipment is populated only by TryEquip (Entity.cs:813); a ship that never equips + // anything leaves it null, and Entity.Update (:932) throws on that regardless of this cut. Equip a + // gun so the target is a real, ordinarily-constructed ship rather than a workaround-shaped one. + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + Assert.True(target.TryEquip(new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 1 }, new int2(0, 0))); + zone.Entities.Add(target); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, 10); // dead ahead of the None-rotation hardpoint, well within range and arc + + shooter.Target.Value = target; + // Cut 3: CombatState now gates on FireControl.HitProbability, which is zero for an undetected target + // (the target is not in VisibleEntities) regardless of arc -- cross TargetDetectionInfoThreshold + // directly, the same way a Sensor's own info accrual eventually would. + shooter.EntityInfoGathered[target] = 1f; + // This fixture's shooter carries no targeting system, so Accuracy falls back to UnaidedAccuracy (Q4: + // authored deliberately bad). This test's own concern is the headless pipeline reaching a fire + // decision at all (Cut 1's regression), not AgentMinHitProbability tuning -- zero the threshold so an + // unaided shot still counts as "worth it." + items.GameplaySettings.AgentMinHitProbability = 0f; + zone.Agents.Add(new Minion(shooter)); + + var ex = Record.Exception(() => + { + zone.Update(1f); // tick 1: transitions the Minion into CombatState and evaluates weapon stats for the first time + zone.Update(1f); // tick 2: CombatState.Update reaches FireControl.InArc -- the line that used to throw headless + }); + + Assert.Null(ex); + var weapon = shooter.Weapons.Single(); + Assert.True(weapon.Firing); // arc + range satisfied: the fight actually decided to fire, not just avoided a crash + } +} diff --git a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs new file mode 100644 index 000000000..6844bbf5f --- /dev/null +++ b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs @@ -0,0 +1,265 @@ +using System; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Cut 1 (docs/stats-and-power-cut.md), R-heat: the authored heat-response shape (minimum, maximum, optimum, +// plateau width), its load-time validation, and the wear-lever consequence (the plateau is where Wear's thermal +// term goes to zero). Each rule here has a matching mutation in tests/mutation_tests_stats_power_cut1.py. +public sealed class HeatResponseTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-heatresponse-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private static readonly int2 HardpointCell = new int2(0, 0); + + public HeatResponseTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + // A hull plus one thermal gear design, equipped and settled at the given start temperature (two + // UpdatePerformance calls so deltaTemp reads 0 before the test drives its own change). + private EquippedItem BuildEquippedAt(CultCache cache, ItemManager items, float startTemperature, out Ship ship) + { + var maker = cache.RefOf(cache.GetByName("Maker")); + var hullData = cache.GetByName("Skiff"); + var thermalData = cache.GetByName("Thermal"); + var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); + ship = new Ship(items, null, hullItem, new EntitySettings()); + var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f)); + Assert.True(ship.TryEquip(gearItem, HardpointCell)); + var equipped = ship.Equipment.Single(e => e.Data is GearData); + foreach (var cell in equipped.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = startTemperature; + equipped.UpdatePerformance(); + equipped.UpdatePerformance(); // settle: oldTemperature now equals startTemperature, deltaTemp reads 0 + return equipped; + } + + private void SetTemperature(EquippedItem equipped, Ship ship, float temperature) + { + foreach (var cell in equipped.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = temperature; + } + + private CultCache OpenCatalogWithThermalGear() + { + var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" }); + var hullShape = new Shape(3, 3); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Price = 100, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } + }); + // Plateau 40-60 of a 0-100 range: full performance across it, linear falloff to the bounds outside it. + cache.Upsert(new GearData + { + Name = "Thermal", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, + Durability = 100, MinimumTemperature = 0, MaximumTemperature = 100, + OptimalTemperature = 50, PlateauWidth = 20 + }); + cache.FlushAsync().Wait(); + return cache; + } + + // --- R-heat shape: Performance() is 1 across the plateau and falls linearly to 0 at the bounds --- + + [Fact] + public void PerformanceIsFullAcrossThePlateau() + { + using var cache = OpenCatalogWithThermalGear(); + var data = cache.GetByName("Thermal"); + Assert.Equal(1f, data.Performance(40), 5); + Assert.Equal(1f, data.Performance(50), 5); + Assert.Equal(1f, data.Performance(60), 5); + } + + [Fact] + public void PerformanceFallsLinearlyOutsideThePlateau() + { + using var cache = OpenCatalogWithThermalGear(); + var data = cache.GetByName("Thermal"); + // Halfway from the plateau's low edge (40) down to the minimum (0) -> halfway performance. + Assert.Equal(.5f, data.Performance(20), 5); + Assert.Equal(0f, data.Performance(0), 5); + Assert.Equal(0f, data.Performance(100), 5); + } + + // S7 (docs/stats-and-power-cut.md Cut 1 findings): a design is dead at and beyond its bounds -- that is the + // point of R-heat's ruling, not an accident. A mutation restoring the old curve's "1 at any temperature" + // behaviour outside the bounds must fail this, at the boundary itself and strictly beyond it on both sides. + [Fact] + public void PerformanceIsZeroAtAndBeyondEachBound() + { + using var cache = OpenCatalogWithThermalGear(); + var data = cache.GetByName("Thermal"); + Assert.Equal(0f, data.Performance(0), 5); + Assert.Equal(0f, data.Performance(-10), 5); + Assert.Equal(0f, data.Performance(100), 5); + Assert.Equal(0f, data.Performance(150), 5); + } + + // --- Validation: fails loudly at catalog load AND at every write through CultRecordRefs.Upsert (S6: the + // writable path used to trust Open()'s one-time check and could write an invalid catalog that only failed + // the next time somebody reopened it). Each test below proves the earlier refusal at Upsert time, then + // confirms the record never reached disk by reopening read-only and finding it absent. --- + + private static string NewRoot() + { + var root = Path.Combine(Path.GetTempPath(), "aetheria-heatresponse-invalid-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + [Fact] + public void UpsertRefusesAnOptimumOutsideItsBounds() + { + var root = NewRoot(); + try + { + var catalog = Path.Combine(root, "Aetheria.cc"); + using var cache = AetheriaStores.Open(catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var ex = Assert.Throws(() => cache.Upsert(new GearData + { + Name = "BadOptimum", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, + MinimumTemperature = 0, MaximumTemperature = 100, OptimalTemperature = 150, PlateauWidth = 0 + })); + Assert.Contains("BadOptimum", ex.Message); + cache.FlushAsync().Wait(); + Assert.Null(cache.GetAll().SingleOrDefault(g => g.Name == "BadOptimum")); + } + finally { Directory.Delete(root, true); } + } + + [Fact] + public void UpsertRefusesANegativePlateauWidth() + { + var root = NewRoot(); + try + { + var catalog = Path.Combine(root, "Aetheria.cc"); + using var cache = AetheriaStores.Open(catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var ex = Assert.Throws(() => cache.Upsert(new GearData + { + Name = "BadPlateau", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, + MinimumTemperature = 0, MaximumTemperature = 100, OptimalTemperature = 50, PlateauWidth = -1 + })); + Assert.Contains("BadPlateau", ex.Message); + } + finally { Directory.Delete(root, true); } + } + + // S5/S6: a zero-span range (Tractor Beam's exact bug) is refused, not silently accepted as an authored + // immunity. + [Fact] + public void UpsertRefusesAZeroSpanRange() + { + var root = NewRoot(); + try + { + var catalog = Path.Combine(root, "Aetheria.cc"); + using var cache = AetheriaStores.Open(catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var ex = Assert.Throws(() => cache.Upsert(new GearData + { + Name = "ZeroSpan", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, + MinimumTemperature = 50, MaximumTemperature = 50, OptimalTemperature = 50, PlateauWidth = 0 + })); + Assert.Contains("ZeroSpan", ex.Message); + } + finally { Directory.Delete(root, true); } + } + + // S5/S6: NaN in any of the four heat-response fields is refused. + [Theory] + [InlineData(float.NaN, 100f, 50f, 20f)] + [InlineData(0f, float.NaN, 50f, 20f)] + [InlineData(0f, 100f, float.NaN, 20f)] + [InlineData(0f, 100f, 50f, float.NaN)] + public void UpsertRefusesNaNInAnyHeatResponseField(float min, float max, float optimum, float plateau) + { + var root = NewRoot(); + try + { + var catalog = Path.Combine(root, "Aetheria.cc"); + using var cache = AetheriaStores.Open(catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var ex = Assert.Throws(() => cache.Upsert(new GearData + { + Name = "NaNHeat", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, + MinimumTemperature = min, MaximumTemperature = max, OptimalTemperature = optimum, PlateauWidth = plateau + })); + Assert.Contains("NaNHeat", ex.Message); + } + finally { Directory.Delete(root, true); } + } + + // S5/S6: the plateau clamps to the bounds rather than poking past them -- an authored half-width that would + // reach past Minimum or Maximum is refused at write time, naming the item, rather than silently clamped. + [Theory] + [InlineData(0f, 100f, 10f, 40f)] // low edge: 10 - 20 = -10 < 0 + [InlineData(0f, 100f, 90f, 40f)] // high edge: 90 + 20 = 110 > 100 + public void UpsertRefusesAPlateauThatPokesPastItsBounds(float min, float max, float optimum, float plateau) + { + var root = NewRoot(); + try + { + var catalog = Path.Combine(root, "Aetheria.cc"); + using var cache = AetheriaStores.Open(catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var ex = Assert.Throws(() => cache.Upsert(new GearData + { + Name = "PokingPlateau", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, + MinimumTemperature = min, MaximumTemperature = max, OptimalTemperature = optimum, PlateauWidth = plateau + })); + Assert.Contains("PokingPlateau", ex.Message); + } + finally { Directory.Delete(root, true); } + } + + // --- The wear lever (Entity.cs UpdatePerformance): inside the plateau the thermal term is zero --- + + [Fact] + public void HeldInsidePlateauTakesNoThermalWear() + { + using var cache = OpenCatalogWithThermalGear(); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var equipped = BuildEquippedAt(cache, items, 50f, out var ship); + Assert.Equal(1f, equipped.ThermalPerformance, 5); + Assert.Equal(0f, equipped.Wear, 5); // no thermal term (Performance == 1) and no deltaTemp (settled) + } + + [Fact] + public void HeldOutsidePlateauTakesThermalWear() + { + using var cache = OpenCatalogWithThermalGear(); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var equipped = BuildEquippedAt(cache, items, 10f, out var ship); // outside 40-60, inside 0-100 + Assert.True(equipped.ThermalPerformance < 1f); + Assert.True(equipped.Wear > 0f); + } + + [Fact] + public void AFastSwingAcrossThePlateauStillWears() + { + using var cache = OpenCatalogWithThermalGear(); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var equipped = BuildEquippedAt(cache, items, 10f, out var ship); + // Jump straight into the plateau in one tick: ThermalPerformance ends at 1 (no thermal term), but the + // swing itself (deltaTemp) still costs. + SetTemperature(equipped, ship, 50f); + equipped.UpdatePerformance(); + Assert.Equal(1f, equipped.ThermalPerformance, 5); + Assert.True(equipped.Wear > 0f); + } +} diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 6c01aafc9..31e390abe 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -32,7 +32,7 @@ public sealed class IffAndCombatTests : IDisposable cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, - Behaviors = { new InstantWeaponData() } + Behaviors = { new InstantWeaponData { Count = new PerformanceStat { Min = 1, Max = 1 }, Cooldown = new PerformanceStat { Min = 1, Max = 1 } } } }); cache.FlushAsync().Wait(); } @@ -248,9 +248,11 @@ public sealed class IffAndCombatTests : IDisposable shooter.Target.Value = target; shooter.SetIff(target, false); // explicitly neutral + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); + weapon.Execute(.01f); - Assert.Equal(0f, weapon.Progress); // Trigger() was safed: no cooldown/burst was started + Assert.True(weapon.CanFire); // Trigger() was safed: no burst fired, so no cooldown } [Fact] @@ -264,9 +266,11 @@ public sealed class IffAndCombatTests : IDisposable shooter.Target.Value = target; shooter.SetIff(target, true); + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); + weapon.Execute(.01f); - Assert.Equal(1f, weapon.Progress); // Trigger() ran: cooldown/burst started + Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } [Fact] @@ -277,9 +281,11 @@ public sealed class IffAndCombatTests : IDisposable var shooter = NewShip(items, zone, new Faction { Name = "Shooter" }, equipGun: true); var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); + weapon.Execute(.01f); - Assert.Equal(1f, weapon.Progress); + Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } [Fact] diff --git a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs new file mode 100644 index 000000000..8f33e75e1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Cut 4 (docs/stats-and-power-cut.md, Cut 4): closes Cut 3's named, temporary exception for the four instant +// draws (a burst, a shot, a ping, a hit taken) -- each now spends from its own InputCapacitor, fed continuously +// by PowerBus like every other consumer, instead of the entity's shared bus capacitors directly. This file pins +// Cut 4's own verification bullets ("no item ever receives a fraction of a shot"; halving the grant halves the +// sustained rate of fire without changing damage per shot) plus the atomicity and half-charge-under-brownout +// rules the map's per-file notes require. Each rule has a matching mutation in +// tests/mutation_tests_stats_power_cut4.py. +public sealed class InputCapacitorTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-inputcap-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public InputCapacitorTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // Same fixture shape as PowerBusTests.OpenCatalog (see its comment for why): a 3x3 interior hull, wide heat + // bounds so ThermalOnline never has to be exercised, and a Reactor whose Charge this method leaves at 0 -- + // BuildShip below sets the actual generation per test. + private CultCache OpenCatalog() + { + var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ReactorData + { + Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) + } } + }); + // BurstCount and Cooldown must be authored: InstantWeapon.UpdateStats divides Damage/Heat/Energy by + // (int)BurstCount and Execute divides dt by Cooldown, so the catalog's own zero-valued PerformanceStat + // default is not a usable fixture value here (unlike Reactor/Capacitor above, which tolerate it). + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new InstantWeaponData + { + Count = Constant(1), Cooldown = Constant(1), Energy = Constant(10), MagazineSize = 0 + } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + // Capacity/RefillDuration/RestoreDuration authored explicitly (shield reserve ruling, 2026-09-19): + // ShieldData no longer derives its reserve size from EnergyUsage, so a fixture that only authors + // Efficiency/EnergyUsage now gets a zero-capacity reserve that breaks on every hit. Capacity = 1 + // reproduces this fixture's pre-ruling reserve size exactly (EnergyUsage was 1); durations are + // unused by the tests below (they never provoke a break here) but must be nonzero so RefreshReserve + // never divides by a zero duration. + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1), RefillDuration = Constant(1), RestoreDuration = Constant(1) } } + }); + cache.FlushAsync().Wait(); + return cache; + } + + private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + private (Ship ship, ItemManager items, Reactor reactor) BuildShip(CultCache cache, float reactorCharge) + { + var ledger = new ProvenanceLedger(); + var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hull = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("Reactor"); + ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); + + Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); + + zone.Entities.Add(ship); + ship.Activate(); + + return (ship, items, ship.GetBehavior()); + } + + private InstantWeapon EquipGun(CultCache cache, Ship ship) + { + var gunData = cache.GetByName("Gun"); + ship.Deactivate(); + Assert.True(ship.TryEquip(Mint(cache, ship.ItemManager, gunData))); + ship.Activate(); + return ship.GetBehavior(); + } + + private Shield EquipShield(CultCache cache, Ship ship) + { + var shieldData = cache.GetByName("Shield"); + ship.Deactivate(); + Assert.True(ship.TryEquip(Mint(cache, ship.ItemManager, shieldData))); + ship.Activate(); + return ship.GetBehavior(); + } + + // Cut 4's own verification bullet: "a weapon whose input capacitor is partly filled does not fire a + // partial shot." Gun costs 10 energy/shot with a 1s cooldown (Rate = 10/s); a 5-generation reactor fills + // the capacitor to exactly half (5) after one second -- not enough to fire, and not a rejected-but-costly + // attempt either: ammo/heat/cooldown state must be completely untouched. + [Fact] + public void WeaponDoesNotFireOnAPartialCharge() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 5); + var gun = EquipGun(cache, ship); + var fired = false; + gun.OnFire += _ => fired = true; + + // Trigger() reads BurstCount/Cooldown off cached properties that only exist after UpdateStats has run + // once (inside Execute) -- a zero-dt warm-up tick populates them without spending any time or charge, + // the same role PowerBusTests.BuildShip's own warm-up tick plays for Capacitor.Capacity. + ship.Update(0f); + + // Arm the burst BEFORE the tick that charges the capacitor: InstantWeapon.Execute adds this tick's + // grant and then runs its burst loop in the same call, so a single dt=1 tick both fills the capacitor + // to exactly half (5 of 10) and attempts the shot against that same half charge -- proving the failure + // is read from the charge actually banked this tick, not a stale or optimistic value. + gun.Activate(); + ship.Update(1f); // capacitor request = 10, granted 5 -> half charge, then the burst loop's own attempt + + Assert.False(fired); + } + + // The other half of the same rule: once the reactor can cover a full second's worth (10 generation), the + // capacitor reaches exactly Capacity in the same tick the burst loop runs, and the shot goes through. + [Fact] + public void WeaponFiresOnceFullyCharged() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 10); + var gun = EquipGun(cache, ship); + var fired = false; + gun.OnFire += _ => fired = true; + + ship.Update(0f); // warm-up: populates BurstCount/Cooldown before Trigger() reads them + gun.Activate(); + ship.Update(1f); // capacitor request = 10, granted 10 -> full charge, then the burst loop fires + + Assert.True(fired); + } + + // Cut 4's other verification bullet: "halving the grant halves the refill rate ... without changing damage + // per shot." Pinned directly on InputCapacitor -- the mechanism PowerBus and every owning behaviour's + // Execute drive identically (RequestedFill(dt) for the request, AddCharge(request * PowerSupply) for the + // fill) -- rather than on a full ship simulation, whose demand naturally shrinks as a capacitor tops up and + // so does not hold a constant grant fraction across ticks. + [Fact] + public void HalvingTheGrantHalvesTheChargeBankedWithoutChangingCapacity() + { + var full = new InputCapacitor(); + full.UpdateStats(energy: 10f, cooldown: 1f); // Capacity = 10, Rate = 10/s + var half = new InputCapacitor(); + half.UpdateStats(energy: 10f, cooldown: 1f); + + var fullRequest = full.RequestedFill(.1f); + var halfRequest = half.RequestedFill(.1f); + Assert.Equal(fullRequest, halfRequest, 5); // identical ask -- only the grant differs + + full.AddCharge(fullRequest * 1f); // PowerSupply == 1: full grant + half.AddCharge(halfRequest * .5f); // PowerSupply == .5: half grant + + Assert.Equal(2f * half.Charge, full.Charge, 4); // half the grant banks exactly half the charge + Assert.Equal(10f, full.Capacity, 3); + Assert.Equal(10f, half.Capacity, 3); // per-shot cost (Capacity) is unaffected by the grant + } + + // A half-charged item's own reserve neither drains nor advances on its own when the bus grants nothing + // (Item.PowerSupply == 0, e.g. under total brownout): AddCharge(0) must be a true no-op, not a slow leak or + // a forced top-up. Exercised directly on InputCapacitor -- the unit under test PowerBus.Step and every + // owning behaviour's Execute both call into. + [Fact] + public void HalfChargedCapacitorHoldsItsChargeWhenNothingIsGranted() + { + var capacitor = new InputCapacitor(); + capacitor.UpdateStats(energy: 10f, cooldown: 1f); + capacitor.AddCharge(5f); + Assert.Equal(5f, capacitor.Charge, 3); + + capacitor.AddCharge(0f); // the bus granted nothing this tick + Assert.Equal(5f, capacitor.Charge, 3); + Assert.False(capacitor.TrySpend(capacitor.Capacity)); // still can't fire on half charge + Assert.Equal(5f, capacitor.Charge, 3); // and the refused attempt did not touch it + } + + // AddCharge must clamp to Capacity -- the bus over-filling by a rounding error, or a Capacity that shrank + // under a stat change after charge had already accumulated, must never leave Charge reporting more energy + // than a single activation could ever need. + [Fact] + public void AddChargeNeverExceedsCapacity() + { + var capacitor = new InputCapacitor(); + capacitor.UpdateStats(energy: 10f, cooldown: 1f); + + capacitor.AddCharge(999f); + + Assert.Equal(10f, capacitor.Charge, 3); + } + + // InputCapacitor.TrySpend must be atomic -- Entity.TrySpendCapacitorCharge's old contract, now scoped to + // one behaviour's own buffer: either the cost is covered in full, or Charge is untouched. + [Fact] + public void TrySpendMovesNothingWhenTheCostCannotBeFullyCovered() + { + var capacitor = new InputCapacitor(); + capacitor.UpdateStats(energy: 10f, cooldown: 1f); + capacitor.AddCharge(6f); + + Assert.False(capacitor.TrySpend(10f)); + Assert.Equal(6f, capacitor.Charge, 3); + + Assert.True(capacitor.TrySpend(6f)); + Assert.Equal(0f, capacitor.Charge, 3); + } + + // Q4 (operator ruling): capacity/rate derive from Energy and Cooldown by default, but an authored override + // replaces the rate -- ChargedWeapon's own mechanism (RateOverride => ChargeEnergy). Pinned directly on + // InputCapacitor: a nonzero rateOverride wins over the Capacity/cooldown default. + [Fact] + public void RateOverrideReplacesTheDerivedDefault() + { + var derived = new InputCapacitor(); + derived.UpdateStats(energy: 10f, cooldown: 2f); // default rate = 10/2 = 5 + Assert.Equal(5f, derived.Rate, 3); + + var overridden = new InputCapacitor(); + overridden.UpdateStats(energy: 10f, cooldown: 2f, rateOverride: 100f); + Assert.Equal(100f, overridden.Rate, 3); + } + + // Shield is the map's named "odd one out": a hit is not a chosen activation, so its reserve is a continuous + // one, not an activation buffer -- CanTakeHit/TakeHit may draw an arbitrary fraction of the reserve (never + // "must be exactly Capacity" the way a weapon's shot must). But the underlying spend is still atomic: a hit + // costing more than the whole reserve holds is refused entirely, not partially absorbed. + [Fact] + public void ShieldReserveAllowsAPartialDrawButRefusesAHitItCannotFullyCover() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 10); + var shield = EquipShield(cache, ship); + + ship.Update(1f); // EnergyUsage = 1 -> reserve Capacity = Rate = 1, granted in full -> Charge = 1 + + // A small hit (cost 0.5, well under the 1-unit reserve) is a legitimate partial draw, unlike a weapon + // shot, which is only ever spent whole. + Assert.True(shield.CanTakeHit(DamageType.Kinetic, .5f)); + shield.TakeHit(DamageType.Kinetic, .5f); + + // With .5 left, a smaller hit (0.3) is still absorbed -- this is the assertion that actually + // distinguishes "may draw a partial amount" from "requires the reserve to be at full Capacity", the + // rule the map draws between Shield and the weapon/sensor buffers above. + Assert.True(shield.CanTakeHit(DamageType.Kinetic, .3f)); + + // A hit needing more than what's left (0.5 remains, hit costs 1) is refused outright, not partially + // absorbed. + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 1f)); + } +} diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 911434dcf..48cd75cbd 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -39,8 +39,13 @@ public sealed class LoadoutTests : IDisposable var cargo = cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 }); var gun = cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, Behaviors = { new InstantWeaponData() } }); cache.Upsert(new GearData { Name = "Orphan", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1 }); + // Cut 2 (docs/fire-control-cut.md, Q4): required equipment for any entity with a weapon + // (LoadoutGenerator.FillInterior). "Gun" carries a weapon behaviour and shares this catalog's one + // Sensors hardpoint type with "Lamp"/"Orphan", so any generated loadout that happens to draw Gun + // needs a targeting-system product available or FillInterior throws. + var targeting = cache.Upsert(new GearData { Name = "Scope", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new TargetingSystemData() } }); - foreach (var (name, design) in new[] { ("Skiff by Maker", hull.Key), ("Lamp by Maker", gear.Key), ("Crate by Maker", cargo.Key), ("Gun by Maker", gun.Key) }) + foreach (var (name, design) in new[] { ("Skiff by Maker", hull.Key), ("Lamp by Maker", gear.Key), ("Crate by Maker", cargo.Key), ("Gun by Maker", gun.Key), ("Scope by Maker", targeting.Key) }) cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); cache.FlushAsync().Wait(); } @@ -519,8 +524,8 @@ public sealed class LoadoutTests : IDisposable var instance = (EquippableItem) items.CreateInstance(commonLot); var rareInstance = (EquippableItem) items.CreateInstance(rareLot); - var withRole = new PerformanceStat { FromRole = "lens", Min = 0, Max = 1, QualityExponent = 1 }; - var noRole = new PerformanceStat { Min = 0, Max = 1, QualityExponent = 1 }; + var withRole = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1, Role = "lens" } } }; + var noRole = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1 } } }; Assert.Equal(.9f, items.Evaluate(withRole, instance), 3); Assert.Equal(.2f, items.Evaluate(noRole, instance), 3); Assert.Equal(.2f, items.GetTier(instance).tier.Quality, 3); @@ -532,6 +537,24 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(.2f, items.GetTier(secondInstance).tier.Quality, 3); } + // S7 (docs/stats-and-power-cut.md Cut 1 findings): a Quality term must raise the lot's quality to its own + // declared Exponent, not always to 1. StatsReadTheLot uses Exponent = 1 throughout, which cannot distinguish + // "read the term's exponent" from "always use 1" -- pow(x, 1) == x either way. Exponent = 2 here can: a + // mutation that drops the term's own exponent (using 1f regardless of what is declared) reads .5 instead of + // the correct .25. + [Fact] + public void QualityTermAppliesItsOwnExponent() + { + using var cache = Open(); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var lamp = cache.GetByName("Lamp"); + var lotId = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .5f, Roles = new List() }); + var instance = (EquippableItem) items.CreateInstance(lotId); + + var squared = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 2 } } }; + Assert.Equal(.25f, items.Evaluate(squared, instance), 3); + } + // F4: GetPrice reads the lot's quality through GameplaySettings.QualityPriceModifier. [Fact] public void GetPriceReadsLotQuality() @@ -549,8 +572,8 @@ public sealed class LoadoutTests : IDisposable } // F4: the durability exponent path in ItemManager.Evaluate reads the lot's quality (GameplaySettings. - // DurabilityQuality{Min,Max,Exponent}), independently of the quality-for-role term (zeroed here via - // QualityExponent 0). + // DurabilityQuality{Min,Max,Exponent}), independently of the quality-for-role term (absent here -- the + // stat declares only a Durability term). [Fact] public void EvaluateDurabilityExponentReadsLotQuality() { @@ -566,7 +589,7 @@ public sealed class LoadoutTests : IDisposable low.Durability = items.GetData(low).Durability / 2; high.Durability = items.GetData(high).Durability / 2; - var stat = new PerformanceStat { Min = 0, Max = 1, QualityExponent = 0, DurabilityExponentMultiplier = 1 }; + var stat = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Durability, Exponent = 1 } } }; Assert.NotEqual(items.Evaluate(stat, low), items.Evaluate(stat, high)); } @@ -597,6 +620,135 @@ public sealed class LoadoutTests : IDisposable Assert.NotEqual(lowEquipped.ThermalExponent, highEquipped.ThermalExponent); } + // Cut 0 (docs/stats-and-power-cut.md): the collapsed evaluation path (PerformanceStat.Evaluate(IStatContext)) + // gives the unequipped and the equipped-at-full-health-and-optimal-temperature cases the same number, for a + // design whose heat curve genuinely reaches full performance at its plateau. Before the cut this equality was + // not even expressible: the two paths were separate hand-written formulas (ItemManager.Evaluate and + // EquippedItem.Evaluate) with no shared entry point to assert against. It is expressible now because both + // resolve through the one function via an IStatContext, which is the structural point of the cut. + // + // Cut 1 (docs/stats-and-power-cut.md), R-heat: the authored shape is minimum, maximum, optimum and plateau + // width, with linear falloffs -- no curve. This fixture authors a plateau (40-60 out of a 0-100 range) that + // reaches exactly 1.0 across it, the R-heat equivalent of the old flat-topped bezier fixture, so the + // equality this test pins (unequipped agrees with equipped-at-optimum) is expressible the same way Cut 0 + // left it. + [Fact] + public void UnequippedAgreesWithEquippedAtFullHealthAndOptimalTemperature() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + var maker = cache.RefOf(cache.GetByName("Maker")); + var hullData = cache.GetByName("Skiff"); + + var thermalGear = cache.Upsert(new GearData + { + Name = "Thermal", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, + Durability = 100, MinimumTemperature = 0, MaximumTemperature = 100, + OptimalTemperature = 50, PlateauWidth = 20 + }); + cache.FlushAsync().Wait(); + var thermalData = cache.Get(thermalGear); + + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); + var ship = new Ship(items, null, hullItem, new EntitySettings()); + var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f)); + Assert.True(ship.TryEquip(gearItem, HardpointCell)); + var equipped = ship.Equipment.Single(e => e.Data is GearData); + + // Drive the entity's temperature to the design's optimal, at full durability (CreateInstance's default). + foreach (var cell in equipped.InsetShape.Coordinates) + ship.Temperature[cell.x, cell.y] = thermalData.OptimalTemperature; + equipped.UpdatePerformance(); + Assert.Equal(1f, equipped.ThermalPerformance, 4); + Assert.Equal(1f, equipped.DurabilityPerformance, 4); + + var stat = new PerformanceStat + { + Min = 0, Max = 1, + Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 }, new StatTerm { Source = StatSource.Durability, Exponent = 1 } } + }; + var equippedResult = equipped.Evaluate(stat); + var unequippedResult = items.Evaluate(stat, gearItem); + Assert.Equal(unequippedResult, equippedResult, 5); + } + + // Cut 0: the unequipped context has no heat, by name (UnequippedStatContext.HeatFactor is a fixed 1), + // regardless of the item's actual temperature or the term's exponent. This is one of the two named + // disagreements the cut preserves rather than resolves -- the trade menu shows the un-discounted value on + // purpose. Mutation: have HeatFactor read a real thermal performance instead of the constant 1; this goes + // red because the item is equipped far off its optimal temperature while unequipped stays unchanged. + [Fact] + public void UnequippedIgnoresHeatRegardlessOfTemperature() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + var maker = cache.RefOf(cache.GetByName("Maker")); + var hullData = cache.GetByName("Skiff"); + // A peak with zero plateau width: performance reaches 1.0 only exactly at the optimum, matching the old + // curve fixture's single-point peak at .5 of its range. + var thermalGear = cache.Upsert(new GearData + { + Name = "ColdThermal", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, + Durability = 100, MinimumTemperature = 0, MaximumTemperature = 100, + OptimalTemperature = 50, PlateauWidth = 0 + }); + cache.FlushAsync().Wait(); + var thermalData = cache.Get(thermalGear); + + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); + var ship = new Ship(items, null, hullItem, new EntitySettings()); + var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f)); + Assert.True(ship.TryEquip(gearItem, HardpointCell)); + var equipped = ship.Equipment.Single(e => e.Data is GearData); + + // Drive the item to the coldest end of its range -- far from optimal, ThermalPerformance -> 0. + foreach (var cell in equipped.InsetShape.Coordinates) + ship.Temperature[cell.x, cell.y] = thermalData.MinimumTemperature; + equipped.UpdatePerformance(); + Assert.True(equipped.ThermalPerformance < 0.01f); + + var stat = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 } } }; + var equippedResult = equipped.Evaluate(stat); + var unequippedResult = items.Evaluate(stat, gearItem); + Assert.True(equippedResult < 0.01f); // heat tanks the equipped read + Assert.Equal(1f, unequippedResult, 3); // unequipped never reads heat at all + } + + // Cut 1: ConsumableItemEffect's progress-through-duration is now its own declared term (ConsumableProgress) + // rather than a hard override of "heat" applied to every stat regardless of what it declares. A stat that + // declares the term reads progress; one that does not is unaffected by it, the same as any other source a + // context has no reader for. Mutation: apply ConsumableProgressFactor unconditionally instead of gating it + // on a declared term; the "undeclared" assertion goes red because Undeclared would then also read .5. + [Fact] + public void ConsumableProgressAppliesOnlyWhenDeclared() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + var maker = cache.RefOf(cache.GetByName("Maker")); + var hullData = cache.GetByName("Skiff"); + var ramp = new BezierCurve { Keys = new[] { new float4(0f, 0f, 0f, 0f), new float4(1f, 1f, 0f, 0f) } }; + var consumableRef = cache.Upsert(new ConsumableItemData { Name = "Booster", Duration = 10, Effectiveness = ramp }); + cache.FlushAsync().Wait(); + var consumableData = cache.Get(consumableRef); + + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); + var ship = new Ship(items, null, hullItem, new EntitySettings()); + // ItemManager.CreateInstance has no ConsumableItemData branch (it only special-cases EquippableItemData, + // falling back to CompoundCommodity otherwise); build the instance directly, as that method does for + // EquippableItem. + var consumableLot = items.CreateLot(consumableData, maker, .5f); + var consumableItem = new ConsumableItem { Data = cache.RefOf(consumableData), Lot = consumableLot }; + var effect = new ConsumableItemEffect(consumableItem, ship); + effect.Update(5f); // half the duration elapsed -> progress .5 -> effectiveness .5 on a linear ramp + + // Exponent 2, not 1: pow(.5, 2) == .25 differs from the raw progress .5, so a mutation that dropped the + // term's own exponent (using the raw effectiveness value unexponentiated) is distinguishable from this. + var declared = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.ConsumableProgress, Exponent = 2 } } }; + var undeclared = new PerformanceStat { Min = 0, Max = 1 }; + Assert.Equal(.25f, effect.Evaluate(declared), 3); + Assert.Equal(1f, effect.Evaluate(undeclared), 3); + } + // F4: CreateLot(product) fills each of the design's roles from the product's own per-role spread // (FactionProductData.Roles), not the design-wide default. [Fact] @@ -715,6 +867,33 @@ public sealed class LoadoutTests : IDisposable } } + // Cut 0 (settings-globals-cut.md): Entity's ctor must copy the template it is given. No entity may alias + // GameplaySettings.DefaultEntitySettings, or a write through one ship (InventoryMenu's Shutdown Threshold + // edit) would silently move the default for every other ship, and once settings are a catalog global, the + // cached record itself. + [Fact] + public void EntitiesDoNotShareDefaultEntitySettings() + { + using var cache = Open(); + var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var maker = cache.RefOf(cache.GetByName("Maker")); + var hullData = cache.GetByName("Skiff"); + var template = items.GameplaySettings.DefaultEntitySettings; + var originalThreshold = template.ShutdownPerformance; + + Ship BuildShip() => new Ship(items, null, (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)), template); + + var shipA = BuildShip(); + var shipB = BuildShip(); + + shipA.Settings.ShutdownPerformance = originalThreshold + 1; + + Assert.NotSame(template, shipA.Settings); + Assert.NotSame(shipA.Settings, shipB.Settings); + Assert.Equal(originalThreshold, shipB.Settings.ShutdownPerformance); + Assert.Equal(originalThreshold, template.ShutdownPerformance); + } + // Everything available: for tests about placement and failure lists. private static Ship Build(ItemManager items, Loadout loadout, List failures) => Loadouts.Materialize(items, null, loadout, _ => true, failures); diff --git a/tests/Aetheria.Shared.Tests/PowerBusTests.cs b/tests/Aetheria.Shared.Tests/PowerBusTests.cs new file mode 100644 index 000000000..1fb04274d --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PowerBusTests.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Cut 3 (docs/stats-and-power-cut.md): the power bus replaces Entity.TryConsumeEnergy/CanConsumeEnergy. At HEAD +// every draw succeeded as long as one reactor was online, and any shortfall was silently taxed onto the reactor +// as heat (§0.5) -- an assertion pinning "demand beyond supply is refused" was unwritable against that code. +// This file pins PowerBus's three verification bullets from the cut map's Cut 3 section. Each rule has a +// matching mutation in tests/mutation_tests_stats_power_cut3.py. +// +// Cut 4 (docs/stats-and-power-cut.md, Cut 4) deleted this file's Entity.TrySpendCapacitorCharge atomicity test +// along with the method itself -- the named, temporary exception Cut 3 left standing for the four instant +// draws is closed; see InputCapacitorTests.cs and mutation_tests_stats_power_cut4.py instead. +public sealed class PowerBusTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-powerbus-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private static readonly int2 HullOrigin = new int2(0, 0); + + public PowerBusTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // A hull with a 5x5 fully-interior Shape: HullData.InteriorCells shrinks the border away, leaving a 3x3 + // (9-cell) interior -- enough room for several Tool-hardpoint items (Reactor/Capacitor/Drain all use Tool, + // which needs an interior cell of its own, unlike the Sensors-hardpoint fixtures elsewhere in this suite). + // Every gear item's heat-response bounds are authored wide around the hull's default 280-degree start + // temperature (MapEntity), so ThermalOnline is true without the test ever having to touch Entity.Temperature. + private CultCache OpenCatalog() + { + var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + // Mass must be nonzero: HullData.SpecificHeat defaults to 1 but Mass defaults to 0, and Entity.MapEntity + // divides by it to seed ThermalMass -- a zero ThermalMass turns UpdateTemperature's conduction average + // into a division by zero (NaN) the moment a real tick (ship.Update, not just UpdatePerformance) runs. + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ReactorData + { + Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) + } } + }); + cache.Upsert(new GearData + { + Name = "Capacitor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new CapacitorData { Capacity = Constant(1000), Efficiency = Constant(1) } } + }); + cache.Upsert(new GearData + { + Name = "Drain", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new EnergyDrawData { EnergyDraw = Constant(0), PerSecond = true } } + }); + cache.FlushAsync().Wait(); + return cache; + } + + private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + // Reactor generation (per tick, dt=1), starting capacitor charge, and one Drain's requested rate (per + // second). Equips in the given order -- opposite orders are how OppositeEquipOrderGetsIdenticalGrants pins + // the death of the old "whoever equipped first drains the capacitors first" hidden priority (§0.5). + private (Ship ship, ItemManager items, Reactor reactor, Capacitor capacitor) BuildShip( + CultCache cache, float reactorCharge, float startingCapacitorCharge, bool reactorFirst) + { + var ledger = new ProvenanceLedger(); + var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hull = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("Reactor"); + var capacitorData = cache.GetByName("Capacitor"); + ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); + + void EquipReactor() => Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); + void EquipCapacitor() => Assert.True(ship.TryEquip(Mint(cache, items, capacitorData))); + + if (reactorFirst) { EquipReactor(); EquipCapacitor(); } else { EquipCapacitor(); EquipReactor(); } + + zone.Entities.Add(ship); + ship.Activate(); + + var reactor = ship.GetBehavior(); + var capacitor = ship.GetBehavior(); + // Capacitor.Capacity defaults to 0 until its own Execute has run once (it clamps AddCharge to + // [0, Capacity]), so a zero-dt warm-up tick has to happen before seeding starting charge. + ship.Update(0f); + if (startingCapacitorCharge > 0) capacitor.AddCharge(startingCapacitorCharge); + return (ship, items, reactor, capacitor); + } + + // TryEquip refuses while the entity is active (Entity.cs), so a Drain added after BuildShip's own Activate() + // needs a deactivate/equip/reactivate round trip -- harmless here: nothing this fixture cares about (charge, + // durability) is reset by Deactivate/Activate, only the hostility/subscription bookkeeping neither test uses. + private EquippedItem EquipDrain(CultCache cache, Ship ship, float rate) + { + // Each Drain item needs its own catalog record and PerformanceStat instance -- the "Drain" record from + // OpenCatalog is shared and evaluated by generation-cached resolver entries keyed on it, so two Drains + // sharing one mutated PerformanceStat would fight over the same rate. + var perItemData = cache.Upsert(new GearData + { + Name = "Drain " + Guid.NewGuid(), Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new EnergyDrawData { EnergyDraw = Constant(rate), PerSecond = true } } + }); + cache.FlushAsync().Wait(); + ship.Deactivate(); + Assert.True(ship.TryEquip(Mint(cache, ship.ItemManager, cache.Get(perItemData)))); + ship.Activate(); + return ship.Equipment.Last(); + } + + // --- Cut 3 verification bullet 1: "a ship whose requests exceed generation gets a total grant equal to + // --- generation plus available capacitor charge, and not more." At HEAD this assertion was unwritable + // --- because every draw succeeded (Entity.TryConsumeEnergy always found an online reactor to bill). --- + [Fact] + public void GrantIsCappedAtGenerationPlusStoredCapacitorCharge() + { + using var cache = OpenCatalog(); + var (ship, _, _, capacitor) = BuildShip(cache, reactorCharge: 10, startingCapacitorCharge: 5, reactorFirst: true); + EquipDrain(cache, ship, rate: 100); + + ship.Update(1f); + + Assert.Equal(15f, ship.PowerBus.TotalGrant, 3); // 10 generation + 5 stored charge, not 100 + Assert.Equal(.15f, ship.PowerBus.GrantRatio, 3); + Assert.Equal(0f, capacitor.Charge, 3); // the whole stored reserve was spent covering the gap + } + + // --- Cut 3 verification bullet 2: "a refused request produces no effect at all -- not a partial one, and + // --- not an overload-heat receipt." Zero generation and zero stored charge: the bus must not finance any of + // --- the demand, the exact HEAD bug (§0.5: "every draw succeeds while one reactor is online"). Map's own + // --- mutation: "make the bus grant the full request and heat the reactor; it goes red." --- + [Fact] + public void ZeroSupplyRefusesTheWholeRequest() + { + using var cache = OpenCatalog(); + var (ship, _, _, _) = BuildShip(cache, reactorCharge: 0, startingCapacitorCharge: 0, reactorFirst: true); + EquipDrain(cache, ship, rate: 50); + + ship.Update(1f); + + Assert.Equal(0f, ship.PowerBus.TotalGrant, 3); + Assert.Equal(0f, ship.PowerBus.GrantRatio, 3); + } + + // --- Cut 3 verification bullet 3: "two ships with identical loadouts fitted in opposite equip order get + // --- identical grants. That is the death of the hidden priority, and it fails at HEAD." At HEAD, + // --- EquippedItem.SortPosition (set only by Reactor) decided who drained the capacitors first (§0.5); here + // --- every consuming item shares the bus's one GrantRatio regardless of equip order, so the two ships (and + // --- the two consumers on either) can never diverge by position. --- + [Fact] + public void OppositeEquipOrderGetsIdenticalGrants() + { + using var cacheA = OpenCatalog(); + var (shipA, _, _, _) = BuildShip(cacheA, reactorCharge: 10, startingCapacitorCharge: 0, reactorFirst: true); + EquipDrain(cacheA, shipA, rate: 40); + + using var cacheB = OpenCatalog(); + var (shipB, _, _, _) = BuildShip(cacheB, reactorCharge: 10, startingCapacitorCharge: 0, reactorFirst: false); + EquipDrain(cacheB, shipB, rate: 40); + + shipA.Update(1f); + shipB.Update(1f); + + Assert.Equal(shipA.PowerBus.GrantRatio, shipB.PowerBus.GrantRatio, 5); + Assert.Equal(shipA.PowerBus.TotalGrant, shipB.PowerBus.TotalGrant, 5); + } + + // The same invariant bullet 3 rests on, pinned directly: with two consumers sharing one shortfall, BOTH read + // back the identical GrantRatio (never "first equipped gets its full request, second one starves"), which is + // what makes equip order unable to matter in the first place. + [Fact] + public void EveryConsumingItemReadsTheSameGrantRatio() + { + using var cache = OpenCatalog(); + var (ship, _, _, _) = BuildShip(cache, reactorCharge: 10, startingCapacitorCharge: 0, reactorFirst: true); + var drainOne = EquipDrain(cache, ship, rate: 30); + var drainTwo = EquipDrain(cache, ship, rate: 30); + + ship.Update(1f); + + Assert.True(ship.PowerBus.GrantRatio < 1f); // a real shortfall: 60 requested against 10 generated + Assert.Equal(ship.PowerBus.GrantRatio, drainOne.PowerSupply, 5); + Assert.Equal(ship.PowerBus.GrantRatio, drainTwo.PowerSupply, 5); + } + + // --- F2 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "one predicate, both directions." Step used to + // --- collect every equipped IPowerConsumer's request unconditionally, but Entity.Update only ever runs a + // --- behaviour's Execute when the item is Active (Enabled && Online) -- so a consumer the player switched + // --- off (Enabled = false) never executes, never spends anything, and yet was still billed here, starving + // --- every live consumer and draining the capacitors for energy nobody spent. A disabled consumer must + // --- contribute nothing to TotalDemand at all. --- + [Fact] + public void DisabledConsumerContributesNoDemand() + { + using var cache = OpenCatalog(); + var (ship, _, _, capacitor) = BuildShip(cache, reactorCharge: 0, startingCapacitorCharge: 100, reactorFirst: true); + var drain = EquipDrain(cache, ship, rate: 50); + drain.Enabled.Value = false; + Assert.False(drain.Active.Value); + + ship.Update(1f); + + Assert.Equal(0f, ship.PowerBus.TotalDemand, 3); + Assert.Equal(100f, capacitor.Charge, 3); // nothing was spent on a request nobody will ever execute + } + + // --- The other direction of the same rule: PowerBus.Step gated a reactor on Item.Online alone, but + // --- Reactor.Execute (the behaviour that actually produces the heat receipt) only runs when Item.Active -- + // --- Enabled && Online -- is true. A reactor the player switched off stays Online (thermal/durability are + // --- fine) but is not Active, so it must not generate here either, or it supplies the whole ship for free + // --- with no heat cost at all. --- + [Fact] + public void DisabledReactorGeneratesNoPower() + { + using var cache = OpenCatalog(); + var (ship, _, reactor, _) = BuildShip(cache, reactorCharge: 100, startingCapacitorCharge: 0, reactorFirst: true); + reactor.Item.Enabled.Value = false; + Assert.True(reactor.Item.Online.Value); + Assert.False(reactor.Item.Active.Value); + EquipDrain(cache, ship, rate: 100); + + ship.Update(1f); + + Assert.Equal(0f, ship.PowerBus.TotalGeneration, 3); + Assert.Equal(0f, ship.PowerBus.TotalGrant, 3); + } +} diff --git a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs new file mode 100644 index 000000000..1a5a1dfd2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs @@ -0,0 +1,417 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Cut 6 (docs/stats-and-power-cut.md): "the power-supply term and the request-independence rule." Purely +// additive -- StatSource.PowerSupply already existed as an identity everywhere (Cut 2), and the bus already wrote +// EquippedItem.PowerSupply (Cut 3/5). This file pins the two things Cut 6 actually adds: a stat that declares a +// PowerSupply term now really degrades as the grant falls, and a stat that decides a power request can never +// have its OWN evaluation depend on that same term. Each rule has a matching mutation in +// tests/mutation_tests_stats_power_cut6.py. +// +// Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19) narrowed the second half: a +// PowerRequest implementation now reads a request field through EquippedItem.EvaluateNominalPower (Entity.cs), +// which pins that stat's own PowerSupplyFactor to 1 -- so a direct PowerSupply term on a request field can no +// longer make the request depend on its own answer, and StatValidation.ValidateNoPowerSupplyOnRequest (the +// static check that used to refuse it) is deleted; the "OpenAccepts.../UpsertAccepts..." tests below pin that. +// What EvaluateNominalPower does NOT pin is ScaleModifier/ConstantModifier (forwarded to the item's real, +// non-nominal resolver entries, same as ConditionRatio's own NominalContext) -- a modifier CHAIN that reaches a +// power-tainted magnitude stat still corrupts a nominal read too, and that half +// (StatModifier.ValidateNoPowerSupplyChain) is unchanged; ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest +// below still pins it. +public sealed class PowerCurveTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-powercurve-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private static readonly int2 HardpointCell = new int2(0, 0); + private static readonly int2 SecondHardpointCell = new int2(1, 0); + + public PowerCurveTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + // Mirrors PowerBusTests/PowerTierTests' own fixture: a 5x5 hull (a real interior for Tool-hardpoint items), + // wide heat bounds around the default start temperature so ThermalOnline never has to be driven separately, + // and a Reactor whose Charge each test sets directly (dt=1 in every Update call here, so reactorCharge reads + // straight as this tick's generation, same as PowerBusTests). The consumer is an EnergyDrawData -- exactly + // PowerBusTests' own "Drain" -- rather than Thruster: Thruster's own request is gated on its Axis, which Ship + // itself recomputes from MovementDirection every tick (Ship.cs, its own thruster-axis pass), so an axis set + // directly on the behaviour is clobbered before PowerBus.Step ever reads it. EnergyDraw's request is a plain + // authored constant, with nothing else deciding it -- deterministic demand is the whole point of this + // fixture, not thruster flight. curveStat is a second PerformanceStat on the same item (CapacitorData's own + // Capacity, which no IPowerConsumer ever requests through) declaring the PowerSupply term under test: reading + // it through the item's Evaluate proves the resolver's own curve, independent of what any particular + // behaviour does with a partial grant. + private CultCache OpenCatalog(PerformanceStat curveStat) + { + var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ReactorData + { + Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) + } } + }); + cache.Upsert(new GearData + { + Name = "Engine", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = + { + new EnergyDrawData { EnergyDraw = Constant(100), PerSecond = true }, + new CapacitorData { Capacity = curveStat, Efficiency = Constant(1) } + } + }); + cache.FlushAsync().Wait(); + return cache; + } + + private Ship BuildShip(CultCache cache, float reactorCharge) + { + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hull = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("Reactor"); + ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); + Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Engine")))); + + zone.Entities.Add(ship); + ship.Activate(); + return ship; + } + + // --- Cut 6 verification bullet 1: "a thruster at half grant produces less thrust, by the authored exponent" -- + // --- generalised to any PowerSupply-termed stat, since the resolver does not care which behaviour declared + // --- it. curveStat's exponent-2 term must degrade to pow(.5, 2) == .25 of its range, not the linear .5 a + // --- careless implementation (or a mutation that drops the exponent) would produce. + // --- + // --- Reads the stat once at full grant BEFORE dropping the reactor's own charge, so the resolver has a real + // --- cached (generation-stamped) value in hand when the grant actually changes -- a fixture that only ever + // --- reads once, after the fact, can never tell "recomputed correctly" from "never cached, so it happened to + // --- read fresh": the missing invalidate half of this cut's own wiring (PowerBus.AllocateTiers) only shows up + // --- against a stat that was already cached under the old grant. --- + [Fact] + public void APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent() + { + var curve = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 2 } } }; + using var cache = OpenCatalog(curve); + var ship = BuildShip(cache, reactorCharge: 1000); // over-supplied: ratio 1, establishes a real cache entry + + ship.Update(1f); + var engine = ship.Equipment.Single(e => e.Data.Name == "Engine"); + Assert.Equal(1f, engine.PowerSupply, 3); + Assert.Equal(100f, engine.Evaluate(curve), 3); // cached at full grant + + var reactor = (ReactorData) ship.Equipment.Single(e => e.Data.Name == "Reactor").Data.Behaviors[0]; + reactor.Charge = Constant(50); // request 100, generation 50 -> ratio .5 + ship.Update(1f); + + Assert.Equal(100f, ship.PowerBus.TotalDemand, 3); + Assert.Equal(.5f, engine.PowerSupply, 3); + Assert.Equal(25f, engine.Evaluate(curve), 2); // lerp(0, 100, pow(.5, 2)) == 25, not the stale 100 + } + + // --- The other half of the same rule, named explicitly in the ruling ("unchanged at full supply"): a + // --- PowerSupply term is the identity at grant 1 regardless of its exponent -- pow(1, n) == 1 -- so an item + // --- that is never starved reads exactly as if it had never declared the term. --- + [Fact] + public void APowerSupplyTermedStatAtFullGrantIsUnaffectedByItsExponent() + { + var curve = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 5 } } }; + using var cache = OpenCatalog(curve); + var ship = BuildShip(cache, reactorCharge: 1000); // generation far exceeds the 100 request + + ship.Update(1f); + + var engine = ship.Equipment.Single(e => e.Data.Name == "Engine"); + Assert.Equal(1f, engine.PowerSupply, 3); + Assert.Equal(100f, engine.Evaluate(curve), 3); + } + + // --- F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): "power supply multiplies, it does not + // --- interpolate." Before this fix PowerSupply was one more term feeding PerformanceStat's Min/Max lerp, so + // --- zero supply bottomed out at Min, not 0 -- Soul measured a radiator still pumping 19-25% of full heat at + // --- zero power. Min here is 1000 (nonzero), so lerp(1000, 4000, pow(0, 2)) would read 1000 under the old + // --- code; the multiplier form must read exactly 0 regardless of Min. --- + [Fact] + public void APowerSupplyTermedStatWithNonzeroMinResolvesToExactlyZeroAtZeroSupply() + { + var curve = new PerformanceStat { Min = 1000, Max = 4000, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 2 } } }; + using var cache = OpenCatalog(curve); + var ship = BuildShip(cache, reactorCharge: 0); // no generation, no stored charge -> demand of 100 is unmet + + ship.Update(1f); + + var engine = ship.Equipment.Single(e => e.Data.Name == "Engine"); + Assert.Equal(0f, engine.PowerSupply, 4); + Assert.Equal(0f, engine.Evaluate(curve), 3); // NOT 1000 (Min) -- the old lerp-based bug's exact number + } + + // --- The ruling's other named consequence: "a stat whose Min equals its Max still responds" -- three of Cut + // --- 7's eight authored terms were inert for exactly this reason, because lerp(x, x, anything) == x. The + // --- multiplier form has no interpolation to degenerate, so it must still move. --- + [Fact] + public void AMinEqualsMaxStatStillRespondsToItsPowerSupplyTerm() + { + var curve = new PerformanceStat { Min = 2500, Max = 2500, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using var cacheStarved = OpenCatalog(curve); + var starved = BuildShip(cacheStarved, reactorCharge: 0); + starved.Update(1f); + var starvedEngine = starved.Equipment.Single(e => e.Data.Name == "Engine"); + + var curve2 = new PerformanceStat { Min = 2500, Max = 2500, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using var cacheFed = OpenCatalog(curve2); + var fed = BuildShip(cacheFed, reactorCharge: 1000); + fed.Update(1f); + var fedEngine = fed.Equipment.Single(e => e.Data.Name == "Engine"); + + Assert.Equal(0f, starvedEngine.Evaluate(curve), 3); // was inert (always 2500) before this fix + Assert.Equal(2500f, fedEngine.Evaluate(curve2), 3); + Assert.NotEqual(fedEngine.Evaluate(curve2), starvedEngine.Evaluate(curve)); + } + + // A small, deliberately fake context (StatResolverTests' own pattern) so the "no cost when undeclared" rule + // is provable without any catalog, entity or item plumbing -- and so it can count exactly how many times the + // resolver ever asked for the power-supply factor. + private sealed class CountingContext : IStatContext + { + public Lot Lot { get; set; } + public int PowerSupplyFactorCalls; + public int HeatFactorCalls; + public float HeatFactor(float exponent) { HeatFactorCalls++; return 1f; } + public float DurabilityFactor(float exponent) => 1f; + public float ConsumableProgressFactor(float exponent) => 1f; + public float PowerSupplyFactor(float exponent) { PowerSupplyFactorCalls++; return 1f; } + public float ScaleModifier(PerformanceStat stat) => 1f; + public float ConstantModifier(PerformanceStat stat) => 0f; + } + + // --- Cut 6 verification bullet 4 / the operator's own "don't pay that cost for every stat evaluation" as an + // --- assertion: a stat with no PowerSupply term never asks the context for one, at all, cache or no cache -- + // --- proving the term list genuinely is not walked for a source the stat never declared, not merely that the + // --- answer happens to be 1. --- + [Fact] + public void AStatWithNoPowerSupplyTermNeverAsksForOne() + { + var resolver = new StatResolver(); + var owner = new object(); + var context = new CountingContext(); + var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 } } }; + + resolver.Resolve(owner, stat, context); + resolver.InvalidateSource(owner, StatSource.PowerSupply); // the bus's own per-tick call, unconditional + resolver.Resolve(owner, stat, context); + + Assert.Equal(1, context.HeatFactorCalls); // recomputed once, for its own declared source + Assert.Equal(0, context.PowerSupplyFactorCalls); // never once asked -- this stat has no PowerSupply term + } + + // --- The same bullet's other half: a stat that DOES declare the term recomputes "at most once per tick," not + // --- once per read and not once per grant change. PowerBus invalidates PowerSupply unconditionally, exactly + // --- once per draw per Step (mirroring Heat/Durability's own "every tick, not only when the value moved" + // --- scheme) -- this pins that shape directly against the resolver, independent of PowerBus's own plumbing. + // --- --- + [Fact] + public void APowerTermedStatRecomputesAtMostOncePerTick() + { + var resolver = new StatResolver(); + var owner = new object(); + var context = new CountingContext(); + var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + + // Tick 1: the bus invalidates once; three reads inside the tick must share one evaluation. + resolver.InvalidateSource(owner, StatSource.PowerSupply); + resolver.Resolve(owner, stat, context); + resolver.Resolve(owner, stat, context); + resolver.Resolve(owner, stat, context); + Assert.Equal(1, context.PowerSupplyFactorCalls); + + // Tick 2: the bus invalidates again (PowerBus.AllocateTiers does this every tick, moved or not). + resolver.InvalidateSource(owner, StatSource.PowerSupply); + resolver.Resolve(owner, stat, context); + Assert.Equal(2, context.PowerSupplyFactorCalls); // exactly one more -- not zero, not three again + } + + // --- Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19), superseding Cut 6 + // --- verification bullet 2: a request field's own Terms naming PowerSupply directly used to be refused here + // --- (StatValidation.ValidateNoPowerSupplyOnRequest, now deleted). PowerRequest now reads every + // --- PowerRequestFields stat through EquippedItem.EvaluateNominalPower, which pins that stat's own + // --- PowerSupplyFactor to 1 regardless of its Terms, so the request can no longer depend on its own answer + // --- -- a direct term on a request field is legal again, and Open must accept it (round-tripping through the + // --- raw UpsertAsync, bypassing CultRecordRefs.Upsert's own validation, the same reach-disk pattern the + // --- refusal version of this test used). --- + [Fact] + public void OpenAcceptsAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm() + { + var goodEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.UpsertAsync(new GearData + { + Name = "CurvedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ThrusterData { EnergyUsage = goodEnergy } } + }).Wait(); + cache.FlushAsync().Wait(); + } + + using var reopened = AetheriaStores.Open(Catalog); + Assert.NotNull(reopened.GetByName("CurvedThruster")); + } + + // The same acceptance at Upsert (the write path every tool, test and migration script actually goes through). + [Fact] + public void UpsertAcceptsAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm() + { + var goodEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + + cache.Upsert(new GearData + { + Name = "CurvedThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ThrusterData { EnergyUsage = goodEnergy } } + }); + Assert.NotNull(cache.GetByName("CurvedThruster")); + } + + // --- F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) named PumpedHeat as a Radiator request field + // --- (PowerRequest's own early-out gate reads it, the exact shape Soul reproduced against the shipped + // --- catalog's "OK Disperser": SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates) and, at the time, + // --- refused a PowerSupply term there for exactly that reason. The nominal-request ruling above is what + // --- actually fixes the oscillation (the request no longer reads the live grant at all), so this shape is + // --- accepted now -- the six shipped records this ruling exists to make legal again include exactly this + // --- one (RadiatorData.PumpedHeat). BrownoutTests' own RunRadiator fixture exercises the resulting behaviour + // --- end to end; this pins acceptance at the catalog-write boundary. --- + [Fact] + public void UpsertAcceptsARadiatorWhosePumpedHeatCarriesAPowerSupplyTerm() + { + var curvedPumpedHeat = new PerformanceStat { Min = 1000, Max = 4000, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + + cache.Upsert(new GearData + { + Name = "CurvedRadiator", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new RadiatorData + { + PumpedHeat = curvedPumpedHeat, WasteHeat = Constant(100), EnergyUsage = Constant(6), + Emissivity = Constant(.5f), ThermalMass = Constant(100), TemperatureFloor = 0 + } } + }); + Assert.NotNull(cache.GetByName("CurvedRadiator")); + } + + // --- The same acceptance on the other consumer the original refusal named by field: Shield.PowerRequest + // --- calls RefreshReserve, which reads RefillDuration (or RestoreDuration while broken) to size the + // --- reserve's own fill rate -- now read through EvaluateNominalPower, so a PowerSupply term there no + // --- longer makes the reserve's own refill speed depend on how much of it was already granted. --- + [Fact] + public void UpsertAcceptsAShieldWhoseRefillDurationCarriesAPowerSupplyTerm() + { + var curvedRefillDuration = new PerformanceStat { Min = 2, Max = 2, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + + cache.Upsert(new GearData + { + Name = "CurvedShield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ShieldData + { + Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(100), + RefillDuration = curvedRefillDuration, RestoreDuration = Constant(5) + } } + }); + Assert.NotNull(cache.GetByName("CurvedShield")); + } + + // --- Cut 6 verification bullet 3: "the same refusal through a modifier chain, at equip time, naming both + // --- items." Drain's own EnergyDraw declares no PowerSupply term (so Upsert/Open both accept it on its own), + // --- but Booster's StatModifier targets it with a magnitude that DOES carry one -- the request would inherit + // --- the dependency the moment the modifier attaches. This is dynamic (StatValidation's static check has no + // --- entity to resolve the modifier's target against), so it is refused at Activate, mirroring + // --- StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip's own equip-time pattern. --- + [Fact] + public void ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest() + { + var request = new PerformanceStat { Min = 10, Max = 10 }; // Drain's own request: no PowerSupply term + var magnitude = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } }; + var modifier = new StatModifierData + { + Stat = new StatReference { Target = nameof(EnergyDrawData), Stat = nameof(EnergyDrawData.EnergyDraw) }, + Modifier = magnitude, + Type = StatModifierType.Constant + }; + + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(3, 3); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, + Hardpoints = + { + new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() }, + new HardpointData { Type = HardpointType.Sensors, Position = SecondHardpointCell, Shape = new Shape() } + } + }); + cache.Upsert(new GearData + { + Name = "Drain", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new EnergyDrawData { EnergyDraw = request, PerSecond = true } } + }); + cache.Upsert(new GearData + { + Name = "Booster", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { modifier } + }); + cache.FlushAsync().Wait(); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hullItem = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hullItem, new EntitySettings()); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Drain")), HardpointCell)); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Booster")), SecondHardpointCell)); + zone.Entities.Add(ship); + + var error = Assert.Throws(() => ship.Activate()); + Assert.Contains("Drain", error.Message); // the item whose request would be corrupted + Assert.Contains("Booster", error.Message); // the item whose modifier would corrupt it + } +} diff --git a/tests/Aetheria.Shared.Tests/PowerTierTests.cs b/tests/Aetheria.Shared.Tests/PowerTierTests.cs new file mode 100644 index 000000000..50f16b530 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PowerTierTests.cs @@ -0,0 +1,280 @@ +using System; +using System.IO; +using System.Linq; +using Xunit; +using CultMath; +using GameCult.Caching; + +// Cut 5 (docs/stats-and-power-cut.md §1.3): replaces PowerBus's single shared GrantRatio with priority tiers +// (PowerTiers.cs). This file pins Cut 5's own verification bullets plus the "rules worth pinning" the cut brief +// names: critical fed in full while a lower tier starves; two items in one tier rationed proportionally +// regardless of equip order; a tier with no demand passes its share down; reactor throttling changes which +// tiers starve; and a tier boundary does not leak. Each rule has a matching mutation in +// tests/mutation_tests_stats_power_cut5.py. +// +// Fixture shape mirrors PowerBusTests.OpenCatalog/BuildShip (see its comments for why: a 3x3 interior hull, wide +// heat bounds, a Reactor whose Charge each test sets directly). EquipDrain here takes an explicit tier so a test +// can put two Drains in different tiers without waiting on -- or fighting -- EnergyDraw's own kind default +// (PowerTiers.Utility for both), which would otherwise put every Drain in this fixture in the same tier. +public sealed class PowerTierTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-powertier-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public PowerTierTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private CultCache OpenCatalog() + { + var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ReactorData + { + Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) + } } + }); + cache.FlushAsync().Wait(); + return cache; + } + + private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List() }) + }; + + private (Ship ship, ItemManager items, Reactor reactor) BuildShip(CultCache cache, float reactorCharge) + { + var ledger = new ProvenanceLedger(); + var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hull = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("Reactor"); + ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); + Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); + + zone.Entities.Add(ship); + ship.Activate(); + + var reactor = ship.GetBehavior(); + ship.Update(0f); + return (ship, items, reactor); + } + + // Every Drain needs its own catalog record and PerformanceStat instance (PowerBusTests.EquipDrain's own + // comment explains why: shared resolver entries keyed on the record would otherwise let two Drains fight + // over one mutated rate). The tier is set on the minted unit BEFORE equipping it, so EquippedItem's + // constructor -- which only ever seeds a kind default onto PowerTiers.Unassigned -- never overwrites it; + // this is exactly the "player choice, not a derivation" path §1.3 describes, just exercised directly instead + // of through a schematic-UI click. + private EquippedItem EquipDrain(CultCache cache, Ship ship, float rate, int tier) + { + var perItemData = cache.Upsert(new GearData + { + Name = "Drain " + Guid.NewGuid(), Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new EnergyDrawData { EnergyDraw = Constant(rate), PerSecond = true } } + }); + cache.FlushAsync().Wait(); + ship.Deactivate(); + var unit = Mint(cache, ship.ItemManager, cache.Get(perItemData)); + unit.PowerTier = tier; + Assert.True(ship.TryEquip(unit)); + ship.Activate(); + return ship.Equipment.Last(); + } + + // --- Cut 5 verification bullet 1 / "rules worth pinning" #1: "with generation short of total demand, tier 0 + // --- is satisfied in full before tier 1 receives anything." Generation (30) covers Critical's own demand + // --- (30) exactly, leaving nothing at all for Utility (50) -- the map's own "starves first" framing, pinned + // --- at its sharpest: full satisfaction for the fed tier, total starvation for the other. --- + [Fact] + public void CriticalTierIsFedInFullWhileLowerTierStarves() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 30); + var critical = EquipDrain(cache, ship, rate: 30, tier: PowerTiers.Critical); + var utility = EquipDrain(cache, ship, rate: 50, tier: PowerTiers.Utility); + + ship.Update(1f); + + Assert.Equal(1f, critical.PowerSupply, 3); + Assert.Equal(0f, utility.PowerSupply, 3); + Assert.Equal(1f, ship.PowerBus.TierGrantRatio[PowerTiers.Critical], 3); + Assert.Equal(0f, ship.PowerBus.TierGrantRatio[PowerTiers.Utility], 3); + } + + // --- "Rules worth pinning" #2: "two items in one tier are rationed proportionally regardless of equip + // --- order." Two Medium consumers, unequal requests (20 and 40), generation covering half the tier's total + // --- demand (30 of 60): both must land at the same .5 ratio, and their absolute grants (10 and 20) must + // --- still be proportional to their own requests, not equal to each other. --- + [Fact] + public void TwoConsumersInOneTierAreRationedProportionally() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 30); + var small = EquipDrain(cache, ship, rate: 20, tier: PowerTiers.Medium); + var large = EquipDrain(cache, ship, rate: 40, tier: PowerTiers.Medium); + + ship.Update(1f); + + Assert.Equal(.5f, small.PowerSupply, 3); + Assert.Equal(.5f, large.PowerSupply, 3); + Assert.Equal(10f, 20f * small.PowerSupply, 3); // the small request's absolute grant: half of 20 + Assert.Equal(20f, 40f * large.PowerSupply, 3); // the large request's absolute grant: half of 40, not equal to the small one's + } + + // --- "Rules worth pinning" #3: "a tier with no demand passes its share down." Only a Utility consumer is + // --- equipped -- Critical, High, Medium and Low all sit at zero demand -- under a shortfall (generation 10 + // --- against a request of 40). If an empty tier silently swallowed its share instead of passing it on, this + // --- ratio would come out far below the true .25 the single active tier is owed. --- + [Fact] + public void ATierWithNoDemandPassesItsShareDown() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 10); + var utility = EquipDrain(cache, ship, rate: 40, tier: PowerTiers.Utility); + + ship.Update(1f); + + Assert.Equal(.25f, utility.PowerSupply, 3); + Assert.Equal(.25f, ship.PowerBus.GrantRatio, 3); + // F3 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): an empty tier reports PowerBus.NoDemand (NaN), + // not the "fully met" signal 1f -- there was nothing here to satisfy, and its share still passed down + // (proven by utility's own .25 above, not by a since-corrected "1f" reading on the empty tiers). + for (var tier = 0; tier < PowerTiers.Utility; tier++) + Assert.True(float.IsNaN(ship.PowerBus.TierGrantRatio[tier]), $"tier {tier} should report NoDemand, read {ship.PowerBus.TierGrantRatio[tier]}"); + } + + // --- "Rules worth pinning" #4: "reactor throttling changes what is available and therefore which tiers + // --- starve." Same two-tier loadout (Critical 30, Utility 50) at two different reactor charges: a weak + // --- reactor starves Utility entirely (as CriticalTierIsFedInFullWhileLowerTierStarves already pins); a + // --- strong one (80, covering both) starves nothing. The player's own reactor-tier choice is what moves a + // --- subsystem across that line, exactly the ruling's "reactor throttling is a player choice." --- + [Fact] + public void ReactorThrottlingChangesWhichTiersStarve() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 80); + var critical = EquipDrain(cache, ship, rate: 30, tier: PowerTiers.Critical); + var utility = EquipDrain(cache, ship, rate: 50, tier: PowerTiers.Utility); + + ship.Update(1f); + + Assert.Equal(1f, critical.PowerSupply, 3); + Assert.Equal(1f, utility.PowerSupply, 3); // unlike the 30-charge case, generation now covers both in full + } + + // --- "Rules worth pinning" #5: "a tier boundary does not leak." Critical alone outweighs generation (demand + // --- 100 against 40), so Critical itself is starved (.4). A lower tier must still receive exactly zero -- + // --- not some leftover sliver reasoned from Critical's own shortfall -- because remaining hits 0 once + // --- Critical's grant is committed, before Utility is ever considered. --- + [Fact] + public void LowerTierGetsExactlyZeroWhenAHigherTierAloneStarves() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 40); + var critical = EquipDrain(cache, ship, rate: 100, tier: PowerTiers.Critical); + var utility = EquipDrain(cache, ship, rate: 50, tier: PowerTiers.Utility); + + ship.Update(1f); + + Assert.Equal(.4f, critical.PowerSupply, 3); + Assert.Equal(0f, utility.PowerSupply, 3); + } + + // --- Cut 5's own verification bullet: "a tier change on an equipped item takes effect on the next tick with + // --- no re-equip." §1.3 calls the tier "a stored player choice, not a derivation" -- this proves the store + // --- is live, not latched at equip time. A fixed Critical consumer (5) and generation of 15 leave 10 for a + // --- Utility mover requesting 20 (.5 ratio); moving the mover into Critical alongside the fixed one changes + // --- the tier's own total demand to 25 against the same 15 supply (.6 ratio) on the very next Step, with no + // --- unequip/re-equip round trip -- proof the read is live, not a value latched once at equip time. + [Fact] + public void ATierChangeOnAnEquippedItemTakesEffectNextTickWithNoReEquip() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 15); + EquipDrain(cache, ship, rate: 5, tier: PowerTiers.Critical); + var mover = EquipDrain(cache, ship, rate: 20, tier: PowerTiers.Utility); + + ship.Update(1f); + Assert.Equal(.5f, mover.PowerSupply, 3); + + mover.EquippableItem.PowerTier = PowerTiers.Critical; + ship.Update(1f); + + Assert.Equal(.6f, mover.PowerSupply, 3); + } + + // --- F3 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "a sub-epsilon demand is granted out of + // --- nothing." A tier whose real demand is genuinely tiny (5e-5, below the old 1e-4 free-pass threshold) is + // --- not empty -- it must be rationed at a total blackout exactly like any other tier: PowerSupply 0, not + // --- the old code's unconditional 1. --- + [Fact] + public void SubEpsilonDemandGetsNoGrantAtTotalBlackout() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 0); + var tiny = EquipDrain(cache, ship, rate: 5e-5f, tier: PowerTiers.Critical); + + ship.Update(1f); + + Assert.Equal(0f, ship.PowerBus.TotalGrant, 6); + Assert.Equal(0f, tiny.PowerSupply, 4); // NOT 1f -- there is no power to grant + } + + // --- The other half of the same bug: a higher tier's sub-epsilon demand must still be subtracted from + // --- `remaining`, or a lower tier is overfed by the sliver the higher tier was never charged for. Critical's + // --- own tiny request (5e-5) and Utility's real one (10) together exceed the 10 generated by exactly that + // --- sliver; the two tiers' actual delivered energy must never exceed what the ship made. --- + [Fact] + public void HigherTierSubEpsilonDemandIsStillSubtractedFromRemaining() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 10); + var critical = EquipDrain(cache, ship, rate: 5e-5f, tier: PowerTiers.Critical); + var utility = EquipDrain(cache, ship, rate: 10f, tier: PowerTiers.Utility); + + ship.Update(1f); + + var delivered = 5e-5f * critical.PowerSupply + 10f * utility.PowerSupply; + Assert.True(delivered <= ship.PowerBus.TotalGrant + 1e-6f, + $"delivered {delivered} against a total grant of {ship.PowerBus.TotalGrant}"); + } + + // --- The first named bug, directly: "an empty tier reports fully supplied." Only a Utility consumer is + // --- equipped and the ship is fully blacked out -- Critical must not read as 1f ("fine"), it must read as + // --- PowerBus.NoDemand, because there is nothing in Critical to be fine or starved. --- + [Fact] + public void EmptyTierAtBlackoutReportsNoDemandNotFullySupplied() + { + using var cache = OpenCatalog(); + var (ship, _, _) = BuildShip(cache, reactorCharge: 0); + EquipDrain(cache, ship, rate: 10f, tier: PowerTiers.Utility); + + ship.Update(1f); + + Assert.True(float.IsNaN(ship.PowerBus.TierGrantRatio[PowerTiers.Critical]), + $"empty Critical tier should report NoDemand at blackout, read {ship.PowerBus.TierGrantRatio[PowerTiers.Critical]}"); + Assert.Equal(0f, ship.PowerBus.TierGrantRatio[PowerTiers.Utility], 3); // Utility itself is genuinely starved + } +} diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs new file mode 100644 index 000000000..b0a125ddf --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -0,0 +1,902 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using Random = CultMath.Random; + +// Cut 1 (docs/locomotion-cut.md, "Restore the legacy thruster ship hulls to the live catalog"). Verifies the +// content restore-hulls landed, against the real shipped catalog, read-only: the hulls exist with the expected +// thruster hardpoint counts, every thruster hardpoint accepts a catalog thruster design through TryEquip at its +// own position, and the ship then moves and turns for a few ticks under MovementDirection/LookDirection on the +// pre-Cut-3 mixer -- proof of the restored content, not of a controller this cut does not touch. +public sealed class RestoredHullsTests +{ + // Soul's finding #1 (this cut's fix batch 3): a hand-set GameplaySettings fixture left thermal radiation and + // conduction at their bare-class defaults (1/1/1) against the shipped 3 / 1e-8 / 0.01 + // (Assets/Resources/Settings.asset), so equipped parts froze toward 0 K, performance collapsed to 0 from + // cold, and TorqueMultiplier defaulting to 0 on top of that made every thruster's yaw contribution silently + // zero (Ship.cs's mixer applies Torque*Thrust*TorqueMultiplier/Mass) -- velocity moved but Direction never + // turned, which looks exactly like a content defect instead of a fixture gap. AuthoredSettings + // (tools/AetherDb/AuthoredSettings.cs) is the same headless YAML reader tools/AetherDb already uses to + // inspect Settings.asset outside Unity; this loads the real GameplaySettings the same way instead of + // hand-copying its fields a second time. + private static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"); + + // Same composition FireControlCut7Tests.OpenReadOnlyRealCatalog uses: a registry scoped to the shipped + // assembly's own [CultDocument] types, so this test's own process-wide type registration never pollutes the + // real catalog's validation the way the shared, AppDomain-scanning CultCache() default would. + private static CultCache OpenReadOnlyRealCatalog(string catalogPath) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + var cache = new CultCache(registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes); + return cache; + } + + private static string FindRepoRoot() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) + return dir.FullName; + throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); + } + + private static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc")); + + // Shared tutorial-galaxy construction settings (Assets/Resources/Settings.asset's own values), factored out + // so every test that needs a real tutorial Galaxy -- not just EntranceZoneAlwaysGetsADockedStationAcrossSeeds + // -- builds it the same way instead of re-inlining the same ~20 fields. + private static ZoneGenerationSettings TutorialZoneSettings() => new ZoneGenerationSettings + { + PlanetSafetyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 2.5f, Constant = 0 }, + MassFloor = 1, SunMass = 5000, GasGiantMass = 1000, PlanetMass = 100, + SatellitePasses = 5, SatelliteCreationMassFloor = 100, SatelliteCreationProbability = .25f, + BinaryCreationProbability = .1f, RosetteProbability = .1f, + ZoneRadius = new ExponentialLerp { Exponent = 1.5f, Minimum = 1000, Maximum = 10000 }, + ZoneMass = new ExponentialLerp { Exponent = 1.5f, Minimum = 10000, Maximum = 500000 }, + SubZoneCount = new ExponentialLerp { Exponent = 1.5f, Minimum = 0, Maximum = 8 }, + ZoneBoundaryRadius = .9f, BeltProbability = .25f, BeltMassCeiling = 500, + AsteroidBeltWidth = new ExponentialCurve { Exponent = .666f, Multiplier = 5, Constant = 50 }, + AsteroidCount = new ExponentialCurve { Exponent = .5f, Multiplier = 1, Constant = 11 }, + AsteroidRotationSpeed = new ExponentialLerp { Exponent = 2, Minimum = .1f, Maximum = .5f }, + SunColorSaturation = .85f, SunSecondaryColorDistance = .33f, SunLightSaturation = .5f, SunFogTintSaturation = .55f, + GasGiantBandCount = new ExponentialLerp { Exponent = 2, Minimum = 5, Maximum = 8 }, + GasGiantBandColorSeparation = .25f, GasGiantBandAltColorChance = .25f, + GasGiantBandSaturation = new ExponentialLerp { Exponent = .5f, Minimum = 0, Maximum = .8f }, + GasGiantBandBrightness = new ExponentialLerp { Exponent = .25f, Minimum = .25f, Maximum = 1 }, + NameData = new string[0], + }; + + private static TutorialGenerationSettings TutorialGalaxySettings() => new TutorialGenerationSettings + { + ProtagonistFaction = "Miss", AntagonistFaction = "Zhe", BufferFaction = "Luc", + NeutralFactions = new[] { "Aero", "Finch" }, QuestFaction = "Adras", LinkDensity = .5f, ZoneCount = 64, + }; + + private static SectorBackgroundSettings TutorialBackgroundSettings() => new SectorBackgroundSettings + { + NoiseAmplitude = 1, NoiseOffset = .3f, NoiseGain = .7f, NoiseLacunarity = 2, NoiseFrequency = .1f, + NoisePosition = 536.5106f, CloudExponent = 10, CloudAmplitude = .01f, + }; + + private static NameGeneratorSettings TutorialNameSettings() => + new NameGeneratorSettings { NameGeneratorMinLength = 5, NameGeneratorMaxLength = 10, NameGeneratorOrder = 3 }; + + // A station's orbit is a copy of its source planet's own (Parent, Distance) (ZoneGenerator.CreateLagrangeOrbit), + // so it always matches that one planet. It matches MORE than one planet only when the source planet shares its + // distance with a sibling under the same parent -- exactly what ZoneGenerator's potentialLagrangePoints filter + // excludes. An entrance station whose orbit matches more than one planet orbit this way could therefore only + // have come from the widened candidate set, never from the ordinary (non-rosette) Lagrange selection. + // + // Soul's finding (this cut's fix batch 3): counting planet orbits as pack.Orbits.Count minus the + // OrbitalEntityPack count (the previous version of this method) over-counts whenever a station or turret + // loadout generator returns null -- CreateLagrangeOrbit already appended that orbit to pack.Orbits before the + // null check (ZoneGenerator.cs) skips adding the entity, so the subtraction no longer matches. Deriving the + // set from pack.Planets' own Orbit refs instead (Census.cs's own approach) sidesteps that entirely: every + // real planet, including an ordinary rosette member, names its own orbit directly. Only a rosette ROOT is + // excluded this way (it gets no BodyData/PlanetData entry at all), which is fine here -- a root never shares + // its own Parent's distance with itself, so it was never a sibling candidate for `orbit` in the first place. + private static bool IsRosetteMember(CultCache cache, ZonePack pack, OrbitData orbit) + { + if (!orbit.Parent.IsSet()) return false; + var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).ToArray(); + return planetOrbits.Count(o => o.Parent.IsSet() && o.Parent.Key.Equals(orbit.Parent.Key) && abs(o.Distance - orbit.Distance) < .1f) > 1; + } + + // ZoneGenerator.GenerateZone writes OrbitData/BodyData (run-store types, AetheriaStores.RunTypes), so a + // catalog-only cache has no home for them. A scratch run file, deleted after the seed that used it. + private static CultCache OpenReadOnlyRealCatalogWithScratchRun(string catalogPath, string runPath) + { + var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(t => t.GetCustomAttribute() != null)); + var cache = new CultCache(registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(runPath), AetheriaStores.RunTypes); + return cache; + } + + // Headless asset-reference rule: every hull's Prefab, every ThrusterData.ParticlesPrefab, and every + // non-empty Schematic names a GUID that actually exists under Assets/ (a .meta file carries it). Unity's + // EngineAssetCheck enforces this too, but only in-editor; this is the same check run headless against the + // shipped catalog, so a fabricated or dropped GUID fails a build that never opens Unity. Soul's own probe + // (commit message, this cut's fix batch) found Djinni.Prefab fabricated and all five restored thrusters' + // ParticlesPrefab null; this test is that probe, committed so it can be rerun and mutated. + [Fact] + public void EveryAssetReferenceResolvesToAMetaGuid() + { + var assetGuids = new HashSet(Directory.EnumerateFiles(Path.Combine(FindRepoRoot(), "Assets"), "*.meta", SearchOption.AllDirectories) + .Select(f => File.ReadLines(f).FirstOrDefault(l => l.StartsWith("guid: "))?.Substring("guid: ".Length).Trim()) + .Where(g => g != null)); + + using var cache = OpenCatalog(); + var missing = new List(); + foreach (var hull in cache.GetAll()) + if (!assetGuids.Contains(hull.Prefab ?? "")) + missing.Add($"{hull.Name}.Prefab = {hull.Prefab ?? "null"}"); + foreach (var design in cache.GetAll()) + { + foreach (var thruster in design.Behaviors.OfType()) + if (!assetGuids.Contains(thruster.ParticlesPrefab ?? "")) + missing.Add($"{design.Name}.ParticlesPrefab = {thruster.ParticlesPrefab ?? "null"}"); + if (!string.IsNullOrEmpty(design.Schematic) && !assetGuids.Contains(design.Schematic)) + missing.Add($"{design.Name}.Schematic = {design.Schematic}"); + } + + Assert.True(missing.Count == 0, "Dangling asset references:\n" + string.Join("\n", missing)); + } + + [Theory] + [InlineData("Longinus", 4)] + [InlineData("Djinni", 8)] + public void RestoredHullExistsWithExpectedThrusterHardpointCount(string name, int expectedThrusterHardpoints) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + Assert.NotNull(hull); + Assert.Equal(HullType.Ship, hull.HullType); + Assert.Equal(expectedThrusterHardpoints, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); + } + + [Theory] + [InlineData("Longinus")] + [InlineData("Djinni")] + public void EveryThrusterHardpointAcceptsACatalogThrusterAtItsOwnPosition(string name) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + var thrusterDesigns = cache.GetAll().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray(); + Assert.NotEmpty(thrusterDesigns); + + var settings = Settings(); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; + var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); + + var lot = 2; + foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) + { + var cells = hardpoint.Shape.Coordinates.Length; + var design = thrusterDesigns.FirstOrDefault(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == cells); + Assert.True(design != null, $"{name}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} has no fitting catalog thruster design (needs {cells} cells)."); + var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++ }; + Assert.True(ship.TryEquip(gearItem, hardpoint.Position), + $"{name}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} refused {design.Name} via TryEquip."); + } + } + + // "The ship then moves and turns... for a few ticks" (docs/locomotion-cut.md verification list). Equips + // every thruster hardpoint with a fitting design, then drives MovementDirection/LookDirection the same way + // ActionGameManager's player-input path does (docs' own consumer audit) and checks Entity.Velocity and + // Entity.Direction both move over a few ticks on the current (pre-Cut-3) mixer. + [Theory] + [InlineData("Longinus")] + [InlineData("Djinni")] + public void RestoredHullMovesAndTurnsUnderMovementAndLookDirection(string name) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + var thrusterDesigns = cache.GetAll().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray(); + + var settings = Settings(); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; + var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); + + var lot = 2; + foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster)) + { + var cells = hardpoint.Shape.Coordinates.Length; + var design = thrusterDesigns.First(d => d.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) && d.Shape.Coordinates.Length == cells); + var gearItem = new EquippableItem { Data = cache.RefOf(design), Durability = design.Durability, Lot = lot++ }; + Assert.True(ship.TryEquip(gearItem, hardpoint.Position)); + } + + // A reactor: Victoire (one of Cut 1's own restored thrusters, docs/locomotion-cut.md's own note on it) + // carries a real EnergyUsage draw unlike its four siblings, so an unpowered ship reads as though only + // some fitting thrusters produce thrust -- an artifact of which design TryEquip's own hardpoint search + // happens to land on, not of the restored content. Every hull here also has a Reactor hardpoint; a real + // ship always carries one, so this fixture should too. + var reactorHardpoint = hull.Hardpoints.FirstOrDefault(h => h.Type == HardpointType.Reactor); + if (reactorHardpoint != null) + { + var reactorDesign = cache.GetAll().First(g => g.Hardpoint == HardpointType.Reactor && + g.Shape.FitsWithin(reactorHardpoint.Shape, reactorHardpoint.Rotation, out _) && g.Shape.Coordinates.Length == reactorHardpoint.Shape.Coordinates.Length); + var reactorItem = new EquippableItem { Data = cache.RefOf(reactorDesign), Durability = reactorDesign.Durability, Lot = lot++ }; + Assert.True(ship.TryEquip(reactorItem, reactorHardpoint.Position)); + } + + zone.Entities.Add(ship); + ship.Position = float3.zero; + ship.Activate(); + zone.Update(0f); // warm-up: resolves equipment stats before the driven ticks read them + + var startVelocity = ship.Velocity; + var startDirection = ship.Direction; + + ship.MovementDirection = float2(0, 1); // full forward, same axis ActionGameManager.Input.Player.Move drives + ship.LookDirection = float3(1, 0, 0); // off-axis target heading, so the mixer's yaw thrusters have work to do + for (var i = 0; i < 10; i++) zone.Update(1f / 60f); + + Assert.NotEqual(startVelocity, ship.Velocity); + Assert.NotEqual(startDirection, ship.Direction); + } + + // Operator ruling, 2026-09-25 (docs/locomotion-cut.md, "Tutorial station"): "What we're testing is going to + // be the tutorial level, right? Let's drop a station in there." The tutorial's entrance zone always gets a + // generated faction station with a docking bay, deterministically -- not merely likely. This REPLACES + // TutorialZoneGenerationCanProduceADockedStation, which never used the tutorial construction path at all: it + // built an ad hoc single-zone SavedGame/Galaxy and asserted a docking bay showed up in at least one of 25 + // zone-name variants, which is a claim about the BACKGROUND station path in general, not about the entrance + // zone Galaxy.cs actually picks (lowest-influence, unowned -- the one case ZoneGenerator.cs's own stationCount + // roll produces 0 on essentially every seed, docs' own probe: 30 of 30). This test builds the real tutorial + // Galaxy exactly as Assets/Resources/Settings.asset configures it and ActionGameManager.StartGame -> + // PopulateLevel(CurrentGalaxy.Entrance) generates it, across seeds, and requires a docked station in every + // run. It must fail without ZoneGenerator's tutorial-entrance override. + // + // Also requires that the NORMAL case (a zone with a real Lagrange candidate, the common case across 25 seeds) + // seats its station on a genuine, non-rosette Lagrange orbit rather than the widened fallback -- the widened + // candidate set is for the rare zone with none, not a replacement for ordinary placement. + [Fact] + public void EntranceZoneAlwaysGetsADockedStationAcrossSeeds() + { + var zoneSettings = TutorialZoneSettings(); + var tutorialSettings = TutorialGalaxySettings(); + var background = TutorialBackgroundSettings(); + var names = TutorialNameSettings(); + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + + // Galaxy zone-graph generation has a pre-existing, seed-dependent flakiness of its own (a disconnected + // zone graph throws inside the Galaxy constructor on roughly 1 seed in 15, confirmed unrelated to this + // cut: it reproduces identically against the pre-fix-batch catalog). That failure belongs to Galaxy.cs, + // not to ZoneGenerator's entrance-station override this test is pinning, so it is skipped rather than + // asserted on; every seed whose Galaxy actually builds must still get a docked entrance station. + var succeeded = 0; + var sawGenuineLagrangeOrbit = false; + for (uint seed = 1; seed <= 25; seed++) + { + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-station-{Guid.NewGuid():N}.cc"); + try + { + // Galaxy mutates Faction.InfluenceDistance in place; a fresh catalog open per seed keeps runs independent. + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + Galaxy galaxy; + try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } + catch (Exception) { continue; } + succeeded++; + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var dockedStation = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + Assert.True(dockedStation != null, $"seed {seed}: the tutorial entrance zone generated no station with a docking bay."); + + if (!IsRosetteMember(cache, pack, cache.Get(dockedStation.Orbit))) + sawGenuineLagrangeOrbit = true; + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result."); + Assert.True(sawGenuineLagrangeOrbit, "none of the 25 seeds seated the entrance station on a genuine (non-rosette) Lagrange orbit."); + } + + // Cut 1 fix batch 3 (F6, N1): a per-seed strengthening of the "across seeds" test above. That test only + // requires a genuine Lagrange orbit at least once in 25 seeds; it says nothing about a seed where a genuine + // candidate DID exist but the station landed on the widened fallback anyway (which the widening condition's + // own isTutorialEntrance-and-empty-candidates guard should make impossible). This pins the normal case + // directly, seed by seed: whenever the entrance zone actually has a genuine (non-rosette) Lagrange candidate, + // its station must be seated on one. + [Fact] + public void EntranceStationSitsOnAGenuineLagrangeOrbitWheneverOneExists() + { + var zoneSettings = TutorialZoneSettings(); + var tutorialSettings = TutorialGalaxySettings(); + var background = TutorialBackgroundSettings(); + var names = TutorialNameSettings(); + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + + var succeeded = 0; + var sawGenuineCandidate = false; + for (uint seed = 1; seed <= 25; seed++) + { + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-normalcase-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + Galaxy galaxy; + try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } + catch (Exception) { continue; } + succeeded++; + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray(); + var hasGenuineCandidate = planetOrbits.Any(o => planetOrbits.Count(c => c.Parent.Key.Equals(o.Parent.Key) && abs(c.Distance - o.Distance) < .1f) == 1); + if (!hasGenuineCandidate) continue; + sawGenuineCandidate = true; + + var dockedStation = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + Assert.True(dockedStation != null, $"seed {seed}: the tutorial entrance zone generated no station with a docking bay."); + Assert.False(IsRosetteMember(cache, pack, cache.Get(dockedStation.Orbit)), + $"seed {seed}: a genuine Lagrange candidate existed, but the station sat on the widened (rosette) fallback anyway."); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + Assert.True(succeeded >= 15, $"only {succeeded} of 25 seeds produced a Galaxy at all; too few runs to trust this result."); + Assert.True(sawGenuineCandidate, "none of the 25 seeds had a genuine Lagrange candidate at all; too few runs to trust this result."); + } + + // Cut 1 fix batch 3 (F5): the throw ZoneGenerator.cs's own entrance-station override raises + // (ZoneGenerator.cs's "every planet in the zone is a parentless root" case, fixed in this same batch, F3) is + // reachable through authored settings, not just a hypothetical: a real seed-1 tutorial entrance with + // RosetteProbability 0 (no rosettes to widen into) and MassFloor far above every possible child mass (no + // satellite ever clears the floor, so every planet stays a parentless root) leaves the entrance with no + // candidate orbit at all, even after widening. + [Fact] + public void ThrowReachableWithAuthoredSettings() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-throw-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var zoneSettings = TutorialZoneSettings(); + zoneSettings.RosetteProbability = 0; + zoneSettings.MassFloor = 1e9f; + + Assert.Throws(() => + ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true)); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 3 (F6, N3): a multi-root, heavily-widened entrance via authored settings -- RosetteProbability + // 1 (every subzone becomes a rosette) and a fixed SubZoneCount of 8 (several independent rosette systems, each + // its own root, never each other's sibling) with satellite/binary creation turned off so as few non-rosette + // orbits form as this generator's own unconditional "captured planet" step (ZoneGenerator.cs's ExpandRosette + // caller always adds at least one) allows. Verified directly against seed 1's real entrance: this leaves 14 + // distinct parents among its planets, not the single root a boring system would have. Whatever candidate + // selection actually runs on a topology this fragmented -- ordinary or widened -- every resulting station + // orbit must still be a real, positioned orbit: a set Parent and a positive Distance, never the zero-distance, + // parentless orbit the pre-F4 fallback used to synthesize. + [Fact] + public void MultiRootEntranceStationOrbitsAreAlwaysParentedWithPositiveDistance() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-multiroot-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var zoneSettings = TutorialZoneSettings(); + zoneSettings.RosetteProbability = 1; + zoneSettings.SubZoneCount = new ExponentialLerp { Exponent = 1, Minimum = 8, Maximum = 8 }; + zoneSettings.SatelliteCreationProbability = 0; + zoneSettings.BinaryCreationProbability = 0; + + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var roots = pack.Planets.Select(p => cache.Get(p).Orbit) + .Select(cache.Get) + .Select(o => o.Parent.IsSet() ? o.Parent.Key.Value : "(root)") + .Distinct().Count(); + Assert.True(roots > 1, $"this fixture no longer produces a multi-root entrance (only {roots} distinct parent); it no longer tests the fragmented-topology case."); + + var stations = pack.Entities.OfType() + .Where(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station).ToArray(); + Assert.NotEmpty(stations); + foreach (var station in stations) + { + var orbit = cache.Get(station.Orbit); + Assert.True(orbit.Parent.IsSet(), "a station's orbit has no Parent."); + Assert.True(orbit.Distance > 0, $"a station's orbit Distance is not positive: {orbit.Distance}."); + } + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 3 (F6, N9): a station-offset pin. CreateLagrangeOrbit (ZoneGenerator.cs) builds the + // station's orbit as a COPY of its source planet's (Parent, Distance) but offsets Phase by + // PI/3 * sign(random...) -- a real angular displacement, not the same orbital slot. Must fail if that offset + // is ever dropped (a mutant that seats the station directly on the planet's own orbit object): the station's + // Phase must differ from every planet's Phase among those sharing its own (Parent, Distance), across seeds. + [Fact] + public void EntranceStationPhaseDiffersFromEveryPlanetAtItsOwnDistanceAcrossSeeds() + { + var zoneSettings = TutorialZoneSettings(); + var tutorialSettings = TutorialGalaxySettings(); + var background = TutorialBackgroundSettings(); + var names = TutorialNameSettings(); + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + + var checkedAny = false; + for (uint seed = 1; seed <= 25; seed++) + { + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-offset-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + Galaxy galaxy; + try { galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); } + catch (Exception) { continue; } + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + + var dockedStation = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + if (dockedStation == null) continue; + var stationOrbit = cache.Get(dockedStation.Orbit); + if (!stationOrbit.Parent.IsSet()) continue; + + var siblingPlanets = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)) + .Where(o => o.Parent.IsSet() && o.Parent.Key.Equals(stationOrbit.Parent.Key) && abs(o.Distance - stationOrbit.Distance) < .1f) + .ToArray(); + if (siblingPlanets.Length == 0) continue; + checkedAny = true; + + foreach (var planetOrbit in siblingPlanets) + Assert.True(abs(planetOrbit.Phase - stationOrbit.Phase) > 1e-3f, + $"seed {seed}: the docked station's Phase ({stationOrbit.Phase}) matches a planet's own Phase at the same (Parent, Distance) -- it is sitting on the planet's own orbital slot instead of an offset one."); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + Assert.True(checkedAny, "no seed produced a docked entrance station with an identifiable source planet at its own (Parent, Distance); too few runs to trust this result."); + } + + // Speed cap (docs/locomotion-cut.md Cut 1 verification): Longinus's restored VelocityLimitData caps it at + // the legacy TopSpeed, 100 (100..100) -- asserted as the literal from the legacy record rather than read + // back out of the same catalog value being tested, so a mutated TopSpeed (e.g. 1000) can't also move the + // bound this test checks against. + // + // Cut 1 fix batch 3 (Soul's finding #1): the comment this replaces claimed the run went "long enough to + // approach its drag/thrust balance well past 100" while forcing OverrideShutdown on the hull and both + // firing thrusters for the whole scenario -- a fixture compensating for RestoredHullsTests.Settings() (then + // hand-set, thermal radiation/conduction frozen at 1/1/1 against the shipped 3 / 1e-8 / 0.01) making every + // part freeze toward 0 K and shut down within about a second on real physics. Settings() now loads the real + // GameplaySettings (AuthoredSettings, above), so OverrideShutdown is no longer needed to keep the ship + // flying: a real, unforced Longinus holds thrust (and the hull's own VelocityLimit) running well past the + // window this test drives. + // + // Real settings also retired the old +-10 tolerance's premise. On real physics the mixer's own tick order + // (Ship.cs: a tick's thrust-driven velocity is checked against VelocityLimit's cap on the FOLLOWING tick) + // settles into a steady state at the cap plus one tick's own forward acceleration, not at the literal cap -- + // measured here at ~103.3 for this seed's generated loadout, not the ~110 an unconstrained +-10 tolerance + // would also accept. +4 keeps that real headroom on the ceiling side. The floor is tighter (-3): a + // materially nerfed cap (Soul's own catalog mutant, TopSpeed 100 -> 92) still produces the same kind of + // steady state, just a lower one (~95), which -3 catches but +-10 or a +4 ceiling would not. + // + // Must fail when VelocityLimitData is removed (docs' own probe: Longinus reaches 122 without it), when + // TopSpeed is mutated away from 100, and when VelocityLimit's own trigger check is loosened (a "+6 slack" + // mutant pushes this same steady state to ~109, over the +4 ceiling). + [Fact] + public void LonginusNeverExceedsItsRestoredTopSpeedUnderFullThrust() + { + const float LegacyTopSpeed = 100f; // Longinus's legacy-record TopSpeed (100..100) + + using var cache = OpenCatalog(); + var hull = cache.GetByName("Longinus"); + var settings = Settings(); + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + // ItemManager.Random defaults to a DateTime.Now-seeded instance; without pinning it here, the item + // qualities LoadoutGenerator/EntitySerializer roll through it (not through the `random` below, which + // only drives which designs get picked) would make this test's own measured speed non-deterministic. + items.Random = new Random(1u); + var random = new Random(1); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate == hull); + Assert.NotNull(pack); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var ship = (Ship) EntitySerializer.Unpack(items, zone, pack); + + zone.Entities.Add(ship); + ship.Position = float3.zero; + ship.Activate(); + zone.Update(0f); + + ship.MovementDirection = float2(0, 1); // full forward + ship.LookDirection = float3(ship.Direction.x, 0, ship.Direction.y); // straight line: hold the ship's own heading + + // Soul measured this loadout's forward thrusters crossing 386 K (thermal shutdown) around 7.3 s under + // real settings; 5 s keeps every tick inside the window where thrust runs continuously and unforced, so + // the cap -- not a coasting or shut-down ship -- is what the assertions below are pinning. + const int ticks = 60 * 5; + var lastSpeed = 0f; + for (var i = 0; i < ticks; i++) + { + zone.Update(1f / 60f); + var speed = length(ship.Velocity); + lastSpeed = speed; + Assert.True(speed <= LegacyTopSpeed + 4f, $"tick {i}: speed {speed} exceeded the legacy top speed {LegacyTopSpeed} by more than the real tick-order headroom."); + } + + Assert.True(lastSpeed >= LegacyTopSpeed - 3f, $"final speed {lastSpeed} fell too far below the legacy top speed {LegacyTopSpeed}; the cap looks nerfed."); + } + + // Loadout generation (docs/locomotion-cut.md Cut 1 verification): LoadoutGenerator, filtered to each + // restored hull, must fill every one of its thruster hardpoints -- the check that the restored designs and + // their FactionProductData/roles are authored correctly, not just present. + [Theory] + [InlineData("Longinus")] + [InlineData("Djinni")] + public void LoadoutGeneratorFillsEveryThrusterHardpointAcrossSeeds(string name) + { + using var cache = OpenCatalog(); + var hull = cache.GetByName(name); + var thrusterHardpoints = hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster).ToArray(); + var settings = Settings(); + + for (uint seed = 1; seed <= 10; seed++) + { + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var random = new Random(seed); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate == hull); + Assert.True(pack != null, $"{name}: seed {seed} produced no loadout."); + + foreach (var hardpoint in thrusterHardpoints) + { + var filled = pack.Equipment.Any(e => e.position.Equals(hardpoint.Position) && + (cache.Get(e.item.Data) as GearData)?.Hardpoint == HardpointType.Thruster); + Assert.True(filled, $"{name}: seed {seed}'s {hardpoint.Transform ?? hardpoint.Type.ToString()} hardpoint at {hardpoint.Position} got no thruster."); + } + } + } + + // Builds the real tutorial galaxy for `seed`, then re-targets its Entrance to `entranceZoneName` via the + // public SavedGame/SavedZone round trip (RunSave's own shape: Galaxy -> SavedZone[] -> Galaxy(cache, + // SavedGame, log)), without touching Galaxy's private Entrance backing field by reflection. Zone content + // (planets) is keyed off each zone's own Name/Position, not off which zone happens to be Entrance, so the + // re-targeted galaxy's chosen zone generates identically to the original -- letting a test pick, by name, any + // real zone from a real galaxy (e.g. one already known to have no Lagrange candidate) and make it the Entrance. + private static Galaxy BuildGalaxyWithForcedEntrance(CultCache cache, uint seed, string entranceZoneName) + { + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); + var target = galaxy.Zones.Single(z => z.Name == entranceZoneName); + + var factions = galaxy.Factions; + var savedZones = galaxy.Zones.Select(zone => new SavedZone + { + Name = zone.Name, + Position = zone.Position, + AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(), + Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(), + Contents = zone.PackedContents, + Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner), + }).ToArray(); + var keys = savedZones.Select((_, i) => new CultRecordKey($"restoredhullstests-forced-entrance-zone-{i}")).ToArray(); + cache.Commit(batch => { for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), savedZones[i], keys[i]); }); + + var game = new SavedGame + { + Zones = keys.Select(k => new CultRecordRef(k)).ToArray(), + Factions = factions.Select(f => cache.RefOf(f)).ToArray(), + HomeZones = galaxy.HomeZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)), + BossZones = galaxy.BossZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)), + Entrance = Array.IndexOf(galaxy.Zones, target), + Exit = -1, + Relationships = factions.Select(f => galaxy.FactionRelationships[f]).ToArray(), + DiscoveredZones = Array.Empty(), + Background = galaxy.Background, + IsTutorial = true, + }; + return new Galaxy(cache, game, _ => { }); + } + + // Cut 1 fix batch 2 (Soul's finding #2): the tutorial entrance's widened candidate set. Seed 3's real tutorial + // galaxy has a zone, "EAC-7089", whose planets are entirely one rosette with no captured satellites at all: 4 + // planets sharing one Distance under one Parent, and nothing else -- potentialLagrangePoints is empty for it + // (verified directly against this exact fixture, not carried over from Soul's own forced-fallback probe, whose + // own candidate check undercounted planet orbits by using pack.Planets.Count -- which excludes Empty rosette + // roots -- and so misidentified some zones, including its originally-reported "EAC-3208", as candidate-less + // when they were not). Re-targeted here to be the Entrance through BuildGalaxyWithForcedEntrance above. Its + // docked station can therefore only have come from the widened candidate set (every parented planet orbit, + // not just non-rosette ones), never from a synthesized, unparented fallback orbit. Pins: still docked, and + // turrets sit on a real orbit (a real Parent, a finite Phase). + // + // Cut 1 fix batch 3 (F7): the Distance check this comment used to describe as "near their station" was + // vacuous -- PlaceTurret (ZoneGenerator.cs) sets a turret orbit's Distance from `orbit.Distance` directly, + // unconditionally, the same as CreateLagrangeOrbit does for the station itself, so every turret's Distance + // equals the station's by construction regardless of placement; the check could never fail. What actually + // makes a turret "near" its station is Phase: PlaceTurrets' own `dist = t/2` gives each turret a small + // angular nudge (`20 * dist / orbit.Distance`) off the station's own Phase, not a placement anywhere else on + // the orbit. Replaced with a bound on that real offset, generous over PlaceTurrets' own formula. + [Fact] + public void TutorialEntranceWithNoLagrangeCandidateStillSeatsAStationWithFiniteNearbyTurrets() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-widened-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = BuildGalaxyWithForcedEntrance(cache, seed: 3u, "EAC-7089"); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, galaxy.Entrance, isTutorial: true); + + var station = pack.Entities.OfType() + .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); + Assert.True(station != null, "the known no-Lagrange-candidate entrance zone generated no docked station."); + var stationOrbit = cache.Get(station.Orbit); + + // Confirms this fixture still exercises the widened path (a regression here would mean the fixture no + // longer probes what this test claims to probe). + Assert.True(IsRosetteMember(cache, pack, stationOrbit), "the entrance station no longer sits on a rosette-only orbit -- this fixture no longer tests the widened candidate path."); + + var turrets = pack.Entities.OfType() + .Where(e => (cache.Get(e.Hull.Data) as HullData)?.HullType != HullType.Station).ToArray(); + Assert.NotEmpty(turrets); + // Generous over PlaceTurrets' own max |distanceMultiplier| ((turrets.Length - 1) / 2): a real bound, + // not the vacuous Distance-always-matches check this replaces. + var maxPhaseOffset = 20f * turrets.Length / stationOrbit.Distance; + foreach (var turret in turrets) + { + var turretOrbit = cache.Get(turret.Orbit); + Assert.True(turretOrbit.Parent.IsSet(), "a turret's orbit has no Parent."); + Assert.False(float.IsNaN(turretOrbit.Phase) || float.IsInfinity(turretOrbit.Phase), $"a turret's orbit Phase is not finite: {turretOrbit.Phase}."); + var phaseDelta = abs(turretOrbit.Phase - stationOrbit.Phase); + Assert.True(phaseDelta <= maxPhaseOffset, + $"a turret's Phase ({turretOrbit.Phase}) is not near the station's own Phase ({stationOrbit.Phase}): delta {phaseDelta} > {maxPhaseOffset}."); + } + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 2 (Soul's finding #2, leak check): the entrance override must not leak to (a) other + // tutorial zones or (b) a non-tutorial game's own entrance zone. Both are pinned against a seed-1 galaxy -- + // the widened-candidate test above uses a different fixture, seed 3's "EAC-7089" -- so this test and the one + // below it stand or fall on this one known fixture, not that one. + [Fact] + public void EntranceOverrideDoesNotLeakToOtherTutorialZonesOrNonTutorialGames() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-leak-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + int StationCount(GalaxyZone zone, bool isTutorial) => + ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, zone, isTutorial) + .Entities.OfType() + .Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); + + // (a) A non-entrance tutorial zone that naturally rolls zero stations keeps that roll under + // isTutorial: true -- the floor must not leak past the galaxy.Entrance reference check. + var otherZone = galaxy.Zones.Single(z => z.Name == "EAC-2733"); + Assert.NotSame(galaxy.Entrance, otherZone); + Assert.Equal(0, StationCount(otherZone, isTutorial: true)); + + // (b) The entrance zone itself, generated for a non-tutorial game (isTutorial: false), keeps its + // natural roll too -- the floor must not leak past the isTutorial flag either. + Assert.Equal(0, StationCount(galaxy.Entrance, isTutorial: false)); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 2 (Soul's finding #2, baseStationCount): the entrance override raises stationCount (the + // orbit-selection floor) but must not also raise the enemyCount roll, which is keyed to baseStationCount + // (the pre-override value) precisely so the forced station doesn't buy the zone an extra enemy. Seed 1's + // entrance zone rolls baseStationCount 0 (confirmed via the isTutorial: false StationCount == 0 above) and + // the override raises stationCount to 1; this pins the resulting ship count at the value baseStationCount + // produces. It must fail if the enemy-count formula is changed to add stationCount instead (it would add a + // ship instead of zero). + [Fact] + public void ForcedTutorialEntranceStationDoesNotInflateEnemyCount() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-enemycount-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, galaxy.Entrance, isTutorial: true); + var stationCount = pack.Entities.OfType().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); + // Soul's finding (this cut's fix batch 3): pack.Entities.OfType().Count() also counts the + // zone's neutral wanderers (ZoneGenerator.cs's own EligibleWandererFactions spawn, keyed to + // ZoneGenerationSettings.NeutralWandererCount -- 2 by TutorialZoneSettings' own default, unrelated to + // this fixture's own enemy roll), so the old assertion of exactly 2 was pinning the wanderer count, + // not the enemy count baseStationCount is supposed to gate. The nearest faction here is the same one + // ZoneGenerator computes internally (galaxy.HomeZones' own distance-to-zone ordering); every ShipPack + // names its faction directly (EntityPack.Faction), so filtering on it separates the two spawns. + var nearestFaction = galaxy.Factions.MinBy(f => galaxy.HomeZones[f].Distance[galaxy.Entrance]); + var enemyShipCount = pack.Entities.OfType().Count(s => cache.Get(s.Faction) == nearestFaction); + + Assert.Equal(1, stationCount); // the override did raise the station count (from a natural roll of 0) + Assert.Equal(0, enemyShipCount); // the enemy roll stayed keyed to the pre-override baseStationCount of 0 + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Cut 1 fix batch 3 (F6, N2): the entrance-only widening must not leak to a non-entrance zone that would + // otherwise get zero stations. Seed 3's "EAC-7089" (the same no-Lagrange fixture the widened-candidate test + // above uses, this time NOT retargeted as Entrance) rolls a real, nonzero station count on its own -- forced + // here by boosting one faction's presence there (HomeZone distance 0, InfluenceDistance 50, so the roll's + // floor(rand * (factionPresence+1)) is overwhelmingly likely nonzero) via the same public SavedGame round + // trip BuildGalaxyWithForcedEntrance uses, not by touching ZoneGenerator's private roll. Verified directly: + // temporarily widening every zone (dropping ZoneGenerator's isTutorialEntrance guard) turns this fixture's + // station count from 0 into 2, so a natural nonzero roll really is hiding behind the empty candidate set. + // Must fail if that guard is dropped for real. + private static Galaxy BuildGalaxyWithBoostedPresence(CultCache cache, uint seed, string zoneName) + { + var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), TutorialNameSettings(), + cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); + var target = galaxy.Zones.Single(z => z.Name == zoneName); + + var factions = galaxy.Factions; + var savedZones = galaxy.Zones.Select(zone => new SavedZone + { + Name = zone.Name, + Position = zone.Position, + AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(), + Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(), + Contents = zone.PackedContents, + Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner), + }).ToArray(); + var keys = savedZones.Select((_, i) => new CultRecordKey($"restoredhullstests-boosted-presence-zone-{i}")).ToArray(); + cache.Commit(batch => { for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), savedZones[i], keys[i]); }); + + var homeZones = galaxy.HomeZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)); + var boosted = Enumerable.Range(0, factions.Length).OrderByDescending(i => factions[i].InfluenceDistance).First(); + homeZones[boosted] = Array.IndexOf(galaxy.Zones, target); + factions[boosted].InfluenceDistance = 50; + + var game = new SavedGame + { + Zones = keys.Select(k => new CultRecordRef(k)).ToArray(), + Factions = factions.Select(f => cache.RefOf(f)).ToArray(), + HomeZones = homeZones, + BossZones = galaxy.BossZones.ToDictionary(x => Array.IndexOf(factions, x.Key), x => Array.IndexOf(galaxy.Zones, x.Value)), + Entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance), + Exit = -1, + Relationships = factions.Select(f => galaxy.FactionRelationships[f]).ToArray(), + DiscoveredZones = Array.Empty(), + Background = galaxy.Background, + IsTutorial = true, + }; + return new Galaxy(cache, game, _ => { }); + } + + [Fact] + public void WideningDoesNotLeakToANonEntranceNoLagrangeZoneThatRollsAStation() + { + var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"); + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-widen-leak-{Guid.NewGuid():N}.cc"); + try + { + using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); + var galaxy = BuildGalaxyWithBoostedPresence(cache, seed: 3u, "EAC-7089"); + var zone = galaxy.Zones.Single(z => z.Name == "EAC-7089"); + Assert.NotSame(galaxy.Entrance, zone); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, zone, isTutorial: true); + + // Confirms this fixture still has no genuine (non-rosette) Lagrange candidate -- a regression here + // would mean the fixture no longer probes what this test claims to probe. + var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray(); + var hasGenuineCandidate = planetOrbits.Any(o => planetOrbits.Count(c => c.Parent.Key.Equals(o.Parent.Key) && abs(c.Distance - o.Distance) < .1f) == 1); + Assert.False(hasGenuineCandidate, "the entrance-adjacent fixture zone now has a genuine Lagrange candidate; it no longer tests the no-Lagrange leak path."); + + var stationCount = pack.Entities.OfType().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); + Assert.Equal(0, stationCount); + } + finally + { + if (File.Exists(scratchRun)) File.Delete(scratchRun); + } + } + + // Soul's finding #3 (asset identity): the existence-only check (EveryAssetReferenceResolvesToAMetaGuid) passes + // even when Djinni.Prefab is swapped for Longinus's -- both are real GUIDs somewhere under Assets/. This pins + // the actual identity: each restored reference names the specific legacy-record file it is supposed to, not + // merely some meta file that happens to exist. Must fail under Soul's own "djprefab" mutation (Djinni.Prefab + // set to Longinus's GUID). + [Fact] + public void RestoredAssetReferencesNameTheirOwnLegacyFileNotJustAnyExistingGuid() + { + string GuidOf(string assetPathUnderRepoRoot) + { + var meta = Path.Combine(FindRepoRoot(), assetPathUnderRepoRoot + ".meta"); + return File.ReadLines(meta).First(l => l.StartsWith("guid: ")).Substring("guid: ".Length).Trim(); + } + + using var cache = OpenCatalog(); + + var djinniPrefabGuid = GuidOf("Assets/Content/Prefabs/Ships/Djinni.prefab"); + var djinni = cache.GetByName("Djinni"); + Assert.Equal(djinniPrefabGuid, djinni.Prefab); + + // Every restored thruster's particle effect is Thruster 1.prefab, by the legacy record (soul-dump.txt). + var thruster1Guid = GuidOf("Assets/Content/Prefabs/Thrusters/Thruster 1.prefab"); + var restoredThrusterNames = new[] { "deep space burnout", "Large Drive", "Victoire", "Small Drive", "Talaria", "RevvITup 2.0", "Medium Drive" }; + foreach (var name in restoredThrusterNames) + { + var design = cache.GetAll().First(g => g.Name == name); + var thrusterBehavior = design.Behaviors.OfType().Single(); + Assert.Equal(thruster1Guid, thrusterBehavior.ParticlesPrefab); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs b/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs new file mode 100644 index 000000000..0a17e2732 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Cut 7 (docs/stats-and-power-cut.md): designs declare roles, stats name the role they read, and products author +// per-role quality. Two rules pinned here that StatsReadTheLot (LoadoutTests.cs) and CreateLotFillsRolesFromProductSpread +// (LoadoutTests.cs) do not already cover: a stat naming a role its design lacks is refused at Upsert, and two +// products of one design with different role means resolve to different values through the same stat. +public sealed class RoleAuthoringTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-roles-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public RoleAuthoringTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + // The rule the ruling names directly: "validation already refuses a stat naming a role its design lacks." + // A term reading StatSource.Quality with Role "ghost" on a design that declares no roles at all must not + // reach disk -- silently falling back to Lot.Quality (Lot.QualityForRole's own behaviour for an unmatched + // role) would hide the authoring mistake instead of naming it. + [Fact] + public void UpsertRefusesAStatNamingARoleItsDesignLacks() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + var ghost = new GearData + { + Name = "Ghost Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, + Behaviors = + { + new EnergyDrawData + { + EnergyDraw = new PerformanceStat + { + Min = 0, Max = 1, + Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1, Role = "ghost" } } + } + } + } + }; + var ex = Assert.Throws(() => cache.Upsert(ghost)); + Assert.Contains("ghost", ex.Message); + } + + // The positive control for the same rule: a stat naming a role the design DOES declare must not throw -- + // otherwise UpsertRefusesAStatNamingARoleItsDesignLacks could be passing because Upsert refuses every + // role-bearing stat, not because it distinguishes declared from undeclared. + [Fact] + public void UpsertAcceptsAStatNamingADeclaredRole() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + var lensed = new GearData + { + Name = "Lensed Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, + Roles = { new ItemRole { Name = "lens" } }, + Behaviors = + { + new EnergyDrawData + { + EnergyDraw = new PerformanceStat + { + Min = 0, Max = 1, + Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1, Role = "lens" } } + } + } + } + }; + cache.Upsert(lensed); // must not throw + } + + // F4/Cut 7: "a market segment is a second product with a role raised" -- two products of the same design, + // differing only in one role's Mean, must mint lots that resolve a role-termed stat to different values. + // Zero StandardDeviation on both keeps the roll deterministic, so this cannot pass by coincidence. + [Fact] + public void TwoProductsOfOneDesignWithDifferentRoleMeansResolveDifferentValues() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + var makerRef = cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" }); + var lensGear = cache.Upsert(new GearData + { + Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, + Roles = { new ItemRole { Name = "lens" } } + }); + var cheapProduct = cache.Upsert(new FactionProductData + { + Name = "Lensed Economy", Design = new CultRecordRef(lensGear.Key), Manufacturer = makerRef, + Roles = { new ProductRole { Role = "lens", Mean = .3f, StandardDeviation = 0 } } + }); + var premiumProduct = cache.Upsert(new FactionProductData + { + Name = "Lensed Premium", Design = new CultRecordRef(lensGear.Key), Manufacturer = makerRef, + Roles = { new ProductRole { Role = "lens", Mean = .9f, StandardDeviation = 0 } } + }); + cache.FlushAsync().Wait(); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var cheapLot = items.CreateLot(cache.GetAll().Single(p => p.Name == "Lensed Economy")); + var premiumLot = items.CreateLot(cache.GetAll().Single(p => p.Name == "Lensed Premium")); + var cheapInstance = (EquippableItem) items.CreateInstance(cheapLot); + var premiumInstance = (EquippableItem) items.CreateInstance(premiumLot); + + var stat = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1, Role = "lens" } } }; + var cheapValue = items.Evaluate(stat, cheapInstance); + var premiumValue = items.Evaluate(stat, premiumInstance); + + Assert.Equal(.3f, cheapValue, 3); + Assert.Equal(.9f, premiumValue, 3); + Assert.NotEqual(cheapValue, premiumValue); + } +} diff --git a/tests/Aetheria.Shared.Tests/SafeAssert.cs b/tests/Aetheria.Shared.Tests/SafeAssert.cs new file mode 100644 index 000000000..2ab52d6bd --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SafeAssert.cs @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using Xunit; + +// Assertions over collections of entities and shots. xunit formats a collection's contents into its failure +// message, and an Entity, a PendingShot or a ShotOutcome drags the whole zone graph in with it: the formatter +// recurses until the test host dies of a stack overflow, which a mutation tool scores as a kill of every test +// that was running. A failure here must be an ordinary red naming the test, so these compare counts and +// identities and never hand xunit an entity. +internal static class SafeAssert +{ + public static void NoShots(Zone zone) => + Assert.True(zone.PendingShots.Count == 0, $"{zone.PendingShots.Count} shot(s) still pending"); + + public static PendingShot OnlyShot(Zone zone) + { + Assert.True(zone.PendingShots.Count == 1, $"expected exactly one pending shot, found {zone.PendingShots.Count}"); + return zone.PendingShots[0]; + } + + public static T Only(List list) + { + Assert.True(list.Count == 1, $"expected exactly one {typeof(T).Name}, found {list.Count}"); + return list[0]; + } + + public static void Empty(List list) => Assert.True(list.Count == 0, $"expected no {typeof(T).Name}, found {list.Count}"); + + public static void In(Zone zone, Entity entity, string why = "entity should be in the zone") => + Assert.True(zone.Entities.Contains(entity), why); + + public static void NotIn(Zone zone, Entity entity, string why = "entity should have left the zone") => + Assert.False(zone.Entities.Contains(entity), why); +} diff --git a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs new file mode 100644 index 000000000..9c3afb11b --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): supersedes Cut 4's +// derived reserve sizing. ShieldData now authors Capacity, RefillDuration and RestoreDuration directly; the +// shield gains a Broken state a hit it cannot fully cover falls into, rather than being silently refused with +// its charge intact. Two further operator rulings on the fork the first left open: the breaking hit passes +// through in full (no partial absorption -- TakeHit is never called for it), and the reserve empties to zero on +// break so the restore duration means the same thing every time regardless of how full the shield was when it +// broke. This file pins all of that; InputCapacitorTests.cs keeps pinning the surviving Cut 4 rules (a hit +// within the reserve may draw a partial amount; TrySpend/AddCharge atomicity) against the same class. Each rule +// here has a matching mutation in tests/mutation_tests_shield_reserve.py. +// +// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit no longer breaks the shield as a side +// effect of the query -- it did, on the false premise that every caller queries it exactly once per hit, but +// RaycastAll hands every real caller both the shield and the hull collider for one shot, so it was queried +// twice. Every test below that means to simulate a breaking hit now calls Shield.Break() explicitly, the same +// way Assets/Scripts/Gameplay/Weapons/* now does at the point it decides to route a hit past the shield. +public sealed class ShieldReserveTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-shieldreserve-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public ShieldReserveTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // Same fixture shape as InputCapacitorTests.OpenCatalog: a Reactor whose generation BuildShip sets per test, + // and a Shield authoring Capacity = 10, RefillDuration = 2 (Rate = 5/s while up), RestoreDuration = 5 + // (Rate = 2/s while broken) -- the two durations deliberately far apart so a test that used the wrong one + // would be caught well outside rounding noise. EnergyUsage = 1 keeps damage == reserve cost, so test + // arithmetic reads directly in damage units. + private CultCache OpenCatalog() + { + var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ReactorData + { + Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) + } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ShieldData + { + Efficiency = Constant(1), EnergyUsage = Constant(1), + Capacity = Constant(10), RefillDuration = Constant(2), RestoreDuration = Constant(5) + } } + }); + cache.FlushAsync().Wait(); + return cache; + } + + private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + private (Ship ship, Shield shield) BuildShipWithShield(CultCache cache, float reactorGeneration) + { + var ledger = new ProvenanceLedger(); + var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hull = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("Reactor"); + ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorGeneration); + Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); + + var shieldData = cache.GetByName("Shield"); + Assert.True(ship.TryEquip(Mint(cache, items, shieldData))); + + zone.Entities.Add(ship); + ship.Activate(); + + return (ship, ship.GetBehavior()); + } + + // Rule: a hit within the reserve is absorbed and spends charge. Capacity = 10, RefillDuration = 2 -> Rate = + // 5/s; a generous reactor (20/s) grants the full request every tick, so a 1s warm-up tick banks 5 charge. + // A 3-damage hit (cost 3, EnergyUsage = 1) is well inside that -- absorbed -- and spends charge rather than + // leaving it untouched: a second hit that needs more than the 2 left (2.5) is refused, proving the first + // hit actually drew the reserve down instead of being a free pass. + [Fact] + public void HitWithinReserveIsAbsorbedAndSpendsCharge() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // reserve Rate = 5/s, granted in full -> Charge = 5 + + Assert.True(shield.CanTakeHit(DamageType.Kinetic, 3f)); + shield.TakeHit(DamageType.Kinetic, 3f); // Charge: 5 -> 2 + + // Proves the hit actually drew the reserve down (not a free absorb that left Charge at 5): a second hit + // that would have fit inside the original 5 (4, with clear margin either side of the exact 2 left, to + // stay clear of float rounding at the boundary) no longer fits inside what remains. Per the ruling that + // any refusal is itself a break, Break() (the caller's own next step, F5) puts the shield into Broken. + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 4f)); + shield.Break(); + Assert.True(shield.Broken); + } + + // Rule: a hit beyond the reserve breaks the shield. + [Fact] + public void HitBeyondReserveBreaksTheShield() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + + Assert.False(shield.Broken); + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // exceeds the 5 held + shield.Break(); + Assert.True(shield.Broken); + } + + // Rule: the breaking hit passes through in full -- no partial absorption. Operator's second ruling on the + // fork the first left open: TakeHit must never be called for this hit at all (every caller treats + // CanTakeHit's false branch as "route the whole hit to the hull instead"), so the reserve must not have + // spent, or banked, any part of the 5 it held. Pinned by timing rather than a single large tick (a big + // enough single Update always tops up to Capacity regardless of where it started, because RequestedFill + // caps at Capacity-Charge and hides the difference): if TakeHit had run for the breaking hit, its own + // TrySpend is atomic and cost (6) exceeds the charge held (5), so TrySpend would refuse and leave that 5 + // charge in place, un-emptied -- restoring measurably faster than a genuinely emptied reserve. + [Fact] + public void BreakingHitIsNotPartiallyAbsorbed() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break; TakeHit must not run for this hit + shield.Break(); + + ship.Update(3f); // from zero: 6 of 10 -- not yet full. From a surviving 5 (TakeHit ran, refused, kept it + // untouched): 5 + 6 clamped to 10 -- already full. + Assert.True(shield.Broken); + } + + // Rule: a broken shield absorbs nothing -- gated on Broken itself, not merely on the reserve happening to + // read empty (a break always leaves it empty right away, so a hit against a *freshly* broken shield would + // still fail on reserve arithmetic alone and would not catch a missing Broken check). Restore partway (1s + // at Rate = 2/s -> 2 of 10) so the reserve genuinely holds enough to cover a small hit on arithmetic alone, + // and confirm the shield still refuses it while Broken remains true. + [Fact] + public void BrokenShieldAbsorbsNothing() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break, reserve now emptied by Break() + shield.Break(); + Assert.True(shield.Broken); + + ship.Update(1f); // restoring at 2/s -> 2 of 10, still short of full -- still Broken + Assert.True(shield.Broken); + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 1f)); // reserve arithmetic alone would allow this + } + + // Rule: a break empties the reserve to zero -- the restore duration means the same thing every time, + // regardless of how full the shield was when the breaking hit landed. Pinned by timing, not by a boundary + // CanTakeHit call (which would short-circuit on Broken alone and not actually distinguish the two + // hypotheses): break with 5 of 10 charge still in play, at RestoreDuration = 5 (Rate = 2/s). If that + // leftover 5 survived the break, only (10-5)/2 = 2.5s more would be needed to reach Capacity, so by 3s + // post-break the shield would already have restored. If the break truly zeroed the reserve, 3s only banks + // 6 of 10 and the shield is still Broken; a further 2s (5s total, RestoreDuration exactly) is what actually + // restores it from zero. + [Fact] + public void BreakEmptiesTheReserveRegardlessOfChargeHeldAtTheMoment() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break with 5 charge in play + shield.Break(); + + ship.Update(3f); // from zero: 6 of 10 -- not yet full. From a surviving 5: would already be full. + Assert.True(shield.Broken); + + ship.Update(2f); // total 5s since the break -- exactly RestoreDuration, from zero + Assert.False(shield.Broken); + } + + // Rule: a broken shield returns to full on the restore duration, not the refill duration. RefillDuration = + // 2 (Rate 5/s) is far shorter than RestoreDuration = 5 (Rate 2/s); ticking for exactly RefillDuration's + // span while broken must NOT be enough to restore (that would mean the refill rate leaked into the broken + // path), but ticking for the full RestoreDuration must be. + [Fact] + public void BrokenShieldRestoresOnRestoreDurationNotRefillDuration() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break, reserve now emptied by Break() + shield.Break(); + + ship.Update(2f); // RefillDuration's span -- must not be enough to restore at RestoreDuration's rate + Assert.True(shield.Broken); + + ship.Update(3f); // total 5s since the break -- exactly RestoreDuration + Assert.False(shield.Broken); + Assert.True(shield.CanTakeHit(DamageType.Kinetic, 10f)); // and genuinely full, not just "not broken" + } + + // Rule: a partial grant from the bus stretches both durations proportionally -- the bus still rations + // everything by one fraction (no tiers yet). A reactor generating half the reserve's Rate * 1 demand grants + // exactly half every tick, so the reserve should read as having banked half of what a fully-supplied + // reactor would over the same span. Pinned by comparing two ships built with different reactor generation + // rather than reading raw charge (Shield exposes no such accessor -- CanTakeHit's own boundary is the + // observable this suite uses throughout). + [Fact] + public void PartialGrantStretchesTheRefillDurationProportionally() + { + using var fullCache = OpenCatalog(); + var (fullShip, fullShield) = BuildShipWithShield(fullCache, reactorGeneration: 20); // covers demand (5/s) twice over + fullShip.Update(1f); // granted in full -> Charge = 5 + + using var halfCache = OpenCatalog(); + var (halfShip, halfShield) = BuildShipWithShield(halfCache, reactorGeneration: 2.5f); // half of the 5/s request + halfShip.Update(1f); // granted half -> Charge = 2.5 + + // F5: CanTakeHit is a pure query now, so calling it more than once no longer risks the shield breaking + // itself out from under a later assertion -- these three reads can be in any order. The half-grant + // reserve affords roughly its half share (2, with margin either side of the exact 2.5 to stay clear of + // float rounding at the boundary); the final check is a hit big enough that even the full-grant reserve + // could not have covered it more than four-fold over, to show the two really do differ. + Assert.True(halfShield.CanTakeHit(DamageType.Kinetic, 2f)); + Assert.True(fullShield.CanTakeHit(DamageType.Kinetic, 4f)); + Assert.False(halfShield.CanTakeHit(DamageType.Kinetic, 4f)); + } + + // --- F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "make the query a query." Calling CanTakeHit + // --- any number of times, in any mix of true/false answers, must never move the reserve or set Broken -- + // --- exactly what every real caller under Assets/Scripts/Gameplay/Weapons now relies on (RaycastAll hands + // --- it both the shield and the hull collider for one shot, so it is queried twice per hit). --- + [Fact] + public void CanTakeHitNeverMutatesRegardlessOfHowManyTimesItIsCalled() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + + Assert.True(shield.CanTakeHit(DamageType.Kinetic, 3f)); + Assert.True(shield.CanTakeHit(DamageType.Kinetic, 3f)); // asked again -- still true, nothing spent + Assert.False(shield.Broken); + + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would refuse -- exceeds the 5 held + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // asked again -- still false, still not broken + Assert.False(shield.Broken); + + // The reserve itself never moved either: a hit well within the original 5 still fits. + Assert.True(shield.CanTakeHit(DamageType.Kinetic, 4f)); + } + + // --- The mutation side of the same split: Break() is what a caller now invokes explicitly, and it must be + // --- safe to call more than once for one hit (a caller structure that cannot guarantee single-call + // --- discipline, per the six weapon files' own double-query shape) -- idempotent, not a double-drain or a + // --- crash on an already-empty reserve. --- + [Fact] + public void BreakIsIdempotentAcrossRepeatedCalls() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + + shield.Break(); + shield.Break(); // called again for the same hit (e.g. both the shield- and hull-collider branches) + shield.Break(); + Assert.True(shield.Broken); + + // Restores exactly on RestoreDuration from zero, same as a single Break() call -- proving the repeats + // did not re-empty an already-restoring reserve or otherwise disturb the clock. + ship.Update(3f); + Assert.True(shield.Broken); + ship.Update(2f); // total 5s -- exactly RestoreDuration + Assert.False(shield.Broken); + } + + // --- F4 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): "restoration must not depend on the item being + // --- active." A hit that breaks the shield usually also cooks it, which can knock the item Active.Value + // --- false in the same moment (thermal/durability shutdown) -- Entity only runs Execute (and, pre-F4, the + // --- unbreak check that lived inside it) while Active is true, so the shield could never come back no + // --- matter how long it stayed offline. Simulate that here directly (Enabled = false covers every reason + // --- Active can go false; the mechanism doesn't care which) and confirm the reserve keeps restoring and + // --- clears Broken on schedule regardless. --- + [Fact] + public void RestorationProceedsWhileTheItemIsInactive() + { + using var cache = OpenCatalog(); + var (ship, shield) = BuildShipWithShield(cache, reactorGeneration: 20); + ship.Update(1f); // Charge = 5 + Assert.False(shield.CanTakeHit(DamageType.Kinetic, 6f)); // would break + shield.Break(); + Assert.True(shield.Broken); + + shield.Item.Enabled.Value = false; + Assert.False(shield.Item.Active.Value); + + // RestoreDuration (5s) entirely while inactive -- if restoration depended on Active (Execute-gated, the + // pre-F4 shape), none of this would move the reserve at all and Broken would still be true. + ship.Update(5f); + Assert.False(shield.Broken); + } +} diff --git a/tests/Aetheria.Shared.Tests/StatResolverTests.cs b/tests/Aetheria.Shared.Tests/StatResolverTests.cs new file mode 100644 index 000000000..6c0677a2a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StatResolverTests.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Cut 2 (docs/stats-and-power-cut.md): the resolver owns every value. PerformanceStat -- the catalog object -- +// used to hold two Dictionary fields (the modifier sets) that grew one entry per entity that ever +// evaluated it, for the life of the process (§0.3). This file pins the two structural claims that fix replaces +// them with: one StatResolver per Entity, reachable only from it, and a resolved value that recomputes only when +// a source it declared actually moves. +public sealed class StatResolverTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-statresolver-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private static readonly int2 HardpointCell = new int2(0, 0); + private static readonly int2 SecondHardpointCell = new int2(1, 0); + + public StatResolverTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private static GameplaySettings Settings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + private CultCache OpenCatalog(CapacitorData capacitor, StatModifierData modifier = null) + { + var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(3, 3); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, + Hardpoints = + { + new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() }, + new HardpointData { Type = HardpointType.Sensors, Position = SecondHardpointCell, Shape = new Shape() } + } + }); + cache.Upsert(new GearData { Name = "Battery", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { capacitor } }); + if (modifier != null) + cache.Upsert(new GearData { Name = "Booster", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { modifier } }); + cache.FlushAsync().Wait(); + return cache; + } + + private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + // A ship holding one Battery (and, if present, one Booster), activated in a real (non-null) Zone so + // Entity.Activate runs every IInitializableBehavior.Initialize -- StatModifier's included. + private Ship BuildActivatedShip(CultCache cache, ItemManager items, bool withBooster) + { + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hullItem = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hullItem, new EntitySettings()); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Battery")), HardpointCell)); + if (withBooster) + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Booster")), SecondHardpointCell)); + zone.Entities.Add(ship); + ship.Activate(); + return ship; + } + + // §1.1's authority claim, structurally: "StatResolver, one per Entity, constructed with it." Mutation: make + // Entity.Resolver a static field -- every entity then shares one resolver, and NotSame goes red. + [Fact] + public void EachEntityOwnsItsOwnResolver() + { + var capacity = new PerformanceStat { Min = 1, Max = 1 }; + using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var shipA = BuildActivatedShip(cache, items, withBooster: false); + var shipB = BuildActivatedShip(cache, items, withBooster: false); + + Assert.NotSame(shipA.Resolver, shipB.Resolver); + } + + // The consequence of one-resolver-per-entity: a modifier attached on one entity's item never touches another + // entity's resolved value, even though both equip the very same design (the very same PerformanceStat + // instance, from the same catalog record). The same static-Resolver mutation above kills this test too, since + // a shared resolver is the only way two entities could ever collide on an owner-keyed entry in this design. + [Fact] + public void AModifierOnOneEntityDoesNotReachAnotherEntitysResolvedValue() + { + var capacity = new PerformanceStat { Min = 1, Max = 1 }; + var modifier = new StatModifierData + { + Stat = new StatReference { Target = nameof(CapacitorData), Stat = nameof(CapacitorData.Capacity) }, + Modifier = new PerformanceStat { Min = 5, Max = 5 }, + Type = StatModifierType.Constant + }; + using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }, modifier); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var boosted = BuildActivatedShip(cache, items, withBooster: true); + var plain = BuildActivatedShip(cache, items, withBooster: false); + + // Drive the Booster's StatModifier directly (Execute then Update) rather than a full tick, so the test + // does not also have to bring the item thermally/durability online first. + var boosterEquipped = boosted.Equipment.Single(e => e.Data.Name == "Booster"); + var modifierBehavior = boosterEquipped.GetBehavior(); + Assert.NotNull(modifierBehavior); + modifierBehavior.Execute(0f); + modifierBehavior.Update(0f); + + var boostedBattery = boosted.Equipment.Single(e => e.Data.Name == "Battery"); + var plainBattery = plain.Equipment.Single(e => e.Data.Name == "Battery"); + + Assert.Equal(6f, boostedBattery.Evaluate(capacity), 3); // 1 (Min==Max) + 5 (the Constant modifier) + Assert.Equal(1f, plainBattery.Evaluate(capacity), 3); // untouched + } + + // Soul's Gate 2 finding on Cut 2 (docs/stats-and-power-cut.md): StatModifier.Initialize recomputes _targets + // by matching the *behaviour type* the modifier targets (here, any equipped item with a CapacitorData + // behaviour) fresh every time it runs -- but _applied was never reset alongside it. So after the modifier + // has already applied once, deactivating, unequipping its target, equipping a replacement with the same + // targeted behaviour, and reactivating used to leave the modifier permanently unattached: Update saw + // `_executed && _applied` (both still true from the stale attachment) and neither branch of its latch fired, + // so ApplyModifier never ran against the new target. Mutation: delete the `if (_applied) RemoveModifier();` + // line this cut adds to Initialize, and the final assertion goes red (the replacement reads 1f, unboosted). + [Fact] + public void ARefitTargetReattachesTheModifierAfterReactivation() + { + var capacity = new PerformanceStat { Min = 1, Max = 1 }; + var modifier = new StatModifierData + { + Stat = new StatReference { Target = nameof(CapacitorData), Stat = nameof(CapacitorData.Capacity) }, + Modifier = new PerformanceStat { Min = 5, Max = 5 }, + Type = StatModifierType.Constant + }; + using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }, modifier); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + var ship = BuildActivatedShip(cache, items, withBooster: true); + var boosterEquipped = ship.Equipment.Single(e => e.Data.Name == "Booster"); + var modifierBehavior = boosterEquipped.GetBehavior(); + Assert.NotNull(modifierBehavior); + + // First activation: applies against the original Battery. + modifierBehavior.Execute(0f); + modifierBehavior.Update(0f); + var originalBattery = ship.Equipment.Single(e => e.Data.Name == "Battery"); + Assert.Equal(6f, originalBattery.Evaluate(capacity), 3); + + // Deactivate, unequip the target, equip a replacement with the same targeted behaviour, reactivate. + ship.Deactivate(); + Assert.NotNull(ship.TryUnequip(originalBattery)); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Battery")), HardpointCell)); + ship.Activate(); + + // The modifier behaviour re-executes on the new activation, exactly as it did on the first. + modifierBehavior.Execute(0f); + modifierBehavior.Update(0f); + + var replacementBattery = ship.Equipment.Single(e => e.Data.Name == "Battery"); + Assert.Equal(6f, replacementBattery.Evaluate(capacity), 3); // reattached; not stuck unboosted at 1f + } + + // §0.3's headline claim: the catalog used to keep every entity that ever evaluated one of its stats alive for + // the life of the process, through PerformanceStat's own per-entity modifier dictionaries. This fails at HEAD + // before Cut 2. Mutation: make Entity.Resolver static -- its dictionaries then live on a field the catalog's + // assembly can reach for the life of the process, and the entity (and its EquippedItem) never dies. + [Fact] + public void UnequippingAndDroppingAnEntityLeavesItCollectible() + { + var capacity = new PerformanceStat { Min = 1, Max = 1 }; + using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + + WeakReference EquipEvaluateAndDrop() + { + var ship = BuildActivatedShip(cache, items, withBooster: false); + var battery = ship.Equipment.Single(e => e.Data is GearData); + battery.Evaluate(capacity); // exactly the call that leaked pre-cut (Entity.cs, EquippedItem.ScaleModifier/ConstantModifier) + return new WeakReference(ship); + } + + var weakShip = EquipEvaluateAndDrop(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Assert.False(weakShip.IsAlive); + } + + // Cut 2's Adds: "StatValidation for self-dependency ... cycles." A modifier whose own magnitude stat is one + // of the stats it writes would resolve one tick behind itself forever -- refused at equip (Entity.Activate), + // naming the entity and the (Target, Stat) reference. Mutation: neuter the DFS's self-check in + // StatModifier.ValidateNoCycle and this goes from throwing to succeeding silently. + [Fact] + public void AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip() + { + var capacity = new PerformanceStat { Min = 1, Max = 1 }; + var selfModifier = new StatModifierData + { + Stat = new StatReference { Target = nameof(CapacitorData), Stat = nameof(CapacitorData.Capacity) }, + Modifier = capacity, // the modifier's own magnitude IS the stat it targets + Type = StatModifierType.Constant + }; + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(3, 3); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } + }); + cache.Upsert(new GearData + { + Name = "Loop", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, + Behaviors = { new CapacitorData { Capacity = capacity }, selfModifier } + }); + cache.FlushAsync().Wait(); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hullItem = Mint(cache, items, cache.GetByName("Skiff")); + var ship = new Ship(items, zone, hullItem, new EntitySettings()); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Loop")), HardpointCell)); + zone.Entities.Add(ship); + + var error = Assert.Throws(() => ship.Activate()); + Assert.Contains(ship.Name, error.Message); // the entity, by name ("Skiff", HullData.Name) + Assert.Contains(nameof(CapacitorData), error.Message); + Assert.Contains(nameof(CapacitorData.Capacity), error.Message); + } + + // A small, deliberately fake context so the resolver's own caching rule is provable without any catalog, + // entity or item plumbing: a stat recomputes when a source it declared moves, and only then. + private sealed class FakeContext : IStatContext + { + public Lot Lot { get; set; } + public float Heat = 1f; + public float Durability = 1f; + // Real contexts (EquippedItem, ConsumableItemEffect) route these through their entity's resolver, keyed + // by themselves as owner; a resolver-only test needs the same wiring or it can never observe an attached + // modifier, which is the whole point of AttachingAModifierInvalidatesImmediately below. + public StatResolver Resolver; + public object Owner; + public float HeatFactor(float exponent) => Heat; + public float DurabilityFactor(float exponent) => Durability; + public float ConsumableProgressFactor(float exponent) => 1f; + public float PowerSupplyFactor(float exponent) => 1f; + public float ScaleModifier(PerformanceStat stat) => Resolver?.ScaleModifier(Owner, stat) ?? 1f; + public float ConstantModifier(PerformanceStat stat) => Resolver?.ConstantModifier(Owner, stat) ?? 0f; + } + + [Fact] + public void ResolveDoesNotRecomputeWhenNoDeclaredSourceMoved() + { + var resolver = new StatResolver(); + var owner = new object(); + var context = new FakeContext { Heat = 1f }; + var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 } } }; + + Assert.Equal(10f, resolver.Resolve(owner, stat, context), 3); + context.Heat = 0f; // the live context changed, but nothing told the resolver a source moved + Assert.Equal(10f, resolver.Resolve(owner, stat, context), 3); // still the stale, cached value + } + + [Fact] + public void ResolveRecomputesOnlyAfterItsDeclaredSourceIsInvalidated() + { + var resolver = new StatResolver(); + var owner = new object(); + var context = new FakeContext { Heat = 1f }; + var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 } } }; + + Assert.Equal(10f, resolver.Resolve(owner, stat, context), 3); + context.Heat = 0f; + resolver.InvalidateSource(owner, StatSource.Heat); + Assert.Equal(0f, resolver.Resolve(owner, stat, context), 3); + } + + // Invalidating an undeclared source must not touch a stat that never read it -- otherwise every stat would + // recompute on every tick regardless of its own Terms, which is exactly the "recomputes only when its own + // source moves" rule this cut adds. + [Fact] + public void InvalidatingAnUnrelatedSourceDoesNotRecomputeAStatThatDidNotDeclareIt() + { + var resolver = new StatResolver(); + var owner = new object(); + var context = new FakeContext { Durability = 1f }; + var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.Durability, Exponent = 1 } } }; + + Assert.Equal(10f, resolver.Resolve(owner, stat, context), 3); + context.Durability = 0f; + resolver.InvalidateSource(owner, StatSource.Heat); // a different source moving + Assert.Equal(10f, resolver.Resolve(owner, stat, context), 3); // Durability's own generation never bumped + } + + // Attaching or detaching a modifier invalidates immediately, independent of the source-generation scheme -- + // a modifier is not one of the stat's declared Terms. + [Fact] + public void AttachingAModifierInvalidatesImmediately() + { + var resolver = new StatResolver(); + var owner = new object(); + var context = new FakeContext { Resolver = resolver, Owner = owner }; + var stat = new PerformanceStat { Min = 1, Max = 1 }; + + Assert.Equal(1f, resolver.Resolve(owner, stat, context), 3); + resolver.AttachModifier(owner, stat, "modifier-a", StatModifierType.Constant, 4f); + Assert.Equal(5f, resolver.Resolve(owner, stat, context), 3); + resolver.DetachModifier(owner, stat, "modifier-a"); + Assert.Equal(1f, resolver.Resolve(owner, stat, context), 3); + } + + // Soul's Gate 1 finding on Cut 2 (docs/stats-and-power-cut.md §0b: a resolver entry is "destroyed at + // unequip / entity teardown"): nothing ever removed an owner from _cache/_modifiers/_generations, so an + // EquippedItem that was unequipped, evaluated, and replaced kept growing the resolver's retained set forever. + // This pins the fix at the retained-set level, not only via a WeakReference on one entity (which the existing + // UnequippingAndDroppingAnEntityLeavesItCollectible already covers): repeatedly equip, evaluate (populating + // _cache and, through UpdatePerformance, _generations), and unequip, then assert the resolver's own entry + // counts return to the same baseline they started at, cycle after cycle. Mutation: delete the + // Resolver.Forget(item) call in Entity.TryUnequip and this goes red because the counts climb instead. + [Fact] + public void UnequipReturnsTheResolversRetainedSetToBaseline() + { + var capacity = new PerformanceStat { Min = 1, Max = 1 }; + using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var ship = BuildActivatedShip(cache, items, withBooster: false); + var resolver = ship.Resolver; + + var baselineGenerations = resolver.GenerationOwnerCount; + var baselineCache = resolver.CacheEntryCount; + var baselineModifiers = resolver.ModifierEntryCount; + + for (var i = 0; i < 20; i++) + { + var battery = ship.Equipment.Single(e => e.Data.Name == "Battery"); + battery.UpdatePerformance(); // Cut 2: bumps this owner's Heat and Durability generations + battery.Evaluate(capacity); // populates a _cache entry keyed by (battery, capacity) + + ship.Deactivate(); + Assert.NotNull(ship.TryUnequip(battery)); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Battery")), HardpointCell)); + ship.Activate(); + } + + Assert.Equal(baselineGenerations, resolver.GenerationOwnerCount); + Assert.Equal(baselineCache, resolver.CacheEntryCount); + Assert.Equal(baselineModifiers, resolver.ModifierEntryCount); + } + + // The other half of Gate 1: a ConsumableItemEffect that expires drops out of Entity._activeConsumables + // (Entity.Update) without ever telling the resolver, so 20 activate/evaluate/expire cycles left 22 generation + // owners at HEAD (this ship's own EquippedItem owners plus one per expired consumable instance). Mutation: + // delete the Resolver.Forget(_activeConsumables[i]) call at expiry and this goes red. + [Fact] + public void ConsumableExpiryReturnsTheResolversRetainedSetToBaseline() + { + using var cache = OpenCatalog(new CapacitorData { Capacity = new PerformanceStat { Min = 1, Max = 1 } }); + var boosterRef = cache.Upsert(new ConsumableItemData { Name = "Stim", Duration = 0.01f }); + cache.FlushAsync().Wait(); + var boosterData = cache.Get(boosterRef); + + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var ship = BuildActivatedShip(cache, items, withBooster: false); + var resolver = ship.Resolver; + + // A warm-up tick with no consumable active: the still-equipped Battery owns a permanent Heat/Durability + // generation entry from here on (correctly -- it is never unequipped in this test), so the baseline this + // test pins against must be taken after that entry exists, not before it. + ship.Update(0f); + var baselineGenerations = resolver.GenerationOwnerCount; + var baselineCache = resolver.CacheEntryCount; + var baselineModifiers = resolver.ModifierEntryCount; + + for (var i = 0; i < 20; i++) + { + var lot = items.Lots.Add(new Lot { Design = cache.RefOf(boosterData), Origin = new Attributed(), Quality = .5f, Roles = new List() }); + var consumableItem = new ConsumableItem { Data = cache.RefOf(boosterData), Lot = lot }; + ship.ActivateConsumable(consumableItem); + + // Duration is 0.01s; one 1-second tick expires it and drives Entity.Update's expiry branch. + ship.Update(1f); + } + + Assert.Equal(baselineGenerations, resolver.GenerationOwnerCount); + Assert.Equal(baselineCache, resolver.CacheEntryCount); + Assert.Equal(baselineModifiers, resolver.ModifierEntryCount); + } +} diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs new file mode 100644 index 000000000..478bc93c2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -0,0 +1,414 @@ +/* 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.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using Random = CultMath.Random; + +// Cut 2 (docs/fire-control-cut.md): pins the targeting subsystem, the reveal rule between the two info +// thresholds, the selection slot/predicate, and the required-equipment ruling (Q4). Builds its own +// hull/catalog fixtures, the same style FireControlTests uses for Cut 1, rather than reusing that file's +// or LoadoutTests' private helpers. +public sealed class TargetingSystemTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-targeting-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public TargetingSystemTests() => Directory.CreateDirectory(_root); + + private CultCache _openCache; + + public void Dispose() + { + _openCache?.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetArmorInfoThreshold = .3f, + TargetGearInfoThreshold = .9f, + UnaidedAccuracy = .1f, + FiringArc = 120 + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + // Min=0 (not Min=Max): a PowerSupply term can only be observed to degrade something with room to move + // between floor and authored ceiling (BrownoutTests' own convention). + private static PerformanceStat Curved(float max, float exponent) => + new PerformanceStat { Min = 0, Max = max, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = exponent } } }; + + // A 6x4 hull with one Sensors hardpoint at (0,0) (hardpoint-mounted weapon) and interior room for two + // more Tool items, one 2-cell and one 1-cell. Non-hull equipment ends up authored in this order -- + // hardpoint weapon, wide interior item, narrow interior item -- but it is FireControl.IsRevealed's own + // ranking, not authoring order, that RevealOrder actually pins. + // `targetingData`, when given, authors a fourth non-hull item (a Tool gear carrying that behaviour) in + // the same catalog-build pass, so a test that needs a targeting system never has to reopen the catalog + // file after the live, already-open ItemManager exists (CultCache is single-writer). + private (ItemManager items, Ship target, EquippedItem weapon, EquippedItem wideItem, EquippedItem narrowItem, EquippedItem targetingItem) + BuildTarget(GameplaySettings settings, TargetingSystemData targetingData = null) + { + var hullShape = new Shape(6, 4); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hullData = new HullData + { + Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + using (var buildCache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + buildCache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + buildCache.Upsert(hullData); + buildCache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData() } + }); + var wideShape = new Shape(2, 1); + wideShape[new int2(0, 0)] = true; + wideShape[new int2(1, 0)] = true; + buildCache.Upsert(new GearData + { + Name = "Wide", Hardpoint = HardpointType.Tool, Shape = wideShape, Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + buildCache.Upsert(new GearData + { + Name = "Narrow", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + if (targetingData != null) + buildCache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { targetingData } + }); + buildCache.FlushAsync().Wait(); + } + + _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; // gun + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; // wide + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; // narrow + ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; // targeting (unused unless authored above) + var items = new ItemManager(_openCache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); + var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }; + var target = new Ship(items, zone, hull, new EntitySettings()); + + // Cut 7 (docs/fire-control-cut.md, 7.2): equipped in size-ascending, hardpoint-last order -- the + // opposite of IsRevealed's own ranking (hardpoint-mounted first, then size descending) -- so that + // dropping the explicit ranking in FireControl.IsRevealed is actually observable. Equipping in + // rank order let a "drop the ordering, rank by equipment order alone" mutation pass: Entity.Equipment + // already happened to enumerate in the correct final order by sheer coincidence of authoring order, + // so RevealOrder's own assertions never distinguished the ranked read from the raw one. + var narrowRef = items.ItemData.RefOf(items.ItemData.GetByName("Narrow")); + var narrow = new EquippableItem { Data = narrowRef, Durability = 1, Lot = 4 }; + Assert.True(target.TryFindSpace(narrow, out var narrowPos)); + Assert.True(target.TryEquip(narrow, narrowPos)); + var narrowItem = target.Equipment.Single(e => e.EquippableItem == narrow); + + var wideRef = items.ItemData.RefOf(items.ItemData.GetByName("Wide")); + var wide = new EquippableItem { Data = wideRef, Durability = 1, Lot = 3 }; + Assert.True(target.TryFindSpace(wide, out var widePos)); + Assert.True(target.TryEquip(wide, widePos)); + var wideItem = target.Equipment.Single(e => e.EquippableItem == wide); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(target.TryEquip(gun, new int2(0, 0))); + var weapon = target.Equipment.Single(e => e.EquippableItem == gun); + + EquippedItem targetingItem = null; + if (targetingData != null) + { + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 5 }; + Assert.True(target.TryFindSpace(targeting, out var targetingPos)); + Assert.True(target.TryEquip(targeting, targetingPos)); + targetingItem = target.Equipment.Single(e => e.EquippableItem == targeting); + } + + zone.Entities.Add(target); + target.Activate(); + + return (items, target, weapon, wideItem, narrowItem, targetingItem); + } + + // A bare-hull observer sharing the target's zone, with no equipment of its own -- only its + // EntityInfoGathered dictionary and Target/TrySelectTargetItem matter to these tests. + private Ship BuildObserver(ItemManager items, Zone zone) + { + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); + var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }; + var observer = new Ship(items, zone, hull, new EntitySettings()); + zone.Entities.Add(observer); + observer.Activate(); + return observer; + } + + // R5 / the selection predicate: TrySelectTargetItem accepts only a revealed item of the current target, + // a Target change nulls the aim point, and decay drops it on read with no loop clearing it. + // Mutation: IsRevealed returns true unconditionally; the Target-change subscription is removed; or + // ResolvedTargetItem returns TargetItem.Value without re-checking. + [Fact] + public void SelectionNeedsReveal() + { + var (items, target, weapon, _, _, _) = BuildTarget(TestSettings()); + var observer = BuildObserver(items, target.Zone); + observer.Target.Value = target; + + observer.EntityInfoGathered[target] = .29f; // below the single item's own tier (Armor, N==1) + Assert.False(observer.TrySelectTargetItem(weapon)); + Assert.Null(observer.TargetItem.Value); + + observer.EntityInfoGathered[target] = .3f; // at tier: revealed + Assert.True(observer.TrySelectTargetItem(weapon)); + Assert.Same(weapon, observer.TargetItem.Value); + Assert.Same(weapon, observer.ResolvedTargetItem); + + // A Target change nulls the aim point. + observer.Target.Value = null; + Assert.Null(observer.TargetItem.Value); + + // Decay: re-select, then let info fall back below tier without touching TargetItem.Value at all -- + // the raw field still holds the item (no loop cleared it), but the read accessor drops it. + observer.Target.Value = target; + Assert.True(observer.TrySelectTargetItem(weapon)); + observer.EntityInfoGathered[target] = .1f; + Assert.Same(weapon, observer.TargetItem.Value); + Assert.Null(observer.ResolvedTargetItem); + } + + // The ranking: hardpoint-mounted gear before interior Tool gear, larger before smaller within a group. + // Mutation: drop the ordering (e.g. rank by equipment order alone). + [Fact] + public void RevealOrder() + { + var (items, target, weapon, wideItem, narrowItem, _) = BuildTarget(TestSettings()); + var observer = BuildObserver(items, target.Zone); + + observer.EntityInfoGathered[target] = .35f; // above Armor (.3, index 0), below the mid tier (.6, index 1) + Assert.True(FireControl.IsRevealed(observer, weapon)); + Assert.False(FireControl.IsRevealed(observer, wideItem)); + Assert.False(FireControl.IsRevealed(observer, narrowItem)); + + observer.EntityInfoGathered[target] = .65f; // above the mid tier, below Gear (.9, index 2) + Assert.True(FireControl.IsRevealed(observer, weapon)); + Assert.True(FireControl.IsRevealed(observer, wideItem)); + Assert.False(FireControl.IsRevealed(observer, narrowItem)); + + observer.EntityInfoGathered[target] = .95f; // above Gear: everything revealed + Assert.True(FireControl.IsRevealed(observer, weapon)); + Assert.True(FireControl.IsRevealed(observer, wideItem)); + Assert.True(FireControl.IsRevealed(observer, narrowItem)); + } + + // The tiers really are the two authored settings: the first item reveals exactly at + // TargetArmorInfoThreshold, the last exactly at TargetGearInfoThreshold. Mutation: swap the two + // settings, or use TargetDetectionInfoThreshold instead. + [Fact] + public void RevealSpansBothThresholds() + { + var settings = TestSettings(); + var (items, target, weapon, wideItem, narrowItem, _) = BuildTarget(settings); + var observer = BuildObserver(items, target.Zone); + + observer.EntityInfoGathered[target] = settings.TargetArmorInfoThreshold; + Assert.True(FireControl.IsRevealed(observer, weapon)); + Assert.False(FireControl.IsRevealed(observer, wideItem)); + Assert.False(FireControl.IsRevealed(observer, narrowItem)); + + observer.EntityInfoGathered[target] = settings.TargetGearInfoThreshold; + Assert.True(FireControl.IsRevealed(observer, weapon)); + Assert.True(FireControl.IsRevealed(observer, wideItem)); + Assert.True(FireControl.IsRevealed(observer, narrowItem)); + } + + // Q4: the subsystem is what supplies accuracy. No targeting system, a destroyed one, and a disabled one + // all resolve the authored unaided figures. Mutation: fall back to the (destroyed/offline) system's own + // stats instead of the unaided ones. + [Fact] + public void UnaidedFiresWorse() + { + var settings = TestSettings(); + + var targetingData = new TargetingSystemData + { + Accuracy = Constant(.8f), Resolution = Constant(.6f), Precision = Constant(.7f), Tracking = Constant(.5f) + }; + var (items, target, _, _, _, targetingUnit) = BuildTarget(settings, targetingData); + var equippedTargeting = targetingUnit; + var system = equippedTargeting.Behaviors.OfType().Single(); + system.Execute(0f); + + // No targeting system at all: a second, bare-hull ship in the same catalog/zone (one CultCache + // handle stays open per test -- BuildTarget is not called twice). + var bareShip = BuildObserver(items, target.Zone); + Assert.Equal(settings.UnaidedAccuracy, FireControl.Accuracy(bareShip), 4); + // Cut 6d (docs/fire-control-cut.md): Precision now falls back to UnaidedPrecision, the same shape as + // Accuracy/Tracking above -- it used to fall back to a bare 0, which was fine when Precision only fed + // the old coin-flip's aimed-item branch and meant nothing to a shot with no aim point. Now it derives + // every shot's sigma, aimed or not, so the unaided floor has to be an authored number, not a bare zero. + Assert.Equal(settings.UnaidedPrecision, FireControl.Precision(bareShip), 4); + Assert.Equal(1f, FireControl.Resolution(bareShip), 4); + + // Bring it online to prove the fixture can actually supply its own stats... + equippedTargeting.UpdatePerformance(); + Assert.True(equippedTargeting.Active.Value); + Assert.Equal(.8f, FireControl.Accuracy(target), 4); + Assert.Equal(.7f, FireControl.Precision(target), 4); + + // ...then destroy it: durability gone, still falls back. + equippedTargeting.EquippableItem.Durability = 0f; + equippedTargeting.UpdatePerformance(); + Assert.False(equippedTargeting.Active.Value); + Assert.Equal(settings.UnaidedAccuracy, FireControl.Accuracy(target), 4); + Assert.Equal(settings.UnaidedPrecision, FireControl.Precision(target), 4); + Assert.Equal(1f, FireControl.Resolution(target), 4); + + // Repair it, then disable it directly instead: same fallback. + equippedTargeting.EquippableItem.Durability = 1f; + equippedTargeting.UpdatePerformance(); + Assert.True(equippedTargeting.Active.Value); + equippedTargeting.Enabled.Value = false; + Assert.False(equippedTargeting.Active.Value); + Assert.Equal(settings.UnaidedAccuracy, FireControl.Accuracy(target), 4); + Assert.Equal(settings.UnaidedPrecision, FireControl.Precision(target), 4); + } + + // The brownout ruling (docs/stats-and-power-cut.md) applies to Accuracy same as any other stat: a + // half-grant halves it (PowerSupply exponent 1), not a switch-off. Reactor + a real PowerBus tick, the + // same fixture shape BrownoutTests uses, because Item.PowerSupply's setter is internal to a different + // assembly here and can only be driven through a live grant. + [Fact] + public void StarvedTargetingRollsWorse() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ReactorData { Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = + { + new EnergyDrawData { EnergyDraw = Constant(100) }, + new TargetingSystemData { Accuracy = Curved(1, 1), Resolution = Constant(1), Precision = Constant(1), Tracking = Constant(1) } + } + }); + cache.FlushAsync().Wait(); + + EquippableItem Mint(ItemData design) => new EquippableItem + { + Data = cache.RefOf(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + }; + + Ship BuildShip(float reactorCharge, out ItemManager items) + { + items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var hullData = cache.GetByName("Skiff"); + var hull = Mint(hullData); + hull.Lot = items.Lots.Add(new Lot { Design = cache.RefOf(hullData), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List() }); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("Reactor"); + ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); + var reactor = Mint(reactorData); + reactor.Lot = items.Lots.Add(new Lot { Design = cache.RefOf(reactorData), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List() }); + Assert.True(ship.TryEquip(reactor)); + + var targetingData = cache.GetByName("Targeting"); + var targeting = Mint(targetingData); + targeting.Lot = items.Lots.Add(new Lot { Design = cache.RefOf(targetingData), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List() }); + Assert.True(ship.TryEquip(targeting)); + + zone.Entities.Add(ship); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + return ship; + } + + float ResolveAccuracy(float reactorCharge) + { + var ship = BuildShip(reactorCharge, out _); + ship.Update(1f); // one tick: PowerBus.Step grants, then Execute resolves Accuracy against it + return FireControl.Accuracy(ship); + } + + var full = ResolveAccuracy(1000); // demand 100, generation far exceeds it -> ratio 1 + var half = ResolveAccuracy(50); // demand 100, generation 50 -> ratio .5 + + Assert.Equal(1f, full, 3); + Assert.Equal(.5f, half, 3); + } + + // Q4: required equipment for anything that can fire. Across several seeds, every generated loadout with + // a weapon also carries a targeting system. Mutation: drop `required: true` on the targeting-system + // RandomProduct call in LoadoutGenerator.FillInterior. + [Fact] + public void EveryArmedLoadoutGetsATargetingSystem() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var maker = cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" }); + + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hull = cache.Upsert(new HullData + { + Name = "Armed", HullType = HullType.Ship, Shape = hullShape, Price = 100, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }); + var gun = cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, Behaviors = { new InstantWeaponData() } }); + var cargo = cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 }); + var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); + var targeting = cache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new TargetingSystemData() } }); + + foreach (var (name, design) in new[] + { + ("Armed by Maker", hull.Key), ("Gun by Maker", gun.Key), ("Crate by Maker", cargo.Key), + ("Cap by Maker", capacitor.Key), ("Targeting by Maker", targeting.Key) + }) + cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); + cache.FlushAsync().Wait(); + + for (var seed = 1; seed <= 10; seed++) + { + var random = new Random((uint) seed); + var items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Armed"); + Assert.NotNull(pack); + Assert.Contains(pack.Equipment, e => (cache.Get(e.item.Data) as GearData)?.Behaviors.Any(b => b is TargetingSystemData) ?? false); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/stryker-config.json b/tests/Aetheria.Shared.Tests/stryker-config.json new file mode 100644 index 000000000..f1708b314 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/stryker-config.json @@ -0,0 +1,12 @@ +{ + "stryker-config": { + "project": "Aetheria.Shared.csproj", + "mutate": [ + "**/Assets/Scripts/ServerShared/**/*.cs", + "!**/Assets/Plugins/**" + ], + "mutation-level": "Standard", + "reporters": [ "progress", "html", "json" ], + "thresholds": { "high": 80, "low": 60, "break": 0 } + } +} diff --git a/tests/mutation_tests.py b/tests/mutation_tests.py deleted file mode 100644 index 5d951bc8f..000000000 --- a/tests/mutation_tests.py +++ /dev/null @@ -1,536 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for the Cut B and Cut C provenance rules (docs/item-provenance-cut.md). - -For each rule this applies its named mutation to an exact, unique anchor of -source text, runs the one test that should catch it, restores the file -byte-for-byte, and reports whether the mutant was killed. A no-op control -mutation (anchor == replacement) proves the read/replace/write/restore path -itself is transparent: every test it touches must stay green. - -Usage: - python tests/mutation_tests.py --cultlib-root - -Requires the CultLib worktree described in docs/item-provenance-cut.md section 6. -Only touches files under this repository; every mutation is reversed before -the script exits, including on failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=' [Key(0)] public int NextLot = 1;', - mutated=' [Key(0)] public int NextLot = 1;', - test="RunSaveTests.MissingLotIsLoud", - expect="green", - ), - # --- MissingLotIsLoud: the indexer and Reachable must throw on an absent lot --- - Mutation( - rule="ProvenanceLedger indexer throws on an absent id", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=( - " public Lot this[int id]\n" - " {\n" - " get\n" - " {\n" - " if (!Lots.TryGetValue(id, out var lot))\n" - " throw new InvalidOperationException($\"Lot {id} is not in the provenance ledger.\");\n" - " return lot;\n" - " }\n" - " }" - ), - mutated=( - " public Lot this[int id]\n" - " {\n" - " get\n" - " {\n" - " Lots.TryGetValue(id, out var lot);\n" - " return lot;\n" - " }\n" - " }" - ), - test="RunSaveTests.MissingLotIsLoud", - expect="red", - ), - Mutation( - rule="Reachable throws on a missing root instead of skipping it", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=" var lot = this[id];\n reached[id] = lot;", - mutated=" if (!Lots.TryGetValue(id, out var lot)) continue;\n reached[id] = lot;", - test="RunSaveTests.MissingLotIsLoud", - expect="red", - ), - # --- MatchingHardpointsShareALot: the second unit must mint from the first unit's lot --- - Mutation( - rule="matching hardpoints share a lot, not a fresh mint", - file="Assets/Scripts/ServerShared/LoadoutGenerator.cs", - anchor=( - " // Matching hardpoints carry matching units: same lot\n" - " var item = (previousItem != null\n" - " ? ItemManager.CreateInstance(previousItem.EquippableItem.Lot)\n" - " : ItemManager.CreateInstance(entry.product)) as EquippableItem;" - ), - mutated=( - " // Matching hardpoints carry matching units: same lot\n" - " var item = ItemManager.CreateInstance(entry.product) as EquippableItem;" - ), - test="LoadoutTests.MatchingHardpointsShareALot", - expect="red", - ), - # --- FirstAvailableProductInKeyOrderBuildsTheSlot: Brand and Materialize must both respect key order --- - Mutation( - rule="Brand matches the lot's maker, not just the design", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " var product = ItemData.GetAll()\n" - " .Where(p => p.Manufacturer.Key.Equals(maker.Key) && p.Design.Key.Equals(item.Data.Key))" - ), - mutated=( - " var product = ItemData.GetAll()\n" - " .Where(p => p.Design.Key.Equals(item.Data.Key))" - ), - test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot", - expect="red", - ), - Mutation( - rule="Materialize resolves a design's product in record-key order", - file="Assets/Scripts/ServerShared/Loadout.cs", - anchor="var products = cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();", - mutated="var products = cache.GetAll().ToArray();", - test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot", - expect="red", - ), - # --- BrandPicksFirstProductInKeyOrderForSameMakerAndDesign: Brand's own tie-break must be ascending key order --- - Mutation( - rule="Brand's tie-break among a maker's products for one design is ascending record-key order", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" .OrderBy(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)", - mutated=" .OrderByDescending(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)", - test="LoadoutTests.BrandPicksFirstProductInKeyOrderForSameMakerAndDesign", - expect="red", - ), - # --- StatsReadTheLot: stats must read the lot, including its per-role fills --- - Mutation( - rule="Evaluate reads the lot's per-role fill for a role stat", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor="var quality = pow(lot.QualityForRole(stat.FromRole), stat.QualityExponent);", - mutated="var quality = pow(lot.Quality, stat.QualityExponent);", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - Mutation( - rule="QualityForRole falls back to the lot's own workmanship, not a fixed value", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=" if (string.IsNullOrEmpty(role) || Roles == null) return Quality;", - mutated=" if (string.IsNullOrEmpty(role) || Roles == null) return 1f;", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - # --- F2 (item-provenance-cut.md): the GC root walk must cover every EntityPack root --- - Mutation( - rule="Items yields the Equipment root, not just the hull", - file="Assets/Scripts/ServerShared/EntitySerializer.cs", - anchor=( - " yield return pack.Hull;\n" - " foreach (var (_, item) in pack.Equipment) yield return item;" - ), - mutated=" yield return pack.Hull;", - test="LoadoutTests.MaterializedLotsSurviveSaveAndReload", - expect="red", - ), - # --- F3: GetTier and CreateInstance(int) must read/preserve the lot's real quality --- - Mutation( - rule="GetTier reads the lot's quality, not a fixed value", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" var quality = GetLot(item).Quality;", - mutated=" var quality = .999f;", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - Mutation( - rule="CreateInstance(int) does not re-roll the lot's quality", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " public CraftedItemInstance CreateInstance(int lot)\n" - " {\n" - " var l = Lots[lot];" - ), - mutated=( - " public CraftedItemInstance CreateInstance(int lot)\n" - " {\n" - " var l = Lots[lot];\n" - " l.Quality = RollQuality();" - ), - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - # --- F4: GetPrice, the durability/thermal exponent paths, role fills, Produced brand, multi-zone roots --- - Mutation( - rule="GetPrice reads the lot's quality, not a fixed value", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(GetLot(item).Quality) * data.Price);", - mutated=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(.5f) * data.Price);", - test="LoadoutTests.GetPriceReadsLotQuality", - expect="red", - ), - Mutation( - rule="Evaluate's durability exponent reads the lot's quality", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " var durabilityExponent = lerp(\n" - " GameplaySettings.DurabilityQualityMin,\n" - " GameplaySettings.DurabilityQualityMax,\n" - " pow(lot.Quality, GameplaySettings.DurabilityQualityExponent));" - ), - mutated=( - " var durabilityExponent = lerp(\n" - " GameplaySettings.DurabilityQualityMin,\n" - " GameplaySettings.DurabilityQualityMax,\n" - " .5f);" - ), - test="LoadoutTests.EvaluateDurabilityExponentReadsLotQuality", - expect="red", - ), - Mutation( - rule="EquippedItem's thermal exponent reads the lot's quality", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " ThermalExponent = lerp(\n" - " ItemManager.GameplaySettings.ThermalQualityMin,\n" - " ItemManager.GameplaySettings.ThermalQualityMax,\n" - " pow(Lot.Quality, ItemManager.GameplaySettings.ThermalQualityExponent));" - ), - mutated=( - " ThermalExponent = lerp(\n" - " ItemManager.GameplaySettings.ThermalQualityMin,\n" - " ItemManager.GameplaySettings.ThermalQualityMax,\n" - " .5f);" - ), - test="LoadoutTests.EquippedItemThermalExponentReadsLotQuality", - expect="red", - ), - Mutation( - rule="CreateLot(product) fills roles from the product's own spread, not the design default", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();", - mutated=" var build = new ProductRole();", - test="LoadoutTests.CreateLotFillsRolesFromProductSpread", - expect="red", - ), - Mutation( - rule="Brand reads the maker off a Produced origin too, not only Attributed", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " var maker = lot.Origin switch\n" - " {\n" - " Attributed attributed => attributed.Faction,\n" - " Produced produced => produced.Faction,\n" - " _ => default\n" - " };" - ), - mutated=( - " var maker = lot.Origin switch\n" - " {\n" - " Attributed attributed => attributed.Faction,\n" - " _ => default\n" - " };" - ), - test="LoadoutTests.BrandReadsProducedFaction", - expect="red", - ), - Mutation( - rule="RunSave.Commit takes GC roots from every zone, not just the first", - file="Assets/Scripts/ServerShared/SavedGame.cs", - anchor=" var roots = zones\n .SelectMany(zone => zone.Contents?.Entities ?? new List())", - mutated=" var roots = zones.Take(1)\n .SelectMany(zone => zone.Contents?.Entities ?? new List())", - test="RunSaveTests.CommitTakesRootsFromEveryZone", - expect="red", - ), - # --- Cut C: the manufacturer moves from the design to the product; the catalog fixture and its - # round-trip assertion (FactionIsASingletonInstance) must actually depend on that wiring. --- - Mutation( - rule="the catalog fixture's product carries the maker, not an unset reference", - file="tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs", - anchor=( - 'cache.Upsert(new FactionProductData { Name = "Lance", ' - "Design = new CultRecordRef(lance.Key), Manufacturer = cache.RefOf(faction) });" - ), - mutated=( - 'cache.Upsert(new FactionProductData { Name = "Lance", ' - "Design = new CultRecordRef(lance.Key), Manufacturer = default });" - ), - test="AetheriaStoresTests.FactionIsASingletonInstance", - expect="red", - ), - # --- Hand-testing combat (2026-09-17): IFF overrides, grudge, detection-gating, and the shooter- - # stance weapon fire gate on Entity/Weapon (docs not yet written; see IffAndCombatTests.cs). --- - Mutation( - rule="an IFF override decides IsHostileTo outright at the top level", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", - mutated=" if (false)\n return default;", - test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule", - expect="red", - ), - Mutation( - rule="the recursive reciprocity sub-query skips overrides (no mirroring another entity's stance)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", - mutated=" if (_iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", - test="IffAndCombatTests.DerivedReciprocityDoesNotMirrorAnotherEntitysOverride", - expect="red", - ), - Mutation( - rule="leaving the zone clears this entity's IFF overrides on the departing entity", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" _iffOverrides.Remove(remove.Value);\n if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))", - mutated=" if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))", - test="IffAndCombatTests.LeavingZoneClearsOverrides", - expect="red", - ), - Mutation( - rule="an override change refreshes EntityHostility immediately (event-driven, no tick required)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));", - mutated=" // _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n // _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));", - test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule", - expect="red", - ), - Mutation( - rule="non-player entities wire a grudge watch; player-controlled entities never do", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (!IsPlayerControlled) WatchForGrudge(entity);", - mutated=" WatchForGrudge(entity);", - test="IffAndCombatTests.PlayerControlledEntitiesNeverAutoGrudge", - expect="red", - ), - Mutation( - rule="grudge only takes hold while this entity currently detects the marker", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))", - mutated=" if (otherIsHostileToThis && !_iffOverrides.ContainsKey(other))", - test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker", - expect="red", - ), - Mutation( - rule="detecting an already-hostile entity grudges it immediately (VisibleEntities.ObserveAdd wiring)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " VisibleEntities.ObserveAdd()\n" - " .Where(add => add.Value == other)\n" - " .Subscribe(_ =>\n" - " {\n" - " if (other.EntityHostility.TryGetValue(this, out var hostile))\n" - " CheckGrudge(hostile);\n" - " })" - ), - mutated=( - " VisibleEntities.ObserveAdd()\n" - " .Where(add => false)\n" - " .Subscribe(_ =>\n" - " {\n" - " if (other.EntityHostility.TryGetValue(this, out var hostile))\n" - " CheckGrudge(hostile);\n" - " })" - ), - test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker", - expect="red", - ), - Mutation( - rule="a grudge is sticky: it never re-evaluates (mirrors current stance) once already grudged", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))\n SetIff(other, true);", - mutated=" if (VisibleEntities.Contains(other))\n SetIff(other, otherIsHostileToThis);", - test="IffAndCombatTests.GrudgeIsStickyAndDoesNotForgiveWhenMarkerGoesNeutral", - expect="red", - ), - Mutation( - rule="PerceivedStanceOf is unknown (null) until this entity detects the other", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " public bool? PerceivedStanceOf(Entity other) =>\n" - " VisibleEntities.Contains(other) && other.EntityHostility.TryGetValue(this, out var hostile)\n" - " ? hostile\n" - " : (bool?) null;" - ), - mutated=( - " public bool? PerceivedStanceOf(Entity other) =>\n" - " other.EntityHostility.TryGetValue(this, out var hostile)\n" - " ? hostile\n" - " : (bool?) null;" - ), - test="IffAndCombatTests.PerceivedStanceIsUnknownUntilDetectedThenReadsTheActualStance", - expect="red", - ), - Mutation( - rule="Weapon.StanceAllowsFire gates on the shooter's own stance, not an unconditional true", - file="Assets/Scripts/ServerShared/Behaviors/Weapon.cs", - anchor=" public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value);", - mutated=" public bool StanceAllowsFire => true;", - test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget", - expect="red", - ), - Mutation( - rule="InstantWeapon.Trigger is safed by StanceAllowsFire before it starts a burst", - file="Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs", - anchor=" if (!StanceAllowsFire) return;", - mutated=" if (false) return;", - test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget", - expect="red", - ), - Mutation( - rule="LockWeapon builds lock on the shooter's own hostility toward the target, not the reverse", - file="Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs", - anchor=" if (Entity.Target.Value != null && Entity.IsHostileTo(Entity.Target.Value))", - mutated=" if (Entity.Target.Value != null && Entity.Target.Value.IsHostileTo(Entity))", - test="IffAndCombatTests.LockWeaponBuildsLockOnTheShootersOwnStanceNotTheTargets", - expect="red", - ), - Mutation( - rule="ZoneGenerator's neutral-wanderer pool excludes the zone owner and the nearest faction", - file="Assets/Scripts/ServerShared/ZoneGenerator.cs", - anchor="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.Where(f => f != owner && f != nearest).ToArray();", - mutated="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.ToArray();", - test="IffAndCombatTests.EligibleWandererFactionsExcludesOwnerAndNearest", - expect="red", - ), - # --- Capability event contract (docs/headless-playground-cut.md fork L, 2026-09-17): each event - # kind must reach only its own subscribers. --- - Mutation( - rule="control (no-op round trip) for the capability event routing test", - file="Assets/Scripts/ServerShared/CapabilityEvents.cs", - anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", - mutated=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", - test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers", - expect="green", - ), - Mutation( - rule="PublishAbsorb notifies only the Absorb stream, not Thrust as well", - file="Assets/Scripts/ServerShared/CapabilityEvents.cs", - anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", - mutated=" public void PublishAbsorb(AbsorbEvent e) { _absorb.OnNext(e); _thrust.OnNext(default); }", - test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers", - expect="red", - ), -] - - -def run_test(cultlib_root: str, filter_expr: str) -> tuple[str, str]: - """Runs the one filtered test. Returns ("ERROR", ...) when the build itself failed (a - compile error is never a legitimate kill or survival — it means the mutation broke - something the test never got a chance to exercise), otherwise ("PASS"|"FAIL", output).""" - proc = subprocess.run( - [ - "dotnet", "test", str(TEST_PROJECT), - f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{filter_expr}", - "--nologo", - ], - cwd=REPO_ROOT, - capture_output=True, - text=True, - ) - output = proc.stdout + proc.stderr - # A build failure never reaches "Test run for ..."/"Passed!"/"Failed!"; it reports - # "Build FAILED." and one or more "error CS..." lines instead. - if "Build FAILED" in output or "error CS" in output: - return "ERROR", output - passed = proc.returncode == 0 and "Failed!" not in output - return ("PASS" if passed else "FAIL"), output - - -def detect_newline(original: bytes) -> bytes: - return b"\r\n" if b"\r\n" in original else b"\n" - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - results = [] - failures = [] - - for m in MUTATIONS: - path = REPO_ROOT / m.file - original = path.read_bytes() - newline = detect_newline(original).decode("ascii") - # Anchors are authored with plain "\n"; translate to the file's own line ending - # before matching, so CRLF-checked-out files (this repo's default) still match. - anchor = m.anchor.replace("\n", newline) - mutated = m.mutated.replace("\n", newline) - text = original.decode("utf-8") - count = text.count(anchor) - if count != 1: - print(f"[ABORT] anchor for '{m.rule}' matches {count} times in {m.file}, expected exactly 1") - failures.append(m.rule) - continue - - mutated_text = text.replace(anchor, mutated, 1) - path.write_bytes(mutated_text.encode("utf-8")) - try: - status_code, output = run_test(args.cultlib_root, m.test) - finally: - # Reverse write: restore the exact original bytes regardless of outcome. - path.write_bytes(original) - restored = path.read_bytes() - if restored != original: - print(f"[FATAL] {m.file} did not restore byte-exact after '{m.rule}'!") - return 2 - - if status_code == "ERROR": - status = "ERROR (build failure)" - failures.append(m.rule) - elif m.expect == "red": - killed = status_code == "FAIL" - status = "KILLED" if killed else "SURVIVED (bad)" - if not killed: - failures.append(m.rule) - else: # control: must stay green - passed = status_code == "PASS" - status = "GREEN (ok)" if passed else "RED (bad: round trip corrupted something)" - if not passed: - failures.append(m.rule) - - results.append((m.rule, m.test, status)) - print(f"{status:22} | {m.test:55} | {m.rule}") - - print() - print("Summary:") - for rule, test, status in results: - print(f" {status:22} {test:55} {rule}") - - if failures: - print(f"\n{len(failures)} mutation(s) did not behave as expected:") - for f in failures: - print(f" - {f}") - return 1 - - print(f"\nAll {len(results)} mutations behaved as expected.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_addressables_cut.py b/tests/mutation_tests_addressables_cut.py new file mode 100644 index 000000000..3194b47cb --- /dev/null +++ b/tests/mutation_tests_addressables_cut.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Mutation tests for docs/addressables-cut.md Cut 2. + +EngineAssetCheck (Assets/Scripts/Editor/EngineAssetCheck.cs) is the rule that keeps every stored +[CultInspectorAssetGuid] value honest. A check nobody can break is a check nobody can trust: each +case here mutates the *real* catalog (GameData/Aetheria.cc) or the source in one specific way the +cut promises to catch, runs EngineAssetCheck.Run in Unity batchmode, asserts it fails naming the +record, then restores the catalog/source exactly. A "control" case first proves the unmutated +catalog passes, so a case that "fails" for the wrong reason (a bad harness, not a caught mutation) +cannot hide. + +Run from the repo root: python tests/mutation_tests_addressables_cut.py +Exit code 0 only if every case behaved as the cut's own verification section (Cut 2) demands. + +This is a separate script rather than an addition to tests/mutation_tests.py: that file is the +item-provenance cut's own harness (docs/item-provenance-cut.md), a source-anchor-mutation-plus- +`dotnet test` runner with no path to driving a Unity batchmode check, which is what +EngineAssetCheck requires. A concurrent session sharing this working tree landed asset moves +under an unrelated commit message (see docs/addressables-cut.md history around 2026-09-18); this +file's name avoids repeating that collision on the one shared harness filename. +""" +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CATALOG = REPO_ROOT / "GameData" / "Aetheria.cc" +UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe" +ITEM_DATA_CS = REPO_ROOT / "Assets" / "Scripts" / "ServerShared" / "ItemData.cs" +DIRECTORY_BUILD_PROPS = REPO_ROOT / "Directory.Build.props" + +# A known-good record to mutate: HullData "LonginusX", whose Prefab already resolves (see the Cut 2 +# migration report). Texture2D guid is its own Schematic (schema_Longinus.png), still a real, +# addressable asset, just the wrong type for Prefab (which wants a GameObject+EntityInstance). +TEXTURE_GUID = "51702555e534a7a4fb39eb105b80bbaf" +# A real, existing, non-addressable asset (a .cs script under Assets/Scripts, outside Assets/Content). +NON_ADDRESSABLE_GUID = "3f3cca818d26268459d28af1cdb469a5" +RANDOM_GUID = "deadbeefdeadbeefdeadbeefdeadbeef" +OLD_PATH = "Assets/Resources/Prefabs/Ships/Longinus.prefab" + + +def run(cmd, **kwargs): + print(f"$ {' '.join(str(c) for c in cmd)}") + return subprocess.run(cmd, capture_output=True, text=True, **kwargs) + + +def pinned_cultlib_revision() -> str: + text = DIRECTORY_BUILD_PROPS.read_text(encoding="utf-8") + match = re.search(r"([0-9a-f]{40})", text) + if not match: + raise SystemExit("could not find CultLibRevision in Directory.Build.props") + return match.group(1) + + +def resolve_cultlib_root(scratch: Path) -> str: + """The mutator (like the rest of the .NET build) must build against the exact CultLib + revision Directory.Build.props pins, not whatever CultLib main happens to be on disk right + now. Reuse CULTLIB_ROOT / the sibling checkout when it already matches; otherwise check out a + detached scratch worktree at the pinned revision, same as the cut's own verification pass.""" + revision = pinned_cultlib_revision() + + candidates = [] + if os.environ.get("CULTLIB_ROOT"): + candidates.append(Path(os.environ["CULTLIB_ROOT"])) + candidates.append(REPO_ROOT.parent / "CultLib") + + for candidate in candidates: + if not candidate.exists(): + continue + head = run(["git", "-C", str(candidate), "rev-parse", "HEAD"]).stdout.strip() + dirty = run(["git", "-C", str(candidate), "status", "--porcelain"]).stdout.strip() + if head == revision and not dirty: + return str(candidate) + + worktree = scratch / f"cultlib-{revision[:12]}" + if not worktree.exists(): + result = run(["git", "-C", str(REPO_ROOT.parent / "CultLib"), "worktree", "add", "--detach", str(worktree), revision]) + if result.returncode != 0: + print(result.stdout, result.stderr) + raise SystemExit("failed to create pinned CultLib worktree for the mutator build") + return str(worktree) + + +def build_mutator(build_dir: Path, cultlib_root: str) -> Path: + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / "Mutate.csproj").write_text( + """ + + Exe + net10.0 + disable + disable + + + + + +""" % (REPO_ROOT / "Aetheria.Shared" / "Aetheria.Shared.csproj"), + encoding="utf-8", + ) + (build_dir / "Program.cs").write_text( + r"""using System; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; + +// Scratch-only (not landed): sets HullData "LonginusX".Prefab to an arbitrary string in a copy of +// the catalog, for tests/mutation_tests.py to feed to EngineAssetCheck and prove it fails. +internal static class Program +{ + private static int Main(string[] args) + { + var catalogPath = args[0]; + var newValue = args[1]; + var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); + try + { + var stored = cache.AllStoredDocuments.First(s => + s.Descriptor.DocumentType.Name == "HullData" && + (string)s.Descriptor.DocumentType.GetField("Name").GetValue(s.Document) == "LonginusX"); + var field = stored.Descriptor.DocumentType.GetField("Prefab", BindingFlags.Public | BindingFlags.Instance); + field.SetValue(stored.Document, newValue); + cache.UpsertAsync(stored.Descriptor.DocumentType, stored.Document, stored.Key).Wait(); + cache.FlushAllBackingStores(); + Console.WriteLine($"Set HullData \"LonginusX\".Prefab = \"{newValue}\""); + return 0; + } + finally + { + cache.Dispose(); + } + } +} +""", + encoding="utf-8", + ) + build = run([ + "dotnet", "build", str(build_dir / "Mutate.csproj"), + "-c", "Release", "-o", str(build_dir / "out"), + f"-p:CultLibRoot={cultlib_root}", + ]) + if build.returncode != 0: + print(build.stdout, build.stderr) + raise SystemExit("mutator build failed") + return build_dir / "out" / "Mutate.dll" + + +def mutate_catalog(mutator_dll: Path, scratch_catalog: Path, value: str): + shutil.copyfile(CATALOG, scratch_catalog) + result = run(["dotnet", str(mutator_dll), str(scratch_catalog), value]) + if result.returncode != 0: + print(result.stdout, result.stderr) + raise SystemExit("mutation failed to apply") + + +def run_engine_asset_check(log_path: Path) -> tuple[int, str]: + proc = subprocess.run( + [UNITY_EXE, "-batchmode", "-quit", "-projectPath", str(REPO_ROOT), + "-executeMethod", "EngineAssetCheck.Run", "-logFile", str(log_path)], + timeout=600, + ) + text = log_path.read_text(encoding="utf-8", errors="replace") + return proc.returncode, text + + +def assert_contains(log_text: str, needle: str, case_name: str): + if needle not in log_text: + raise SystemExit(f"[{case_name}] FAIL: expected log to contain {needle!r}; it did not") + + +def main(): + if not CATALOG.exists(): + raise SystemExit(f"catalog not found: {CATALOG}") + + original_catalog = CATALOG.read_bytes() + original_item_data = ITEM_DATA_CS.read_text(encoding="utf-8") + scratch = Path(tempfile.mkdtemp(prefix="aetheria-mutation-")) + failures = [] + + try: + cultlib_root = resolve_cultlib_root(scratch) + print(f"CultLibRoot for the mutator build: {cultlib_root}") + mutator_dll = build_mutator(scratch / "mutator", cultlib_root) + + # Control: the real, unmutated catalog must pass with 0 failures. If this fails, every + # other case below is meaningless (the harness itself, not the mutation, would be at fault). + print("\n=== control: unmutated catalog ===") + log = scratch / "control.log" + code, text = run_engine_asset_check(log) + if code != 0 or "0 failure" not in text and "stored reference(s) OK" not in text: + failures.append("control: expected exit 0 and a clean report on the real catalog") + else: + print("control OK: exit 0, no failures") + + cases = [ + ("wrong-type (Texture2D guid for a Prefab field)", TEXTURE_GUID, "not a GameObject"), + ("non-addressable guid (real asset outside Assets/Content)", NON_ADDRESSABLE_GUID, "is not addressable"), + ("random 32-hex guid (no such asset)", RANDOM_GUID, "names no existing file"), + ("old Resources path (pre-migration form)", OLD_PATH, "not 32 lowercase hex characters"), + ] + + for name, value, expect in cases: + print(f"\n=== mutation: {name} ===") + try: + mutated = scratch / "mutated.cc" + mutate_catalog(mutator_dll, mutated, value) + shutil.copyfile(mutated, CATALOG) + + log = scratch / (name.split()[0] + ".log") + code, text = run_engine_asset_check(log) + + if code == 0: + failures.append(f"{name}: EngineAssetCheck exited 0; the mutation was not caught") + continue + if "LonginusX" not in text: + failures.append(f"{name}: failure did not name the record (LonginusX)") + continue + assert_contains(text, expect, name) + print(f"OK: caught, exit {code}, named LonginusX, mentioned {expect!r}") + except SystemExit as exc: + failures.append(f"{name}: {exc}") + finally: + CATALOG.write_bytes(original_catalog) + + # Missing consumer contract: a new [CultInspectorAssetGuid] member EngineAssetCheck's table + # doesn't know about must fail loudly, not be silently skipped. + print("\n=== mutation: attributed member missing from the consumer contract table ===") + try: + marker = " [Inspectable, JsonProperty(\"hardpoints\"), Key(23)] \n public List Hardpoints = new List();" + replacement = marker + ( + "\n\n // Scratch mutation (tests/mutation_tests.py): an attributed member with no " + "row in EngineAssetCheck.ConsumerContracts. Must fail the check, not be skipped.\n" + " [Inspectable, CultInspectorAssetGuid, JsonProperty(\"scratchMutation\"), Key(30)]\n" + f" public string ScratchMutationField = \"{TEXTURE_GUID}\";" + ) + if marker not in original_item_data: + raise SystemExit("anchor text for the scratch field insertion was not found in ItemData.cs") + mutated_source = original_item_data.replace(marker, replacement, 1) + ITEM_DATA_CS.write_text(mutated_source, encoding="utf-8") + + log = scratch / "missing-contract.log" + code, text = run_engine_asset_check(log) + if code == 0: + failures.append("missing-contract: EngineAssetCheck exited 0; an untabled member was not caught") + elif "ScratchMutationField" not in text or "no consumer contract registered" not in text: + failures.append("missing-contract: failure did not name the untabled member and reason") + else: + print("OK: caught, exit", code, "named ScratchMutationField with no registered contract") + finally: + ITEM_DATA_CS.write_text(original_item_data, encoding="utf-8") + + # Restoration proof: after reverting the source, a fresh batchmode compile + check must be + # clean again — the mutation must leave no residue once undone. + print("\n=== restoration check: reverted source recompiles clean ===") + log = scratch / "restored.log" + code, text = run_engine_asset_check(log) + if code != 0: + failures.append("restoration: EngineAssetCheck did not exit 0 after reverting the scratch field") + else: + print("OK: restored source compiles and the check is clean again") + + finally: + CATALOG.write_bytes(original_catalog) + ITEM_DATA_CS.write_text(original_item_data, encoding="utf-8") + shutil.rmtree(scratch, ignore_errors=True) + print(f"\nrestored {CATALOG} and {ITEM_DATA_CS} to their original content.") + + if failures: + print("\n=== MUTATION TESTS FAILED ===") + for f in failures: + print(" -", f) + return 1 + + print("\n=== all mutation tests passed ===") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/mutation_tests_shield_panel_cut1.py b/tests/mutation_tests_shield_panel_cut1.py new file mode 100644 index 000000000..00128c413 --- /dev/null +++ b/tests/mutation_tests_shield_panel_cut1.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Mutation tests for docs/shield-panel-cut.md Cut 1 (the shield-panel source landing). + +Assets/Scripts/Editor/ShieldPanelCut1Verify.cs is the batchmode probe Cut 1 added because no +NUnit coverage can reach these files: Tests.asmdef cannot see Assembly-CSharp (the map's Q7), and +the rules it defends are Unity-native (shader/compute compile errors, cell counts a compute +kernel would read back). A probe nobody can break is a probe nobody can trust, so each case here +mutates the *real* source in one specific way the cut's own text says it fixed, runs +ShieldPanelCut1Verify.Run in Unity batchmode, asserts it goes red for the reason the cut names, +then restores the source exactly. A control case first proves the unmutated tree passes, so a +case that "fails" for the wrong reason (a broken harness, not a caught mutation) cannot hide. + +This is a separate script rather than an addition to tests/mutation_tests.py (the item-provenance +cut's source-anchor-mutation-plus-`dotnet test` runner, with no path to driving a Unity batchmode +check) or tests/mutation_tests_addressables_cut.py (EngineAssetCheck's own harness, same reason +this one exists, different rule). It touches only files this cut owns. + +Run from the repo root: python tests/mutation_tests_shield_panel_cut1.py +Exit code 0 only if every case behaved as Cut 1's verification section demands. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe" + +SHIELD_FIELD = REPO_ROOT / "Assets" / "Shaders" / "Compute" / "ShieldField" +TILINGS_CS = SHIELD_FIELD / "Tilings.cs" +SHIELD_COMMON_HLSL = SHIELD_FIELD / "ShieldCommon.hlsl" +SHIELD_PANEL_CS = SHIELD_FIELD / "ShieldPanel.cs" + + +def run_probe(log_path: Path) -> tuple[int, str]: + proc = subprocess.run( + [UNITY_EXE, "-batchmode", "-nographics", "-quit", "-projectPath", str(REPO_ROOT), + "-executeMethod", "Aetheria.EditorTools.ShieldPanelCut1Verify.Run", + "-logFile", str(log_path)], + timeout=600, + ) + text = log_path.read_text(encoding="utf-8", errors="replace") + return proc.returncode, text + + +def replace_unique(path: Path, anchor: str, mutated: str) -> str: + original = path.read_text(encoding="utf-8") + count = original.count(anchor) + if count != 1: + raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") + path.write_text(original.replace(anchor, mutated), encoding="utf-8") + return original + + +def main() -> int: + scratch = REPO_ROOT / "tests" / "_scratch_shield_panel_cut1" + scratch.mkdir(parents=True, exist_ok=True) + failures: list[str] = [] + + original_tilings = TILINGS_CS.read_text(encoding="utf-8") + original_common = SHIELD_COMMON_HLSL.read_text(encoding="utf-8") + original_panel = SHIELD_PANEL_CS.read_text(encoding="utf-8") + + try: + # --- control: the real, unmutated tree must pass --- + print("\n=== control: unmutated tree ===") + code, text = run_probe(scratch / "control.log") + if code != 0 or "[ShieldPanelCut1Verify] PASS" not in text: + failures.append("control: expected exit 0 and PASS on the unmutated tree") + else: + print("control OK: exit 0, PASS") + + # --- D18: Penrose must stop expressing panelRadius if the derivation is reverted --- + print("\n=== mutation: D18 regression (Penrose iterations decoupled from radius again) ===") + anchor = ( + " int iterations = Mathf.Clamp(\n" + " Mathf.CeilToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)) + 1,\n" + " 1, 12);\n" + " return Penrose(radius, iterations);" + ) + mutated = ( + " // mutation_tests_shield_panel_cut1.py: reintroduce the D18 bug — a fixed\n" + " // iteration count, independent of radius or cellSize.\n" + " return Penrose(radius, 5);" + ) + try: + replace_unique(TILINGS_CS, anchor, mutated) + code, text = run_probe(scratch / "d18.log") + if code == 0 or "D18 not fixed" not in text: + failures.append("D18 regression: probe did not go red naming the D18 regression") + else: + print("OK: caught — probe exited non-zero and named the D18 regression") + finally: + TILINGS_CS.write_text(original_tilings, encoding="utf-8") + + # --- D4: `centroid` is a reserved word in the shader/compute compiler; reintroducing it as + # a CellStatic field name must produce a compile error somewhere it is used (ShieldCommon.hlsl + # is shared by both ShieldSim.compute and ShieldPanel.shader) --- + print("\n=== mutation: D4 regression (centroid field name reintroduced) ===") + anchor = "struct CellStatic\n{\n float2 center;" + mutated = "struct CellStatic\n{\n float2 centroid;" + try: + replace_unique(SHIELD_COMMON_HLSL, anchor, mutated) + # Every use of `.center` on a CellStatic instance across the two GPU-side consumers + # must track the field rename for this mutation to compile-check the reserved name + # (not just silently fail on a dangling reference to a field that no longer exists + # under that name — the point is the *reserved word*, not a typo). + sim_cs_path = SHIELD_FIELD / "ShieldSim.compute" + shader_path = SHIELD_FIELD / "ShieldPanel.shader" + orig_sim = sim_cs_path.read_text(encoding="utf-8") + orig_shader = shader_path.read_text(encoding="utf-8") + sim_cs_path.write_text(orig_sim.replace("c.center", "c.centroid"), encoding="utf-8") + shader_path.write_text( + orig_shader.replace("x.c.center", "x.c.centroid").replace("nc.center", "nc.centroid"), + encoding="utf-8", + ) + try: + code, text = run_probe(scratch / "d4.log") + caught = ("Compute shader error" in text or "Shader error" in text) and "centroid" in text + if code == 0 or not caught: + failures.append("D4 regression: probe did not go red naming the reserved 'centroid' token") + else: + print("OK: caught — probe exited non-zero and named the reserved 'centroid' token") + finally: + sim_cs_path.write_text(orig_sim, encoding="utf-8") + shader_path.write_text(orig_shader, encoding="utf-8") + finally: + SHIELD_COMMON_HLSL.write_text(original_common, encoding="utf-8") + + # --- D2/D5: RenderParams.shaderPass does not exist in 6000.3.24f1; reintroducing it must + # fail the C# compile, not silently resurrect the deleted per-pass draw path --- + print("\n=== mutation: D2 regression (RenderParams.shaderPass reintroduced) ===") + anchor = " Graphics.RenderPrimitivesIndirect(rp, MeshTopology.Triangles, argsFill, 1, 0);\n }" + mutated = " rp.shaderPass = 0;\n Graphics.RenderPrimitivesIndirect(rp, MeshTopology.Triangles, argsFill, 1, 0);\n }" + try: + replace_unique(SHIELD_PANEL_CS, anchor, mutated) + code, text = run_probe(scratch / "d2.log") + if code == 0: + failures.append("D2 regression: batchmode exited 0 despite a nonexistent RenderParams.shaderPass member") + else: + print("OK: caught — batchmode failed to compile with shaderPass reintroduced") + finally: + SHIELD_PANEL_CS.write_text(original_panel, encoding="utf-8") + + finally: + # belt-and-suspenders: never leave a mutated file behind even if something above threw + TILINGS_CS.write_text(original_tilings, encoding="utf-8") + SHIELD_COMMON_HLSL.write_text(original_common, encoding="utf-8") + SHIELD_PANEL_CS.write_text(original_panel, encoding="utf-8") + + print("\n=== summary ===") + if failures: + for f in failures: + print(f"FAIL: {f}") + return 1 + print(f"All cases behaved as Cut 1's verification section demands. Logs: {scratch}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/mutation_tests_shield_panel_cut2.py b/tests/mutation_tests_shield_panel_cut2.py new file mode 100644 index 000000000..bf6987842 --- /dev/null +++ b/tests/mutation_tests_shield_panel_cut2.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Mutation tests for docs/shield-panel-cut.md Cut 2 (the ShieldEnvelope landing). + +Assets/Scripts/Editor/ShieldPanelCut2Verify.cs is the batchmode probe Cut 2 added, for the same +reason Cut 1 has one (the map's Q7): Tests.asmdef cannot see Assembly-CSharp, so ShieldEnvelope +(pure, testable math though it is) has no NUnit path. A probe nobody can break is a probe nobody +can trust, so this mutates the *real* source in specific ways the cut's own text calls out as the +plausible wrong answers, runs ShieldPanelCut2Verify.Run in Unity batchmode, asserts it goes red +for the reason the cut names, then restores the source exactly. A control case first proves the +unmutated tree passes. + +The mutation this file exists for is the one the map calls out by name: "the ellipsoid normal is +exactly the kind of arithmetic that has a plausible-looking wrong answer" — SurfaceNormal +collapsing to normalize(p), which is only correct for a sphere and silently wrong (but +plausible-looking) for Longinus's 4:1 ellipsoid. Two more mutations cover the other two +correctness-load-bearing lines this cut introduced: the per-axis mesh-extents factor collapsing +to a flat guess, and the covector (inverse-scale) transform collapsing to the more "obvious" but +wrong TransformDirection. + +This is a separate script rather than an addition to tests/mutation_tests.py (the item-provenance +cut's headless dotnet-test runner, with no path to driving a Unity batchmode check) or +tests/mutation_tests_shield_panel_cut1.py (that one owns Cut 1's rules; this one touches only +Cut 2's file, ShieldEnvelope.cs). + +Run from the repo root: python tests/mutation_tests_shield_panel_cut2.py +Exit code 0 only if every case behaved as Cut 2's verification section demands. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe" + +SHIELD_ENVELOPE_CS = REPO_ROOT / "Assets" / "Scripts" / "Gameplay" / "ShieldEnvelope.cs" + + +def run_probe(log_path: Path) -> tuple[int, str]: + proc = subprocess.run( + [UNITY_EXE, "-batchmode", "-nographics", "-quit", "-projectPath", str(REPO_ROOT), + "-executeMethod", "Aetheria.EditorTools.ShieldPanelCut2Verify.Run", "-logFile", str(log_path)], + timeout=600, + ) + text = log_path.read_text(encoding="utf-8", errors="replace") + return proc.returncode, text + + +def replace_unique(path: Path, anchor: str, mutated: str) -> str: + original = path.read_text(encoding="utf-8") + count = original.count(anchor) + if count != 1: + raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") + path.write_text(original.replace(anchor, mutated), encoding="utf-8") + return original + + +def main() -> int: + scratch = REPO_ROOT / "tests" / "_scratch_shield_panel_cut2" + scratch.mkdir(parents=True, exist_ok=True) + failures: list[str] = [] + + original_envelope = SHIELD_ENVELOPE_CS.read_text(encoding="utf-8") + + try: + # --- control: the real, unmutated tree must pass --- + print("\n=== control: unmutated tree ===") + code, text = run_probe(scratch / "control.log") + if code != 0 or "[ShieldPanelCut2Verify] PASS" not in text: + failures.append("control: expected exit 0 and PASS on the unmutated tree") + else: + print("control OK: exit 0, PASS") + + # --- the mutation the map calls out by name: SurfaceNormal collapsing to normalize(p). + # This is the "plausible-looking wrong answer" — correct for a sphere, silently wrong for + # a non-uniform ellipsoid — that the map's Cut 2 verification section says must have a + # test that fails under its own mutation. --- + print("\n=== mutation: SurfaceNormal degenerates to normalize(p) ===") + anchor = ( + " var s = transform.lossyScale;\n" + " var gradientOverScale = new Vector3(\n" + " s.x != 0 ? gradient.x / s.x : gradient.x,\n" + " s.y != 0 ? gradient.y / s.y : gradient.y,\n" + " s.z != 0 ? gradient.z / s.z : gradient.z);\n" + " var worldGradient = transform.rotation * gradientOverScale;" + ) + mutated = ( + " // mutation_tests_shield_panel_cut2.py: reintroduce normalize(p) as the normal —\n" + " // correct for a sphere, plausible-looking, wrong for a non-uniform ellipsoid.\n" + " var worldGradient = transform.TransformDirection(local);" + ) + try: + replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated) + code, text = run_probe(scratch / "normalize_p.log") + caught = "SurfaceNormal equals normalize(p)" in text or "disagrees with the finite-difference gradient" in text + if code == 0 or not caught: + failures.append("normalize(p) regression: probe did not go red naming the sphere-vs-ellipsoid distinction") + else: + print("OK: caught — probe exited non-zero, sphere-vs-ellipsoid distinction check fired") + finally: + SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8") + + # --- the covector-transform bug this cut's own Hands run hit and fixed: dividing by scale + # is required (a normal is a covector), multiplying (TransformDirection) is the "obvious" + # but wrong choice for a non-uniform scale. --- + print("\n=== mutation: SurfaceNormal multiplies by scale instead of dividing (TransformDirection) ===") + anchor = ( + " var s = transform.lossyScale;\n" + " var gradientOverScale = new Vector3(\n" + " s.x != 0 ? gradient.x / s.x : gradient.x,\n" + " s.y != 0 ? gradient.y / s.y : gradient.y,\n" + " s.z != 0 ? gradient.z / s.z : gradient.z);\n" + " var worldGradient = transform.rotation * gradientOverScale;" + ) + mutated = ( + " // mutation_tests_shield_panel_cut2.py: the \"obvious\" wrong transform — a normal\n" + " // is a covector and must be scaled by the INVERSE of lossyScale, not lossyScale.\n" + " var worldGradient = transform.TransformDirection(gradient);" + ) + try: + replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated) + code, text = run_probe(scratch / "transformdirection.log") + caught = "disagrees with the finite-difference gradient" in text + if code == 0 or not caught: + failures.append("TransformDirection regression: probe did not go red naming the finite-difference disagreement") + else: + print("OK: caught — probe exited non-zero, finite-difference gradient check fired") + finally: + SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8") + + # --- the mesh-extents factor collapsing to a flat, wrong guess (the map's own draft + # guessed 0.5; this checks the fix stays measured-per-axis, not re-flattened). --- + print("\n=== mutation: Radii factor flattened to a guessed 0.5 instead of the measured mesh extents ===") + anchor = "_localExtents = mesh != null ? mesh.bounds.extents : Vector3.one;" + mutated = "_localExtents = mesh != null ? new Vector3(0.5f, 0.5f, 0.5f) : Vector3.one;" + try: + replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated) + code, text = run_probe(scratch / "flat_half.log") + caught = "does not match lossyScale*measuredExtents" in text + if code == 0 or not caught: + failures.append("flat-0.5 regression: probe did not go red naming the Radii mismatch") + else: + print("OK: caught — probe exited non-zero, Radii mismatch check fired") + finally: + SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8") + + # --- the regression the coordinator flagged directly: ShieldManager's (and FieldDriver's) + # missing-envelope fallback must reproduce the deleted pre-Cut-2 formula, not silently swap + # in a fixed direction. This mutates the shared static SurfaceDirection entry point back to + # the fixed Vector3.forward every hit direction degraded to before the fix. --- + print("\n=== mutation: SurfaceDirection fallback collapses to a fixed Vector3.forward ===") + anchor = ( + " public static Vector3 SurfaceDirection(Transform transform, Vector3 worldPoint)\n" + " => LocalUnitDirection(transform, worldPoint);" + ) + mutated = ( + " // mutation_tests_shield_panel_cut2.py: reintroduce the exact regression the\n" + " // coordinator flagged -- a fallback that silently changes behaviour instead of\n" + " // reproducing the deleted formula.\n" + " public static Vector3 SurfaceDirection(Transform transform, Vector3 worldPoint)\n" + " => Vector3.forward;" + ) + try: + replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated) + code, text = run_probe(scratch / "fixed_forward.log") + caught = "SurfaceDirection(transform, p) fallback diverged" in text + if code == 0 or not caught: + failures.append("fixed-Vector3.forward regression: probe did not go red naming the SurfaceDirection divergence") + else: + print("OK: caught — probe exited non-zero, legacy fallback pin fired") + finally: + SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8") + + # --- the other fallback static, mutated to the "wrong transform" shape the coordinator + # named: dropping the local-frame projection and returning the raw world point instead. --- + print("\n=== mutation: LegacyLocalSurfacePoint fallback returns the raw world point ===") + anchor = ( + " public static Vector3 LegacyLocalSurfacePoint(Transform transform, Vector3 worldPoint)\n" + " => Vector3.Scale(LocalUnitDirection(transform, worldPoint), transform.localScale);" + ) + mutated = ( + " // mutation_tests_shield_panel_cut2.py: the \"wrong transform\" mutation -- skip the\n" + " // local-frame projection entirely and hand back the untransformed world point.\n" + " public static Vector3 LegacyLocalSurfacePoint(Transform transform, Vector3 worldPoint)\n" + " => worldPoint;" + ) + try: + replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated) + code, text = run_probe(scratch / "wrong_transform.log") + caught = "LegacyLocalSurfacePoint(transform, p) fallback diverged" in text + if code == 0 or not caught: + failures.append("raw-world-point regression: probe did not go red naming the LegacyLocalSurfacePoint divergence") + else: + print("OK: caught — probe exited non-zero, legacy fallback pin fired") + finally: + SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8") + + finally: + # belt-and-suspenders: never leave a mutated file behind even if something above threw + SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8") + + print("\n=== summary ===") + if failures: + for f in failures: + print(f"FAIL: {f}") + return 1 + print(f"All cases behaved as Cut 2's verification section demands. Logs: {scratch}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/mutation_tests_shield_panel_cut3.py b/tests/mutation_tests_shield_panel_cut3.py new file mode 100644 index 000000000..23c4f0675 --- /dev/null +++ b/tests/mutation_tests_shield_panel_cut3.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Mutation tests for docs/shield-panel-cut.md Cut 3 (the interceptor presenter and panel pool). + +Assets/Scripts/Editor/ShieldPanelCut3Verify.cs is the batchmode probe Cut 3 added, for the same +reason Cut 1 and Cut 2 have one (the map's Q7): Tests.asmdef cannot see Assembly-CSharp. Unlike +Cut 1/2's probes this one drives actual GraphicsBuffer dispatches (it steps ShieldPanel's private +Update() by reflection, since Cut 1 deliberately removed [ExecuteAlways] -- D13), so it must run +WITHOUT -nographics. + +Each case here mutates the *real* source in one specific way the cut's own text says it fixed or +established as a rule, runs ShieldPanelCut3Verify.Run in Unity batchmode, asserts it goes red for +the reason the cut names, then restores the source exactly. A control case first proves the +unmutated tree passes, so a case that "fails" for the wrong reason (a broken harness, not a caught +mutation) cannot hide. + +This is a separate script rather than an addition to mutation_tests_shield_panel_cut1.py or +_cut2.py (each of those owns its own cut's rules and touches only that cut's files) or +mutation_tests.py (the item-provenance cut's headless dotnet-test runner, with no path to a Unity +batchmode check). It touches only files Cut 3 owns: ShieldPanel.cs (the D17/D20/pooling fixes), +Tilings.cs (D21), and ShieldInterceptor.cs (reuse/exhaustion). + +Run from the repo root: python tests/mutation_tests_shield_panel_cut3.py +Exit code 0 only if every case behaved as Cut 3's verification section demands. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe" + +SHIELD_FIELD = REPO_ROOT / "Assets" / "Shaders" / "Compute" / "ShieldField" +SHIELD_PANEL_CS = SHIELD_FIELD / "ShieldPanel.cs" +TILINGS_CS = SHIELD_FIELD / "Tilings.cs" +SHIELD_INTERCEPTOR_CS = REPO_ROOT / "Assets" / "Scripts" / "Gameplay" / "ShieldInterceptor.cs" + + +def run_probe(log_path: Path) -> tuple[int, str]: + proc = subprocess.run( + [UNITY_EXE, "-batchmode", "-quit", "-projectPath", str(REPO_ROOT), + "-executeMethod", "Aetheria.EditorTools.ShieldPanelCut3Verify.Run", + "-logFile", str(log_path)], + timeout=600, + ) + text = log_path.read_text(encoding="utf-8", errors="replace") + return proc.returncode, text + + +def replace_unique(path: Path, anchor: str, mutated: str) -> None: + original = path.read_text(encoding="utf-8") + count = original.count(anchor) + if count != 1: + raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") + path.write_text(original.replace(anchor, mutated), encoding="utf-8") + + +def main() -> int: + scratch = REPO_ROOT / "tests" / "_scratch_shield_panel_cut3" + scratch.mkdir(parents=True, exist_ok=True) + failures: list[str] = [] + + original_panel = SHIELD_PANEL_CS.read_text(encoding="utf-8") + original_tilings = TILINGS_CS.read_text(encoding="utf-8") + original_interceptor = SHIELD_INTERCEPTOR_CS.read_text(encoding="utf-8") + + def restore_all() -> None: + SHIELD_PANEL_CS.write_text(original_panel, encoding="utf-8") + TILINGS_CS.write_text(original_tilings, encoding="utf-8") + SHIELD_INTERCEPTOR_CS.write_text(original_interceptor, encoding="utf-8") + + try: + # --- control: the real, unmutated tree must pass --- + print("\n=== control: unmutated tree ===") + code, text = run_probe(scratch / "control.log") + if code != 0 or "[ShieldPanelCut3Verify] PASS" not in text: + failures.append("control: expected exit 0 and PASS on the unmutated tree") + else: + print("control OK: exit 0, PASS") + + # --- D20: reverting the backdated arm instant to the pre-Cut-3 hardcoded 0f does NOT + # swallow the spawn-frame hit itself (that's the seeded bState, D17's own fix, still doing + # its job for frame 1) -- it reopens a narrower, one-frame-later symptom: KUpdate resets + # growth back to 0 at the end of frame 1, so frame 2's own injection is what gets gated. + # This is exactly why the probe checks frame 2 separately from frame 1. --- + print("\n=== mutation: D20 regression (panelSpawnTime hardcoded back to 0f) ===") + try: + replace_unique( + SHIELD_PANEL_CS, + 'sim.SetFloat("_PanelSpawnTime", panelSpawnTime);', + 'sim.SetFloat("_PanelSpawnTime", 0f);', + ) + code, text = run_probe(scratch / "d17_spawntime.log") + caught = code != 0 and "D20 not fixed" in text + if not caught: + failures.append("D20 regression (spawn time): probe did not go red naming D20") + else: + print("OK: caught — probe named D20 not fixed") + finally: + restore_all() + + # --- D17: the other half of the fix -- if growth is never seeded ahead of time (left at + # KInit's 0), the gate blocks the spawn-frame hit even with panelSpawnTime correct. --- + print("\n=== mutation: D17 regression (growth seed always 0) ===") + try: + replace_unique( + SHIELD_PANEL_CS, + "growthSeed[i].growth = Mathf.Clamp01(\n" + " (growDuration - data.cells[i].spawnDelay) / Mathf.Max(growDuration, 1e-3f));", + "growthSeed[i].growth = 0f; // mutation_tests_shield_panel_cut3.py: reintroduce D17", + ) + code, text = run_probe(scratch / "d17_seed.log") + caught = code != 0 and "D17 not fixed" in text + if not caught: + failures.append("D17 regression (growth seed): probe did not go red naming D17") + else: + print("OK: caught — probe named D17 not fixed") + finally: + restore_all() + + # --- Pooling: reverting OnEnable/OnDisable to the pre-Cut-3 unconditional Rebuild/ + # ReleaseAll must rebuild the tiling on every pooled reuse, which the pool exists to avoid + # (§2.4: 1.3-21ms vs 8-11us) -- CheckPoolReuseDoesNotRebuild is the rule that pins this. --- + print("\n=== mutation: pooling regression (OnEnable rebuilds every reuse again) ===") + try: + replace_unique( + SHIELD_PANEL_CS, + "void OnEnable() { if (data == null) Rebuild(); }\n" + " void OnDisable() { }\n" + " void OnDestroy() { ReleaseAll(); }", + "void OnEnable() { Rebuild(); } // mutation_tests_shield_panel_cut3.py: reintroduce the pre-Cut-3 rebuild-on-every-reuse bug\n" + " void OnDisable() { ReleaseAll(); }", + ) + code, text = run_probe(scratch / "pooling.log") + caught = code != 0 and "OnEnable is rebuilding (or OnDisable is releasing)" in text + if not caught: + failures.append("pooling regression: probe did not go red naming the rebuild-on-reuse regression") + else: + print("OK: caught — probe named the rebuild-on-reuse regression") + finally: + restore_all() + + # --- Reuse: a second hit inside a living panel must strike it, not spawn a new one. Force + # FindReusable to always miss and the rule collapses -- the reuse probe must see live count + # go to 2 on the second (nearby) strike instead of staying at 1. --- + print("\n=== mutation: reuse regression (FindReusable always misses) ===") + try: + replace_unique( + SHIELD_INTERCEPTOR_CS, + " int FindReusable(Vector3 point)\n {\n float threshold = PanelRadius * ReuseFraction;", + " int FindReusable(Vector3 point)\n {\n return -1; // mutation_tests_shield_panel_cut3.py: never reuse\n" + " float threshold = PanelRadius * ReuseFraction;", + ) + code, text = run_probe(scratch / "reuse.log") + caught = code != 0 and "spawned a new panel" in text + if not caught: + failures.append("reuse regression: probe did not go red naming the spurious new panel") + else: + print("OK: caught — probe named the spurious new panel") + finally: + restore_all() + + # --- Pool exhaustion: MaxLivePanels must cap the live set. Removing the eviction branch (so + # exhaustion always spawns a fresh instance instead of recycling the oldest) must blow the cap. --- + print("\n=== mutation: exhaustion regression (cap never enforced) ===") + try: + replace_unique( + SHIELD_INTERCEPTOR_CS, + " ShieldPanel panel;\n" + " Prototype panelProto;\n" + " if (_live.Count >= MaxLivePanels)\n", + " ShieldPanel panel;\n" + " Prototype panelProto;\n" + " if (false) // mutation_tests_shield_panel_cut3.py: never evict, blow the cap\n", + ) + code, text = run_probe(scratch / "exhaustion.log") + caught = code != 0 and "the cap did not hold" in text + if not caught: + failures.append("exhaustion regression: probe did not go red naming the blown cap") + else: + print("OK: caught — probe named the blown cap") + finally: + restore_all() + + # --- D21: reverting the Round-to-nearest fix to the pre-Cut-3 CeilToInt(...) + 1 formula + # must blow past the small-factor bound again (24x/8x order-of-magnitude, not 2-5x). --- + print("\n=== mutation: D21 regression (Penrose iteration count reverts to Ceil+1) ===") + try: + replace_unique( + TILINGS_CS, + " int iterations = Mathf.Clamp(\n" + " Mathf.RoundToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)),\n" + " 1, 12);", + " int iterations = Mathf.Clamp(\n" + " Mathf.CeilToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)) + 1,\n" + " 1, 12); // mutation_tests_shield_panel_cut3.py: reintroduce D21's overshoot", + ) + code, text = run_probe(scratch / "d21.log") + caught = code != 0 and "not within a small factor" in text + if not caught: + failures.append("D21 regression: probe did not go red naming the small-factor violation") + else: + print("OK: caught — probe named the small-factor violation") + finally: + restore_all() + + finally: + # belt-and-suspenders: never leave a mutated file behind even if something above threw + restore_all() + + print("\n=== summary ===") + if failures: + for f in failures: + print(f"FAIL: {f}") + return 1 + print(f"All cases behaved as Cut 3's verification section demands. Logs: {scratch}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 25e704998..46aae9575 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -23,14 +23,21 @@ public static class Program case "census": return Census(); case "station-fit": return StationFit(); case "hardpoint-fit": return HardpointFit(); - case "loadout": return Loadout(args.Skip(1).FirstOrDefault()); + case "loadout": return Loadout(args.Skip(1).ToArray()); case "save": return Save(); case "factions": return Factions(); case "dangling": return Dangling(args.Skip(1).ToArray()); case "settings": return Settings(); case "settings-dump": return SettingsDump(); + case "shield-migrate": return ShieldMigrate(args.Contains("apply")); + case "brownout-migrate": return BrownoutMigrate(args.Contains("apply")); + case "roles-migrate": return RolesMigrate(args.Contains("apply")); + case "firing-arc-migrate": return FiringArcMigrate(args.Contains("apply")); + case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); + case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); + case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); return 1; } } @@ -89,6 +96,29 @@ public static class Program string.Join(", ", product.Roles.Select(r => $"{r.Role} {r.Mean:0.00}±{r.StandardDeviation:0.00}"))); if (authored.Length == 0) Console.WriteLine(" none yet: add roles to a design, then set each product's means"); + // Cut 7 (docs/stats-and-power-cut.md): "adding a role to a shipped design changes nothing for existing + // lots... but a newly minted lot from a product with no spread for that role rolls new ProductRole() -- + // mean .5 ... The census is where that is caught, and the two must land together." A design's roles and + // a selling product's role spread can drift independently (a role added after the product was authored, + // or a product added after roles landed), so every sold, role-bearing design is checked against every + // product that actually sells it, not only the ones this run's roles-migrate happened to touch. + var roleGaps = 0; + foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0)) + { + var design = db.Cache.RefOf(item).Key; + var sellers = products.Where(p => p.Design.Key.Equals(design)).ToArray(); + foreach (var product in sellers) + { + var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role)); + var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray(); + if (missing.Length == 0) continue; + roleGaps++; + Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" + + " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality"); + } + } + Console.WriteLine($"\n{roleGaps} (product, role) pairs where a sold, role-bearing design's product authors no matching spread"); + // Brand() picks the first (maker, design) product in record-key order; more than one means the choice is // arbitrary rather than authored, which the rule until segment bands exist forbids. var duplicateBrands = products @@ -107,6 +137,287 @@ public static class Program return 0; } + // Cut 7 (docs/stats-and-power-cut.md): "products author quality, designs author roles." Zero designs + // declared a role and zero products authored role quality before this command; every field->role table below + // is the one place that content is decided, so the operator reviews a legible table instead of hand-picking + // through 51 catalog records. Field names are matched against the concrete behaviour Type they appear on, not + // by name alone, so "Range" on a launcher's guidance system and "Range" on a mining tool never collide. + // + // A stat is only worth pointing at a role if it actually varies with Quality *and* the value moves (Min != + // Max) -- Cut 1's flat placeholder stats (min == max, a bare "Heat^1" term the migration used as a harmless + // non-empty Terms list) carry no observable role-vs-workmanship difference, so authoring a role there would + // be authoring fiction. Fields the maps below do not mention keep generic quality; the report says why. + private static readonly Dictionary BallisticWeaponRoles = new Dictionary + { + ["Damage"] = "barrel", ["Range"] = "barrel", ["Velocity"] = "barrel", ["Penetration"] = "barrel", + ["Cooldown"] = "feed mechanism", ["Spread"] = "feed mechanism", ["Heat"] = "feed mechanism", + ["Visibility"] = "feed mechanism", ["Energy"] = "feed mechanism", + }; + + private static readonly Dictionary EnergyWeaponRoles = new Dictionary + { + ["Damage"] = "focusing array", ["Range"] = "focusing array", ["Velocity"] = "focusing array", + ["Penetration"] = "focusing array", + ["Energy"] = "power coupling", ["Heat"] = "power coupling", ["ChargeTime"] = "power coupling", + ["ChargeEnergy"] = "power coupling", ["ChargeHeat"] = "power coupling", ["Cooldown"] = "power coupling", + ["Spread"] = "power coupling", ["Visibility"] = "power coupling", + }; + + private static readonly Dictionary LauncherRoles = new Dictionary + { + ["Damage"] = "warhead", ["Penetration"] = "warhead", ["DamageSpread"] = "warhead", + ["LockSpeed"] = "guidance system", ["SensorImpact"] = "guidance system", ["LockAngle"] = "guidance system", + ["DirectionImpact"] = "guidance system", ["Decay"] = "guidance system", ["Range"] = "guidance system", + ["Cooldown"] = "guidance system", ["Spread"] = "guidance system", ["Visibility"] = "guidance system", + ["Energy"] = "guidance system", ["Heat"] = "guidance system", + ["Thrust"] = "thruster", ["MissileVelocity"] = "thruster", ["Velocity"] = "thruster", + }; + + private static readonly Dictionary RadiatorRoles = new Dictionary + { + ["Emissivity"] = "fins", ["WasteHeat"] = "fins", + ["PumpedHeat"] = "pump", ["EnergyUsage"] = "pump", + ["ThermalMass"] = "reservoir", + }; + + private static readonly Dictionary ReactorRoles = new Dictionary + { + ["Charge"] = "core", ["Efficiency"] = "core", + ["OverloadEfficiency"] = "regulator", ["ThrottlingFactor"] = "regulator", + }; + + private static readonly Dictionary SensorRoles = new Dictionary + { + ["Sensitivity"] = "receiver", + ["PingBoost"] = "emitter", ["PingEnergy"] = "emitter", ["PingRange"] = "emitter", + ["PingVisibility"] = "emitter", ["PingCooldown"] = "emitter", + }; + + private static readonly Dictionary ThrusterRoles = new Dictionary + { + ["Thrust"] = "nozzle", ["Visibility"] = "nozzle", + ["Heat"] = "injector", ["EnergyUsage"] = "injector", + }; + + private static readonly Dictionary AetherDriveRoles = new Dictionary + { + ["MaximumRpm"] = "rotor", ["Torque"] = "rotor", + ["CouplingEfficiency"] = "coupling", ["PassiveCoupling"] = "coupling", ["EnergyDraw"] = "coupling", + }; + + private static readonly Dictionary HullShipRoles = new Dictionary + { + ["CrossSection"] = "plating", + }; + + // Tool designs are not a shared kind the way a laser or a radiator is -- each is its own bespoke gadget, so + // its role map is keyed by design name rather than by behaviour field, same as the census already treats + // "Tool" as a catch-all HardpointType. A design with no entry here carries no field with a natural role + // (Assembly Line, Deep Ore Extractor, Refinery, Shipyard, Surface Ore Extractor carry no PerformanceStat + // behaviour at all in the current catalog). + private static readonly Dictionary> ToolRolesByDesign = + new Dictionary> + { + ["Industrial Thermostatic Heater"] = new Dictionary + { + ["EnergyDraw"] = "heating element", ["Heat"] = "heating element", + }, + ["PotaT+-"] = new Dictionary + { + ["Capacity"] = "storage cell", ["Efficiency"] = "converter", + }, + }; + + private static string KindOf(EquippableItemData item) => + item is HullData hull ? $"Hull/{hull.HullType}" + : item is DockingBayData ? "DockingBay" + : item is CargoBayData ? "CargoBay" + : item is WeaponItemData weapon ? $"Weapon/{weapon.HardpointType}" + : item.HardpointType.ToString(); + + // The field->role map for one design, or null when its kind carries no per-part behaviour stat at all + // (CargoBay, DockingBay, ControlModule, Hull/Station, Hull/Turret -- none of their behaviours read + // StatSource.Quality today, so there is no natural role to author and the report says so per design). + private static Dictionary RoleMapFor(EquippableItemData item, string kind) => kind switch + { + "Weapon/Ballistic" => BallisticWeaponRoles, + "Weapon/Energy" => EnergyWeaponRoles, + "Weapon/Launcher" => LauncherRoles, + "Radiator" => RadiatorRoles, + "Reactor" => ReactorRoles, + "Sensors" => SensorRoles, + "Thruster" => ThrusterRoles, + "AetherDrive" => AetherDriveRoles, + _ when kind == "Hull/Ship" => HullShipRoles, + "Tool" => ToolRolesByDesign.TryGetValue(item.Name, out var byName) ? byName : null, + _ => null, + }; + + private sealed record RoleAuthoring( + EquippableItemData Item, string Kind, List<(string Behavior, string Field, string Role)> StatsAssigned, + List RolesDeclared, string SkipReason); + + // The content pass itself: declares roles on designs, points each design's own StatTerm.Role at the role that + // governs it, and authors matching ProductRole quality on every product that sells a now-rolegetting design. + // Dry run unless passed "apply", which alone opens the catalog writable; every record must derive cleanly or + // nothing is written, the same all-or-nothing contract ShieldMigrate and BrownoutMigrate already use. Prints + // the operator review table (grouped by item kind) either way. + private static int RolesMigrate(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var items = db.Cache.GetAll().ToArray(); + var products = db.Cache.GetAll().ToArray(); + var changedDesigns = new List<(object Document, CultRecordKey Key)>(); + var changedProducts = new List<(object Document, CultRecordKey Key)>(); + var report = new List(); + + foreach (var item in items.OrderBy(i => KindOf(i)).ThenBy(i => i.Name, StringComparer.Ordinal)) + { + var kind = KindOf(item); + var map = RoleMapFor(item, kind); + if (map == null) + { + report.Add(new RoleAuthoring(item, kind, new List<(string, string, string)>(), new List(), + "kind carries no behaviour reading StatSource.Quality; generic quality stays")); + continue; + } + + var rolesUsed = new SortedSet(StringComparer.Ordinal); + var assigned = new List<(string Behavior, string Field, string Role)>(); + foreach (var behavior in item.Behaviors ?? new List()) + { + if (behavior == null) continue; + foreach (var field in behavior.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat))) + { + if (!(field.GetValue(behavior) is PerformanceStat stat) || stat.Terms == null) continue; + if (stat.Min == stat.Max) continue; // flat: no observable role-vs-workmanship difference + var qualityTerm = stat.Terms.FirstOrDefault(t => t.Source == StatSource.Quality); + if (qualityTerm == null || !string.IsNullOrEmpty(qualityTerm.Role)) continue; + if (!map.TryGetValue(field.Name, out var role)) continue; + qualityTerm.Role = role; + rolesUsed.Add(role); + assigned.Add((behavior.GetType().Name, field.Name, role)); + } + } + + if (rolesUsed.Count == 0) + { + report.Add(new RoleAuthoring(item, kind, assigned, new List(), + "no quality-bearing, non-flat stat matched this kind's role map")); + continue; + } + + item.Roles = rolesUsed.Select(r => new ItemRole { Name = r }).ToList(); + report.Add(new RoleAuthoring(item, kind, assigned, item.Roles.Select(r => r.Name).ToList(), null)); + changedDesigns.Add((item, db.Cache.RefOf(item).Key)); + } + + // Products author quality for every role their design now declares. A design's Roles may also include + // roles authored on an earlier pass (none exist yet, but the loop is idempotent either way): every sold + // product is checked against the full current Roles list, not just what changed this run. + var productAuthoring = new List<(FactionProductData Product, string Design, List<(string Role, float Mean, float Dev, string Basis)> Added)>(); + var byDesign = items.ToDictionary(i => db.Cache.RefOf(i).Key, i => i); + foreach (var product in products.OrderBy(p => p.Name, StringComparer.Ordinal)) + { + if (!byDesign.TryGetValue(product.Design.Key, out var design) || design.Roles == null || design.Roles.Count == 0) + continue; + var existing = new HashSet((product.Roles ?? new List()).Select(r => r.Role)); + var added = new List<(string, float, float, string)>(); + foreach (var role in design.Roles) + { + if (existing.Contains(role.Name)) continue; + var (mean, dev, basis) = AuthorProductRole(db, product, design.Name, role.Name); + product.Roles.Add(new ProductRole { Role = role.Name, Mean = mean, StandardDeviation = dev }); + added.Add((role.Name, mean, dev, basis)); + } + if (added.Count == 0) continue; + productAuthoring.Add((product, design.Name, added)); + changedProducts.Add((product, db.Cache.RefOf(product).Key)); + } + + PrintRolesReport(report, productAuthoring); + + var totalDesigns = report.Count(r => r.SkipReason == null); + var totalStats = report.Sum(r => r.StatsAssigned.Count); + Console.WriteLine($"\n{totalDesigns} designs authored roles ({report.Count - totalDesigns} left at generic quality), " + + $"{totalStats} stats pointed at a role, {changedProducts.Count} products authored role quality."); + + if (changedDesigns.Count == 0 && changedProducts.Count == 0) return 0; + if (!apply) + { + Console.WriteLine($"\nDry run. Pass \"apply\" to land {changedDesigns.Count} design(s) and {changedProducts.Count} product(s)."); + return 0; + } + + db.Cache.Commit(batch => + { + foreach (var (document, key) in changedDesigns) batch.Upsert(document.GetType(), document, key); + foreach (var (document, key) in changedProducts) batch.Upsert(document.GetType(), document, key); + }); + Console.WriteLine($"Landed {changedDesigns.Count} design(s) and {changedProducts.Count} product(s) in Aetheria.cc"); + return 0; + } + + // A stable (process-independent) hash: string.GetHashCode() is randomized per process, which would make a dry + // run and the later "apply" run author different numbers for the same product. Deterministic FNV-1a instead. + private static uint StableHash(string s) + { + unchecked + { + var h = 2166136261u; + foreach (var c in s) { h ^= c; h *= 16777619u; } + return h; + } + } + + // Alakrita's Arctica is the one product the lore already speaks for (docs/item-provenance-target.md: "Arctica + // is Alakrita branding, by the name I presume they're optimizing for heat efficiency"): its radiating fins get + // a real premium and tight quality control, everything else on it stays near the design-wide default. Every + // other product has no established brand identity yet, so its means and deviations are derived deterministically + // from (product name, role) -- reproducible across a dry run and the later apply, and varied enough that two + // products of one design will not coincide by construction -- and are named as generic in the report rather + // than presented as lore. The operator review (O7) is exactly where a real identity replaces a generic one. + private static (float Mean, float Dev, string Basis) AuthorProductRole(AetherDb db, FactionProductData product, string designName, string role) + { + var maker = db.Cache.Get(product.Manufacturer)?.Name; + if (designName == "Arctica" && maker == "Alakrita" && role == "fins") + return (.78f, .07f, "lore: Alakrita brands on heat efficiency (item-provenance-target.md)"); + + var hash = StableHash($"{product.Name}|{role}"); + var mean = .45f + (hash % 1000) / 1000f * .3f; // [.45, .75) + var dev = .08f + (hash / 1000 % 1000) / 1000f * .1f; // [.08, .18) + return (mean, dev, "generic: no established brand identity, needs operator review"); + } + + private static void PrintRolesReport(List report, + List<(FactionProductData Product, string Design, List<(string Role, float Mean, float Dev, string Basis)> Added)> productAuthoring) + { + Console.WriteLine("=== Cut 7: roles authored, by item kind ==="); + foreach (var kindGroup in report.GroupBy(r => r.Kind).OrderBy(g => g.Key, StringComparer.Ordinal)) + { + Console.WriteLine($"\n-- {kindGroup.Key} --"); + foreach (var r in kindGroup.OrderBy(r => r.Item.Name, StringComparer.Ordinal)) + { + if (r.SkipReason != null) + { + Console.WriteLine($" {r.Item.Name,-28} (none) -- {r.SkipReason}"); + continue; + } + Console.WriteLine($" {r.Item.Name,-28} roles: {string.Join(", ", r.RolesDeclared)}"); + foreach (var (behavior, field, role) in r.StatsAssigned) + Console.WriteLine($" {behavior}.{field,-18} -> {role}"); + } + } + + Console.WriteLine("\n=== Cut 7: product role quality authored ==="); + foreach (var (product, design, added) in productAuthoring.OrderBy(p => p.Product.Name, StringComparer.Ordinal)) + { + Console.WriteLine($" {product.Name,-28} ({design})"); + foreach (var (role, mean, dev, basis) in added) + Console.WriteLine($" {role,-18} mean {mean:0.00} dev {dev:0.00} {basis}"); + } + } + private static HashSet SoldDesigns(IEnumerable products) => new HashSet(products.Select(p => p.Design.Key)); @@ -268,6 +579,11 @@ public static class Program return 0; } + // F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this repair must preserve the record's existing + // key, which the validating CultRecordRefs.Upsert extension does not support (see AetheriaStores.cs's own + // Validate comment) -- so it calls the same validation directly, right before the identity-preserving + // write, instead of writing raw and unvalidated the way this line used to. + foreach (var (document, _) in changed) CultRecordRefs.Validate(document); db.Cache.Commit(batch => { foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); @@ -428,26 +744,75 @@ public static class Program } // Runs the real LoadoutGenerator against every hull, so a generation failure reproduces here instead of on a - // flight to a populated sector. No galaxy, so this covers placement, roles and products but NOT availability - // filtering or distance weighting, which need a real galaxy. Seeded: a failure repeats. - private static int Loadout(string seedArgument) + // flight to a populated sector. No galaxy by default, so this covers placement, roles and products but NOT + // availability filtering or distance weighting, which need a real galaxy. Seeded: a failure repeats. + // + // Cut 6c, 6c.2 verification: "exclude-sellers Short1,Short2,..." builds a minimal one-zone Galaxy (the same + // SavedGame/Galaxy round trip FireControlCut6cTests uses) whose faction roster is every catalog faction + // EXCEPT the named ones, so IsAvailable actually rejects their products instead of the null-Galaxy "every + // product is on offer" shortcut this command otherwise takes. That is the one way to reproduce the single + // point of failure 6c.2 fixed: a null-galaxy run can never exercise Galaxy.ContainsFaction at all. + private static int Loadout(string[] args) { - var db = AetherDb.Open(); + var seedArgument = args.FirstOrDefault(a => !a.StartsWith("exclude-sellers")); + var excludeArg = args.FirstOrDefault(a => a.StartsWith("exclude-sellers")); var seed = uint.TryParse(seedArgument, out var parsed) ? parsed : 1u; + var db = AetherDb.Open(); var settings = new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp() }; + + Galaxy galaxy = null; + CultCache galaxyCache = null; + if (excludeArg != null) + { + var excludedShortNames = excludeArg.Contains(':') + ? excludeArg.Substring(excludeArg.IndexOf(':') + 1).Split(',', StringSplitOptions.RemoveEmptyEntries) + : Array.Empty(); + var excluded = new HashSet(excludedShortNames); + + // A second, scratch-run-store cache over the SAME catalog file, read-only on the catalog side -- + // RunSave.Commit only ever touches run-store record types (SavedGame, SavedZone, ProvenanceLedger), + // but it needs a store that is "home" to them, which the plain read-only db.Cache above is not. + // The scratch run.cc lives in the OS temp directory, never under GameData, so this command cannot + // leave behind or corrupt a real save. Factions are looked up through THIS cache instance, not + // db.Cache -- CultCache.RefOf needs the document to be one this specific instance's identity map + // tracks, and two Open() calls over the same file do not share object identity. + var scratchRun = Path.Combine(Path.GetTempPath(), $"aetherdb-loadout-exclude-{Guid.NewGuid():N}.cc"); + galaxyCache = AetheriaStores.Open(Path.Combine(db.Root, "GameData", "Aetheria.cc"), runPath: scratchRun); + var includedFactions = galaxyCache.GetAll().Where(f => !excluded.Contains(f.ShortName)).ToArray(); + Console.WriteLine($"Excluding sellers [{string.Join(", ", excluded)}] -- galaxy carries {includedFactions.Length} of {galaxyCache.GetAll().Count()} factions.\n"); + + var game = new SavedGame + { + Factions = includedFactions.Select(f => galaxyCache.RefOf(f)).ToArray(), + Relationships = includedFactions.Select(_ => FactionRelationship.Neutral).ToArray(), + HomeZones = new Dictionary { { 0, 0 } }, + BossZones = new Dictionary(), + DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], + Exit = -1 + }; + var zones = new[] + { + new SavedZone { Name = "Zone 0", Position = new float2(0, 0), AdjacentZones = Array.Empty(), Factions = new[] { 0 }, Owner = 0, Contents = null } + }; + RunSave.Commit(galaxyCache, game, zones, new ProvenanceLedger()); + galaxy = new Galaxy(galaxyCache, galaxyCache.GetGlobal(), _ => { }); + } + var log = new List(); var itemManager = new ItemManager(db.Cache, new ProvenanceLedger(), settings, log.Add); var failures = 0; + var targetingGaps = 0; foreach (var hull in db.Cache.GetAll().OrderBy(h => h.HullType).ThenBy(h => h.Name)) { var random = new Random(seed); - var generator = new LoadoutGenerator(ref random, itemManager, null, null, null, .5f); + var generator = new LoadoutGenerator(ref random, itemManager, galaxy, null, null, .5f); log.Clear(); string outcome; try @@ -460,6 +825,7 @@ public static class Program }; outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok"; if (pack == null) failures++; + if (pack != null && log.Any(l => l.Contains("targeting system available", StringComparison.OrdinalIgnoreCase))) targetingGaps++; } catch (Exception e) { @@ -471,7 +837,641 @@ public static class Program foreach (var line in log.Distinct()) Console.WriteLine($" {line}"); } - Console.WriteLine($"\n{failures} hulls failed to generate a loadout (seed {seed})"); + Console.WriteLine($"\n{failures} hulls failed to generate a loadout (seed {seed}), {targetingGaps} armed hulls generated with no targeting system (fired unaided)"); + galaxyCache?.Dispose(); return failures; } + + // Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): ShieldData now + // authors Capacity/RefillDuration/RestoreDuration directly instead of deriving the reserve from EnergyUsage + // at runtime. Every existing shield design's PerformanceStat default (Min = Max = 0) reads as an authored + // zero reserve post-migration -- CanTakeHit would break on the very first hit -- so this command must land + // in the same commit as the schema change. Dry run unless passed "apply", which alone opens the catalog + // writable; every record must derive cleanly or nothing is written (TEMP: prints the derivation table for + // operator review either way). + private static int ShieldMigrate(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + + // ShieldData nests inside either host's Behaviors list (GearData: an equipped item with a hardpoint + // Shape to size from; ConsumableItemData: a consumable effect with no Shape -- cells reads 0 there, and + // the derivation below floors it to 1). Both are scanned because the schema change applies to every + // ShieldData record, not just equipped ones. + var hosts = db.Cache.GetAll().Select(g => (Name: g.Name, Cells: g.Shape.Coordinates.Length, Document: (object) g, Key: db.Cache.RefOf(g).Key, Behaviors: g.Behaviors)) + .Concat(db.Cache.GetAll().Select(c => (Name: c.Name, Cells: 0, Document: (object) c, Key: db.Cache.RefOf(c).Key, Behaviors: c.Behaviors))); + var shields = hosts + .Select(h => (h.Name, h.Cells, h.Document, h.Key, Shield: h.Behaviors.OfType().FirstOrDefault())) + .Where(p => p.Shield != null) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .ToArray(); + + Console.WriteLine($"{shields.Length} shield designs\n"); + Console.WriteLine($"{"design",-24} {"cells",5} {"energy",7} {"capacity",9} {"refill",7} {"restore",8} derived from"); + var changed = new List<(object Document, CultRecordKey Key)>(); + var alreadyAuthored = 0; + foreach (var (name, cells, document, key, shield) in shields) + { + if (shield.Capacity.Max > 0f || shield.RefillDuration.Max > 0f || shield.RestoreDuration.Max > 0f) + { + alreadyAuthored++; + Console.WriteLine($"{name,-24} {cells,5} {shield.EnergyUsage.Max,7:0.##} {shield.Capacity.Max,9:0.##} " + + $"{shield.RefillDuration.Max,7:0.##} {shield.RestoreDuration.Max,8:0.##} (already authored, left alone)"); + continue; + } + + // EnergyUsage is a per-damage-point cost multiplier, not an energy quantity of its own -- Cut 4's + // mistake was treating it as a reserve size. A reserve needs to be sized in damage points, so this + // divides back out: a shield with EnergyUsage 1 costs 1 energy per point of damage, so a Capacity of + // (BaseAbsorption * EnergyUsage) buys BaseAbsorption points of raw damage before breaking, scaling + // with the item's own cost multiplier the same way the old runtime-derived Capacity did (Capacity = + // EnergyUsage) but sized for a real fight instead of one hit. BaseAbsorption = 10 raw damage points, + // then scaled by the item's own cell count (larger reserved capacitors in a bigger hull slot) with a + // floor of 1 cell so a consumable-hosted shield (no Shape, Cells = 0) or a malformed 0-cell fixture + // does not zero the result. + const float baseAbsorption = 10f; + var energyUsage = shield.EnergyUsage.Max; + var capacity = baseAbsorption * Math.Max(1f, energyUsage) * Math.Max(1, cells); + + // Refill (holding) is the fast lever; restore (broken) is the punish window and is authored several + // times slower so breaking a shield costs real time regardless of how it broke. 2s/12s are flat + // starting points (no other authored duration on ShieldData to scale from), not derived from any + // per-design number -- flagged for operator tuning against real combat pacing, same footing Cut 4 + // flagged its own guess on. + const float refillDuration = 2f; + const float restoreDuration = 12f; + + shield.Capacity = new PerformanceStat { Min = capacity, Max = capacity }; + shield.RefillDuration = new PerformanceStat { Min = refillDuration, Max = refillDuration }; + shield.RestoreDuration = new PerformanceStat { Min = restoreDuration, Max = restoreDuration }; + + Console.WriteLine($"{name,-24} {cells,5} {energyUsage,7:0.##} {capacity,9:0.##} " + + $"{refillDuration,7:0.##} {restoreDuration,8:0.##} {baseAbsorption:0.#} x max(1,energy) x cells; flat durations"); + changed.Add((document, key)); + } + + Console.WriteLine($"\n{changed.Count} designs migrated, {alreadyAuthored} already authored (left alone)"); + if (changed.Count == 0) return 0; + if (!apply) + { + Console.WriteLine($"Dry run. Pass \"apply\" to land {changed.Count} changed records."); + return 0; + } + + // F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this repair must preserve the record's existing + // key, which the validating CultRecordRefs.Upsert extension does not support (see AetheriaStores.cs's own + // Validate comment) -- so it calls the same validation directly, right before the identity-preserving + // write, instead of writing raw and unvalidated the way this line used to. + foreach (var (document, _) in changed) CultRecordRefs.Validate(document); + db.Cache.Commit(batch => + { + foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); + }); + Console.WriteLine($"Landed {changed.Count} changed records in Aetheria.cc"); + return 0; + } + + // Operator ruling, docs/stats-and-power-target.md: "Continuous consumers brown out through a power supply + // curve on their performance stats." The Cut 7 code change (PowerBus.cs's five continuous-consumer gates) + // only has anything to curve if some shipped stat actually declares a PowerSupply term -- otherwise every + // one of those stats keeps answering PowerSupplyFactor's identity (Entity.cs), and removing the gates makes + // a partial grant read exactly like a full one instead of a reduced one. This authors the term, once, on the + // one performance stat each of the four curve-eligible behaviours (EnergyDraw has none -- see EnergyDraw.cs) + // actually reads for its continuous effect: ThrusterData.Thrust, AetherDriveData.Torque, + // RadiatorData.PumpedHeat, ConstantWeaponData.Damage. Two of the four (AetherDriveData.Torque, + // RadiatorData.PumpedHeat) are ALSO power-request fields (StatValidation.PowerRequestFields) -- at the time + // this tool authored the shipped catalog's curves that briefly made those two records illegal under F6's + // now-deleted static check (StatValidation.ValidateNoPowerSupplyOnRequest); the nominal-request ruling + // (docs/stats-and-power-cut.md, operator ruling 2026-09-19, see ItemData.cs's PowerRequestFields comment) + // resolved that by having PowerRequest read request fields nominally instead of forbidding the term, so + // authoring it here on a request field is legal again. Exponent 1 (linear) is the gentle default the ruling + // calls for -- half supply reads as half performance, not a cliff -- and is left for the operator to steepen + // per design later. Dry run unless passed "apply"; every record must derive cleanly or nothing is written, + // same contract as ShieldMigrate. + private static int BrownoutMigrate(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + const float gentleExponent = 1f; + + // (behaviour type, target performance-stat field, short label) -- two of these are also request fields + // (StatValidation.PowerRequestFields); that is legal under the nominal-request ruling above. + var targets = new (Type BehaviorType, string Field, string Label)[] + { + (typeof(ThrusterData), nameof(ThrusterData.Thrust), "thrust"), + (typeof(AetherDriveData), nameof(AetherDriveData.Torque), "torque"), + (typeof(RadiatorData), nameof(RadiatorData.PumpedHeat), "pumpedHeat"), + (typeof(ConstantWeaponData), nameof(WeaponData.Damage), "damage"), + }; + + var hosts = db.Cache.GetAll().Select(g => (Name: g.Name, Document: (object) g, Key: db.Cache.RefOf(g).Key, Behaviors: g.Behaviors)) + .Concat(db.Cache.GetAll().Select(c => (Name: c.Name, Document: (object) c, Key: db.Cache.RefOf(c).Key, Behaviors: c.Behaviors))); + + Console.WriteLine($"{"design",-24} {"behaviour",-14} {"stat",-10} {"before",8} {"after",8} exponent"); + var changed = new List<(object Document, CultRecordKey Key)>(); + var alreadyAuthored = 0; + var failures = 0; + foreach (var (name, document, key, behaviors) in hosts.OrderBy(h => h.Name, StringComparer.Ordinal)) + { + foreach (var behavior in behaviors) + { + foreach (var (behaviorType, field, label) in targets) + { + if (!behaviorType.IsInstanceOfType(behavior)) continue; + PerformanceStat stat; + try + { + stat = behaviorType.GetField(field)?.GetValue(behavior) as PerformanceStat + ?? throw new InvalidOperationException($"{behaviorType.Name}.{field} is not a PerformanceStat field"); + } + catch (Exception e) + { + Console.WriteLine($"FAILED reading {name} {behaviorType.Name}.{field}: {e.Message}"); + failures++; + continue; + } + + if (stat.Terms.Any(t => t.Source == StatSource.PowerSupply)) + { + alreadyAuthored++; + Console.WriteLine($"{name,-24} {behaviorType.Name,-14} {label,-10} {"(already authored, left alone)",-17}"); + continue; + } + + var before = stat.Terms.Count; + stat.Terms.Add(new StatTerm { Source = StatSource.PowerSupply, Exponent = gentleExponent }); + Console.WriteLine($"{name,-24} {behaviorType.Name,-14} {label,-10} {before,8} {stat.Terms.Count,8} {gentleExponent:0.##}"); + changed.Add((document, key)); + } + } + } + + Console.WriteLine($"\n{changed.Count} stats authored a PowerSupply term, {alreadyAuthored} already authored (left alone), {failures} failures"); + if (failures > 0) + { + Console.WriteLine("Refusing to write: at least one record failed to derive cleanly."); + return 1; + } + if (changed.Count == 0) return 0; + if (!apply) + { + Console.WriteLine($"Dry run. Pass \"apply\" to land {changed.Count} changed records."); + return 0; + } + + // F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this repair must preserve the record's existing + // key, which the validating CultRecordRefs.Upsert extension does not support (see AetheriaStores.cs's own + // Validate comment) -- so it calls the same validation directly, right before the identity-preserving + // write, instead of writing raw and unvalidated the way this line used to. + foreach (var (document, _) in changed) CultRecordRefs.Validate(document); + db.Cache.Commit(batch => + { + foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); + }); + Console.WriteLine($"Landed {changed.Count} changed records in Aetheria.cc"); + return 0; + } + + // Cut 1 (docs/fire-control-cut.md, "Catalog" under Cut 1 / Q1): a turret hardpoint authors + // FiringArc: 360 -- no new type for what a number already says (R6 already gives every other hardpoint + // the GameplaySettings.FiringArc default via 0). Scoped to the Turret hull's own hardpoints, the only + // ones the cut names; other hulls' hardpoints are left at 0 (default arc) until a design pass says + // otherwise. Dry run unless passed "apply", same contract as ShieldMigrate/BrownoutMigrate. + private static int FiringArcMigrate(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + + var hulls = db.Cache.GetAll().Where(h => h.Name == "Turret").ToArray(); + if (hulls.Length == 0) + { + Console.WriteLine("No hull named \"Turret\" found."); + return 1; + } + + Console.WriteLine($"{"hull",-16} {"hardpoint",-10} {"position",-10} {"was",5} {"now",5}"); + var changed = new List<(object Document, CultRecordKey Key)>(); + foreach (var hull in hulls) + { + var key = db.Cache.RefOf(hull).Key; + var touchedThisHull = false; + foreach (var hardpoint in hull.Hardpoints) + { + if (hardpoint.Type != HardpointType.Ballistic) continue; + var position = $"({hardpoint.Position.x},{hardpoint.Position.y})"; + var was = hardpoint.FiringArc; + if (was >= 360f) + { + Console.WriteLine($"{hull.Name,-16} {hardpoint.Type,-10} {position,-10} {was,5:0} {was,5:0} (already authored, left alone)"); + continue; + } + hardpoint.FiringArc = 360f; + Console.WriteLine($"{hull.Name,-16} {hardpoint.Type,-10} {position,-10} {was,5:0} {360,5:0}"); + touchedThisHull = true; + } + if (touchedThisHull) changed.Add((hull, key)); + } + + Console.WriteLine($"\n{changed.Count} hull records migrated"); + if (changed.Count == 0) return 0; + if (!apply) + { + Console.WriteLine($"Dry run. Pass \"apply\" to land {changed.Count} changed records."); + return 0; + } + + foreach (var (document, _) in changed) CultRecordRefs.Validate(document); + db.Cache.Commit(batch => + { + foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); + }); + Console.WriteLine($"Landed {changed.Count} changed records in Aetheria.cc"); + return 0; + } + + // Fire control Cut 6a (docs/fire-control-cut.md, Cut 2's "Catalog:" bullet). Cut 2 shipped the behaviour and + // never shipped the data: no targeting-system design exists, so tools/AetherDb loadout throws + // InvalidLoadoutException ("No compatible targeting system found for entity!") for every armed hull. This + // authors the two designs Cut 2 asked for (1-cell and 2-cell, each with a NiteLife product -- the one + // manufacturer that already sells the capacitor, so IsAvailable finds one without falling back to "any + // manufacturer") plus one product each for the four designs nothing currently sells (plight, pswarm, Core + // Power, Large Drive), matching each to the manufacturer its own flavour text or the roster already implies. + // Dry run unless passed "apply", same contract as the other *-migrate commands. + private static int TargetingCatalog(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + + Faction FactionByShortName(string shortName) + { + var faction = db.Cache.GetAll().FirstOrDefault(f => f.ShortName == shortName); + if (faction == null) throw new InvalidOperationException($"No faction with short name \"{shortName}\"."); + return faction; + } + + EquippableItemData DesignByName(string name) + { + var design = db.Cache.GetAll().FirstOrDefault(i => i.Name == name); + if (design == null) throw new InvalidOperationException($"No design named \"{name}\"."); + return design; + } + + PerformanceStat Flat(float value) => new PerformanceStat { Min = value, Max = value, Terms = new List() }; + + PerformanceStat Rolled(float min, float max, string role) => new PerformanceStat + { + Min = min, + Max = max, + Terms = new List + { + new StatTerm { Source = StatSource.Heat, Exponent = 0.0625f }, + new StatTerm { Source = StatSource.Durability, Exponent = 0.25f }, + new StatTerm { Source = StatSource.Quality, Exponent = 2f, Role = role }, + }, + }; + + // Both roles are new vocabulary (docs/stats-power-cut7-roles.md's convention is one vocabulary per new + // behaviour kind, same as content-batch-one's shield "emitter/reservoir/regulator"): "processor" for the + // aim-quality half (Accuracy, Precision), "array" for the sensor/tracking-hardware half (Tracking). + // Resolution carries no role and no terms -- it is authored as a flat info-level threshold per design + // (see the design-time note below), not a stat that wear or manufacturing quality moves. + GearData BuildTargetingDesign(string name, string description, int cells, int durability, float mass, + int price, float minTemp, float maxTemp, float optimal, float plateau, + (float min, float max) accuracy, float resolution, (float min, float max) precision, + (float min, float max) tracking, float energyDraw, float heat) + { + var shape = new Shape(); + if (cells > 1) shape.Width = cells; + return new GearData + { + Name = name, + Description = description, + Hardpoint = HardpointType.Tool, + Shape = shape, + Mass = mass, + Price = price, + Durability = durability, + MinimumTemperature = minTemp, + MaximumTemperature = maxTemp, + ThermalResilience = 1, + OptimalTemperature = optimal, + PlateauWidth = plateau, + SpecificHeat = 1, + Conductivity = 1, + Roles = new List { new ItemRole { Name = "processor" }, new ItemRole { Name = "array" } }, + Behaviors = new List + { + new TargetingSystemData + { + Accuracy = Rolled(accuracy.min, accuracy.max, "processor"), + Resolution = Flat(resolution), + Precision = Rolled(precision.min, precision.max, "processor"), + Tracking = Rolled(tracking.min, tracking.max, "array"), + }, + new EnergyDrawData { EnergyDraw = Flat(energyDraw), PerSecond = true }, + new HeatData { Heat = Flat(heat), PerSecond = true }, + }, + }; + } + + // Sibling argued: PotaT+- (the capacitor, the only other bare Tool gadget in the catalog) is 1x1, + // Durability 50, Mass 75, Price 75000, heat response 200-500/276.5/36. GameplaySettings.UnaidedAccuracy + // is .05 (AetherDb settings) and GameplaySettings.UnaidedTracking will be 10 once Cut 5 lands (not yet on + // this branch) -- both designs clear it by 50% or more. Resolution is placed against the live thresholds + // this branch reads from AetherDb settings: TargetDetectionInfoThreshold .1, TargetArmorInfoThreshold .5, + // TargetGearInfoThreshold .8. FireControl.HitProbability reads + // pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info)), so a HIGHER Resolution + // needs MORE gathered info before sensor state stops limiting hits -- it is a cost, not a bonus. The + // 1-cell system is placed low (.3, just above the detection floor) so it reaches its own modest ceiling + // off a cheap scan; the 2-cell system is placed high (.75, near the gear-info tier) so its much higher + // ceiling is only fully reachable after sustained scanning. That is an authored reading of "the 1-cell + // design lower than the 2-cell", not a re-derivation of the spec, and the report flags it for review. + var targetingComputer = BuildTargetingDesign( + "Targeting Computer", + "Resolves sensor return into a firing solution for the weapons it feeds.", + cells: 1, durability: 50, mass: 60, price: 60000, + minTemp: 200, maxTemp: 500, optimal: 276.5f, plateau: 36, + accuracy: (.45f, .6f), resolution: .3f, precision: (.05f, .2f), tracking: (15f, 25f), + energyDraw: 2f, heat: 15f); + + var fireControlArray = BuildTargetingDesign( + "Fire Control Array", + "A larger fire-control computer: a dedicated tracking array pairs with deeper prediction, at higher mass and price.", + cells: 2, durability: 60, mass: 110, price: 150000, + minTemp: 220, maxTemp: 480, optimal: 270f, plateau: 28, + accuracy: (.65f, .85f), resolution: .75f, precision: (.35f, .55f), tracking: (30f, 45f), + energyDraw: 4f, heat: 25f); + + var newDesigns = new (GearData Design, string ProductName, Faction Maker, string ProductDescription, (string Role, float Mean, float Dev)[] Roles)[] + { + (targetingComputer, "LockOn", FactionByShortName("NiteLife"), + "Keeps the lights on your target. (new text)", + new[] { ("processor", .55f, .15f), ("array", .5f, .16f) }), + (fireControlArray, "LockOn Pro", FactionByShortName("NiteLife"), + "Same promise, sharper lock. (new text)", + new[] { ("processor", .72f, .12f), ("array", .68f, .13f) }), + }; + + // Part 3: one product each for the four designs nothing currently sells. Manufacturer assignment is the + // brief's own (fire-control-cut.md Cut 6a): plight -> Lucent Media, pswarm -> Aeronautics Unlimited, + // Core Power -> Rossum & Douglas, Large Drive -> Lightsail Express. Role coverage matches each design's + // own already-declared Roles (read from the catalog, not invented): plight [power coupling, focusing + // array], pswarm [guidance system, thruster, warhead], Core Power [core, regulator], Large Drive [] (it + // declares no roles today, so its product declares none either -- adding roles to an existing design is + // outside this cut's brief). + var existingDesigns = new (string DesignName, string ProductName, Faction Maker, string ProductDescription, (string Role, float Mean, float Dev)[] Roles)[] + { + ("plight", "DragOnBreath", FactionByShortName("Lucent"), + "The subsidiary plight's own description already namechecks: DragOnBreath, a Lucent Media property.", + new[] { ("focusing array", .7f, .13f), ("power coupling", .55f, .15f) }), + ("pswarm", "Leonid", FactionByShortName("AU"), + "A rain of pain that falls mainly on space planes.", + new[] { ("guidance system", .65f, .14f), ("thruster", .63f, .15f), ("warhead", .7f, .13f) }), + ("Core Power", "Steadfast", FactionByShortName("R&D"), + "Rated for continuous operation. \"Continuous\" does not imply \"uninterrupted.\" (new text)", + new[] { ("core", .5f, .12f), ("regulator", .5f, .12f) }), + ("Large Drive", "True North", FactionByShortName("Lightsail"), + "Gets your cargo there. Getting you there is your problem. (new text)", + Array.Empty<(string, float, float)>()), + }; + + Console.WriteLine("New designs:"); + foreach (var (design, productName, maker, _, _) in newDesigns) + Console.WriteLine($" {design.Name} ({design.Shape.Width}x{design.Shape.Height}, {design.Durability} durability, {design.Mass}kg, {design.Price}c) -> product \"{productName}\" by {maker.ShortName}"); + Console.WriteLine("\nNew products for existing unsold designs:"); + foreach (var (designName, productName, maker, _, roles) in existingDesigns) + Console.WriteLine($" {designName} -> \"{productName}\" by {maker.ShortName}, roles [{string.Join(", ", roles.Select(r => r.Role))}]"); + + if (!apply) + { + Console.WriteLine($"\nDry run. Pass \"apply\" to land {newDesigns.Length} new designs and {newDesigns.Length + existingDesigns.Length} new products."); + return 0; + } + + // Validate before staging, same as ShieldMigrate/BrownoutMigrate/RolesMigrate/FiringArcMigrate above: + // CultRecordRefs.Validate is the body AetheriaStores.Open and cache.Upsert both run, but a raw + // batch.Upsert(Type, document, key) -- needed here because a product must land in the same batch as the + // design it references, before that design has a minted key of its own -- does not call it. + foreach (var (design, _, _, _, _) in newDesigns) CultRecordRefs.Validate(design); + + db.Cache.Commit(batch => + { + var designKeys = new Dictionary(); + foreach (var (design, _, _, _, _) in newDesigns) + designKeys[design.Name] = batch.Upsert(typeof(GearData), design); + + foreach (var (design, productName, maker, description, roles) in newDesigns) + { + var product = new FactionProductData + { + Name = productName, + Description = description, + Design = new CultRecordRef(designKeys[design.Name]), + Manufacturer = db.Cache.RefOf(maker), + Roles = roles.Select(r => new ProductRole { Role = r.Role, Mean = r.Mean, StandardDeviation = r.Dev }).ToList(), + }; + batch.Upsert(typeof(FactionProductData), product); + } + + foreach (var (designName, productName, maker, description, roles) in existingDesigns) + { + var design = DesignByName(designName); + var product = new FactionProductData + { + Name = productName, + Description = description, + Design = new CultRecordRef(db.Cache.RefOf(design).Key), + Manufacturer = db.Cache.RefOf(maker), + Roles = roles.Select(r => new ProductRole { Role = r.Role, Mean = r.Mean, StandardDeviation = r.Dev }).ToList(), + }; + batch.Upsert(typeof(FactionProductData), product); + } + }); + + Console.WriteLine($"\nLanded {newDesigns.Length} new designs and {newDesigns.Length + existingDesigns.Length} new products in Aetheria.cc"); + return 0; + } + + // Fire control Cut 6c (docs/fire-control-cut.md, "the formula gets fixed, and the single point of + // failure"). Two parts: + // + // 6c.1 (operator ruling 2026-09-19): FireControl.HitProbability now takes Resolution's reciprocal to + // derive the sensor-limiting info ceiling, so a bigger authored number is a bigger benefit, matching + // every other stat on this behaviour. Cut 6a's two designs were authored against the old (ceiling-read- + // directly) reading -- Targeting Computer at .3, Fire Control Array at .75 -- which is why the premium + // design scored strictly worse at low info. Re-authored on the new scale: 1-cell 2 (ceiling + // .1 + .9/2 = .55), 2-cell 4 (ceiling .1 + .9/4 = .325). Both clear the unaided ceiling of 1 by a wide + // margin (lower is better under the new reading), and the 2-cell design is unambiguously ahead of the + // 1-cell one at every info level, which was the whole point of the fix. + // + // 6c.2: NiteLife Energy was the only seller of both designs -- "at least the manufacturers that sell the + // capacitor" (Cut 2's letter) rather than the thematic sellers the roster actually names for targeting + // hardware (Cut 2's intent). Finch Cybernetics (passive sensors) and Lucent Media (active sensors, lasers) + // both get a product per design, so a galaxy that does not draw NiteLife still has two other chances to + // sell one. Brand names and flavour text are each manufacturer's own; the design stays dry and carries no + // manufacturer field (standing text policy). Role coverage matches the designs' own already-declared Roles + // (processor, array), same convention TargetingCatalog's newDesigns used above. + // + // Dry run unless passed "apply", same contract as the other *-migrate commands. + private static int TargetingCatalog6c(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + + Faction FactionByShortName(string shortName) + { + var faction = db.Cache.GetAll().FirstOrDefault(f => f.ShortName == shortName); + if (faction == null) throw new InvalidOperationException($"No faction with short name \"{shortName}\"."); + return faction; + } + + GearData DesignByName(string name) + { + var design = db.Cache.GetAll().FirstOrDefault(i => i.Name == name); + if (design == null) throw new InvalidOperationException($"No design named \"{name}\"."); + return design; + } + + var targetingComputer = DesignByName("Targeting Computer"); + var fireControlArray = DesignByName("Fire Control Array"); + + var resolutionChanges = new (GearData Design, float OldResolution, float NewResolution)[] + { + (targetingComputer, targetingComputer.Behaviors.OfType().Single().Resolution.Max, 2f), + (fireControlArray, fireControlArray.Behaviors.OfType().Single().Resolution.Max, 4f), + }; + + var newProducts = new (GearData Design, string ProductName, Faction Maker, string ProductDescription, (string Role, float Mean, float Dev)[] Roles)[] + { + (targetingComputer, "Panopticon", FactionByShortName("Finch"), + "Sees everything, judges nothing. Well, mostly nothing. (new text)", + new[] { ("processor", .5f, .13f), ("array", .58f, .12f) }), + (fireControlArray, "Panopticon Prime", FactionByShortName("Finch"), + "The upgrade nobody at Finch will admit they needed until they had it. (new text)", + new[] { ("processor", .68f, .11f), ("array", .75f, .10f) }), + (targetingComputer, "ClapBack", FactionByShortName("Lucent"), + "Your target won't clock it until the killcam. (new text)", + new[] { ("processor", .6f, .16f), ("array", .48f, .15f) }), + (fireControlArray, "ClapBack Ultra", FactionByShortName("Lucent"), + "Now with main-character energy built in. (new text)", + new[] { ("processor", .78f, .14f), ("array", .65f, .13f) }), + }; + + Console.WriteLine("Resolution re-authored to the reciprocal-reading scale (6c.1):"); + foreach (var (design, oldResolution, newResolution) in resolutionChanges) + Console.WriteLine($" {design.Name,-20} {oldResolution,6:0.##} -> {newResolution,4:0.##}"); + + Console.WriteLine("\nNew products (6c.2, thematic sellers alongside NiteLife):"); + foreach (var (design, productName, maker, _, roles) in newProducts) + Console.WriteLine($" {design.Name} -> \"{productName}\" by {maker.ShortName}, roles [{string.Join(", ", roles.Select(r => r.Role))}]"); + + if (!apply) + { + Console.WriteLine($"\nDry run. Pass \"apply\" to land {resolutionChanges.Length} Resolution changes and {newProducts.Length} new products."); + return 0; + } + + foreach (var (design, _, newResolution) in resolutionChanges) + { + var behavior = design.Behaviors.OfType().Single(); + behavior.Resolution = new PerformanceStat { Min = newResolution, Max = newResolution, Terms = new List() }; + } + + // Validate before staging, same contract as ShieldMigrate/TargetingCatalog above. + foreach (var (design, _, _) in resolutionChanges) CultRecordRefs.Validate(design); + + db.Cache.Commit(batch => + { + foreach (var (design, _, _) in resolutionChanges) + batch.Upsert(typeof(GearData), design, db.Cache.RefOf(design).Key); + + foreach (var (design, productName, maker, description, roles) in newProducts) + { + var product = new FactionProductData + { + Name = productName, + Description = description, + Design = new CultRecordRef(db.Cache.RefOf(design).Key), + Manufacturer = db.Cache.RefOf(maker), + Roles = roles.Select(r => new ProductRole { Role = r.Role, Mean = r.Mean, StandardDeviation = r.Dev }).ToList(), + }; + batch.Upsert(typeof(FactionProductData), product); + } + }); + + Console.WriteLine($"\nLanded {resolutionChanges.Length} Resolution changes and {newProducts.Length} new products in Aetheria.cc"); + return 0; + } + + // Fire control Cut 6d (docs/fire-control-cut.md, "where a hit lands is a dart throw, not a coin flip"). + // + // Precision stops being a probability (the old coin flip's "chance the hit lands exactly on the aimed + // item") and becomes a grouping tightness on FireControl.Sigma's own scale (sigma = 1/Precision, in hull- + // schematic cell units -- the same units Shape.CenterOfMass and GearOccupancy already use). The two + // designs' authored ranges (Cut 6a: 1-cell .05-.2, 2-cell .35-.55) were probabilities under the old rule + // and are meaningless as written under the new one -- re-authored here on the sigma-reciprocal scale + // instead, same convention as 6c.1's Resolution fix. + // + // Both ranges are chosen against real hull scale (GameData/Aetheria.cc's own hulls: LonginusX 6x17, + // Zenith 12x12, Turret 8x8) and the unaided floor this cut adds alongside them + // (GameplaySettings.UnaidedPrecision, .3 -> sigma 3.33 cells): a design that did not clear the unaided + // floor by a wide margin would make the required Tool item pointless to equip. + // + // 1-cell Targeting Computer: .5-.8 (sigma 2.0-1.25 cells) -- tight enough that aiming at a named subsystem + // meaningfully concentrates fire on it and its immediate neighbours, well past the unaided floor. + // 2-cell Fire Control Array: .9-1.3 (sigma 1.11-0.77 cells) -- strictly tighter than the 1-cell design at + // every point along both authored ranges, the same "premium item unambiguously ahead" shape 6c.1 restored + // for Resolution. + // + // Dry run unless passed "apply", same contract as the other *-migrate commands. + private static int TargetingCatalog6d(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + + GearData DesignByName(string name) + { + var design = db.Cache.GetAll().FirstOrDefault(i => i.Name == name); + if (design == null) throw new InvalidOperationException($"No design named \"{name}\"."); + return design; + } + + var targetingComputer = DesignByName("Targeting Computer"); + var fireControlArray = DesignByName("Fire Control Array"); + + var precisionChanges = new (GearData Design, (float Min, float Max) Old, (float Min, float Max) New)[] + { + (targetingComputer, + (targetingComputer.Behaviors.OfType().Single().Precision.Min, + targetingComputer.Behaviors.OfType().Single().Precision.Max), + (.5f, .8f)), + (fireControlArray, + (fireControlArray.Behaviors.OfType().Single().Precision.Min, + fireControlArray.Behaviors.OfType().Single().Precision.Max), + (.9f, 1.3f)), + }; + + Console.WriteLine("Precision re-authored from a coin-flip probability to a grouping-tightness scale (Cut 6d):"); + foreach (var (design, old, @new) in precisionChanges) + Console.WriteLine($" {design.Name,-20} {old.Min,4:0.##}-{old.Max,-4:0.##} -> {@new.Min,4:0.##}-{@new.Max,-4:0.##}"); + + if (!apply) + { + Console.WriteLine($"\nDry run. Pass \"apply\" to land {precisionChanges.Length} Precision changes."); + return 0; + } + + foreach (var (design, _, @new) in precisionChanges) + { + var behavior = design.Behaviors.OfType().Single(); + var terms = behavior.Precision.Terms; + behavior.Precision = new PerformanceStat { Min = @new.Min, Max = @new.Max, Terms = terms }; + } + + // Validate before staging, same contract as ShieldMigrate/TargetingCatalog/TargetingCatalog6c above. + foreach (var (design, _, _) in precisionChanges) CultRecordRefs.Validate(design); + + db.Cache.Commit(batch => + { + foreach (var (design, _, _) in precisionChanges) + batch.Upsert(typeof(GearData), design, db.Cache.RefOf(design).Key); + }); + + Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); + return 0; + } }